Documentation
¶
Overview ¶
Package rulesfs carries small, tool-agnostic helpers this adapter needs that previously lived in root-module adapters (internal/adapter/toolkit, internal/adapter/xdgconfig). The engine module must not import the root module (the fstools precedent, #269), so the EXACT bodies are carried here and pinned byte-identical to their origins. Keep them in sync with the originals; do not diverge behaviour.
Index ¶
- Constants
- Variables
- func Discover(dir string) ([]Discovered, []SkipError, error)
- func IndexClosingDelim(s string) int
- func NewFSSource(ctx context.Context, sources ...RuleSource) (*FSSource, []SkipError, error)
- func SplitFrontmatter(s string) (fm, body string, ok bool)
- func TruncateRunes(s string, maxBytes int) string
- func UserConfigDir(env ResolveEnv) string
- type DirSource
- type Discovered
- type FSSource
- type MultiSource
- type ResolveEnv
- type ResolveOptions
- type Rule
- type RuleSource
- type SkipError
Constants ¶
const ( // ProjectDirMecatl is the project-level rules dir under the workspace. ProjectDirMecatl = ".mecatl/rules" // ProjectDirClaude is the Claude-Code-compatible project-level rules dir. ProjectDirClaude = ".claude/rules" )
Conventional rule sub-paths (Claude-Code-style "extra paths"). A rule is laid out as <conventional-dir>/<name>.md.
const RuleFileExt = ".md"
RuleFileExt is the conventional extension of a rule file. A rule lives at <dir>/<name>.md (a FLAT file, not a <name>/RULE.md subdir), matching Claude Code's .claude/rules/ layout — the agentfs flat-file twin, not the skills subdir shape.
Variables ¶
var OSEnv = ResolveEnv{Getenv: os.Getenv, UserHomeDir: os.UserHomeDir}
OSEnv binds a resolver to the real process environment + filesystem.
mirrors internal/adapter/xdgconfig.OSEnv EXACTLY (minus ReadFile) — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).
Functions ¶
func Discover ¶
func Discover(dir string) ([]Discovered, []SkipError, error)
Discover scans dir for rules and returns them. It is a thin convenience wrapper over DirSource for callers (and tests) that want single-directory discovery without composing a Source.
func IndexClosingDelim ¶
IndexClosingDelim returns the byte offset, within s, of the start of the first line that is exactly "---" (the closing frontmatter delimiter), or -1 if none.
mirrors internal/adapter/toolkit.IndexClosingDelim EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).
func NewFSSource ¶
NewFSSource resolves the given sources ONCE (highest-precedence first, the NewMultiSource collision rule) and returns the snapshot source plus the aggregated discovery diagnostics. A genuine discovery fault returns a non-nil error (with the diagnostics gathered so far); an absent dir is simply "no rules".
func SplitFrontmatter ¶
SplitFrontmatter separates a leading YAML frontmatter block, delimited by a line containing only "---" at the very start and a matching closing "---" line, from the markdown body that follows. It returns the frontmatter text (without the delimiters), the body, and whether a well-formed frontmatter block was found. A leading UTF-8 BOM is tolerated and CRLF line endings are normalised so the delimiter match is line-ending agnostic. It is the single source of truth for the agents/skills frontmatter parsers.
mirrors internal/adapter/toolkit.SplitFrontmatter EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).
func TruncateRunes ¶
TruncateRunes trims s to at most maxBytes on a rune boundary and appends a single-character ellipsis ("…"). Unlike Truncate (which appends a verbose, byte-count marker for tool output), this is the compact form used to cap always-in-context metadata such as agent/skill descriptions and bodies. When s already fits within maxBytes it is returned unchanged.
mirrors internal/adapter/toolkit.TruncateRunes EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269). The rune-boundary check is stdlib utf8.RuneStart (byte-identical to the old carried UTF8RuneStart helper, deleted per the #328 panel review).
func UserConfigDir ¶
func UserConfigDir(env ResolveEnv) string
UserConfigDir returns the XDG config base for a user-level config location: the value of $XDG_CONFIG_HOME when set, else ~/.config. It returns "" when neither can be resolved (the caller then skips the user-level source). This preserves the exact semantics the four adapters shared before extraction.
mirrors internal/adapter/xdgconfig.UserConfigDir EXACTLY — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).
Types ¶
type DirSource ¶
type DirSource struct {
// Dir is the directory to scan. An empty or absent Dir yields no rules.
Dir string
// Label is an optional human-readable name for this source (e.g.
// "project(.mecatl)", "user(xdg)"), surfaced in diagnostics (it prefixes
// each Discovered entry's Detail). It does not affect discovery.
Label string
// Tier is the admission tier stamped onto every rule this source produces
// (prompt.Rule.Origin on the port). ResolveSources sets it per conventional
// location; a zero Tier defaults to prompt.RuleOriginUser (a
// hand-constructed source is a personal location).
Tier prompt.RuleOrigin
}
DirSource is the local-OS-filesystem implementation of RuleSource: it produces the rules laid out as <Dir>/<name>.md (flat files) under a single directory.
func (DirSource) Rules ¶
Rules implements RuleSource for a single local directory. It scans Dir for flat <name>.md files, parses each one's optional YAML frontmatter and markdown body, and returns the valid rules sorted by name.
It is forgiving by design: an empty or missing Dir yields no rules and no error; a malformed-frontmatter file is SKIPPED and reported via the returned []SkipError rather than aborting the scan. It returns a non-nil error only for a genuine I/O fault reading the directory itself.
A rule's name is the FILENAME stem (NOT a frontmatter `name`, which Claude Code's rules do not use — one is ignored if present). Duplicate stems WITHIN this directory are resolved keep-first in sorted-path order. Cross-source collisions are resolved one level up by MultiSource.
Every kept rule is stamped with this source's admission tier (Origin) and carried with its adapter-private locator (Detail = "<label>: <path>") — SkipError diagnostics keep the verbatim path.
type Discovered ¶
Discovered is one discovered rule together with its adapter-private locator string. The PORT value object (prompt.Rule) carries no path/dir/root concept; Detail is the NON-PORT diagnostics channel a reviewer traces a rule back through — "<label>: <path>" for a filesystem source, "driver: <target>" for a remote driver. It never crosses the prompt.RulesSource port.
type FSSource ¶
type FSSource struct {
// contains filtered or unexported fields
}
FSSource is the FILESYSTEM implementation of the prompt.RulesSource port: a snapshot of the rules discovered from the composed Source list (the conventional project + user locations). The port carries no path/dir/root concept; the locator business the composition layer's diagnostics still need is exposed as adapter-public NON-PORT methods (Discovered/Detail), mirroring the agentfs FSSource split.
SNAPSHOT SEMANTICS: discovery runs ONCE at construction (NewMultiSource over the given sources) and the rules are retained in memory, so ListRules is stable for the life of the source — the build-once trust-gate invariant (an untrusted workspace's project tier is gated at SOURCE CONSTRUCTION, in ResolveSources).
func (*FSSource) Detail ¶
Detail returns the named rule's adapter-private locator ("<label>: <path>") and whether the snapshot holds one. NON-PORT, diagnostics only.
func (*FSSource) Discovered ¶
func (s *FSSource) Discovered() []Discovered
Discovered returns the name-sorted Discovered snapshot (rule + adapter-private Detail). Adapter-public, NON-PORT: it backs the composition layer's diagnostics, never the port.
type MultiSource ¶
type MultiSource struct {
// contains filtered or unexported fields
}
MultiSource composes an ORDERED list of Sources into one, with a defined precedence on name collisions and aggregated diagnostics.
PRECEDENCE (collision rule): EARLIER sources win. When two sources both produce a rule with the same effective name, the one from the earlier source is kept and the later one is SHADOWED — dropped, with a SkipError notice. Callers order the slice highest-precedence-first; ResolveSources builds it as project > user.
A fatal error from ANY source is returned immediately (with the diagnostics gathered so far). Output is sorted by name for deterministic, cache-stable ordering.
func NewMultiSource ¶
func NewMultiSource(sources ...RuleSource) MultiSource
NewMultiSource builds a MultiSource over the given ordered sources (highest-precedence first). nil entries are ignored so callers can assemble the slice conditionally without sprinkling nil checks.
func (MultiSource) Rules ¶
func (m MultiSource) Rules(ctx context.Context) ([]Discovered, []SkipError, error)
Rules aggregates every composed source, applies the earlier-wins precedence on name collisions, and returns the merged rules sorted by name. Shadowed lower-precedence rules are dropped and reported.
type ResolveEnv ¶
ResolveEnv abstracts the process environment so a resolver is testable with a fake home / XDG, without touching the real one. The composition root binds OSEnv (the real os funcs); tests pass a fake.
trimmed to the fields this adapter uses; the root xdgconfig.ResolveEnv also carries ReadFile for permconfig/soul.
mirrors internal/adapter/xdgconfig.ResolveEnv EXACTLY (minus ReadFile) — keep byte-identical; carried because engine must not import the root module (fstools precedent, #269).
type ResolveOptions ¶
type ResolveOptions struct {
// Conventional, when true, admits the built-in conventional project- and
// user-level locations. Always true in production; the field exists so a
// test can resolve a single lane (or none) in isolation.
Conventional bool
// Workspace is the session workspace root used to resolve the project-level
// conventional paths. Only consulted when Conventional is true and non-empty.
Workspace string
// IncludeProjectTier, when true, admits the PROJECT-tier conventional locations
// (<workspace>/.mecatl/rules, <workspace>/.claude/rules). The composition layer
// sets it false when the workspace is UNTRUSTED (the Workspace-Trust fold) so
// a cloned repo's project rules cannot steer the model before the operator
// trusts it; the user-tier sources stay active regardless. It gates ONLY the
// project tier. Mirrors agentfs.ResolveOptions.IncludeProjectTier.
//
// ZERO-VALUE NOTE: false by default; callers set it explicitly. Composition
// uses the folded trust bool. Only Conventional==true makes the project tier
// eligible.
IncludeProjectTier bool
}
ResolveOptions configures the known-path resolver. There is NO explicit tier (issue #329 operator amendment: conventional discovery is the ONLY admission path, always-on in production). Conventional exists so tests can disable the lanes; the zero value resolves NOTHING.
type Rule ¶
Rule is the pure value object for one rule, re-aliased from engine/prompt so the adapter and the port speak one type (the agentfs.AgentDef precedent).
type RuleSource ¶
type RuleSource interface {
Rules(ctx context.Context) ([]Discovered, []SkipError, error)
}
RuleSource is the pluggable EXTENSIBILITY POINT for where rules come from. A Source produces a set of Discovered entries (the Rule value object + its adapter-private Detail) together with non-fatal diagnostics (SkipError), and a fatal error only for a genuine infrastructure fault that prevented the source from being consulted at all.
It is the verbatim shape of agentfs.AgentSource: the local-OS-filesystem layout (DirSource) is just ONE implementation; an embedded default set or a remote registry would satisfy the same interface and slot in via MultiSource without touching the consumer or the Rule value object.
Rules(ctx) returns:
- the discovered rules (deterministically ordered by the implementation),
- the non-fatal diagnostics (skipped/duplicate/truncated/shadowed entries),
- a non-nil error ONLY for a hard fault. An absent source (e.g. a missing directory) is "no rules", not an error.
func ResolveSources ¶
func ResolveSources(opts ResolveOptions) []RuleSource
ResolveSources builds the ORDERED, highest-precedence-first Source list from the conventional locations, ready to hand to NewMultiSource. The precedence is:
project: <workspace>/.mecatl/rules, <workspace>/.claude/rules [highest]
> user: $XDG_CONFIG_HOME/mecatl/rules (or ~/.config/mecatl/rules),
~/.claude/rules [lowest]
so a project rule overrides a personal one of the same name. Each location becomes a labelled DirSource; missing directories are harmless. When Conventional is false NO sources are included (the test-isolation lane).
type SkipError ¶
type SkipError struct {
// Path is the <name>.md the problem was found at.
Path string
// Reason is a short, human-readable description of the problem.
Reason string
// Fatal reports whether the rule was DROPPED (true) or KEPT-but-ADJUSTED
// (false, the zero value). See the type doc-comment.
Fatal bool
}
SkipError records one diagnostic from discovery. It carries a structural two-way split via Fatal — NOT a string-matched one — so the composition root can word the log honestly instead of overloading "skipped":
- Fatal == true: the rule was DROPPED (excluded from the set). Causes: it could not be read, its frontmatter was malformed, a duplicate name within a directory, or it was SHADOWED by a higher-precedence source.
- Fatal == false (the zero value): the rule was KEPT but ADJUSTED — a non-fatal modification was applied (its body was truncated to the cap). The rule is still in the set.
Discovery keeps scanning rather than aborting and returns the collected diagnostics so the composition root can surface them. A SkipError is never returned as a Source's fatal error.