From 9a2181318eb8996a123ad7239d63372ac363c84d Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Fri, 12 Jun 2026 20:15:30 +0100 Subject: [PATCH] feat: add user-story-conversation skill and fast-check integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the user-story-conversation Pi skill following Ron Jeffries' Card, Conversation, Confirmation mantra enhanced with Matt Wynne's Example Mapping. The skill guides users through six steps: 1. Card — user story 2. Conversation — Example Mapping (Rules, Examples, Questions, Answers) 3. Allium Spec — formal behavioural specification 4. fast-check Properties — executable verification 5. TypeScript ADTs — value objects and discriminated unions 6. Confirmation — verify all properties pass Also update AGENTS.md with workflow guidance and add the new skill to the Allium routing table. Add fast-check as a dependency. --- .pi/skills/allium/SKILL.md | 1 + .pi/skills/user-story-conversation/SKILL.md | 426 ++++++++++++++++++++ AGENTS.md | 131 +++++- package-lock.json | 41 ++ package.json | 3 + 5 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 .pi/skills/user-story-conversation/SKILL.md diff --git a/.pi/skills/allium/SKILL.md b/.pi/skills/allium/SKILL.md index 8957136..be29879 100644 --- a/.pi/skills/allium/SKILL.md +++ b/.pi/skills/allium/SKILL.md @@ -34,6 +34,7 @@ Allium does NOT specify programming language or framework choices, database sche | Modifying an existing spec | `/skill:tend` | User wants targeted changes to `.allium` files | | Checking spec-to-code alignment | `/skill:weed` | User wants to find or fix divergences between spec and implementation | | Generating tests from a spec | `/skill:propagate` | User wants to generate tests, PBT properties or state machine tests from a specification | +| Full Card→Conversation→Confirmation workflow | `/skill:user-story-conversation` | User wants to walk through a user story with Example Mapping, Allium specs, and fast-check properties | ## Quick syntax summary diff --git a/.pi/skills/user-story-conversation/SKILL.md b/.pi/skills/user-story-conversation/SKILL.md new file mode 100644 index 0000000..d26dc91 --- /dev/null +++ b/.pi/skills/user-story-conversation/SKILL.md @@ -0,0 +1,426 @@ +--- +name: user-story-conversation +description: Guide users through Ron Jeffries' Card, Conversation, Confirmation workflow using Example Mapping, Allium specs, and fast-check properties. +disable-model-invocation: true +license: MIT +metadata: + methodology: example-mapping + references: + - https://www.examplerelated.com/ + - https://www.ronjeffries.com/ +--- + +# User Story Conversation + +A skill for turning user stories into formal specifications and executable properties, following Ron Jeffries' **Card, Conversation, Confirmation** mantra enhanced with Matt Wynne's **Example Mapping**. + +## The Six Steps + +``` +Card → User story (the "what") +Conversation → Example Mapping (the "understanding") + → Allium Spec (the "formal model") +Confirmation → fast-check Properties (the "executable verification") + → TypeScript ADTs (the "type-safe implementation") +``` + +## When to Use + +- Starting a new feature or user story +- Clarifying ambiguous requirements +- Breaking down a complex user story into testable properties +- Aligning the team on domain understanding before implementation + +## How to Run + +1. **Start with the Card** — Ask the user for the user story (or use an existing one from `user-stories.md`) +2. **Example Map** — Work through the story together, identifying Rules, Examples, Questions, and Answers +3. **Allium Spec** — Formalize the rules and answers into a `.allium` spec +4. **fast-check Properties** — Translate invariants into property-based tests +5. **TypeScript ADTs** — Implement with value objects and discriminated unions +6. **Confirmation** — Verify the implementation satisfies all properties + +## Step 1: Card + +Present the user story as a card. Use the format: + +``` +As a [role] +I want [feature] +So that [benefit] +``` + +If the user already has a user story (e.g., from `user-stories.md`), use that. Otherwise, help them write one. + +## Step 2: Conversation — Example Mapping + +Work through the user story using Example Mapping. For each rule, identify: + +### Rules (yellow) — Domain concepts +- Nouns and concepts in the domain +- What the system manages +- Example: "Health", "Damage", "Character", "Level", "Faction" + +### Examples (blue) — Concrete scenarios +- Specific instances of rules +- Should be testable +- Example: "Character with 500 health takes 200 damage → 300 health" + +### Questions (pink) — Ambiguities +- Things we don't know or aren't sure about +- Example: "What happens when damage exceeds health?" + +### Answers (green) — Resolved questions +- Write directly on the pink sticky +- Example: "Health becomes 0, character dies" + +### Example Mapping Output Format + +Create a structured output like this: + +```markdown +## Example Map: [User Story Title] + +### Rules +1. [Rule description] +2. [Rule description] + +### Examples +- [Example 1] +- [Example 2] + +### Questions +- [Question 1] +- [Question 2] + +### Answers +- [Answer to Question 1] +- [Answer to Question 2] +``` + +## Step 3: Allium Spec + +Formalize the rules, examples, and answers into an Allium spec. Use the skills in this repository: + +- `/skill:elicit` — if you need to explore requirements further +- `/skill:tend` — if you need to evolve an existing spec +- `/skill:allium` — for language reference + +### Spec Structure + +Create a `.allium` file with: + +```allium +-- allium: 3 + +-- allium: [module-name] + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity Character { + name: String + health: Health + status: alive | dead + level: Level + factions: Set +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule DamageReducesHealth { + when: CharacterTakesDamage(character, damage) + ensures: character.health = character.health - damage +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +invariant HealthNonNegative { + for c in Characters: + c.health >= 0 +} + +invariant DeathAtZeroHealth { + for c in Characters: + c.health = 0 implies c.status = dead +} +``` + +### Key Principles + +- **Entities** represent domain objects with identity +- **Rules** describe state transitions and behaviour +- **Invariants** capture properties that must always hold +- **Enums** and **value types** for constrained data +- **Rules** should be testable — each one maps to a property + +## Step 4: fast-check Properties + +Translate Allium invariants and rules into fast-check properties. + +### Property Patterns + +#### Invariant Properties + +```typescript +import fc from 'fast-check'; + +// "Health is never negative" +fc.property( + fc.integer({ min: 0, max: 10000 }), + (health) => { + const c = new Character({ health: Health.create(health) }); + return c.health.value >= 0; + } +); +``` + +#### Rule Properties (State Transitions) + +```typescript +// "Damage reduces health, capped at 0" +fc.property( + fc.integer({ min: 0, max: 10000 }), + fc.integer({ min: 0, max: 10000 }), + (health, damage) => { + const c = new Character({ health: Health.create(health) }); + c.takeDamage(Damage.create(damage)); + return c.health.value === Math.max(0, health - damage); + } +); +``` + +#### Edge Case Properties + +```typescript +// "Zero damage changes nothing" +fc.property( + fc.integer({ min: 0, max: 10000 }), + (health) => { + const c = new Character({ health: Health.create(health) }); + c.takeDamage(Damage.create(0)); + return c.health.value === health; + } +); +``` + +### Property Naming + +Name properties after the rule or invariant they verify: + +```typescript +describe('DamageReducesHealth', () => { + it('property: health is reduced by damage amount', () => { ... }); + it('property: health never goes below zero', () => { ... }); + it('property: character dies when health reaches zero', () => { ... }); +}); +``` + +## Step 5: TypeScript ADTs + +Implement with "I can't believe it's not Haskell" principles: + +### Discriminated Unions (ADTs) + +```typescript +type Status = { kind: 'alive' } | { kind: 'dead' }; + +type FactionMember = { kind: 'member'; faction: Faction }; +type FactionNeutral = { kind: 'neutral' }; +type FactionMembership = FactionMember | FactionNeutral; +``` + +### Value Objects + +```typescript +class Health { + private constructor(private readonly value: number) {} + + static create(n: number): Health { + if (n < 0) throw new Error('Health cannot be negative'); + return new Health(n); + } + + static maxForLevel(level: number): number { + return level >= 6 ? 1500 : 1000; + } + + get value(): number { return this.value; } + + add(amount: number): Health { + return Health.create(Math.min(this.value + amount, Health.maxForLevel(this.level.value))); + } + + sub(amount: number): Health { + return Health.create(Math.max(0, this.value - amount)); + } +} +``` + +### Immutable Entities + +```typescript +class Character { + constructor( + readonly name: string, + readonly health: Health, + readonly status: Status, + readonly level: Level, + readonly factions: ReadonlySet, + ) {} + + // Pure functions — no mutation + takeDamage(damage: Damage): Character { + const newHealth = this.health.sub(damage.value); + const newStatus = newHealth.value === 0 ? { kind: 'dead' as const } : this.status; + return new Character(this.name, newHealth, newStatus, this.level, this.factions); + } +} +``` + +### Key Principles + +- **ADTs over classes** — discriminated unions for state/variants +- **Value objects over primitives** — `Health`, `Damage`, `Level` instead of `number` +- **Immutability** — return new instances, never mutate +- **Invariants at construction** — invalid states are unrepresentable +- **Pure functions** — domain logic has no side effects + +## Step 6: Confirmation + +Verify that the implementation satisfies all properties: + +```bash +npm test +``` + +All properties should pass. If any fail, the property reveals a gap in understanding — revisit the Example Map or Allium spec. + +## Output Artifacts + +After completing the workflow, produce: + +1. **Example Map** — Markdown file with structured domain knowledge +2. **Allium Spec** — `.allium` file with formal behavioural specification +3. **fast-check Properties** — Test files with property-based tests +4. **TypeScript Implementation** — ADTs, value objects, and pure functions + +## Example: Full Workflow + +Here's a complete example walking through "Characters can Deal Damage": + +### Card + +> Characters can Deal Damage to Characters. + +### Example Map + +**Rules:** +1. Damage is subtracted from Health +2. When damage exceeds health, health becomes 0 and character dies +3. A Character cannot Deal Damage to itself + +**Examples:** +- Character with 1000 health takes 200 damage → 800 health +- Character with 100 health takes 200 damage → 0 health, dead +- Character tries to deal damage to self → no effect + +**Questions:** +- Can a dead character take damage? +- Does damage stack across multiple attacks? + +**Answers:** +- Dead characters cannot take damage (they're already dead) +- Yes, damage stacks (each attack reduces health further) + +### Allium Spec + +```allium +entity Character { + name: String + health: Health + status: alive | dead +} + +rule DamageReducesHealth { + when: CharacterTakesDamage(character, damage) + requires: character.status = alive + ensures: character.health = character.health - damage + ensures: + if character.health <= 0: + character.status = dead +} + +invariant HealthNonNegative { + for c in Characters: + c.health >= 0 +} + +invariant SelfDamageForbidden { + for a in Characters, t in Characters: + a.dealDamage(t, _) implies a.name != t.name +} +``` + +### fast-check Properties + +```typescript +describe('DamageReducesHealth', () => { + it('property: health is reduced by damage', () => { ... }); + it('property: health never goes below zero', () => { ... }); + it('property: character dies when health reaches zero', () => { ... }); + it('property: dead characters cannot take damage', () => { ... }); + it('property: self-damage is forbidden', () => { ... }); +}); +``` + +### TypeScript Implementation + +```typescript +type Status = { kind: 'alive' } | { kind: 'dead' }; + +class Health { + static create(n: number): Health { ... } + sub(amount: number): Health { ... } +} + +class Character { + constructor( + readonly name: string, + readonly health: Health, + readonly status: Status, + ) {} + + dealDamage(target: Character, damage: Damage): void { + if (this.name === target.name) return; // self-damage forbidden + if (target.status.kind === 'dead') return; // dead can't take damage + const newHealth = target.health.sub(damage.value); + const newStatus = newHealth.value === 0 ? { kind: 'dead' as const } : target.status; + // ... update target + } +} +``` + +## Integration with Allium Skills + +This skill works alongside the Allium skills: + +- **Before starting** — Use `/skill:allium` to understand the language +- **During spec writing** — Use `/skill:tend` to evolve specs, `/skill:propagate` to generate test obligations +- **After implementation** — Use `/skill:weed` to check spec-code alignment +- **When clarifying requirements** — Use `/skill:elicit` to explore with stakeholders + +## Tips + +- **Start small** — Map one user story at a time +- **Examples first** — Concrete examples help surface ambiguities +- **Properties before implementation** — Write fast-check properties before code +- **Let properties guide design** — If a property is hard to write, the design needs work +- **Iterate** — Example Mapping is a conversation, not a document. Revise as understanding deepens +- **Use the Allium CLI** — Run `allium check` to validate specs +- **Run fast-check with many iterations** — Use `fc.property(...).check({ numRuns: 1000 })` for thorough testing diff --git a/AGENTS.md b/AGENTS.md index 40c9088..4d3a58d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,139 @@ ## What this project is -An implementation of the RPG Combat rules engine. There are six user stories described in [user-stories.md](user-stories.md) +An implementation of the RPG Combat rules engine. There are six user stories described in [user-stories.md](user-stories.md). We use a **spec-first, property-based testing** approach. ## Build and Test Scripts -- `npm test`: runs unit tests using vitest +- `npm test`: runs unit tests using vitest + fast-check - `npm run lint:fix`: runs eslint with autofix - `npm run format:fix`: runs prettier with autofix - `npm run typecheck`: runs tsc without emit - `npm run checks`: runs the pre-commit gate (format:fix, lint:fix, typecheck, test) + +## Allium + fast-check Workflow + +This project combines three practices: + +1. **Allium** (`.allium` specs) — formal behavioural specifications that capture *what* the system does +2. **fast-check** — property-based testing that verifies those properties hold across thousands of random inputs +3. **"I can't believe it's not Haskell"** — TypeScript with ADTs, value objects, and immutability + +### Step 1: Spec with Allium + +Use the Allium skills to formalize user stories into `.allium` specs: +- `/skill:elicit` — explore requirements with stakeholders +- `/skill:distill` — extract specs from existing code +- `/skill:tend` — evolve specs as understanding deepens + +The spec captures **invariants** (always-true properties) and **rules** (state transitions). These become the source of truth for your properties. + +### Step 2: Properties with fast-check + +Translate Allium invariants and rules into fast-check properties. Each invariant becomes a property: + +```typescript +import fc from 'fast-check'; +import { Character } from './domain'; + +// Invariant: "A character's health is never negative" +fc.property( + fc.integer({ min: 0, max: 1000 }), + (initialHealth) => { + const c = new Character({ name: 'hero', health: initialHealth }); + return c.health >= 0; + } +); + +// Property: "Dealing damage reduces health, capped at 0" +fc.property( + fc.record({ + attacker: fc.character(), + target: fc.character(), + damage: fc.integer({ min: 1, max: 5000 }), + }), + ({ attacker, target, damage }) => { + const a = new Character({ name: attacker, health: 1000 }); + const t = new Character({ name: target, health: 1000 }); + a.dealDamage(t, damage); + return t.health === Math.max(0, 1000 - damage); + } +); +``` + +### Step 3: "I can't believe it's not Haskell" + +Use TypeScript's type system to encode domain constraints: + +```typescript +// ADTs via discriminated unions +type Status = { kind: 'alive' } | { kind: 'dead' }; + +// Value objects with invariants enforced at construction +class Health { + private constructor(private readonly value: number) {} + static create(n: number): Health { + if (n < 0) throw new Error('Health cannot be negative'); + return new Health(n); + } + get value() { return this.value; } + add(n: number) { return Health.create(this.value + n); } + sub(n: number) { return Health.create(Math.max(0, this.value - n)); } +} + +// Immutable entities +class Character { + constructor( + readonly name: string, + readonly health: Health, + readonly status: Status, + readonly level: Level, + readonly factions: ReadonlySet, + ) {} + + dealDamage(target: Character, amount: number): void { + // ... pure logic, no mutation + } +} +``` + +**Key principles:** +- **ADTs over classes** — use discriminated unions for state/variants +- **Value objects over primitives** — `Health`, `Damage`, `Level` instead of `number` +- **Immutability** — no `this.health = ...`, return new instances +- **Invariants at boundaries** — constructors enforce invariants, not getters/setters +- **Pure functions** — domain logic has no side effects, testable in isolation + +### Example: Damage Property + +From user story: *"When damage received exceeds current Health, Health becomes 0 and the character dies"* + +```typescript +// Allium invariant (in .allium spec) +// invariant HealthNonNegative { for c in Characters: c.health >= 0 } +// invariant DeathAtZeroHealth { for c in Characters: c.health = 0 implies c.status = dead } + +// fast-check property +fc.property( + fc.integer({ min: 0, max: 10000 }), + fc.integer({ min: 0, max: 10000 }), + (health, damage) => { + const c = new Character({ name: 'goblin', health: Health.create(health) }); + c.takeDamage(Damage.create(damage)); + return c.health.value === Math.max(0, health - damage); + } +).check(/* ... */); +``` + +## Skill Invocation + +Allium skills are available in this project: +- `/skill:allium` — entry point and language reference +- `/skill:elicit` — explore requirements +- `/skill:distill` — extract specs from code +- `/skill:propagate` — generate test obligations from specs +- `/skill:tend` — evolve specs +- `/skill:weed` — check spec-code alignment + +Domain workflow skill: +- `/skill:user-story-conversation` — Card, Conversation, Confirmation workflow with Example Mapping, Allium specs, and fast-check properties diff --git a/package-lock.json b/package-lock.json index e39595b..c14928f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "rpg-combat", "version": "0.1.0", "license": "MIT", + "dependencies": { + "fast-check": "^4.8.0" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^25.9.1", @@ -1305,6 +1308,28 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2027,6 +2052,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", diff --git a/package.json b/package.json index 2dc969d..d612e29 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,8 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.60.0", "vitest": "^4.1.7" + }, + "dependencies": { + "fast-check": "^4.8.0" } }