12 KiB

name, description, disable-model-invocation, license, metadata
name description disable-model-invocation license metadata
user-story-conversation Guide users through Ron Jeffries' Card, Conversation, Confirmation workflow using Example Mapping, Allium specs, and fast-check properties. true MIT
methodology references
example-mapping
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:

## 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: 3

-- allium: [module-name]

------------------------------------------------------------
-- Entities and Variants
------------------------------------------------------------

entity Character {
    name: String
    health: Health
    status: alive | dead
    level: Level
    factions: Set<Faction>
}

------------------------------------------------------------
-- 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

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)

// "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

// "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:

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)

type Status = { kind: 'alive' } | { kind: 'dead' };

type FactionMember = { kind: 'member'; faction: Faction };
type FactionNeutral = { kind: 'neutral' };
type FactionMembership = FactionMember | FactionNeutral;

Value Objects

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

class Character {
  constructor(
    readonly name: string,
    readonly health: Health,
    readonly status: Status,
    readonly level: Level,
    readonly factions: ReadonlySet<Faction>,
  ) {}

  // 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 primitivesHealth, 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:

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

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

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

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