feat(acr,yaml): v5.6 Foundation — load-pattern enumeration + block-seq parser
Foundation chunk of v5.6 "steering-model II" (internal plumbing for B/C;
no command-output change, so --json/--raw/SC-5/6/7 stay byte-stable, count
stays 13).
active-config-reader.mjs:
- deriveLoadPattern(kind,{scoped}) — pure helper mapping each source kind to
loadPattern {always,on-demand,external} + survivesCompaction {yes,no,n/a}
+ derivationConfidence {confirmed,inferred}, traced to the published
loading model (V-rows in docs/v5.5-steering-model-plan.md).
- enumerateRules / enumerateAgents / enumerateOutputStyles — the three
source kinds previously unenumerated (mirror enumerateSkills). Output-style
discovery is direct (not a new file-discovery type) to keep the discovery
surface stable.
- readActiveConfig now exposes rules/agents/outputStyles arrays + totals
counts/subtotals (folded into grandTotal).
yaml-parser.mjs:
- parseSimpleYaml now reads YAML block sequences (paths:\n - a), not just
inline paths:. An empty-valued key with no `- ` items stays null
(backcompat). Resolves a pre-existing RUL false-positive (a block-seq-scoped
rule was misread as unscoped) — fix flows through unchanged RUL code.
Tests +35 (961 -> 996): block-seq parser cases, RUL block-seq regression
(no-misflag + durability-fires), deriveLoadPattern table, three enumerators
(positive+negative). Amended two existing ACR asserts (top-level key shape +
grandTotal sum). self-audit A/A, readmeCheck passed, mismatches []. tests
badge 961+->996+; README testing prose de-staled (635/36 -> 996/56);
CLAUDE.md Foundation note.
B (manifest/tokens render + snapshot regen) and C (CA-OST, count->14)
deferred to their own sessions/GO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
parent
d03c3831bd
commit
62d910ed6d
8 changed files with 575 additions and 25 deletions
|
|
@ -53,6 +53,56 @@ export function estimateTokens(bytes, kind = 'markdown', opts = {}) {
|
|||
return Math.ceil(bytes / 4);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Load-pattern model (v5.6 Foundation)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive how a config source loads into context and whether it survives a
|
||||
* `/compact`, from the published Claude Code loading model. Deterministic,
|
||||
* side-effect-free. `derivationConfidence` is 'confirmed' when a primary-doc
|
||||
* row nails the row (V-rows in docs/v5.5-steering-model-plan.md), 'inferred'
|
||||
* when reasoned from an analogue (so a renderer can choose to mark it).
|
||||
*
|
||||
* loadPattern ∈ { 'always', 'on-demand', 'external', 'unknown' }
|
||||
* survivesCompaction ∈ { 'yes', 'no', 'n/a' }
|
||||
*
|
||||
* @param {string} kind - source kind (see switch)
|
||||
* @param {{scoped?: boolean}} [opts] - kind-specific discriminators
|
||||
* @returns {{loadPattern:string, survivesCompaction:string, derivationConfidence:string}}
|
||||
*/
|
||||
export function deriveLoadPattern(kind, opts = {}) {
|
||||
const mk = (loadPattern, survivesCompaction, derivationConfidence) =>
|
||||
({ loadPattern, survivesCompaction, derivationConfidence });
|
||||
switch (kind) {
|
||||
// CLAUDE.md cascade
|
||||
case 'claude-md-root': return mk('always', 'yes', 'confirmed'); // V1
|
||||
case 'claude-md-nested': return mk('on-demand', 'no', 'confirmed'); // V3
|
||||
case 'claude-md-user':
|
||||
case 'claude-md-managed':
|
||||
case 'claude-md-import': return mk('always', 'yes', 'inferred');
|
||||
// Rules
|
||||
case 'rule':
|
||||
return opts.scoped
|
||||
? mk('on-demand', 'no', 'confirmed') // V2, V4 (loads on Read of a match)
|
||||
: mk('always', 'yes', 'confirmed'); // V1, V6 (unscoped = always-on)
|
||||
// Skills
|
||||
case 'skill-listing': return mk('always', 'n/a', 'confirmed'); // V7 (name+desc every turn)
|
||||
case 'skill-body': return mk('on-demand', 'n/a', 'confirmed'); // V7 (body on invoke)
|
||||
// Agents — name+description load for delegation each turn (skill analogue;
|
||||
// no primary-doc row pins it, so 'inferred').
|
||||
case 'agent': return mk('always', 'n/a', 'inferred');
|
||||
// Output styles modify the system prompt, re-sent every turn (V10, V12).
|
||||
case 'output-style': return mk('always', 'yes', 'confirmed');
|
||||
// Hooks run outside context (V18); the hook itself is external.
|
||||
case 'hook': return mk('external', 'n/a', 'confirmed');
|
||||
// MCP tool schemas are part of the per-turn payload (no explicit
|
||||
// compaction-survival row → 'inferred').
|
||||
case 'mcp': return mk('always', 'yes', 'inferred');
|
||||
default: return mk('unknown', 'n/a', 'inferred');
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Git root detection
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -499,6 +549,126 @@ export async function enumerateSkills(pluginList = []) {
|
|||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rules, agents, output styles (v5.6 Foundation enumeration)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the project/user/plugin directory list for a per-kind enumerator.
|
||||
* Project + user dirs live under `.claude/<dir>`; plugins under each of the
|
||||
* given subpaths relative to the plugin root.
|
||||
*/
|
||||
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const dirs = [{ dir: join(repoPath, '.claude', subdir), source: 'project', pluginName: null }];
|
||||
if (home) dirs.push({ dir: join(home, '.claude', subdir), source: 'user', pluginName: null });
|
||||
for (const p of pluginList) {
|
||||
for (const sub of pluginSubdirs) {
|
||||
dirs.push({ dir: join(p.path, sub), source: 'plugin', pluginName: p.name });
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate rule files: `<repo>/.claude/rules/`, `~/.claude/rules/`, and each
|
||||
* plugin's `rules/` + `.claude/rules/`. A rule is path-scoped when its
|
||||
* frontmatter declares `paths:` (the only documented scoping field, V5) — which
|
||||
* determines its load pattern (scoped = on-demand, unscoped = always, V1/V2/V6).
|
||||
*
|
||||
* @param {string} repoPath
|
||||
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, scoped:boolean, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||
*/
|
||||
export async function enumerateRules(repoPath, pluginList = []) {
|
||||
const out = [];
|
||||
const dirs = configDirs(repoPath, pluginList, 'rules', ['rules', join('.claude', 'rules')]);
|
||||
for (const { dir, source, pluginName } of dirs) {
|
||||
const files = await listMarkdownFiles(dir);
|
||||
for (const f of files) {
|
||||
let scoped = false;
|
||||
try {
|
||||
const content = await readFile(f.path, 'utf-8');
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
scoped = !!(frontmatter && frontmatter.paths);
|
||||
} catch { /* unreadable → treat as unscoped */ }
|
||||
out.push({
|
||||
name: basename(f.path),
|
||||
source,
|
||||
pluginName,
|
||||
path: f.path,
|
||||
scoped,
|
||||
bytes: f.size,
|
||||
estimatedTokens: estimateTokens(f.size, 'markdown'),
|
||||
...deriveLoadPattern('rule', { scoped }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate agent definitions: `<repo>/.claude/agents/`, `~/.claude/agents/`,
|
||||
* and each plugin's `agents/`. Only name+description load for delegation each
|
||||
* turn, so cost is estimated like other frontmatter-only sources.
|
||||
*
|
||||
* @param {string} repoPath
|
||||
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||
*/
|
||||
export async function enumerateAgents(repoPath, pluginList = []) {
|
||||
const out = [];
|
||||
const lp = deriveLoadPattern('agent');
|
||||
const dirs = configDirs(repoPath, pluginList, 'agents');
|
||||
for (const { dir, source, pluginName } of dirs) {
|
||||
const files = await listMarkdownFiles(dir);
|
||||
for (const f of files) {
|
||||
out.push({
|
||||
name: basename(f.path).replace(/\.md$/, ''),
|
||||
source,
|
||||
pluginName,
|
||||
path: f.path,
|
||||
bytes: f.size,
|
||||
estimatedTokens: estimateTokens(f.size, 'frontmatter'),
|
||||
...lp,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate output styles: `<repo>/.claude/output-styles/`,
|
||||
* `~/.claude/output-styles/`, and each plugin's `output-styles/`. An output
|
||||
* style modifies the system prompt and is re-sent every turn (V10, V12).
|
||||
* Foundation only enumerates them; the `keep-coding-instructions` /
|
||||
* `force-for-plugin` checks are the CA-OST scanner (v5.6 C).
|
||||
*
|
||||
* @param {string} repoPath
|
||||
* @param {Array<{name:string, path:string}>} [pluginList]
|
||||
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
|
||||
*/
|
||||
export async function enumerateOutputStyles(repoPath, pluginList = []) {
|
||||
const out = [];
|
||||
const lp = deriveLoadPattern('output-style');
|
||||
const dirs = configDirs(repoPath, pluginList, 'output-styles');
|
||||
for (const { dir, source, pluginName } of dirs) {
|
||||
const files = await listMarkdownFiles(dir);
|
||||
for (const f of files) {
|
||||
out.push({
|
||||
name: basename(f.path).replace(/\.md$/, ''),
|
||||
source,
|
||||
pluginName,
|
||||
path: f.path,
|
||||
bytes: f.size,
|
||||
estimatedTokens: estimateTokens(f.size, 'markdown'),
|
||||
...lp,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Hooks (user + project + plugin)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -841,11 +1011,14 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
readSettingsCascade(absRepoPath),
|
||||
]);
|
||||
|
||||
// Skills depend on plugins
|
||||
const [skills, hooks, mcpServers] = await Promise.all([
|
||||
// Skills, hooks, MCP, and the v5.6 enumerations all depend on plugins
|
||||
const [skills, hooks, mcpServers, rules, agents, outputStyles] = await Promise.all([
|
||||
enumerateSkills(plugins),
|
||||
readActiveHooks(absRepoPath, plugins),
|
||||
readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins),
|
||||
enumerateRules(absRepoPath, plugins),
|
||||
enumerateAgents(absRepoPath, plugins),
|
||||
enumerateOutputStyles(absRepoPath, plugins),
|
||||
]);
|
||||
|
||||
// Totals
|
||||
|
|
@ -854,6 +1027,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
skills: skills.length,
|
||||
mcpServers: mcpServers.length,
|
||||
hooks: hooks.length,
|
||||
rules: rules.length,
|
||||
agents: agents.length,
|
||||
outputStyles: outputStyles.length,
|
||||
claudeMdFiles: claudeMd.files.length,
|
||||
estimatedTokens: {
|
||||
claudeMd: claudeMd.estimatedTokens,
|
||||
|
|
@ -861,6 +1037,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
skills: skills.reduce((s, k) => s + k.estimatedTokens, 0),
|
||||
mcpServers: mcpServers.reduce((s, m) => s + m.estimatedTokens, 0),
|
||||
hooks: hooks.reduce((s, h) => s + h.estimatedTokens, 0),
|
||||
rules: rules.reduce((s, r) => s + r.estimatedTokens, 0),
|
||||
agents: agents.reduce((s, a) => s + a.estimatedTokens, 0),
|
||||
outputStyles: outputStyles.reduce((s, o) => s + o.estimatedTokens, 0),
|
||||
grandTotal: 0,
|
||||
},
|
||||
};
|
||||
|
|
@ -869,7 +1048,10 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
totals.estimatedTokens.plugins +
|
||||
totals.estimatedTokens.skills +
|
||||
totals.estimatedTokens.mcpServers +
|
||||
totals.estimatedTokens.hooks;
|
||||
totals.estimatedTokens.hooks +
|
||||
totals.estimatedTokens.rules +
|
||||
totals.estimatedTokens.agents +
|
||||
totals.estimatedTokens.outputStyles;
|
||||
|
||||
const warnings = [];
|
||||
|
||||
|
|
@ -898,6 +1080,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
|
|||
skills,
|
||||
mcpServers,
|
||||
hooks,
|
||||
rules,
|
||||
agents,
|
||||
outputStyles,
|
||||
settings: { cascade: settingsCascade },
|
||||
totals,
|
||||
suggestDisables,
|
||||
|
|
|
|||
|
|
@ -34,17 +34,51 @@ export function parseSimpleYaml(yaml) {
|
|||
let currentKey = null;
|
||||
let multiLineValue = '';
|
||||
let inMultiLine = false;
|
||||
// Block-sequence state: an empty-valued key may be a YAML block sequence
|
||||
// key:
|
||||
// - item
|
||||
// - item
|
||||
// We defer the decision until the next line: a `- item` line starts an array;
|
||||
// anything else (or end-of-input) leaves the empty-valued key as `null` —
|
||||
// indistinguishable from a plain null value, preserving backwards compatibility.
|
||||
let seqKey = null; // key awaiting/collecting block-sequence items
|
||||
let seqItems = null; // null until the first `- ` item is seen
|
||||
let seqPending = false; // an empty-valued key was seen; items may follow
|
||||
|
||||
// Commit the current block-sequence (or empty-valued key) to result.
|
||||
const commitSeq = () => {
|
||||
if (seqKey !== null) {
|
||||
result[normalizeKey(seqKey)] = seqItems !== null ? seqItems : null;
|
||||
}
|
||||
seqKey = null;
|
||||
seqItems = null;
|
||||
seqPending = false;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
// Skip comments and empty lines (these do NOT terminate a block sequence)
|
||||
if (line.trim().startsWith('#') || line.trim() === '') {
|
||||
if (inMultiLine) multiLineValue += '\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block-sequence item: only while a sequence is pending or active
|
||||
const seqMatch = (seqPending || seqItems !== null) && !inMultiLine
|
||||
? line.match(/^\s+-\s*(.*)$/)
|
||||
: null;
|
||||
if (seqMatch) {
|
||||
if (seqItems === null) seqItems = [];
|
||||
const itemVal = parseValue(seqMatch[1].trim());
|
||||
if (itemVal !== null && itemVal !== '') seqItems.push(itemVal);
|
||||
seqPending = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair
|
||||
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
||||
if (kvMatch && !inMultiLine) {
|
||||
// A new key terminates any pending/active block sequence.
|
||||
commitSeq();
|
||||
if (currentKey && multiLineValue) {
|
||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||
}
|
||||
|
|
@ -58,6 +92,15 @@ export function parseSimpleYaml(yaml) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
// Defer: this may be a block sequence (next line) or a null value.
|
||||
seqKey = currentKey;
|
||||
seqItems = null;
|
||||
seqPending = true;
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[normalizeKey(currentKey)] = parseValue(value);
|
||||
currentKey = null;
|
||||
continue;
|
||||
|
|
@ -79,6 +122,13 @@ export function parseSimpleYaml(yaml) {
|
|||
currentKey = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A non-item, non-key line while a sequence is pending/active terminates it
|
||||
// (then the line is ignored, as before the fix).
|
||||
if (seqPending || seqItems !== null) {
|
||||
commitSeq();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +136,8 @@ export function parseSimpleYaml(yaml) {
|
|||
if (inMultiLine && currentKey) {
|
||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||
}
|
||||
// Flush a trailing block sequence / empty-valued key
|
||||
commitSeq();
|
||||
|
||||
// Normalize arrays for known list fields
|
||||
for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue