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
7 changes: 6 additions & 1 deletion .claudinite/local/packs/canon-curation/pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import packIndependence from './pack-independence.mjs';
// without touching the members' side (grow_with_claudinite).
export default {
id: 'canon-curation',
ruleRoutingGuidance: {
belongs: 'fleet-facing curation of the shared corpus — promoting member lessons into packs/, sweeping the fleet stack, policing packs/',
excludes: 'working rules for developing Claudinite itself — that is the claudinite local pack; a member tidying itself — tidy-repo',
},
badge: 'badge.svg',
detect: null,
marker: null,
Expand All @@ -34,7 +38,8 @@ export default {
contributes: { barriers: [packIndependence] },
// The prose-narration rule polices pack prose CONTENT (not segregation), so
// it stays a code check, bundled here.
rules: [noEnforcementNarration],
worldRules: [noEnforcementNarration],
// writing-claudinite-skills is canon-home activity (authoring corpus skills), so
// this pack bundles it under its own skills/ — members author no corpus skills.
skills: ['writing-claudinite-skills'],
};
6 changes: 5 additions & 1 deletion .claudinite/local/packs/claudinite/pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import homeSeededPacksDeclared from './home-seeded-packs-declared.mjs';
// id must equal its directory name ("claudinite") and may not shadow a canon pack.
export default {
id: 'claudinite',
ruleRoutingGuidance: {
belongs: 'working rules and lessons specific to developing Claudinite itself and not portable to any consumer',
excludes: 'fleet-facing curation duties and policing of the packs/ tree — that is the canon-curation local pack',
},
badge: 'badge.svg',
prose: 'RULES.md',
rules: [homeSeededPacksDeclared],
worldRules: [homeSeededPacksDeclared],
};
5 changes: 4 additions & 1 deletion engine-tests/pack_loader/inject-pack-prose.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ function makeCorpus({ packs }, root = mkdtempSync(join(tmpdir(), 'claudinite-cor
mkdirSync(join(root, 'packs'), { recursive: true });
mkdirSync(join(root, 'engine', 'pack_loader'), { recursive: true });
copyFileSync(join(REPO_ROOT, 'engine', 'pack_loader', 'pack-registry.mjs'), join(root, 'engine', 'pack_loader', 'pack-registry.mjs'));
// The registry validates every manifest against the spec, so the fake corpus
// needs the spec module too — it is part of the loader, not an optional extra.
copyFileSync(join(REPO_ROOT, 'engine', 'pack_loader', 'pack-schema.mjs'), join(root, 'engine', 'pack_loader', 'pack-schema.mjs'));
copyFileSync(join(REPO_ROOT, 'engine', 'pack_loader', 'inject-pack-prose.mjs'), join(root, 'engine', 'pack_loader', 'inject-pack-prose.mjs'));
for (const [id, manifest] of Object.entries(packs)) {
// The def IS the pack.mjs manifest (an optional `prose: '<file>'` field and
// whatever else); each test writes the prose file's content itself.
mkdirSync(join(root, 'packs', id), { recursive: true });
writeFileSync(
join(root, 'packs', id, 'pack.mjs'),
`export default ${JSON.stringify({ id, detect: null, rules: [], ...manifest })};\n`
`export default ${JSON.stringify({ id, detect: null, worldRules: [], ruleRoutingGuidance: { belongs: `whatever ${id} owns`, excludes: `whatever ${id} does not own` }, ...manifest })};\n`
);
}
return root;
Expand Down
5 changes: 4 additions & 1 deletion engine-tests/pack_loader/mount-skills.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ function makeCorpus({ packs }, root = mkdtempSync(join(tmpdir(), 'claudinite-cor
mkdirSync(join(root, 'engine', 'pack_loader'), { recursive: true });
mkdirSync(join(root, 'engine', 'pack_loader'), { recursive: true });
copyFileSync(join(REPO_ROOT, 'engine', 'pack_loader', 'pack-registry.mjs'), join(root, 'engine', 'pack_loader', 'pack-registry.mjs'));
// The registry validates every manifest against the spec, so the fake corpus
// needs the spec module too — it is part of the loader, not an optional extra.
copyFileSync(join(REPO_ROOT, 'engine', 'pack_loader', 'pack-schema.mjs'), join(root, 'engine', 'pack_loader', 'pack-schema.mjs'));
copyFileSync(join(REPO_ROOT, 'engine', 'pack_loader', 'mount-skills.mjs'), join(root, 'engine', 'pack_loader', 'mount-skills.mjs'));
for (const [id, def] of Object.entries(packs)) {
const { skills = [], ...manifest } = def;
mkdirSync(join(root, 'packs', id), { recursive: true });
writeFileSync(
join(root, 'packs', id, 'pack.mjs'),
`export default ${JSON.stringify({ id, detect: null, rules: [], ...manifest })};\n`
`export default ${JSON.stringify({ id, detect: null, worldRules: [], ruleRoutingGuidance: { belongs: `whatever ${id} owns`, excludes: `whatever ${id} does not own` }, ...manifest })};\n`
);
for (const name of skills) {
mkdirSync(join(root, 'packs', id, 'skills', name), { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion engine-tests/pack_loader/pack-registry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ test('discoverPacks: with no localRoot, finds only the canon packs (all non-loca

test('discoverPacks: finds a consumer local pack, stamped local with its own dir', async () => {
const root = makeLocalRoot({
proj: `export default { id: 'proj', prose: 'RULES.md', rules: [], skills: [] };`,
proj: `export default { id: 'proj', prose: 'RULES.md', worldRules: [], ruleRoutingGuidance: { belongs: 'this demo local pack', excludes: 'anything a canon pack owns' } };`,
});
try {
const { packs, errors } = await discoverPacks({ localRoot: root });
Expand Down
89 changes: 89 additions & 0 deletions engine-tests/pack_loader/pack-schema.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validateManifest, normalizeManifest, PACK_FIELDS, RULE_SCOPES, MAX_ROUTING_WORDS } from '../../engine/pack_loader/pack-schema.mjs';
import { loadPacks } from '../../engine/pack_loader/pack-registry.mjs';

const rule = (id) => ({ id, severity: 'blocking', description: 'd', doc: 'x.md', why: 'w', run: () => [] });

const valid = {
id: 'demo',
ruleRoutingGuidance: {
belongs: 'map rendering with the Leaflet library — tile layers, markers, CDN plugin pinning',
excludes: 'generic HTML markup rules — that is html; dependency policy belongs to node',
},
};

const whats = (mod, opts) => validateManifest(mod, opts).map((e) => e.what).join(' | ');

test('a manifest carrying the required fields validates', () => {
assert.deepEqual(validateManifest(valid), []);
});

test('a missing required field is an error naming it', () => {
const { ruleRoutingGuidance, ...without } = valid;
assert.match(whats(without), /declares no "ruleRoutingGuidance"/);
const { id, ...noId } = valid;
assert.match(whats(noId), /declares no "id"/);
});

test('a non-object default export is an error, not a throw', () => {
assert.match(whats(null), /has no object default export/);
assert.match(whats('nope'), /has no object default export/);
});

test('an undeclared field is an error — the vocabulary is closed', () => {
assert.match(whats({ ...valid, skill: ['typo'] }), /declares "skill", which is not a pack manifest field/);
});

test('a declared field of the wrong type is an error', () => {
assert.match(whats({ ...valid, requires: 'barriers' }), /"requires" is not a valid value/);
assert.match(whats({ ...valid, detect: 'yes' }), /"detect" is not a valid value/);
assert.match(whats({ ...valid, worldRules: [{ id: 'x' }] }), /"worldRules" is not a valid value/);
});

test('both routing sides are required and capped', () => {
assert.match(whats({ ...valid, ruleRoutingGuidance: { belongs: 'a b c' } }), /ruleRoutingGuidance declares no "excludes"/);
assert.match(whats({ ...valid, ruleRoutingGuidance: { belongs: ' ', excludes: 'x' } }), /ruleRoutingGuidance declares no "belongs"/);
const over = Array.from({ length: MAX_ROUTING_WORDS + 2 }, (_, i) => `w${i}`).join(' ');
assert.match(
whats({ ...valid, ruleRoutingGuidance: { ...valid.ruleRoutingGuidance, belongs: over } }),
new RegExp(`ruleRoutingGuidance\\.belongs is ${MAX_ROUTING_WORDS + 2} words, over the ${MAX_ROUTING_WORDS}-word cap`)
);
});

test('a rule whose own scope contradicts its placement is drift', () => {
const scoped = { ...rule('r'), scope: 'world' };
assert.match(whats({ ...valid, workRules: [scoped] }), /the rule "r" declares scope "world" but sits in workRules/);
// Agreeing, or staying silent and taking the stamp, are both fine.
assert.deepEqual(validateManifest({ ...valid, workRules: [{ ...rule('r'), scope: 'work' }] }), []);
assert.deepEqual(validateManifest({ ...valid, workRules: [rule('r')] }), []);
});

test('the skills declaration and the tree must agree in both directions', () => {
assert.match(whats({ ...valid, skills: ['ghost'] }, { skillDirs: [] }), /declares a skill "ghost" with no skills\/ghost\/ directory/);
assert.match(whats(valid, { skillDirs: ['orphan'] }), /bundles skills\/orphan\/ but does not declare it/);
assert.deepEqual(validateManifest({ ...valid, skills: ['s'] }, { skillDirs: ['s'] }), []);
});

test('the label prefixes every error so the loader can name the pack', () => {
const [first] = validateManifest({}, { label: 'the pack in packs/x' });
assert.match(first.what, /^the pack in packs\/x: /);
});

test('normalizeManifest flattens the two scoped lists and stamps each rule', () => {
const flat = normalizeManifest({ ...valid, worldRules: [rule('a')], workRules: [rule('b')] });
assert.deepEqual(flat.rules.map((r) => [r.id, r.scope]), [['a', 'world'], ['b', 'work']]);
assert.deepEqual(normalizeManifest(valid).rules, []);
});

test('the scope vocabulary is exactly the two manifest lists', () => {
assert.deepEqual(RULE_SCOPES, { worldRules: 'world', workRules: 'work' });
for (const key of Object.keys(RULE_SCOPES)) assert.ok(PACK_FIELDS[key], `${key} is not in the spec`);
});

test('real corpus: every pack satisfies the spec it is loaded through', async () => {
const errors = [];
const packs = await loadPacks({ localRoot: process.cwd(), onError: (e) => errors.push(e) });
assert.ok(packs.length > 0, 'no packs discovered');
assert.deepEqual(errors.map((e) => e.what), []);
});
8 changes: 6 additions & 2 deletions engine-tests/runner.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,12 @@ test('a skill-owned check rides its owning pack\'s activation, and is listed', (
// A pack.mjs whose one rule fires on a marker file, dependency-free (a real
// local pack's checks can't import the gitignored mount's helpers).
const LOCAL_PACK = `export default {
id: 'proj', prose: 'RULES.md', skills: [],
rules: [{
id: 'proj', prose: 'RULES.md',
ruleRoutingGuidance: {
belongs: 'this demo project pack, whose one rule fires on a marker file',
excludes: 'anything portable to another repo — that is a canon pack',
},
worldRules: [{
id: 'no-todo-marker', severity: 'blocking',
description: 'no TODO_MARKER files', doc: '.claudinite/local_packs/proj/RULES.md',
why: 'demo local check',
Expand Down
34 changes: 28 additions & 6 deletions engine/pack_loader/inject-pack-prose.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,42 @@ try {
// system rather than an explicit @import.
const packs = await loadPacks({ localRoot: projectRoot });

// Nothing active means this repo runs no Claudinite: no prose, and no routing
// table either. The hook stays silent rather than pushing a catalog of the
// corpus into a session that declared none of it.
const active = packs.filter((pack) => isActive(pack, { packs: declared }));
if (!active.length) process.exit(0);

const sections = [];
for (const pack of packs) {
if (!pack.prose || !isActive(pack, { packs: declared })) continue;
for (const pack of active) {
if (!pack.prose) continue;
// Resolve prose off the pack's OWN directory (canon or local_packs), not a
// single shared root — so a local pack's RULES.md is found where it lives.
const prosePath = join(pack.dir, pack.prose);
if (!existsSync(prosePath)) continue;
sections.push(`<!-- pack:${pack.id} -->\n${readFileSync(prosePath, 'utf8').trim()}`);
}

if (sections.length) {
process.stdout.write(
`# Claudinite — active-pack guidance\n\nThe baseline plus the packs this project declares. Deeper per-pack reference (e.g. a pack's release doc) is linked from its prose and read on demand.\n\n${sections.join('\n\n---\n\n')}\n`
);
// The routing table: every pack's own statement of its boundary, so a session
// holding a piece of content (a doc, a rule, a skill) routes it to the pack
// that owns it instead of defaulting into the baseline. Emitted for every pack
// DISCOVERED, not only the active ones — a consumer holds just the packs it
// vendored, so the discovered set is already the set it can route into, and in
// the canon every pack is a legitimate destination whether or not this repo
// declares it. Rows are short by contract (the manifest spec caps each side at
// 20 words — pack-schema.mjs), so the whole table stays a cheap session cost.
const routed = packs.filter((p) => p.ruleRoutingGuidance?.belongs && p.ruleRoutingGuidance?.excludes);
const routingTable = routed.length
? `# Claudinite — where content goes (pack routing)\n\nEach pack states what it owns and what it does not. When a rule, doc, skill or check could live in more than one, this table decides it — and "no pack fits" means a new pack or the project's own \`local_packs/\`, never the baseline by default.\n\n| Pack | Belongs | Does not belong |\n|---|---|---|\n${routed
.map((p) => `| \`${p.id}\`${p.local ? ' (local)' : ''} | ${p.ruleRoutingGuidance.belongs} | ${p.ruleRoutingGuidance.excludes} |`)
.join('\n')}\n`
: '';

if (sections.length || routingTable) {
const guidance = sections.length
? `# Claudinite — active-pack guidance\n\nThe baseline plus the packs this project declares. Deeper per-pack reference (e.g. a pack's release doc) is linked from its prose and read on demand.\n\n${sections.join('\n\n---\n\n')}\n`
: '';
process.stdout.write([guidance, routingTable].filter(Boolean).join('\n---\n\n'));
}
} catch {
// fail soft — a broken loader must never block a session
Expand Down
24 changes: 23 additions & 1 deletion engine/pack_loader/pack-registry.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readdirSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { validateManifest, normalizeManifest } from './pack-schema.mjs';

// This module lives at <canon>/engine/pack_loader/; the packs it scans at <canon>/packs/.
const canonRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
Expand Down Expand Up @@ -140,13 +141,34 @@ async function scanPackDir(dir, { local, subdir }, errors) {
// import needed (the pack owns the interview HYGIENE check; the engine owns
// the manifest-shape validation).
for (const e of packQuestions(mod).errors) errors.push({ ...e, dir: packDir });
const pack = { ...mod, dir: packDir, local };
// The rest of the manifest against the spec (pack-schema.mjs). REPORTED, not
// fatal: a pack whose declaration is incomplete still loads and still runs
// its checks — silently disabling a repo's own rules is a worse failure than
// the one being reported, and the blocking config error is what gets it fixed.
for (const e of validateManifest(mod, { label: `the pack in ${rel}`, skillDirs: skillDirNames(packDir) })) {
errors.push({ ...e, dir: packDir });
}
const pack = { ...normalizeManifest(mod), dir: packDir, local };
pack.skillChecks = await scanSkillChecks(packDir, errors);
out.push(pack);
}
return out;
}

// The skill directory names a pack bundles — the tree side of the manifest's
// `skills` declaration, which the spec holds to it in both directions. Absent or
// unreadable skills/ reads as none: scanSkillChecks reports the unreadable case,
// and the spec must not turn one broken directory into a wall of phantom findings.
function skillDirNames(packDir) {
const skillsRoot = join(packDir, 'skills');
if (!existsSync(skillsRoot)) return [];
try {
return readdirSync(skillsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
} catch {
return [];
}
}

// A pack's skill-owned checks: any <pack>/skills/<skill>/checks.mjs (default
// export = an array of rules). Isolated per import; run gated by the owning
// pack being active, exactly like the pack's own rules — a skill is pack
Expand Down
Loading
Loading