diff --git a/.claudinite/local/packs/canon-curation/pack.mjs b/.claudinite/local/packs/canon-curation/pack.mjs index a8eacc98..49a265fc 100644 --- a/.claudinite/local/packs/canon-curation/pack.mjs +++ b/.claudinite/local/packs/canon-curation/pack.mjs @@ -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, @@ -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'], }; diff --git a/.claudinite/local/packs/claudinite/pack.mjs b/.claudinite/local/packs/claudinite/pack.mjs index 3d9bf37e..92809793 100644 --- a/.claudinite/local/packs/claudinite/pack.mjs +++ b/.claudinite/local/packs/claudinite/pack.mjs @@ -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], }; diff --git a/engine-tests/pack_loader/inject-pack-prose.test.mjs b/engine-tests/pack_loader/inject-pack-prose.test.mjs index 254b8b7f..8d057e8b 100644 --- a/engine-tests/pack_loader/inject-pack-prose.test.mjs +++ b/engine-tests/pack_loader/inject-pack-prose.test.mjs @@ -19,6 +19,9 @@ 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: ''` field and @@ -26,7 +29,7 @@ function makeCorpus({ packs }, root = mkdtempSync(join(tmpdir(), 'claudinite-cor 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; diff --git a/engine-tests/pack_loader/mount-skills.test.mjs b/engine-tests/pack_loader/mount-skills.test.mjs index befa652a..f46cfc89 100644 --- a/engine-tests/pack_loader/mount-skills.test.mjs +++ b/engine-tests/pack_loader/mount-skills.test.mjs @@ -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 }); diff --git a/engine-tests/pack_loader/pack-registry.test.mjs b/engine-tests/pack_loader/pack-registry.test.mjs index 980146bb..15d58bc4 100644 --- a/engine-tests/pack_loader/pack-registry.test.mjs +++ b/engine-tests/pack_loader/pack-registry.test.mjs @@ -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 }); diff --git a/engine-tests/pack_loader/pack-schema.test.mjs b/engine-tests/pack_loader/pack-schema.test.mjs new file mode 100644 index 00000000..b117fc24 --- /dev/null +++ b/engine-tests/pack_loader/pack-schema.test.mjs @@ -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), []); +}); diff --git a/engine-tests/runner.test.mjs b/engine-tests/runner.test.mjs index f0aa9686..fef87e28 100644 --- a/engine-tests/runner.test.mjs +++ b/engine-tests/runner.test.mjs @@ -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', diff --git a/engine/pack_loader/inject-pack-prose.mjs b/engine/pack_loader/inject-pack-prose.mjs index 029ea780..6fb1808e 100644 --- a/engine/pack_loader/inject-pack-prose.mjs +++ b/engine/pack_loader/inject-pack-prose.mjs @@ -25,9 +25,15 @@ 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); @@ -35,10 +41,26 @@ try { sections.push(`\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 diff --git a/engine/pack_loader/pack-registry.mjs b/engine/pack_loader/pack-registry.mjs index a6189a62..e62c698b 100644 --- a/engine/pack_loader/pack-registry.mjs +++ b/engine/pack_loader/pack-registry.mjs @@ -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 /engine/pack_loader/; the packs it scans at /packs/. const canonRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); @@ -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 /skills//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 diff --git a/engine/pack_loader/pack-schema.mjs b/engine/pack_loader/pack-schema.mjs new file mode 100644 index 00000000..3ae4208b --- /dev/null +++ b/engine/pack_loader/pack-schema.mjs @@ -0,0 +1,146 @@ +// THE PACK MANIFEST SPEC — the single declarative statement of what a +// `pack.mjs` may and must carry. Everything a pack declares about itself is +// described here once, and `validateManifest` is the only thing that judges a +// manifest against it. The loader calls it on every pack it imports (canon and a +// consumer's own `local_packs/` alike), so a malformed or incomplete manifest +// surfaces as a blocking `config` error at load — the same class as invalid JSON +// in `.claudinite-checks.json`. +// +// WHY A SPEC AND NOT A CHECK. A required manifest field is part of the pack +// contract, not a conformance opinion about a repo's content: a conformance rule +// would have to be declared BY a pack, run only when that pack is active, and +// re-derive the manifest by reading its source text — enforcing the shape of the +// system from inside one of its members. The spec is upstream of every pack, so +// there is nothing to declare and nothing to parse. +// +// Dependency-free by the engine's module rule: no imports, no filesystem. The +// caller supplies the facts from disk (the `skills/` directory listing), so this +// module is pure and testable standalone. + +// The routing budget. Both sides of `ruleRoutingGuidance` are emitted as one row +// of a table every session loads (engine/pack_loader/inject-pack-prose.mjs), so +// the cap is a context cost, not a style preference: enough for a boundary and a +// pointer to the pack that owns the other side. +export const MAX_ROUTING_WORDS = 20; + +// The two conformance scopes. A rule's scope is its PLACEMENT on the manifest — +// `worldRules` audit repo state, `workRules` judge the change and session in +// front of you — so the two runners' partition is declared where a reader of the +// pack can see it, and a rule module never restates (or contradicts) it. +export const RULE_SCOPES = { worldRules: 'world', workRules: 'work' }; + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); +const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string'); +const isRuleArray = (v) => Array.isArray(v) && v.every((x) => isPlainObject(x) && typeof x.id === 'string' && typeof x.run === 'function'); + +// Every field a manifest may carry. `required` fields must be present; the rest +// are validated only when declared. An UNDECLARED field is an error: the spec is +// the closed vocabulary of a pack, so a typo (`rule:`, `skill:`) fails loudly +// instead of being silently ignored forever. +export const PACK_FIELDS = { + id: { required: true, describe: 'the pack id, matching its directory name', valid: (v) => typeof v === 'string' && v.length > 0 }, + ruleRoutingGuidance: { required: true, describe: 'what belongs in this pack and what does not, each at most 20 words', valid: isPlainObject }, + badge: { describe: 'the pack badge filename, resolved off the pack directory', valid: (v) => typeof v === 'string' }, + detect: { describe: 'a fingerprint predicate over the repo context, or null', valid: (v) => v === null || typeof v === 'function' }, + marker: { describe: 'a human-readable glob naming what detect looks for, or null', valid: (v) => v === null || typeof v === 'string' }, + prose: { describe: 'the RULES.md filename injected at session start, or null', valid: (v) => v === null || typeof v === 'string' }, + seededByDefault: { describe: 'whether bootstrap --init seeds this pack everywhere', valid: (v) => typeof v === 'boolean' }, + requires: { describe: 'pack ids this pack depends on, resolved when the declaration is written', valid: isStringArray }, + contributes: { describe: 'rules addressed to another pack, keyed by that pack id', valid: isPlainObject }, + contributedRules: { describe: 'the seam interpreting other packs contributions to this one', valid: (v) => typeof v === 'function' }, + env: { describe: 'environment requirements the pack needs to run its checks', valid: isPlainObject }, + questions: { describe: 'the pack adoption-interview questions', valid: (v) => Array.isArray(v) }, + skills: { describe: 'the skill directory names bundled under this pack skills/', valid: isStringArray }, + worldRules: { describe: 'rules auditing repo state (check_the_world)', valid: isRuleArray }, + workRules: { describe: 'rules judging the current change and session (check_the_work)', valid: isRuleArray }, +}; + +const REQUIRED = Object.entries(PACK_FIELDS).filter(([, f]) => f.required).map(([k]) => k); + +const wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length; + +// Validate one manifest against the spec. `skillDirs` is the list of directory +// names under `/skills/` (the caller reads it; this module touches no +// disk). Returns `{ what, fix }` errors — the loader's error shape — never +// throws, so one bad manifest can't sink the run. +export function validateManifest(mod, { label, skillDirs = [] } = {}) { + const errors = []; + const at = label ? `${label}: ` : ''; + const err = (what, fix) => errors.push({ what: `${at}${what}`, fix }); + + if (!isPlainObject(mod)) { + err('the pack has no object default export', 'export default { id, ruleRoutingGuidance, ... } from its pack.mjs'); + return errors; + } + + for (const key of REQUIRED) { + if (!(key in mod)) err(`declares no "${key}"`, `add "${key}" — ${PACK_FIELDS[key].describe}`); + } + for (const [key, value] of Object.entries(mod)) { + const field = PACK_FIELDS[key]; + if (!field) { + err(`declares "${key}", which is not a pack manifest field`, `remove it, or add it to the spec in engine/pack_loader/pack-schema.mjs — the known fields are: ${Object.keys(PACK_FIELDS).join(', ')}`); + continue; + } + if (!field.valid(value)) err(`"${key}" is not a valid value`, `${key} is ${field.describe}`); + } + + if (isPlainObject(mod.ruleRoutingGuidance)) { + for (const side of ['belongs', 'excludes']) { + const v = mod.ruleRoutingGuidance[side]; + if (typeof v !== 'string' || !v.trim()) { + err(`ruleRoutingGuidance declares no "${side}"`, side === 'belongs' + ? 'state the kind of content this pack owns, in at most 20 words' + : 'state what belongs elsewhere and which pack owns it, in at most 20 words'); + continue; + } + const n = wordCount(v); + if (n > MAX_ROUTING_WORDS) { + err(`ruleRoutingGuidance.${side} is ${n} words, over the ${MAX_ROUTING_WORDS}-word cap`, + `cut it to ${MAX_ROUTING_WORDS} words — it is one row of a table every session loads`); + } + } + } + + // A rule's scope is where it is declared; the manifest is the authority. A + // rule module may still carry `scope` for the dispatch seam that reads it off + // the rule object (engine/checks/helpers/work.mjs — which hands a work rule + // its fluent surface), but then the two must AGREE: a module that says one + // thing while the list it sits in says another is drift with a wrong context + // waiting at the end of it. + for (const [key, scope] of Object.entries(RULE_SCOPES)) { + if (!isRuleArray(mod[key])) continue; + for (const rule of mod[key]) { + if ('scope' in rule && rule.scope !== scope) { + err(`the rule "${rule.id}" declares scope "${rule.scope}" but sits in ${key}`, + `move the rule to the list matching its scope, or drop its "scope" field — the manifest decides`); + } + } + } + + // The bundled-skills declaration and the tree must agree in BOTH directions: + // an undeclared directory is a skill nothing announces, and a declared name + // with no directory is a manifest that lies. + if (isStringArray(mod.skills)) { + for (const name of mod.skills) { + if (!skillDirs.includes(name)) err(`declares a skill "${name}" with no skills/${name}/ directory`, `create skills/${name}/SKILL.md, or drop the declaration`); + } + } + for (const dir of skillDirs) { + if (!(mod.skills ?? []).includes(dir)) err(`bundles skills/${dir}/ but does not declare it`, `add "${dir}" to the pack's "skills" list`); + } + + return errors; +} + +// The manifest as the rest of the engine consumes it: the two scoped rule lists +// flattened into the single `rules` array every runner already walks, each rule +// stamped with the scope its placement declared. One derivation, here — nothing +// downstream re-decides a rule's scope. +export function normalizeManifest(mod) { + const rules = []; + for (const [key, scope] of Object.entries(RULE_SCOPES)) { + for (const rule of mod[key] ?? []) rules.push({ ...rule, scope }); + } + return { ...mod, rules }; +} diff --git a/packs-tests/product-wiki/pack.test.mjs b/packs-tests/product-wiki/pack.test.mjs index 6662c863..02ed28c9 100644 --- a/packs-tests/product-wiki/pack.test.mjs +++ b/packs-tests/product-wiki/pack.test.mjs @@ -60,8 +60,8 @@ test('pack manifest: id, marker, six uniquely-named rules, the contributed isola assert.equal(pack.id, 'product-wiki'); assert.equal(pack.marker, 'product-wiki/product-requirements/README.md'); assert.equal(pack.prose, 'RULES.md'); - assert.equal(pack.rules.length, 6); - const ids = pack.rules.map((r) => r.id); + assert.equal(pack.worldRules.length, 6); + const ids = pack.worldRules.map((r) => r.id); assert.equal(new Set(ids).size, 6); assert.ok(ids.every((id) => id.startsWith('product-wiki-'))); // The isolation wall rides the barriers mechanism: declared (requires) and diff --git a/packs/README.md b/packs/README.md index 81780dd3..30c21fdc 100644 --- a/packs/README.md +++ b/packs/README.md @@ -1,6 +1,6 @@ # packs/ — the corpus content, active by declaration -Each `packs//` bundles a pack's **prose** (`RULES.md`, injected at session start when the pack is active), its **checks** (run at every Stop), and its **bundled skills** (`/skills/`, mounted at session start). **No pack is active by default** — every pack, the `basics` baseline included, activates only when declared in `.claudinite-checks.json` (bootstrap's `--init` seeds `basics` plus the fingerprinted technology packs; the nightly baselining backfills the explicit `basics` declaration into existing consumers). Discovery is structural — any `packs//pack.mjs` is a pack. Each pack's `README.md` lists its rules with a ≤5-word description and whether each is **hardcoded** (a check) or **prose**. +Each `packs//` bundles a pack's **prose** (`RULES.md`, injected at session start when the pack is active), its **checks** (run at every Stop), and its **bundled skills** (`/skills/`, mounted at session start). **No pack is active by default** — every pack, the `basics` baseline included, activates only when declared in `.claudinite-checks.json` (bootstrap's `--init` seeds `basics` plus the fingerprinted technology packs; the nightly baselining backfills the explicit `basics` declaration into existing consumers). Discovery is structural — any `packs//pack.mjs` is a pack, and that manifest is the pack's index: what it owns, the checks it runs in each scope, the skills it bundles. A pack's `README.md` is **optional** and carries only what the manifest cannot — provenance, design rationale, an index of its prose. A README that restates the manifest is duplication with a drift risk, and several had already drifted. ## Packs @@ -12,7 +12,7 @@ Each `packs//` bundles a pack's **prose** (`RULES.md`, injected at session | [grow_with_claudinite](grow_with_claudinite/README.md) | declared (seeded by `--init`, opt-out by removal) | 1 | growth member-side daily tasks (extract / dedup / pack discovery / conversation-extract) + in-session merge capture | | [tidy-repo](tidy-repo/README.md) | declared (seeded by `--init`, opt-out by removal) | 0 | policy (assess-only-vs-act) + 3 per-dimension tidy tasks (issues daily, PRs/branches weekly) | | [sheepdog](sheepdog/README.md) | declared (opt-in; the fleet-enforcer repo only) | 0 | fleet-enforcer marker + config + the agentless `fleet-census` daily task (the coverage census) | -| [github-actions](github-actions/README.md) | `.github/workflows/` | 8 | 0 | +| [github-actions](github-actions/) | `.github/workflows/` | 8 | 0 | | [chrome-extension](chrome-extension/README.md) | manifest_version manifest | 0 | 8 | | [chrome-extension-release](chrome-extension-release/README.md) | single `Release to Chrome Store` stub (opt-in) | 8 | 0 (+ RELEASE contract) | | [node](node/README.md) | root package.json | 0 | 4 | @@ -25,10 +25,10 @@ Each `packs//` bundles a pack's **prose** (`RULES.md`, injected at session | [flutter](flutter/README.md) | pubspec.yaml | 0 | 0 (stub) | | [firebase](firebase/README.md) | `firebase.json` | 2 | rules / functions / deploy discipline | | [firebase-release](firebase-release/README.md) | declared (opt-in) | 0 | dev/prod split + App Check store gating | -| [android](android/README.md) | `AndroidManifest.xml` | 0 | stub | -| [ios](ios/README.md) | `ios/Runner/Info.plist` | 0 | stub | -| [play-store-release](play-store-release/README.md) | declared (opt-in) | 0 | stub | -| [app-store-release](app-store-release/README.md) | declared (opt-in) | 0 | stub | +| [android](android/) | `AndroidManifest.xml` | 0 | stub | +| [ios](ios/) | `ios/Runner/Info.plist` | 0 | stub | +| [play-store-release](play-store-release/) | declared (opt-in) | 0 | stub | +| [app-store-release](app-store-release/) | declared (opt-in) | 0 | stub | | [research-project](research-project/README.md) | declared (class) | 0 | 56 (14 sections) | | [product-wiki](product-wiki/README.md) | declared (marker: `product-wiki/product-requirements/README.md`) | 7 | wiki growth discipline + weekly growth daily task | | [spec-driven-product](spec-driven-product/README.md) | declared (class) | 0 | 25 (8 sections) | @@ -74,9 +74,36 @@ A pack states the packs it depends on in an optional `requires` field on its `pa This is **not a check** — a pack can't be imported without its dependencies, so the resolution happens **when the declaration is written**, at bootstrap `--init` and the baselining backfill ([bootstrap.md](../bootstrap.md) Part 2): [`resolveDeclaredPacks`](../engine/pack_loader/pack-registry.mjs) pulls each declared pack's transitive `requires` closure into `.claudinite-checks.json`. The prerequisite is materialized and visible in the file — droppable like every other entry, the same reason `basics` is written explicitly rather than defaulted — rather than resolved implicitly at run time. Declared ids keep their order; each pack's pulled-in dependencies land right after it. -## Bundled skills (`/skills/`) +## The manifest spec (`pack.mjs`) -A pack's skills live in its own tree — `/skills//SKILL.md`, one owning pack per skill (#385). The directory listing IS the manifest: there is no `skills` field on `pack.mjs` and no separate skills collection to own or cross-declare. +What a `pack.mjs` may and must carry is declared once, in [`engine/pack_loader/pack-schema.mjs`](../engine/pack_loader/pack-schema.mjs), and [`validateManifest`](../engine/pack_loader/pack-schema.mjs) is the only thing that judges a manifest against it. The **loader** runs it on every pack it imports, canon and local alike, so an incomplete or malformed declaration surfaces as a blocking `config` error at load — the same class as invalid JSON in `.claudinite-checks.json`, and for the same reason: a required manifest field is part of the pack contract, not a conformance opinion about a repo's content. A conformance *check* would have to be declared by a pack, run only when that pack is active, and re-derive the manifest by reading its source text — enforcing the shape of the system from inside one of its members. + +Reporting is 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. The field vocabulary is **closed** — an undeclared key is an error, so a typo (`rule:`, `skill:`) fails loudly instead of being ignored forever. + +### `ruleRoutingGuidance` — what belongs here, and what does not + +```js +ruleRoutingGuidance: { + belongs: 'workflow YAML and Actions runner platform behaviour: triggers, secrets, permissions, scheduling, artifacts, reusable workflows and their pitfalls', + excludes: 'git and GitHub command procedure — git-github; release pipeline content for one product — its release pack', +}, +``` + +Both sides are required and each is capped at **20 words**. The cap is a session-context budget, not a style rule: [`inject-pack-prose`](../engine/pack_loader/inject-pack-prose.mjs) emits the whole set as a **routing table at session start**, one row per pack, so a session deciding where a rule, doc, skill or check goes reads the answer instead of guessing. Guess-by-default lands everything in `basics` — that is the failure this field exists to stop. + +Write `excludes` to **name the pack that owns the other side** wherever one exists (`— that is chrome-extension-release`), so the table routes rather than merely refuses. A boundary that is **true of every pack carries no routing information** and wastes the row: "anything portable belongs in the canon" is the local-pack rule restated, not this local pack's edge. State what separates a pack from its **nearest neighbours** — the packs a reader would actually confuse it with. Sibling packs that split a domain (a technology pack and its `-release` pack, `basics` and `git-github`) are where the pair earns its keep, and their two declarations should agree on where the line falls. "No pack fits" is a real answer — it means a new pack, or the project's own `local_packs/` — never the baseline as a fallback. + +The table is emitted for every pack **discovered**, active or not: a consumer holds only the packs it vendored, so the discovered set is already what it can route into, and in the canon every pack is a legitimate destination. Local packs declare it on the same terms. + +### `worldRules` / `workRules` — a rule's scope is its placement + +A pack declares its checks in two lists, and **which list a rule sits in is what makes it world- or work-scoped**: `worldRules` audit repo state ([`check_the_world`](../engine/checks/check_the_world.mjs)), `workRules` judge the change and session in front of you ([`check_the_work`](../engine/checks/check_the_work.mjs)). The loader flattens both into the single `rules` array the runners walk, stamping each rule's scope from the list it came from — one derivation, nothing downstream re-decides it. A rule module that carries its own `scope` field is a second source for the same fact, free to contradict the manifest a reader trusts, so the spec rejects it. + +A skill's own `checks.mjs` sits outside this partition (it is a skill's content, not a manifest list) and still declares `scope` on the rule itself. + +### `skills` — the bundle, declared + +A pack's skills live in its own tree — `/skills//SKILL.md`, one owning pack per skill (#385) — and the manifest **names them**: `skills: ['merge-to-main', ...]`. The spec holds the declaration and the tree to each other in **both directions**: an undeclared directory is a skill nothing announces, and a declared name with no directory is a manifest that lies. What each skill covers stays in its own `SKILL.md` frontmatter, the description the harness triggers on — the manifest carries the membership, not a second copy of the summary. The SessionStart hook [`../engine/pack_loader/mount-skills.mjs`](../engine/pack_loader/mount-skills.mjs) mounts the **union over the active packs' bundles** (same activation as prose/checks/env) as session-generated `.claude/skills/` symlinks — nothing committed, and a self-ignoring `.claude/skills/.gitignore` keeps them out of git status. A skill rides its pack everywhere the pack goes: the vendor set, the mounts, the sweep (its `checks.mjs` runs when the pack is active). The baseline activities every project has (`merge-to-main`, `writing-tests`, `bug-investigation`, …) ride the `basics` pack's bundle; move a skill's directory to a narrower pack when it stops being a baseline activity. diff --git a/packs/android/README.md b/packs/android/README.md deleted file mode 100644 index f69edae6..00000000 --- a/packs/android/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# android pack - -Active when the repo has an Android app module (`android/app/src/main/AndroidManifest.xml`). Stub — no rules captured yet; add durable, project-agnostic practices to `RULES.md` as they -are earned. Expected first source: missingbulb/ShoutsAndWhispers. diff --git a/packs/android/pack.mjs b/packs/android/pack.mjs index 4f6f52ec..156b8782 100644 --- a/packs/android/pack.mjs +++ b/packs/android/pack.mjs @@ -1,9 +1,15 @@ // Technology stub pack: Android app development (Gradle/AGP, manifests, permissions, signing, flavors). +// Stub — no rules captured yet; add durable, project-agnostic practices to RULES.md +// as they are earned. Expected first source: missingbulb/ShoutsAndWhispers. export default { id: 'android', + ruleRoutingGuidance: { + belongs: 'gradle/AGP builds, AndroidManifest, permissions, signing configs, product flavors and emulator workflows for an Android app module', + excludes: 'store submission and release cadence — play-store-release; Flutter-side widget or Dart code — flutter', + }, badge: 'badge.svg', marker: 'android/app/src/main/AndroidManifest.xml', detect: (ctx) => ctx.tracked.some((f) => f.endsWith('android/app/src/main/AndroidManifest.xml')), prose: 'RULES.md', - rules: [], + worldRules: [], }; diff --git a/packs/app-store-release/README.md b/packs/app-store-release/README.md deleted file mode 100644 index ec9eee4c..00000000 --- a/packs/app-store-release/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# app-store-release pack - -Declared when a project first ships to the App Store. Stub — no rules captured yet; add durable, project-agnostic practices to `RULES.md` as they -are earned. Expected first source: missingbulb/ShoutsAndWhispers. diff --git a/packs/app-store-release/pack.mjs b/packs/app-store-release/pack.mjs index f9d6b72a..3adabfce 100644 --- a/packs/app-store-release/pack.mjs +++ b/packs/app-store-release/pack.mjs @@ -1,10 +1,16 @@ // Opt-in release stub pack: releasing to the Apple App Store (App Store Connect, provisioning, review, TestFlight). Declared when a project first ships // there — no fingerprint until the first exercised release wires one. +// Stub — no rules captured yet; add durable, project-agnostic practices to RULES.md +// as they are earned. Expected first source: missingbulb/ShoutsAndWhispers. export default { id: 'app-store-release', + ruleRoutingGuidance: { + belongs: 'shipping to the Apple App Store: App Store Connect, provisioning, App Attest, TestFlight, review guidelines, release cadence', + excludes: 'iOS coding, Info.plist and Xcode project practices — that is ios; backend environment split — firebase-release', + }, badge: 'badge.svg', marker: null, detect: null, prose: 'RULES.md', - rules: [], + worldRules: [], }; diff --git a/packs/aws-sam/README.md b/packs/aws-sam/README.md index 7a7aedcb..aaae8e6d 100644 --- a/packs/aws-sam/README.md +++ b/packs/aws-sam/README.md @@ -2,14 +2,6 @@ Active when the repo has a SAM `template.yaml`/`.yml`. -## Checks (hardcoded) - -| Check | Enforces (≤5 words) | Severity | -|---|---|---| -| `aws-sam/esbuild-dependency` | esbuild is a regular dependency | blocking | -| `aws-sam/handler-path` | Handler drops the entry subdirectory | blocking | -| `aws-sam/cloudfront-authorization` | policy doesn't list Authorization header | blocking | - ## Prose (`RULES.md`) | Rule (≤5 words) | How enforced | @@ -18,4 +10,4 @@ Active when the repo has a SAM `template.yaml`/`.yml`. | esbuild must be a dependency | prose + check (`aws-sam/esbuild-dependency`) | | CloudFront won't forward Authorization custom-policy | prose + check (`aws-sam/cloudfront-authorization`) | -All three SAM gotchas are now enforced (the last two via the minimal YAML parser in `engine/checks/helpers/`); the pack's runtime-only gotchas would stay prose. +All three SAM gotchas are enforced (the last two via the minimal YAML parser in `engine/checks/helpers/`); the pack's runtime-only gotchas would stay prose. diff --git a/packs/aws-sam/pack.mjs b/packs/aws-sam/pack.mjs index 7c375db8..9d8c0de6 100644 --- a/packs/aws-sam/pack.mjs +++ b/packs/aws-sam/pack.mjs @@ -6,9 +6,13 @@ import cloudfrontAuthorization from './cloudfront-authorization.mjs'; // parser); the jsdom-style runtime ones stay prose in RULES.md. export default { id: 'aws-sam', + ruleRoutingGuidance: { + belongs: 'serverless AWS stacks: SAM template shape, Lambda handler paths, esbuild bundling, API Gateway and CloudFront gotchas', + excludes: 'backend Google ID token validation — google-identity; generic Node packaging habits — node', + }, badge: 'badge.svg', marker: 'a SAM template (template.yaml/.yml)', detect: (ctx) => ctx.tracked.includes('template.yaml') || ctx.tracked.includes('template.yml'), prose: 'RULES.md', - rules: [esbuildDependency, handlerPath, cloudfrontAuthorization], + worldRules: [esbuildDependency, handlerPath, cloudfrontAuthorization], }; diff --git a/packs/barriers/README.md b/packs/barriers/README.md index 712e7543..e030e768 100644 --- a/packs/barriers/README.md +++ b/packs/barriers/README.md @@ -4,12 +4,6 @@ Enforce a **directed folder-access graph** in a repo: declare that the files und Declared like any pack (no fingerprint — wanting structural segregation is the project's own call; a pack that needs it names `barriers` in its `requires`). Check-only, no prose: the finding is the instruction. -## Checks (hardcoded) - -| Check | Enforces (≤5 words) | Severity | -|---|---|---| -| `barrier` | folders honor declared access graph | blocking | - ## Declaring barriers A repo states its graph as `config.rules` on its **barriers pack entry** in `.claudinite-checks.json` — an array of rules. A rule is the unit; each rule owns its exceptions. diff --git a/packs/barriers/pack.mjs b/packs/barriers/pack.mjs index 787b3705..b4b4da55 100644 --- a/packs/barriers/pack.mjs +++ b/packs/barriers/pack.mjs @@ -16,6 +16,10 @@ import { contributedBarrierRules } from './contributed.mjs'; // running empty and guessing separations from existing state. export default { id: 'barriers', + ruleRoutingGuidance: { + belongs: 'directed folder-access graph rules — which directories may never reference which, plus the exceptions each rule allows', + excludes: 'where a file should live or naming conventions — that is basics file-placement, not an access barrier', + }, badge: 'badge.svg', detect: null, marker: null, @@ -25,7 +29,7 @@ export default { prompt: 'What should these barriers accomplish — which folders must never reference which (imports, paths, docs included), and what architectural boundary does each separation protect?', distill: 'derive the directed edge list into this entry\'s config as { "rules": [{ "from": "", "to": "" }] }; if no separation is wanted yet, record that as the answer and leave config unset', }], - rules: [barrier], + worldRules: [barrier], // The runner's generic seam: this pack interprets the barrier contributions // of every ACTIVE pack (engine/checks/check_the_world.mjs hands the list over). contributedRules: (activePacks) => contributedBarrierRules(activePacks), diff --git a/packs/basics/README.md b/packs/basics/README.md index e516f398..56537abb 100644 --- a/packs/basics/README.md +++ b/packs/basics/README.md @@ -2,23 +2,6 @@ The baseline pack — the `RULES.md` prose every session loads (injected by the pack-prose hook) plus the core checks. Declared explicitly like every other pack — no pack is active by default; bootstrap seeds the declaration and the nightly baselining backfills it into existing consumers. -## Checks (hardcoded) - -| Check | Enforces (≤5 words) | Severity | -|---|---|---| -| `reference-integrity` | links resolve; no deleted-path references | blocking | -| `markdown-link-labels` | link label matches its target | blocking | -| `task-lifecycle` | branch commits reference an issue | blocking | -| `warning-suppression` | suppressions carry a reason on-site | blocking | -| `skill-ownership` | every skill required by some pack | blocking | -| `shared-constants` | declared copies match their count | blocking | -| `squash-merge-history` | change introduces no merge commits | blocking | -| `scheduler-workflow-shape` | scheduler workflow is thin shim | blocking | -| `task-declaration-shape` | task.mjs carries full contract | blocking | -| `file-placement` | code references stay near | advisory | -| `claude-md-length` | root CLAUDE.md under ~200 lines | advisory | -| `generated-merge-driver` | GENERATED files get merge=ours | advisory | - ## Prose (`RULES.md`) | Rule (≤5 words) | How enforced | diff --git a/packs/basics/catalog-completeness.mjs b/packs/basics/catalog-completeness.mjs index ad1b730c..b919fee6 100644 --- a/packs/basics/catalog-completeness.mjs +++ b/packs/basics/catalog-completeness.mjs @@ -7,10 +7,11 @@ import { finding } from '../../engine/checks/helpers/findings.mjs'; // Skills have no catalog of their own (#385): a skill is its owning pack's // content, listed nowhere but its pack. // -// The membership token matches the catalog's own listing convention: every -// pack row links `/README.md`. +// The membership token is the pack DIRECTORY, not a README inside it: a pack +// whose whole README was the checks-and-skills table the manifest now carries +// has no README at all, and the catalog still has to list it. const CATALOGS = [ - { kind: 'pack', member: /^packs\/([^/]+)\/pack\.mjs$/, readme: 'packs/README.md', token: (n) => `${n}/README.md` }, + { kind: 'pack', member: /^packs\/([^/]+)\/pack\.mjs$/, readme: 'packs/README.md', token: (n) => `(${n}/` }, ]; const rule = { diff --git a/packs/basics/pack.mjs b/packs/basics/pack.mjs index 2bd7b468..946ad1cd 100644 --- a/packs/basics/pack.mjs +++ b/packs/basics/pack.mjs @@ -20,6 +20,10 @@ import taskDeclarationShape from './task-declaration-shape.mjs'; // authoritative — dropping it is a deliberate choice). export default { id: 'basics', + ruleRoutingGuidance: { + belongs: 'cross-project working discipline, issue-branch-PR lifecycle, repo hygiene, doc/reference integrity and the baseline engineering, testing and debugging skills', + excludes: 'technology-specific content — its own tech pack; GitHub Actions workflow or platform behaviour — github-actions; git procedure — git-github', + }, badge: 'badge.svg', detect: null, marker: null, @@ -33,14 +37,11 @@ export default { // requires closure materializes both declarations alongside it. requires: ['barriers', 'git-github'], contributes: { barriers: [claudiniteIsolation] }, - rules: [ - commentClassification, - referenceIntegrity, + // Rules that audit the repo as it stands, whatever this session did. + worldRules: [ markdownLinkLabels, - taskLifecycle, warningSuppression, filePlacement, - squashMergeHistory, claudeMdLength, generatedMergeDriver, sharedConstants, @@ -53,15 +54,32 @@ export default { schedulerWorkflowShape, taskDeclarationShape, ], + // Rules that judge the change and the session in front of you — the branch's + // commits, the diff, the conversation. + workRules: [ + commentClassification, + referenceIntegrity, + taskLifecycle, + squashMergeHistory, + ], // The baseline skills — general engineering practice every project's work - // can call for, whatever its technology — are bundled under skills/ in this - // pack's own tree and mounted wherever basics is declared (which --init - // seeds everywhere). The directory listing IS the manifest; when one stops - // being a baseline activity, move its directory to the pack whose projects - // need it (#385 moved the git/GitHub and Claudinite-lifecycle skills out). + // can call for, whatever its technology — bundled under skills/ in this pack's + // own tree and mounted wherever basics is declared (which --init seeds + // everywhere). When one stops being a baseline activity, move its directory to + // the pack whose projects need it and move this line with it (#385 moved the + // git/GitHub and Claudinite-lifecycle skills out). // // The baseline scheduled task every repo runs — baselining, the per-repo // self-refresh — lives in this pack's `tasks/baselining/`, discovered by the // scheduler's filesystem scan (engine/scheduler/discover.mjs), not declared // here. Being in basics — declared everywhere — makes it universal. + skills: [ + 'authoring-agent-docs', + 'bug-investigation', + 'bump-version', + 'engineering-practices', + 'file-placement', + 'repo-text-sweeps', + 'writing-tests', + ], }; diff --git a/packs/chrome-extension-release/README.md b/packs/chrome-extension-release/README.md index 266dfc0c..b7beeb61 100644 --- a/packs/chrome-extension-release/README.md +++ b/packs/chrome-extension-release/README.md @@ -1,20 +1,5 @@ # chrome-extension-release pack -The release & Chrome-Web-Store publication standard for our extensions — the reusable workflows' contract, the setup steps, the manual store actions (`RELEASE.md`), the **vendored release set** (`stubs/workflows/` + `stubs/actions/`, materialized into each consumer's own `.github/`), and the conformance checks. **Opt-in**: a project declares it in `.claudinite-checks.json` when it's ready to ship (a `manifest.json` alone does not pull it in). Declaring it is the cue to vendor the release machinery — the migration apply pass materializes the set (the `chrome-release-vendoring` migration), the checks below keep it in shape, and setup opens the one-time first-publication issue. GitHub only resolves a reusable workflow / composite action from a repo's own `.github/`, so the pack holds the templates and each consumer hosts a managed copy — no cross-repo `@main` dependency. +The release & Chrome-Web-Store publication standard for our extensions — the reusable workflows' contract, the setup steps, the manual store actions (`RELEASE.md`), the **vendored release set** (`stubs/workflows/` + `stubs/actions/`, materialized into each consumer's own `.github/`), and the conformance checks. **Opt-in**: a project declares it in `.claudinite-checks.json` when it's ready to ship (a `manifest.json` alone does not pull it in). Declaring it is the cue to vendor the release machinery — the migration apply pass materializes the set (the `chrome-release-vendoring` migration), the pack's checks keep it in shape, and setup opens the one-time first-publication issue. GitHub only resolves a reusable workflow / composite action from a repo's own `.github/`, so the pack holds the templates and each consumer hosts a managed copy — no cross-repo `@main` dependency. Fingerprint: a repo already carrying the standard's `Release to Chrome Store` orchestrator (a workflow with that name — or a legacy pre-rename name like `Release` — that wires the create-package reusable, whether via the vendored local `./.github/workflows/chrome-extension-create-package.yml` or the pre-vendoring canon call `@main`). `--init` uses it to seed the pack into a fresh declaration (including a repo that shipped release before this pack existed); the marker only *suspects* the pack, so it never forces or forbids the declaration afterward. - -## Checks - -| Check | Enforces (≤5 words) | Severity | -|---|---|---| -| `cer/release-workflows` | vendored set present: orchestrator name/schedule + local reusables + actions | blocking | -| `cer/template-tokens` | no unreplaced `__TOKEN__` survives | blocking | -| `cer/release-config` | `.github/release.config` present with all 5 required keys, no unknowns | blocking | -| `cer/version-sync` | manifest and package.json versions agree | blocking | -| `cer/release-layout` | privacy policy source present | blocking | -| `cer/privacy-permission-alignment` | every permission disclosed in PRIVACY.md (test the world) | blocking | -| `cer/permission-added-store-issue` | added permission → open store-dashboard issue (test the work) | advisory | -| `cer/readme-sections` | README has Install + Releasing | blocking | - -`RELEASE.md` is the full contract (setup and the manual store steps); it is read on demand, not loaded every session. Most of its invariants are enforced by the checks above. diff --git a/packs/chrome-extension-release/pack.mjs b/packs/chrome-extension-release/pack.mjs index 7e414560..161444d9 100644 --- a/packs/chrome-extension-release/pack.mjs +++ b/packs/chrome-extension-release/pack.mjs @@ -40,6 +40,10 @@ function shipsReleasePipeline(ctx) { export default { id: 'chrome-extension-release', + ruleRoutingGuidance: { + belongs: 'store publication for a Chrome extension: release workflows, package versioning, release config, privacy and permission disclosure', + excludes: 'extension coding and MV3 runtime gotchas — chrome-extension; generic workflow lint rules — github-actions', + }, badge: 'badge.svg', marker: 'the "Release to Chrome Store" orchestrator (wires the vendored reusable workflows + composite actions)', detect: shipsReleasePipeline, @@ -50,16 +54,17 @@ export default { // and from findings), not always-on prose: it is long, and only the checks // need to be eager. prose: null, - rules: [ + worldRules: [ releaseWorkflows, templateTokens, releaseConfig, versionSync, releaseLayout, privacyPermissionAlignment, - permissionAddedStoreIssue, readmeSections, ], + // Judges the change: a permission added in THIS diff needs its store issue. + workRules: [permissionAddedStoreIssue], // Pack-contributed scheduled task: `tasks/store-release/` — the scheduler's // filesystem scan (engine/scheduler/discover.mjs) picks it up only on repos that // declare chrome-extension-release, so it is not declared here. diff --git a/packs/chrome-extension/README.md b/packs/chrome-extension/README.md index cb1f981b..b599e7cf 100644 --- a/packs/chrome-extension/README.md +++ b/packs/chrome-extension/README.md @@ -4,12 +4,6 @@ Active when a `manifest.json` declares `manifest_version` — the MV3 build/runt Releasing and Chrome-Web-Store publication are a separate, opt-in concern: the [`chrome-extension-release`](../chrome-extension-release/README.md) pack (its `RELEASE.md` standard + conformance checks), declared when the project is ready to ship. -## Checks (hardcoded) - -| Check | Enforces (≤5 words) | Severity | -|---|---|---| -| `declarative-content-set-icon` | SetIcon supplies imageData, not path | blocking | - ## Prose gotchas (`RULES.md`) | Rule (≤5 words) | How enforced | diff --git a/packs/chrome-extension/pack.mjs b/packs/chrome-extension/pack.mjs index 38474a8b..cf553538 100644 --- a/packs/chrome-extension/pack.mjs +++ b/packs/chrome-extension/pack.mjs @@ -9,9 +9,13 @@ import declarativeContentSetIcon from './declarative-content-set-icon.mjs'; // with a static signature in the source convert to checks here. export default { id: 'chrome-extension', + ruleRoutingGuidance: { + belongs: 'manifest V3 service-worker, permissions, content-script and extension-auth gotchas that apply while coding a Chrome extension', + excludes: 'store submission, packaging, versioning and privacy disclosure — that is chrome-extension-release', + }, badge: 'badge.svg', marker: 'a manifest.json declaring manifest_version', detect: (ctx) => findExtensionManifest(ctx) !== null, prose: 'RULES.md', - rules: [declarativeContentSetIcon], + worldRules: [declarativeContentSetIcon], }; diff --git a/packs/executable-requirements/pack.mjs b/packs/executable-requirements/pack.mjs index d9f42711..3e8b2fc2 100644 --- a/packs/executable-requirements/pack.mjs +++ b/packs/executable-requirements/pack.mjs @@ -8,6 +8,10 @@ import featureRequirementsFirst from './feature-requirements-first.mjs'; // structural constant: the spec file itself. export default { id: 'executable-requirements', + ruleRoutingGuidance: { + belongs: 'running a numbered spec as tests: dev/requirements layout, requirement ids, kinds, coverage and gallery gates, determinism rules', + excludes: 'doc-first judgment, owner-owned expecteds and honest-gap tracking — spec-driven-product; general test practice — basics writing-tests', + }, badge: 'badge.svg', marker: 'dev/requirements/requirements.md', detect: (ctx) => ctx.tracked.includes('dev/requirements/requirements.md'), @@ -29,5 +33,5 @@ export default { distill: "record the source; set config.spec to the executable spec's home (default dev/requirements/requirements.md) and extract the initial requirements from the named source", }, ], - rules: [featureRequirementsFirst], + workRules: [featureRequirementsFirst], }; diff --git a/packs/firebase-release/pack.mjs b/packs/firebase-release/pack.mjs index 0479cd73..17caf541 100644 --- a/packs/firebase-release/pack.mjs +++ b/packs/firebase-release/pack.mjs @@ -5,11 +5,15 @@ // its workflow stubs. export default { id: 'firebase-release', + ruleRoutingGuidance: { + belongs: 'dev/prod Firebase project split, committed-default-dev discipline, pipeline-injected prod config, App Check attestation gating, promotion cadence', + excludes: 'everyday Firestore rules, function patterns and deploy layout — firebase; app store submission — play-store-release, app-store-release', + }, badge: 'badge.svg', marker: null, detect: null, // The release standard builds on the Firebase coding/deploy pack. requires: ['firebase'], prose: 'RULES.md', - rules: [], + worldRules: [], }; diff --git a/packs/firebase/README.md b/packs/firebase/README.md index 9d00388c..0db7066a 100644 --- a/packs/firebase/README.md +++ b/packs/firebase/README.md @@ -10,16 +10,6 @@ missingbulb/ShoutsAndWhispers (Firestore + Functions + FCM + Google sign-in). En separation and store gating are deliberately NOT here — that is the opt-in [firebase-release](../firebase-release/README.md) pack. -## Checks - -Both are relevance-first: they say nothing until `firebase.json` declares a functions codebase -whose `package.json` is in the checkout, so a rules-only or hosting-only Firebase repo is untouched. - -| Rule (≤5 words) | Severity | What | -|---|---|---| -| Functions pin their Node major | blocking | a deployed codebase's `package.json` declares `engines.node` | -| Build script wired as predeploy | blocking | a codebase with a `build` script declares a `predeploy` hook in `firebase.json` | - ## Prose (`RULES.md`) — by section | Section (≤5 words) | How enforced | @@ -27,4 +17,4 @@ whose `package.json` is in the checkout, so a rules-only or hosting-only Firebas | Rules are merge-aware, default-deny | prose (+ the project's rules tests) | | Functions own identity and limits | prose | | Test logic pure, rules empirically | prose (+ the project's suites) | -| Deploy layout and aliases | prose + the two checks above | +| Deploy layout and aliases | prose + the pack's two checks | diff --git a/packs/firebase/pack.mjs b/packs/firebase/pack.mjs index 2a59ef3f..0d30bc76 100644 --- a/packs/firebase/pack.mjs +++ b/packs/firebase/pack.mjs @@ -16,6 +16,10 @@ const hasMarkerNearRoot = (ctx, marker) => export default { id: 'firebase', + ruleRoutingGuidance: { + belongs: 'building on Firebase: Firestore security rules, callable Cloud Function patterns, FCM, emulator testing, functions deploy layout', + excludes: 'dev/prod project separation, config injection and App Check store gating — that is firebase-release', + }, badge: 'badge.svg', marker: 'firebase.json (at the repo root or one directory down)', detect: (ctx) => hasMarkerNearRoot(ctx, 'firebase.json'), @@ -24,5 +28,5 @@ export default { // until the repo carries a firebase.json declaring a functions codebase whose // package.json is in this checkout — so a rules-only or hosting-only Firebase // repo never hears from them. - rules: [functionsNodePin, functionsPredeployBuild], + worldRules: [functionsNodePin, functionsPredeployBuild], }; diff --git a/packs/flutter/pack.mjs b/packs/flutter/pack.mjs index 951b66a5..dd33038d 100644 --- a/packs/flutter/pack.mjs +++ b/packs/flutter/pack.mjs @@ -10,11 +10,15 @@ const hasMarkerNearRoot = (ctx, marker) => export default { id: 'flutter', + ruleRoutingGuidance: { + belongs: 'widget-tree architecture, ports and fakes, widget-test and golden mechanics, pub and analyze toolchain habits for Flutter', + excludes: 'native Android or iOS module concerns — android and ios; store shipping — play-store-release, app-store-release', + }, badge: 'badge.svg', marker: 'pubspec.yaml (at the repo root or one directory down)', detect: (ctx) => hasMarkerNearRoot(ctx, 'pubspec.yaml'), prose: 'RULES.md', - rules: [], + worldRules: [], // The Flutter SDK isn't in the Claude Code Web base image, so a cloud session // can't run `flutter test` / analyze / golden regen without it. This declares // how the environment installs it (aggregated into environment-setup-command.sh) and diff --git a/packs/git-github/pack.mjs b/packs/git-github/pack.mjs index e04fdd3b..30ecf7b6 100644 --- a/packs/git-github/pack.mjs +++ b/packs/git-github/pack.mjs @@ -6,9 +6,17 @@ // every declaration — never seeded directly (#385). export default { id: 'git-github', + ruleRoutingGuidance: { + belongs: 'git and GitHub procedure: commit layering, branch and merge mechanics, squash-merge recovery, PR and merge-to-main commands', + excludes: 'the issue-branch-PR lifecycle rules themselves — basics; workflow YAML and Actions runtime behaviour — github-actions', + }, badge: 'badge.svg', detect: null, marker: null, prose: null, - rules: [], + worldRules: [], + skills: [ + 'git-github-advanced', + 'merge-to-main', + ], }; diff --git a/packs/github-actions/README.md b/packs/github-actions/README.md deleted file mode 100644 index ab59750f..00000000 --- a/packs/github-actions/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# github-actions pack - -Active when the repo has `.github/workflows/`. Workflow lints only — no prose. - -## Checks (hardcoded) - -| Check | Enforces (≤5 words) | Severity | -|---|---|---| -| `gha/secrets-in-job-if` | no secrets in job-level if | blocking | -| `gha/run-pipefail` | piped run steps set pipefail | blocking | -| `gha/checkout-submodules` | checkout fetches submodules when present | blocking | -| `gha/scheduled-failure-escalation` | scheduled workflow escalates its failure | advisory | -| `gha/label-create-before-add` | create a label before adding | advisory | -| `gha/unique-automation-branch` | automated branch names are unique | advisory | -| `gha/pages-artifact-symlinks` | Pages upload prunes tooling symlinks | blocking | -| `gha/no-scheduled-fleet-executor` | Claudinite executor stays dispatch-only | blocking | diff --git a/packs/github-actions/pack.mjs b/packs/github-actions/pack.mjs index 3acd2e27..1695da5f 100644 --- a/packs/github-actions/pack.mjs +++ b/packs/github-actions/pack.mjs @@ -9,11 +9,15 @@ import noScheduledFleetExecutor from './no-scheduled-fleet-executor.mjs'; export default { id: 'github-actions', + ruleRoutingGuidance: { + belongs: 'workflow YAML and Actions runner platform behaviour: triggers, secrets, permissions, scheduling, artifacts, reusable workflows and their pitfalls', + excludes: 'git and GitHub command procedure — git-github; release pipeline content for one product — its release pack', + }, badge: 'badge.svg', marker: '.github/workflows/*.ya?ml', detect: (ctx) => ctx.tracked.some((f) => /^\.github\/workflows\/.+\.ya?ml$/.test(f)), prose: null, - rules: [ + worldRules: [ secretsInJobIf, runPipefail, checkoutSubmodules, @@ -23,4 +27,5 @@ export default { pagesArtifactSymlinks, noScheduledFleetExecutor, ], + skills: ['github-actions-scheduling'], }; diff --git a/packs/github-actions/skills/github-actions-scheduling/SKILL.md b/packs/github-actions/skills/github-actions-scheduling/SKILL.md new file mode 100644 index 00000000..db2a2353 --- /dev/null +++ b/packs/github-actions/skills/github-actions-scheduling/SKILL.md @@ -0,0 +1,48 @@ +--- +name: github-actions-scheduling +description: What a GitHub Actions `schedule:` trigger actually guarantees — late fires, dropped fires, the 60-day disable — and how to build and describe scheduled work around it. Use when adding, changing, explaining, or debugging anything that runs on a cron in GitHub Actions. +--- + +# GitHub Actions cron is best-effort + +GitHub's `schedule:` trigger is a **request to queue**, not a promise to run. A cron'd workflow +routinely fires minutes to tens of minutes late, and under load GitHub **drops** a firing +outright — the run never happens, with no failure, no notification, and nothing in the run +ledger to look at. The effect is worst at `:00` and other round minutes, where everyone's cron +lands at once. GitHub's docs concede only that a schedule "may be delayed during periods of high +loads"; the behaviour is measured across a fleet in +[Upptime's write-up](https://upptime.js.org/blog/2021/01/22/github-actions-schedule-not-working/). +Same family, different mechanism: GitHub **disables** a repo's scheduled workflows entirely +after 60 days without repository activity. + +## Building for it + +- **Never let correctness depend on a firing happening, or on when it happened.** Derive what is + due from durable state the run can read — the Actions run ledger, a timestamp in the repo, the + world the job inspects — never from "this fired, so it must be time". Wall-clock equality with + the cron minute is not a test any real run passes. +- **Idempotent and self-catching.** A scheduled job does whatever is outstanding *now*, so a + missed firing costs latency rather than data. Per-tick bookkeeping — counters, "the last hour's + changes", a queue advanced one step per run — loses information the first time a tick vanishes. + If a job cannot catch up, say so where it is declared and design the miss as an accepted loss. +- **Catch up the most recent slot only.** A job that backfills every missed slot turns an outage + into a storm on recovery; one catch-up evaluation per frequency is the shape that survives a + multi-day gap. +- **Pick a minute off `:00`.** Anywhere in `:10–:50` dodges the stampede and stays clear of the + hour boundary any slot math anchors on; across a fleet, hash the repo name into that band so + members spread rather than collide. +- **Deadline-bound work does not belong on a cron.** If something must happen *at* a time, drive + it from the event, not an hourly poll that may skip. + +## Talking about it + +- **Say "about hourly, best-effort", never "hourly".** Stating an interval the platform does not + honour turns ordinary jitter into a bug report. +- **A late or missing run is not a defect until proven one.** First question: did GitHub fire at + all — check the workflow's run list, not the job's logic. Then: has the repo been quiet for 60 + days? Only after both does the code become a suspect. A single missed slot is expected + behaviour. + +Claudinite's own scheduler is built to this: a repo-hashed `:10–:50` minute, due-slot math +against the Actions run ledger rather than the clock, and most-recent-slot-only catch-up — see +[scheduled-tasks.md](../../../basics/scheduled-tasks.md) for the task-authoring side. diff --git a/packs/google-identity/pack.mjs b/packs/google-identity/pack.mjs index 28478c06..66831157 100644 --- a/packs/google-identity/pack.mjs +++ b/packs/google-identity/pack.mjs @@ -12,8 +12,13 @@ // chrome-extension pack's turf; this pack owns the *validator* side. export default { id: 'google-identity', + ruleRoutingGuidance: { + belongs: 'server-side Google Sign-In ID token validation: audience pinning, issuer and email_verified checks, JWT authorizer/OIDC verifier config', + excludes: 'obtaining the token in a browser or extension client — chrome-extension; Firebase Auth usage — firebase', + }, badge: 'badge.svg', marker: 'a backend that validates Google Sign-In ID tokens (a JWT authorizer / OIDC verifier with the Google accounts issuer)', detect: null, - rules: [], + worldRules: [], + skills: ['google-id-token-validation'], }; diff --git a/packs/grow_with_claudinite/pack.mjs b/packs/grow_with_claudinite/pack.mjs index d74742cd..f26bebdf 100644 --- a/packs/grow_with_claudinite/pack.mjs +++ b/packs/grow_with_claudinite/pack.mjs @@ -45,10 +45,22 @@ import dedupIntegrity from './dedup-integrity.mjs'; // active sets retention_days itself. export default { id: 'grow_with_claudinite', + ruleRoutingGuidance: { + belongs: 'rules and tasks for capturing lessons into local packs — extraction, dedup, conversation logs, skill-usage folding', + excludes: 'repo housekeeping of issues, PRs and branches — that is tidy-repo; cross-repo fleet sweeps are sheepdog', + }, badge: 'badge.svg', detect: null, marker: null, seededByDefault: true, prose: null, - rules: [growthConfig, dedupIntegrity], + worldRules: [growthConfig], + workRules: [dedupIntegrity], + skills: [ + 'adopt-claudinite', + 'adopt-pack', + 'generate-project-instructions', + 'prose-to-checks', + 'unattended-agents', + ], }; diff --git a/packs/html/pack.mjs b/packs/html/pack.mjs index 6b6ca48a..cb51e40a 100644 --- a/packs/html/pack.mjs +++ b/packs/html/pack.mjs @@ -2,9 +2,13 @@ // authoritative (detect: null skips the drift check in both directions). export default { id: 'html', + ruleRoutingGuidance: { + belongs: 'hand-authored HTML markup gotchas — element nesting, injected content placement, live browser verification of a page', + excludes: 'javascript runtime APIs — web-speech; map widgets — leaflet; npm and dependency policy — node', + }, badge: 'badge.svg', marker: null, detect: null, prose: 'RULES.md', - rules: [], + worldRules: [], }; diff --git a/packs/ios/README.md b/packs/ios/README.md deleted file mode 100644 index eac13756..00000000 --- a/packs/ios/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# ios pack - -Active when the repo has an iOS app target (`ios/Runner/Info.plist`). Stub — no rules captured yet; add durable, project-agnostic practices to `RULES.md` as they -are earned. Expected first source: missingbulb/ShoutsAndWhispers. diff --git a/packs/ios/pack.mjs b/packs/ios/pack.mjs index 407d752a..8e2cab07 100644 --- a/packs/ios/pack.mjs +++ b/packs/ios/pack.mjs @@ -1,9 +1,15 @@ // Technology stub pack: iOS app development (Xcode project, Info.plist usage strings, entitlements, signing). +// Stub — no rules captured yet; add durable, project-agnostic practices to RULES.md +// as they are earned. Expected first source: missingbulb/ShoutsAndWhispers. export default { id: 'ios', + ruleRoutingGuidance: { + belongs: 'app-target conventions for iOS — Xcode project, Info.plist usage strings, entitlements, code signing', + excludes: 'shipping builds to the App Store — that is app-store-release; Android equivalents are android', + }, badge: 'badge.svg', marker: 'ios/Runner/Info.plist', detect: (ctx) => ctx.tracked.some((f) => f.endsWith('ios/Runner/Info.plist')), prose: 'RULES.md', - rules: [], + worldRules: [], }; diff --git a/packs/leaflet/README.md b/packs/leaflet/README.md index f9f30ea3..5c5e4939 100644 --- a/packs/leaflet/README.md +++ b/packs/leaflet/README.md @@ -2,14 +2,6 @@ Active when the repo references [Leaflet](https://leafletjs.com/) — a CDN asset (`leaflet@` / `leaflet.js` / `leaflet.css`) in HTML, or an `L.map` / `L.tileLayer` / `L.markerClusterGroup` call in JS/TS source. Mostly prose (these are runtime behaviours with no clean static signature); the CDN wiring of the assets themselves is written into the HTML, so it is a check. -## Checks - -Relevance-first: only a `