diff --git a/docs/modules.md b/docs/modules.md index 9137ed8..bc37b5e 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -27,7 +27,7 @@ of eight official modules and never automatically activates external discoveries "id": "project-notes", "version": "1.0.0", "description": "A small notebook", - "requires": { "memory": "1.1.0" }, + "requires": { "memory": "^1.1.0" }, "config": { "title": "Project notes" }, "skills": [{ "name": "project-notes", "source": "skills/project-notes" }], "files": [{ "source": "templates/notes.md", "target": "{{TASKS_DIR}}/notes/README.md", "editable": true }], @@ -37,8 +37,9 @@ of eight official modules and never automatically activates external discoveries ``` Unknown fields are rejected. IDs use lowercase letters, digits and single hyphens; `core` is reserved. -Versions use `major.minor.patch`. Dependencies accept an exact version or `*`; version ranges are -not implemented. Missing dependencies, cycles, duplicate IDs and incompatible versions fail before +Versions use `major.minor.patch`. Dependencies accept `^major.minor.patch` (recommended: the same +major, from that version onwards), an exact version, or `*`; no other range is implemented. +Missing dependencies, cycles, duplicate IDs and incompatible versions fail before writing any destination. One component/file has one owner; destination collisions are errors. | Field | Contract | diff --git a/examples/modules/project-notes/module.json b/examples/modules/project-notes/module.json index 9bade46..1dbccaf 100644 --- a/examples/modules/project-notes/module.json +++ b/examples/modules/project-notes/module.json @@ -3,7 +3,7 @@ "id": "project-notes", "version": "1.0.0", "description": "Keep a small project notebook without enabling weekly reporting", - "requires": { "memory": "1.1.0" }, + "requires": { "memory": "^1.1.0" }, "config": { "title": "Project notes" }, "skills": [{ "name": "project-notes", "source": "skills/project-notes" }], "files": [{ "source": "templates/notes.md", "target": "{{TASKS_DIR}}/notes/README.md", "editable": true }], diff --git a/modules/learning/module.json b/modules/learning/module.json index ab751f0..5cfba09 100644 --- a/modules/learning/module.json +++ b/modules/learning/module.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "learning", - "version": "1.0.0", + "version": "1.0.1", "description": "Learning workflow capabilities", "requires": {}, "skills": [ diff --git a/modules/learning/skills/teach/SKILL.md b/modules/learning/skills/teach/SKILL.md index 24ba826..a0877dd 100644 --- a/modules/learning/skills/teach/SKILL.md +++ b/modules/learning/skills/teach/SKILL.md @@ -12,6 +12,7 @@ The user has asked you to teach them something. This is a stateful request - the Treat the current directory as a teaching workspace. The state of their learning is captured in this directory in several files: - `MISSION.md`: A document capturing the _reason_ the user is interested in the topic. This should be used to ground all teaching. Use the format in [MISSION-FORMAT.md](./MISSION-FORMAT.md). +- `GLOSSARY.md`: The canonical language of this workspace. Lessons, reference materials and learning records should adhere to its terminology, and compressing a concept into a tight definition is itself evidence of learning. Use the format in [GLOSSARY-FORMAT.md](./GLOSSARY-FORMAT.md). - `./reference/*.html`: A directory of reference materials. These are the compressed learnings from the lessons - cheat sheets, reference algorithms, syntax, yoga poses, glossaries. They are the raw units of learning. They should be beautiful documents which print out well, and are designed for quick reference. - `RESOURCES.md`: A list of resources which can be explored to ground your teaching in contextual knowledge, or to acquire knowledge and wisdom. Use the format in [RESOURCES-FORMAT.md](./RESOURCES-FORMAT.md). - `./learning-records/*.md`: A directory of learning records, which capture what the user has learned. These are loosely equivalent to architectural decision records in software development - they capture non-obvious lessons and key insights that may need to be revised later, or drive future sessions. These should be used to calculate the zone of proximal development. They are titled `0001-.md`, where the number increments each time. Use the format in [LEARNING-RECORD-FORMAT.md](./LEARNING-RECORD-FORMAT.md). diff --git a/modules/quality/module.json b/modules/quality/module.json index 2f1744d..b3ee222 100644 --- a/modules/quality/module.json +++ b/modules/quality/module.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "quality", - "version": "1.0.0", + "version": "1.0.1", "description": "Quality workflow capabilities", "requires": { "tasks": "*" diff --git a/modules/quality/skills/code-simplification/SKILL.md b/modules/quality/skills/code-simplification/SKILL.md index 42547e5..e7e6ffc 100644 --- a/modules/quality/skills/code-simplification/SKILL.md +++ b/modules/quality/skills/code-simplification/SKILL.md @@ -102,197 +102,12 @@ Simplification has a failure mode: over-simplification. Watch for these traps: Default to simplifying recently modified code. Avoid drive-by refactors of unrelated code unless explicitly asked to broaden scope. Unscoped simplification creates noise in diffs and risks unintended regressions. -## The Simplification Process +## Where the detail lives -### Step 1: Understand Before Touching (Chesterton's Fence) - -Before changing or removing anything, understand why it exists. This is Chesterton's Fence: if you see a fence across a road and don't understand why it's there, don't tear it down. First understand the reason, then decide if the reason still applies. - -``` -BEFORE SIMPLIFYING, ANSWER: -- What is this code's responsibility? -- What calls it? What does it call? -- What are the edge cases and error paths? -- Are there tests that define the expected behavior? -- Why might it have been written this way? (Performance? Platform constraint? Historical reason?) -- Check git blame: what was the original context for this code? -``` - -If you can't answer these, you're not ready to simplify. Read more context first. - -### Step 2: Identify Simplification Opportunities - -Scan for these patterns — each one is a concrete signal, not a vague smell: - -**Structural complexity:** - -| Pattern | Signal | Simplification | -|---------|--------|----------------| -| Deep nesting (3+ levels) | Hard to follow control flow | Extract conditions into guard clauses or helper functions | -| Long functions (50+ lines) | Multiple responsibilities | Split into focused functions with descriptive names | -| Nested ternaries | Requires mental stack to parse | Replace with if/else chains, switch, or lookup objects | -| Boolean parameter flags | `doThing(true, false, true)` | Replace with options objects or separate functions | -| Repeated conditionals | Same `if` check in multiple places | Extract to a well-named predicate function | - -**Naming and readability:** - -| Pattern | Signal | Simplification | -|---------|--------|----------------| -| Generic names | `data`, `result`, `temp`, `val`, `item` | Rename to describe the content: `userProfile`, `validationErrors` | -| Abbreviated names | `usr`, `cfg`, `btn`, `evt` | Use full words unless the abbreviation is universal (`id`, `url`, `api`) | -| Misleading names | Function named `get` that also mutates state | Rename to reflect actual behavior | -| Comments explaining "what" | `// increment counter` above `count++` | Delete the comment — the code is clear enough | -| Comments explaining "why" | `// Retry because the API is flaky under load` | Keep these — they carry intent the code can't express | - -**Redundancy:** - -| Pattern | Signal | Simplification | -|---------|--------|----------------| -| Duplicated logic | Same 5+ lines in multiple places | Extract to a shared function | -| Dead code | Unreachable branches, unused variables, commented-out blocks | Remove (after confirming it's truly dead) | -| Unnecessary abstractions | Wrapper that adds no value | Inline the wrapper, call the underlying function directly | -| Over-engineered patterns | Factory-for-a-factory, strategy-with-one-strategy | Replace with the simple direct approach | -| Redundant type assertions | Casting to a type that's already inferred | Remove the assertion | - -### Step 3: Apply Changes Incrementally - -Make one simplification at a time. Run tests after each change. **Submit refactoring changes separately from feature or bug fix changes.** A PR that refactors and adds a feature is two PRs — split them. - -``` -FOR EACH SIMPLIFICATION: -1. Make the change -2. Run the test suite -3. If tests pass → continue to the next simplification; commit only with explicit user authorization -4. If tests fail → revert and reconsider -``` - -Avoid batching multiple simplifications into a single untested change. If something breaks, you need to know which simplification caused it. - -**The Rule of 500:** If a refactoring would touch more than 500 lines, invest in automation (codemods, sed scripts, AST transforms) rather than making the changes by hand. Manual edits at that scale are error-prone and exhausting to review. - -### Step 4: Verify the Result - -After all simplifications, step back and evaluate the whole: - -``` -COMPARE BEFORE AND AFTER: -- Is the simplified version genuinely easier to understand? -- Did you introduce any new patterns inconsistent with the codebase? -- Is the diff clean and reviewable? -- Would a teammate approve this change? -``` - -If the "simplified" version is harder to understand or review, revert. Not every simplification attempt succeeds. - -## Language-Specific Guidance - -### TypeScript / JavaScript - -```typescript -// SIMPLIFY: Unnecessary async wrapper -// Before -async function getUser(id: string): Promise { - return await userService.findById(id); -} -// After -function getUser(id: string): Promise { - return userService.findById(id); -} - -// SIMPLIFY: Verbose conditional assignment -// Before -let displayName: string; -if (user.nickname) { - displayName = user.nickname; -} else { - displayName = user.fullName; -} -// After -const displayName = user.nickname || user.fullName; - -// SIMPLIFY: Manual array building -// Before -const activeUsers: User[] = []; -for (const user of users) { - if (user.isActive) { - activeUsers.push(user); - } -} -// After -const activeUsers = users.filter((user) => user.isActive); - -// SIMPLIFY: Redundant boolean return -// Before -function isValid(input: string): boolean { - if (input.length > 0 && input.length < 100) { - return true; - } - return false; -} -// After -function isValid(input: string): boolean { - return input.length > 0 && input.length < 100; -} -``` - -### Python - -```python -# SIMPLIFY: Verbose dictionary building -# Before -result = {} -for item in items: - result[item.id] = item.name -# After -result = {item.id: item.name for item in items} - -# SIMPLIFY: Nested conditionals with early return -# Before -def process(data): - if data is not None: - if data.is_valid(): - if data.has_permission(): - return do_work(data) - else: - raise PermissionError("No permission") - else: - raise ValueError("Invalid data") - else: - raise TypeError("Data is None") -# After -def process(data): - if data is None: - raise TypeError("Data is None") - if not data.is_valid(): - raise ValueError("Invalid data") - if not data.has_permission(): - raise PermissionError("No permission") - return do_work(data) -``` - -### React / JSX - -```tsx -// SIMPLIFY: Verbose conditional rendering -// Before -function UserBadge({ user }: Props) { - if (user.isAdmin) { - return Admin; - } else { - return User; - } -} -// After -function UserBadge({ user }: Props) { - const variant = user.isAdmin ? 'admin' : 'default'; - const label = user.isAdmin ? 'Admin' : 'User'; - return {label}; -} - -// SIMPLIFY: Prop drilling through intermediate components -// Before — consider whether context or composition solves this better. -// This is a judgment call — flag it, don't auto-refactor. -``` +- Before touching code, read [process.md](references/process.md): understanding before changing, + the catalogue of patterns to scan for, incremental application and final verification. +- For the idioms of the language at hand, read + [language-guidance.md](references/language-guidance.md) (TypeScript/JavaScript, Python, React/JSX). ## Common Rationalizations diff --git a/modules/quality/skills/code-simplification/references/language-guidance.md b/modules/quality/skills/code-simplification/references/language-guidance.md new file mode 100644 index 0000000..ebfe625 --- /dev/null +++ b/modules/quality/skills/code-simplification/references/language-guidance.md @@ -0,0 +1,109 @@ +## Language-Specific Guidance + +### TypeScript / JavaScript + +```typescript +// SIMPLIFY: Unnecessary async wrapper +// Before +async function getUser(id: string): Promise { + return await userService.findById(id); +} +// After +function getUser(id: string): Promise { + return userService.findById(id); +} + +// SIMPLIFY: Verbose conditional assignment +// Before +let displayName: string; +if (user.nickname) { + displayName = user.nickname; +} else { + displayName = user.fullName; +} +// After +const displayName = user.nickname || user.fullName; + +// SIMPLIFY: Manual array building +// Before +const activeUsers: User[] = []; +for (const user of users) { + if (user.isActive) { + activeUsers.push(user); + } +} +// After +const activeUsers = users.filter((user) => user.isActive); + +// SIMPLIFY: Redundant boolean return +// Before +function isValid(input: string): boolean { + if (input.length > 0 && input.length < 100) { + return true; + } + return false; +} +// After +function isValid(input: string): boolean { + return input.length > 0 && input.length < 100; +} +``` + +### Python + +```python +# SIMPLIFY: Verbose dictionary building +# Before +result = {} +for item in items: + result[item.id] = item.name +# After +result = {item.id: item.name for item in items} + +# SIMPLIFY: Nested conditionals with early return +# Before +def process(data): + if data is not None: + if data.is_valid(): + if data.has_permission(): + return do_work(data) + else: + raise PermissionError("No permission") + else: + raise ValueError("Invalid data") + else: + raise TypeError("Data is None") +# After +def process(data): + if data is None: + raise TypeError("Data is None") + if not data.is_valid(): + raise ValueError("Invalid data") + if not data.has_permission(): + raise PermissionError("No permission") + return do_work(data) +``` + +### React / JSX + +```tsx +// SIMPLIFY: Verbose conditional rendering +// Before +function UserBadge({ user }: Props) { + if (user.isAdmin) { + return Admin; + } else { + return User; + } +} +// After +function UserBadge({ user }: Props) { + const variant = user.isAdmin ? 'admin' : 'default'; + const label = user.isAdmin ? 'Admin' : 'User'; + return {label}; +} + +// SIMPLIFY: Prop drilling through intermediate components +// Before — consider whether context or composition solves this better. +// This is a judgment call — flag it, don't auto-refactor. +``` diff --git a/modules/quality/skills/code-simplification/references/process.md b/modules/quality/skills/code-simplification/references/process.md new file mode 100644 index 0000000..f1e7982 --- /dev/null +++ b/modules/quality/skills/code-simplification/references/process.md @@ -0,0 +1,81 @@ +## The Simplification Process + +### Step 1: Understand Before Touching (Chesterton's Fence) + +Before changing or removing anything, understand why it exists. This is Chesterton's Fence: if you see a fence across a road and don't understand why it's there, don't tear it down. First understand the reason, then decide if the reason still applies. + +``` +BEFORE SIMPLIFYING, ANSWER: +- What is this code's responsibility? +- What calls it? What does it call? +- What are the edge cases and error paths? +- Are there tests that define the expected behavior? +- Why might it have been written this way? (Performance? Platform constraint? Historical reason?) +- Check git blame: what was the original context for this code? +``` + +If you can't answer these, you're not ready to simplify. Read more context first. + +### Step 2: Identify Simplification Opportunities + +Scan for these patterns — each one is a concrete signal, not a vague smell: + +**Structural complexity:** + +| Pattern | Signal | Simplification | +|---------|--------|----------------| +| Deep nesting (3+ levels) | Hard to follow control flow | Extract conditions into guard clauses or helper functions | +| Long functions (50+ lines) | Multiple responsibilities | Split into focused functions with descriptive names | +| Nested ternaries | Requires mental stack to parse | Replace with if/else chains, switch, or lookup objects | +| Boolean parameter flags | `doThing(true, false, true)` | Replace with options objects or separate functions | +| Repeated conditionals | Same `if` check in multiple places | Extract to a well-named predicate function | + +**Naming and readability:** + +| Pattern | Signal | Simplification | +|---------|--------|----------------| +| Generic names | `data`, `result`, `temp`, `val`, `item` | Rename to describe the content: `userProfile`, `validationErrors` | +| Abbreviated names | `usr`, `cfg`, `btn`, `evt` | Use full words unless the abbreviation is universal (`id`, `url`, `api`) | +| Misleading names | Function named `get` that also mutates state | Rename to reflect actual behavior | +| Comments explaining "what" | `// increment counter` above `count++` | Delete the comment — the code is clear enough | +| Comments explaining "why" | `// Retry because the API is flaky under load` | Keep these — they carry intent the code can't express | + +**Redundancy:** + +| Pattern | Signal | Simplification | +|---------|--------|----------------| +| Duplicated logic | Same 5+ lines in multiple places | Extract to a shared function | +| Dead code | Unreachable branches, unused variables, commented-out blocks | Remove (after confirming it's truly dead) | +| Unnecessary abstractions | Wrapper that adds no value | Inline the wrapper, call the underlying function directly | +| Over-engineered patterns | Factory-for-a-factory, strategy-with-one-strategy | Replace with the simple direct approach | +| Redundant type assertions | Casting to a type that's already inferred | Remove the assertion | + +### Step 3: Apply Changes Incrementally + +Make one simplification at a time. Run tests after each change. **Submit refactoring changes separately from feature or bug fix changes.** A PR that refactors and adds a feature is two PRs — split them. + +``` +FOR EACH SIMPLIFICATION: +1. Make the change +2. Run the test suite +3. If tests pass → continue to the next simplification; commit only with explicit user authorization +4. If tests fail → revert and reconsider +``` + +Avoid batching multiple simplifications into a single untested change. If something breaks, you need to know which simplification caused it. + +**The Rule of 500:** If a refactoring would touch more than 500 lines, invest in automation (codemods, sed scripts, AST transforms) rather than making the changes by hand. Manual edits at that scale are error-prone and exhausting to review. + +### Step 4: Verify the Result + +After all simplifications, step back and evaluate the whole: + +``` +COMPARE BEFORE AND AFTER: +- Is the simplified version genuinely easier to understand? +- Did you introduce any new patterns inconsistent with the codebase? +- Is the diff clean and reviewable? +- Would a teammate approve this change? +``` + +If the "simplified" version is harder to understand or review, revert. Not every simplification attempt succeeds. diff --git a/modules/tasks/module.json b/modules/tasks/module.json index 8840abb..d326463 100644 --- a/modules/tasks/module.json +++ b/modules/tasks/module.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "tasks", - "version": "1.1.0", + "version": "1.1.1", "description": "Tasks workflow capabilities", "requires": {}, "skills": [ diff --git a/modules/tasks/skills/write-task/SKILL.md b/modules/tasks/skills/write-task/SKILL.md index be7cd5f..23eec7a 100644 --- a/modules/tasks/skills/write-task/SKILL.md +++ b/modules/tasks/skills/write-task/SKILL.md @@ -38,38 +38,8 @@ follow [project-workflow](../project-workflow/SKILL.md#integration-proof-before- ## Task contract -File name: `{NN}-{kebab-slug}.md`. - -```md ---- -objective: One verifiable outcome -status: pending | in-progress | implemented | reviewed | blocked -risk: low | medium | high -branch: target implementation branch when known, otherwise — -validator: Observable completion proof ---- - -# NN — Title - -**Depends on**: NN | — -**Blocks**: NN | — - -## Why -{For a feature, state the independently verifiable behavior that becomes usable.} -## Non-goals -{Only when Quality is installed and `soak-readiness` applies, insert its `## Soak contract` block here.} -## ANTS steps -### E1 — One atomic action -**Signal**: one observable proof -{Repeat this step shape, including Signal, for every remaining step.} -## Acceptance criteria -## Validation -{Checks actually run and results; independent review verdict + exact SHA if present, otherwise -not reviewed; commit evidence separately when known, otherwise uncommitted or unknown.} -## Files -## Constraints -**Trace**: `type(scope): imperative sentence` -``` +File name and the full task file template: [templates.md](references/templates.md). Read it before +creating or restructuring a task file. ## Step progression diff --git a/modules/tasks/skills/write-task/references/templates.md b/modules/tasks/skills/write-task/references/templates.md new file mode 100644 index 0000000..c738d7c --- /dev/null +++ b/modules/tasks/skills/write-task/references/templates.md @@ -0,0 +1,34 @@ +## Task contract + +File name: `{NN}-{kebab-slug}.md`. + +```md +--- +objective: One verifiable outcome +status: pending | in-progress | implemented | reviewed | blocked +risk: low | medium | high +branch: target implementation branch when known, otherwise — +validator: Observable completion proof +--- + +# NN — Title + +**Depends on**: NN | — +**Blocks**: NN | — + +## Why +{For a feature, state the independently verifiable behavior that becomes usable.} +## Non-goals +{Only when Quality is installed and `soak-readiness` applies, insert its `## Soak contract` block here.} +## ANTS steps +### E1 — One atomic action +**Signal**: one observable proof +{Repeat this step shape, including Signal, for every remaining step.} +## Acceptance criteria +## Validation +{Checks actually run and results; independent review verdict + exact SHA if present, otherwise +not reviewed; commit evidence separately when known, otherwise uncommitted or unknown.} +## Files +## Constraints +**Trace**: `type(scope): imperative sentence` +``` diff --git a/templates/project/.workflow/runtime/catalog.mjs b/templates/project/.workflow/runtime/catalog.mjs index a09a9c8..55349de 100644 --- a/templates/project/.workflow/runtime/catalog.mjs +++ b/templates/project/.workflow/runtime/catalog.mjs @@ -5,6 +5,7 @@ import { assertKeys, readJson, safePath } from './paths.mjs'; export const officialModules = ['tasks', 'memory', 'continuity', 'quality', 'bugs', 'reporting', 'exploration', 'learning']; const moduleIdPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; const versionPattern = /^\d+\.\d+\.\d+$/; +const dependencyPattern = /^(?:\*|\^?\d+\.\d+\.\d+)$/; const points = ['session-start', 'task-validation', 'before-handoff']; export function validateManifest(manifest) { @@ -16,7 +17,7 @@ export function validateManifest(manifest) { } assertKeys(manifest.requires ?? {}, Object.keys(manifest.requires ?? {}), 'requires'); for (const [moduleId, version] of Object.entries(manifest.requires ?? {})) { - if (!moduleIdPattern.test(moduleId) || (version !== '*' && !versionPattern.test(version))) throw new Error(`Invalid dependency: ${moduleId}`); + if (!moduleIdPattern.test(moduleId) || typeof version !== 'string' || !dependencyPattern.test(version)) throw new Error(`Invalid dependency: ${moduleId}`); } assertKeys(manifest.config ?? {}, Object.keys(manifest.config ?? {}), 'config'); for (const [key, value] of Object.entries(manifest.config ?? {})) { @@ -93,6 +94,15 @@ export function loadCatalog(moduleDirectories, explicitSources) { return { catalog, explicit }; } +// `*` accepts any version, `^X.Y.Z` the same major from X.Y.Z onwards, and a bare version only itself. +export function satisfiesVersion(version, required) { + if (required === '*') return true; + if (!required.startsWith('^')) return version === required; + const [major, minor, patch] = version.split('.').map(Number); + const [requiredMajor, requiredMinor, requiredPatch] = required.slice(1).split('.').map(Number); + return major === requiredMajor && (minor > requiredMinor || (minor === requiredMinor && patch >= requiredPatch)); +} + export function resolveModules(catalog, selected, installed = []) { const known = new Map(installed.map(module => [module.id, module])); const ordered = []; @@ -101,7 +111,7 @@ export function resolveModules(catalog, selected, installed = []) { function visit(moduleId, requiredVersion = '*') { const manifest = known.get(moduleId) ?? catalog.get(moduleId)?.manifest; if (!manifest) throw new Error(`Missing module dependency: ${moduleId}`); - if (requiredVersion !== '*' && manifest.version !== requiredVersion) throw new Error(`Incompatible version: ${moduleId} requires ${requiredVersion}, found ${manifest.version}`); + if (!satisfiesVersion(manifest.version, requiredVersion)) throw new Error(`Incompatible version: ${moduleId} requires ${requiredVersion}, found ${manifest.version}`); if (visiting.has(moduleId)) throw new Error(`Module dependency cycle: ${moduleId}`); if (visited.has(moduleId)) return; visiting.add(moduleId); diff --git a/tests/modules.test.mjs b/tests/modules.test.mjs index 9f5dc61..1302b6f 100644 --- a/tests/modules.test.mjs +++ b/tests/modules.test.mjs @@ -6,7 +6,7 @@ import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { installOrAdd } from '../templates/project/.workflow/runtime/manage.mjs'; import { updateKit } from '../templates/project/.workflow/runtime/update.mjs'; -import { loadCatalog, officialModules } from '../templates/project/.workflow/runtime/catalog.mjs'; +import { loadCatalog, officialModules, resolveModules } from '../templates/project/.workflow/runtime/catalog.mjs'; const repository = resolve(new URL('..', import.meta.url).pathname); // Fixtures use a nested TASKS_DIR with spaces; the kit lives inside it. @@ -79,7 +79,10 @@ test('minimal: selected capabilities only, project context preserved, dry run no const installedWriteTask = read(setup, '.claude/skills/write-task/SKILL.md'); assert.equal(installedWriteTask, sourceWriteTask); assert.equal(read(setup, '.codex/skills/write-task/SKILL.md'), sourceWriteTask); - assert.match(installedWriteTask, /for every remaining step/); + assert.match(installedWriteTask, /\[templates\.md\]\(references\/templates\.md\)/); + const installedTemplates = read(setup, '.claude/skills/write-task/references/templates.md'); + assert.equal(installedTemplates, rendered(readFileSync(join(repository, 'modules/tasks/skills/write-task/references/templates.md'), 'utf8'))); + assert.match(installedTemplates, /for every remaining step/); assert.equal(existsSync(join(setup.project, '.agents')), false); assert.match(read(setup, 'CLAUDE.md'), /^# Local rules/); assert.match(read(setup, 'AGENTS.md'), /^# Agent rules/); @@ -247,9 +250,26 @@ test('module hooks never execute in discovery, dry run or install; execute at se assert.match(command('bash', [join(setup.project, kit, 'hooks/show-handoff.sh')]), /EXTERNAL_HOOK_RAN/); }); +test('module dependencies accept compatible versions of the same major and refuse anything else', () => { + const manifest = (id, version, requires) => ({ schemaVersion: 1, id, version, description: `${id} test module`, requires, skills: [] }); + const resolved = (required, available) => resolveModules(new Map([ + ['memory', { manifest: manifest('memory', available) }], + ['project-notes', { manifest: manifest('project-notes', '1.0.0', { memory: required }) }], + ]), ['project-notes']).map(module => `${module.id}@${module.version}`); + + for (const [required, available] of [['^1.1.0', '1.1.0'], ['^1.1.0', '1.1.1'], ['^1.1.0', '1.2.0'], ['*', '2.0.0'], ['1.1.0', '1.1.0']]) { + assert.deepEqual(resolved(required, available), [`memory@${available}`, 'project-notes@1.0.0'], `${required} / ${available}`); + } + for (const [required, available] of [['^1.1.0', '1.0.9'], ['^1.1.0', '2.0.0'], ['^2.0.0', '1.9.9'], ['1.1.0', '1.1.1']]) { + assert.throws(() => resolved(required, available), /Incompatible version/, `${required} / ${available}`); + } +}); + for (const [label, overrides, expected] of [ ['missing dependency', { requires: { missing: '*' } }, /Missing module dependency/], ['incompatible dependency', { requires: { memory: '99.0.0' } }, /Incompatible version/], + ['incompatible dependency major', { requires: { memory: '^99.0.0' } }, /Incompatible version/], + ['unsupported dependency range', { requires: { memory: '~1.1.0' } }, /Invalid dependency/], ['cycle', { requires: { 'invalid-module': '*' } }, /cycle/], ['unknown field', { surprise: true }, /Unknown manifest field/], ['reserved destination', { files: [{ source: 'payload.md', target: 'AGENTS.md' }] }, /Reserved target/], @@ -359,6 +379,21 @@ test('all official modules have a unique skill owner; all profiles and independe assert.equal(state(complete).modules.length, 8); }); +test('installed skills ship the files their SKILL.md links to', context => { + const setup = fixture(context); install(setup, ['--profile', 'complete']); + const skillsRoot = join(setup.project, '.claude/skills'); + const names = readdirSync(skillsRoot); + assert.ok(names.includes('code-simplification') && names.includes('write-task') && names.includes('teach'), names.join(', ')); + for (const name of names) { + // Fenced blocks are templates for user files, not links to kit content. + const prose = readFileSync(join(skillsRoot, name, 'SKILL.md'), 'utf8').replace(/^```[\s\S]*?^```/gm, ''); + const references = [...prose.matchAll(/\]\(([^)<>:\s]+\.md)(?:#[^)]*)?\)/g)].map(match => match[1]); + for (const reference of references) { + assert.equal(existsSync(resolve(skillsRoot, name, reference)), true, `${name} -> ${reference}`); + } + } +}); + test('every core and official module skill description names when to use it', () => { const skillDirectories = [join(repository, 'core/skills')]; for (const moduleId of readdirSync(join(repository, 'modules'))) skillDirectories.push(join(repository, 'modules', moduleId, 'skills'));