use std::path::{Path, PathBuf};
use crate::core::instruction_package::{
BlockBody, CustomizationTier, InstructionBlock, InstructionPackage, Join, OverrideTier,
SectionId, ValidationError,
};
pub const HOST_FILES: [&str; 1] = ["CLAUDE.md"];
pub(crate) const SUPPORTED_VERSION: u32 = 1;
const MARKER_OPEN: &str = "<!--";
const MARKER_CLOSE: &str = "-->";
const MARKER_NAMESPACE: &str = "TRUSTY-MPM:";
pub const REASON_UNCLOSED: &str = "START marker has no matching END; block skipped";
pub const REASON_MISMATCHED_END: &str = "END marker names a different section than the open START";
pub const REASON_UNMATCHED_END: &str = "END marker with no open START";
pub const REASON_UNKNOWN_SECTION: &str = "unknown section token; block skipped";
pub const REASON_UNSUPPORTED_VERSION: &str = "unsupported marker version; block skipped";
pub const REASON_MISSING_VERSION: &str = "marker has no `v=`; assuming v=1";
pub const REASON_EMPTY_BODY: &str = "override body is empty; keeping the bundled section";
pub const REASON_DUPLICATE: &str = "duplicate section marker; the first well-formed block wins";
pub const REASON_SHADOWED: &str = "section already overridden by a higher-precedence host";
pub const fn section_token(id: SectionId) -> &'static str {
match id {
SectionId::Identity => "IDENTITY",
SectionId::Core => "CORE",
SectionId::PmAllowlist => "PM-ALLOWLIST",
SectionId::DelegationMechanics => "DELEGATION-MECHANICS",
SectionId::AgentRouting => "AGENT-ROUTING",
SectionId::SubagentReEngagement => "SUBAGENT-RE-ENGAGEMENT",
SectionId::Phases => "PHASES",
SectionId::QaGate => "QA-GATE",
SectionId::GitFileTracking => "GIT-FILE-TRACKING",
SectionId::TicketsPrsReleases => "TICKETS-PRS-RELEASES",
SectionId::MessagesReportsSessions => "MESSAGES-REPORTS-SESSIONS",
SectionId::AutonomousExecution => "AUTONOMOUS-EXECUTION",
SectionId::Memory => "MEMORY",
SectionId::Search => "SEARCH",
SectionId::Workflow => "WORKFLOW",
SectionId::AgentDelegation => "AGENT-DELEGATION",
SectionId::Enforcement => "ENFORCEMENT",
SectionId::NonOverridableRules => "NON-OVERRIDABLE-RULES",
SectionId::FrameworkGuaranteedConventions => "FRAMEWORK-GUARANTEED-CONVENTIONS",
}
}
fn section_for_token(token: &str) -> Option<SectionId> {
SectionId::CANONICAL
.into_iter()
.find(|id| token.eq_ignore_ascii_case(section_token(*id)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkerVersion {
Absent,
Supported,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkerKind {
Start(MarkerVersion),
End,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Marker {
token: String,
kind: MarkerKind,
}
fn parse_marker(line: &str) -> Option<Marker> {
let body = line
.trim()
.strip_prefix(MARKER_OPEN)?
.strip_suffix(MARKER_CLOSE)?
.trim();
let namespaced = body
.get(..MARKER_NAMESPACE.len())
.filter(|head| head.eq_ignore_ascii_case(MARKER_NAMESPACE))?;
let mut fields = body[namespaced.len()..].split_whitespace();
let token = fields.next()?.to_string();
let kind = match fields.next()? {
word if word.eq_ignore_ascii_case("START") => MarkerKind::Start(match fields.next() {
None => MarkerVersion::Absent,
Some(field) => match field.get(..2).filter(|k| k.eq_ignore_ascii_case("v=")) {
Some(_) if field[2..].parse::<u32>() == Ok(SUPPORTED_VERSION) => {
MarkerVersion::Supported
}
_ => MarkerVersion::Unsupported,
},
}),
word if word.eq_ignore_ascii_case("END") => MarkerKind::End,
_ => return None,
};
if fields.next().is_some() {
return None;
}
Some(Marker { token, kind })
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedBlock {
section: Option<SectionId>,
version: MarkerVersion,
body: String,
start_line: usize,
end_line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanDiagnostic {
pub host: PathBuf,
pub line: usize,
pub reason: &'static str,
}
fn diagnostic(host: &Path, line_index: usize, reason: &'static str) -> ScanDiagnostic {
ScanDiagnostic {
host: host.to_path_buf(),
line: line_index + 1,
reason,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SectionOverride {
pub section: SectionId,
pub body: String,
pub host: PathBuf,
pub line: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectOverrides {
pub overrides: Vec<SectionOverride>,
pub diagnostics: Vec<ScanDiagnostic>,
}
impl ProjectOverrides {
pub fn log(&self) {
for diagnostic in &self.diagnostics {
tracing::warn!(
path = %diagnostic.host.display(),
line = diagnostic.line,
reason = diagnostic.reason,
"CLAUDE.md named-section override ignored"
);
}
for applied in &self.overrides {
tracing::info!(
path = %applied.host.display(),
section = ?applied.section,
"applying CLAUDE.md named-section override"
);
}
}
}
fn parse_blocks(text: &str, host: &Path) -> (Vec<ParsedBlock>, Vec<ScanDiagnostic>) {
let lines: Vec<&str> = text.lines().collect();
let mut blocks = Vec::new();
let mut diagnostics = Vec::new();
let mut open: Option<(Marker, usize)> = None;
for (index, line) in lines.iter().enumerate() {
let Some(marker) = parse_marker(line) else {
continue;
};
match marker.kind {
MarkerKind::Start(_) => {
if let Some((_, previous)) = open.replace((marker, index)) {
diagnostics.push(diagnostic(host, previous, REASON_UNCLOSED));
}
}
MarkerKind::End => {
let Some((start, start_line)) = open.take() else {
diagnostics.push(diagnostic(host, index, REASON_UNMATCHED_END));
continue;
};
if !start.token.eq_ignore_ascii_case(&marker.token) {
diagnostics.push(diagnostic(host, index, REASON_MISMATCHED_END));
continue;
}
let MarkerKind::Start(version) = start.kind else {
unreachable!("only a START marker is ever stored as open");
};
blocks.push(ParsedBlock {
section: section_for_token(&start.token),
version,
body: lines[start_line + 1..index].join("\n").trim().to_string(),
start_line,
end_line: index,
});
}
}
}
if let Some((_, start_line)) = open {
diagnostics.push(diagnostic(host, start_line, REASON_UNCLOSED));
}
(blocks, diagnostics)
}
pub(crate) fn contains_marker_line(text: &str) -> bool {
text.lines().any(|line| parse_marker(line).is_some())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct HostBlocks {
pub(crate) spans: Vec<(usize, usize)>,
pub(crate) unclosed: bool,
}
pub(crate) fn locate_blocks(text: &str, section: SectionId) -> HostBlocks {
let (blocks, diagnostics) = parse_blocks(text, Path::new("<in-memory>"));
HostBlocks {
spans: blocks
.iter()
.filter(|b| b.section == Some(section))
.map(|b| (b.start_line, b.end_line))
.collect(),
unclosed: diagnostics.iter().any(|d| d.reason == REASON_UNCLOSED),
}
}
fn accept(block: &ParsedBlock) -> Result<SectionId, &'static str> {
if block.version == MarkerVersion::Unsupported {
return Err(REASON_UNSUPPORTED_VERSION);
}
let section = block.section.ok_or(REASON_UNKNOWN_SECTION)?;
if block.body.is_empty() {
return Err(REASON_EMPTY_BODY);
}
Ok(section)
}
fn read_host(path: &Path) -> Option<String> {
match std::fs::read_to_string(path) {
Ok(text) => Some(text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
tracing::warn!(
path = %path.display(),
%err,
"instruction override host unreadable; using bundled sections"
);
None
}
}
}
pub fn scan_project(project_dir: &Path) -> ProjectOverrides {
let mut result = ProjectOverrides::default();
for relative in HOST_FILES {
let host = project_dir.join(relative);
let Some(text) = read_host(&host) else {
continue;
};
let (blocks, diagnostics) = parse_blocks(&text, &host);
result.diagnostics.extend(diagnostics);
for block in blocks {
let section = match accept(&block) {
Ok(section) => section,
Err(reason) => {
result
.diagnostics
.push(diagnostic(&host, block.start_line, reason));
continue;
}
};
if block.version == MarkerVersion::Absent {
result.diagnostics.push(diagnostic(
&host,
block.start_line,
REASON_MISSING_VERSION,
));
}
if let Some(claimed) = result.overrides.iter().find(|o| o.section == section) {
let reason = if claimed.host == host {
REASON_DUPLICATE
} else {
REASON_SHADOWED
};
result
.diagnostics
.push(diagnostic(&host, block.start_line, reason));
continue;
}
result.overrides.push(SectionOverride {
section,
body: block.body,
host: host.clone(),
line: block.start_line + 1,
});
}
}
result.overrides.sort_by_key(|o| o.section);
result
}
pub fn warn_unapplied(scanned: &ProjectOverrides) {
for applied in &scanned.overrides {
tracing::warn!(
path = %applied.host.display(),
section = ?applied.section,
"named-section override NOT applied: this project still uses a legacy \
.trusty-mpm/ override file, which composes the prompt without sections"
);
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Rejection {
#[error("section {section:?} is tier {tier:?} and admits no project override")]
NotOverridable {
section: SectionId,
tier: CustomizationTier,
},
#[error("section {section:?} is not declared by this package")]
UnknownSection {
section: SectionId,
},
#[error("section {section:?} has no authored text block for an override to replace")]
NoTextBlock {
section: SectionId,
},
#[error("override for section {section:?} is empty; keeping the bundled section")]
EmptyBody {
section: SectionId,
},
#[error("overriding section {section:?} would leave it able to emit nothing")]
WouldEmitNothing {
section: SectionId,
},
#[error("overriding section {section:?} invalidates the package: {error}")]
Invalidates {
section: SectionId,
error: ValidationError,
},
#[error("the overridden package failed validation ({0}); composing the bundled package")]
PackageInvalid(ValidationError),
}
fn apply_one(
package: &InstructionPackage,
over: &SectionOverride,
) -> Result<InstructionPackage, Rejection> {
let section = over.section;
let body = over.body.trim();
if body.is_empty() {
return Err(Rejection::EmptyBody { section });
}
let Some(declared) = package.section(section) else {
return Err(Rejection::UnknownSection { section });
};
if !declared.customization_tier.permits(OverrideTier::Project) {
return Err(Rejection::NotOverridable {
section,
tier: declared.customization_tier,
});
}
let mut next = package.clone();
let mut replaced = false;
let last_pinned = next
.blocks
.iter()
.rposition(|b| b.section == section && b.pinned);
next.blocks.retain_mut(|block| {
if block.section != section
|| block.body.authored(&package.sources).is_none()
|| block.pinned
{
return true;
}
if replaced {
return false;
}
block.body = BlockBody::Text {
text: body.to_string(),
};
replaced = true;
true
});
if !replaced {
let Some(at) = last_pinned else {
return Err(Rejection::NoTextBlock { section });
};
next.blocks.insert(
at + 1,
InstructionBlock {
section,
body: BlockBody::Text {
text: body.to_string(),
},
join_before: Join::Blank,
optional: false,
pinned: false,
},
);
}
if !next
.blocks
.iter()
.any(|b| b.section == section && !b.optional)
{
return Err(Rejection::WouldEmitNothing { section });
}
next.validate()
.map_err(|error| Rejection::Invalidates { section, error })?;
Ok(next)
}
impl InstructionPackage {
pub fn with_overrides(&self, overrides: &[SectionOverride]) -> (Self, Vec<Rejection>) {
let mut ordered: Vec<&SectionOverride> = overrides.iter().collect();
ordered.sort_by_key(|o| o.section);
let mut working = self.clone();
let mut rejected = Vec::new();
for over in ordered {
match apply_one(&working, over) {
Ok(next) => working = next,
Err(rejection) => rejected.push(rejection),
}
}
if let Err(error) = working.validate() {
rejected.push(Rejection::PackageInvalid(error));
return (self.clone(), rejected);
}
(working, rejected)
}
}
#[cfg(test)]
#[path = "claude_md_sections_tests.rs"]
mod tests;