From 2c0683820244bc51e2fd8863d4fafe553f08b46e Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 22 Apr 2026 12:31:55 +0100 Subject: [PATCH] phase 1 complete, skill runs in pi --- .gitignore | 1 + .pi/skills/allium/SKILL.md | 312 +++++++++++++++++++++++++++++++++++ .pi/skills/allium/references | 1 + allium-port-plan.md | 15 +- mise.toml | 2 + 5 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 .pi/skills/allium/SKILL.md create mode 120000 .pi/skills/allium/references create mode 100644 mise.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6350e08 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +allium-main/ diff --git a/.pi/skills/allium/SKILL.md b/.pi/skills/allium/SKILL.md new file mode 100644 index 0000000..8957136 --- /dev/null +++ b/.pi/skills/allium/SKILL.md @@ -0,0 +1,312 @@ +--- +name: allium +description: Give your AI agents something more useful than a prompt. Velocity through clarity. +disable-model-invocation: true +license: MIT +metadata: + upstream: https://github.com/juxt/allium + version: 3 +--- + +# Allium + +Allium is a formal language for capturing software behaviour at the domain level. It sits between informal feature descriptions and implementation, providing a precise way to specify what software does without prescribing how it's built. + +The name comes from the botanical family containing onions and shallots, continuing a tradition in behaviour specification tooling established by Cucumber and Gherkin. + +Key principles: + +- Describes observable behaviour, not implementation +- Captures domain logic that matters at the behavioural level +- Generates integration and end-to-end tests (not unit tests) +- Forces ambiguities into the open before implementation +- Implementation-agnostic: the same spec could be implemented in any language + +Allium does NOT specify programming language or framework choices, database schemas or storage mechanisms, API designs or UI layouts, or internal algorithms (unless they are domain-level concerns). + +## Routing table + +| Task | Tool | When | +|------|------|------| +| Writing or reading `.allium` files | this skill | You need language syntax and structure | +| Building a spec through conversation | `/skill:elicit` | User describes a feature or behaviour they want to build | +| Extracting a spec from existing code | `/skill:distill` | User has implementation code and wants a spec from it | +| 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 | + +## Quick syntax summary + +### Entity + +``` +entity Candidacy { + -- Fields + candidate: Candidate + role: Role + status: pending | active | completed | cancelled -- inline enum + retry_count: Integer + + -- Relationships + invitation: Invitation with candidacy = this -- one-to-one + slots: InterviewSlot with candidacy = this -- one-to-many + + -- Projections + confirmed_slots: slots where status = confirmed + pending_slots: slots where status = pending + + -- Derived + is_ready: confirmed_slots.count >= 3 + has_expired: invitation.expires_at <= now +} +``` + +### External entity + +``` +external entity Role { title: String, required_skills: Set, location: Location } +``` + +### Value type + +``` +value TimeRange { start: Timestamp, end: Timestamp, duration: end - start } +``` + +### Sum type + +A base entity declares a discriminator field whose capitalised values name the variants. Variants use the `variant` keyword. + +``` +entity Node { + path: Path + kind: Branch | Leaf -- discriminator field +} + +variant Branch : Node { + children: List +} + +variant Leaf : Node { + data: List + log: List +} +``` + +Lowercase pipe values are enum literals (`status: pending | active`). Capitalised values are variant references (`kind: Branch | Leaf`). Type guards (`requires:` or `if` branches) narrow to a variant and unlock its fields. + +### Module given + +Declares the entity instances a module's rules operate on. All rules inherit these bindings. Not every module needs one: rules scoped by triggers on domain entities get their entities from the trigger. `given` is for specs where rules operate on shared instances that exist once per module scope. + +``` +given { + pipeline: HiringPipeline + calendar: InterviewCalendar +} +``` + +Imported module instances are accessed via qualified names (`scheduling/calendar`) and do not appear in the local `given` block. Distinct from surface `context`, which binds a parametric scope for a boundary contract. + +### Rule + +``` +rule InvitationExpires { + when: invitation: Invitation.expires_at <= now + requires: invitation.status = pending + let remaining = invitation.proposed_slots where status != cancelled + ensures: invitation.status = expired + ensures: + for s in remaining: + s.status = cancelled + @guidance + -- Non-normative implementation advice. +} +``` + +### Trigger types + +- **External stimulus**: `when: CandidateSelectsSlot(invitation, slot)` — action from outside the system +- **State transition**: `when: interview: Interview.status transitions_to scheduled` — entity changed state (transition only, not creation) +- **State becomes**: `when: interview: Interview.status becomes scheduled` — entity has this value, whether by creation or transition +- **Temporal**: `when: invitation: Invitation.expires_at <= now` — time-based condition (always add a `requires` guard against re-firing) +- **Derived condition**: `when: interview: Interview.all_feedback_in` — derived value becomes true +- **Entity creation**: `when: batch: DigestBatch.created` — fires when a new entity is created +- **Chained**: `when: AllConfirmationsResolved(candidacy)` — subscribes to a trigger emission from another rule's ensures clause + +All entity-scoped triggers use explicit `var: Type` binding. Use `_` as a discard binding where the name is not needed: `when: _: Invitation.expires_at <= now`, `when: SomeEvent(_, slot)`. + +### Rule-level iteration + +A `for` clause applies the rule body once per element in a collection: + +``` +rule ProcessDigests { + when: schedule: DigestSchedule.next_run_at <= now + for user in Users where notification_setting.digest_enabled: + let settings = user.notification_setting + ensures: DigestBatch.created(user: user, ...) +} +``` + +### Ensures patterns + +Ensures clauses have four outcome forms: + +- **State changes**: `entity.field = value` +- **Entity creation**: `Entity.created(...)` — the single canonical creation verb +- **Trigger emission**: `TriggerName(params)` — emits an event for other rules to chain from +- **Entity removal**: `not exists entity` — asserts the entity no longer exists + +These forms compose with `for` iteration (`for x in collection: ...`), `if`/`else` conditionals and `let` bindings. + +Entity creation uses `.created()` exclusively. Domain meaning lives in entity names and rule names, not in creation verbs. + +In state change assignments, the right-hand expression references pre-rule field values. Conditions within ensures blocks (`if` guards, creation parameters, trigger emission parameters) reference the resulting state. + +### Surface + +``` +surface InterviewerDashboard { + facing viewer: Interviewer + + context assignment: SlotConfirmation where interviewer = viewer + + exposes: + assignment.slot.time + assignment.status + + provides: + InterviewerConfirmsSlot(viewer, assignment.slot) + when assignment.status = pending + + related: + InterviewDetail(assignment.slot.interview) + when assignment.slot.interview != null +} +``` + +Surfaces define contracts at boundaries. The `facing` clause names the external party, `context` scopes the entity. The remaining clauses use a single vocabulary regardless of whether the boundary is user-facing or code-to-code: `exposes` (visible data, supports `for` iteration over collections), `provides` (available operations with optional when-guards), `contracts:` (references module-level `contract` declarations with `demands`/`fulfils` direction markers), `@guarantee` (named prose assertions about the boundary), `@guidance` (non-normative advice), `related` (associated surfaces reachable from this one), `timeout` (references to temporal rules that apply within the surface's context). + +The `facing` clause accepts either an actor type (with a corresponding `actor` declaration and `identified_by` mapping) or an entity type directly. Use actor declarations when the boundary has specific identity requirements; use entity types when any instance can interact (e.g., `facing visitor: User`). For integration surfaces where the external party is code, declare an actor type with a minimal `identified_by` expression. Actors that reference `within` in their `identified_by` expression must declare the expected context type: `within: Workspace`. + +### Surface-to-implementation contract + +The `exposes` block is the field-level contract: the implementation returns exactly these fields, the consumer uses exactly these fields. Do not add fields not listed. Do not omit fields that are listed. + +### Contract + +```allium +contract Codec { + serialize: (value: Any) -> ByteArray + deserialize: (bytes: ByteArray) -> Any + + @invariant Roundtrip + -- deserialize(serialize(value)) produces a value + -- equivalent to the original for all supported types. +} +``` + +Contracts are module-level declarations referenced by name in surface `contracts:` clauses (`demands Codec`, `fulfils EventSubmitter`). See [Contracts](references/language-reference.md#contracts) for declaration syntax and referencing rules. + +### Expressions + +Navigation: `interview.candidacy.candidate.email`, `reply_to?.author` (optional), `timezone ?? "UTC"` (null coalescing). Collections: `slots.count`, `slot in invitation.slots`, `interviewers.any(i => i.can_solo)`, `for item in collection: item.status = cancelled`, `permissions + inherited` (set union), `old - new` (set difference). Comparisons: `status = pending`, `count >= 2`, `status in {confirmed, declined}`, `provider not in providers`. Boolean logic: `a and b`, `a or b`, `not a`, `a implies b`. + +### Modular specs + +``` +use "github.com/allium-specs/google-oauth/abc123def" as oauth +``` + +Qualified names reference entities across specs: `oauth/Session`. Coordinates are immutable (git SHAs or content hashes). Local specs use relative paths: `use "./candidacy.allium" as candidacy`. + +### Config + +``` +config { + invitation_expiry: Duration = 7.days + max_login_attempts: Integer = 5 + extended_expiry: Duration = invitation_expiry * 2 -- expression-form default + sync_timeout: Duration = core/config.default_timeout -- config parameter reference +} +``` + +Rules reference config values as `config.invitation_expiry`. For default entity instances, use `default`. + +### Defaults + +``` +default Role viewer = { name: "viewer", permissions: { "documents.read" } } +``` + +### Invariant + +```allium +invariant NonNegativeBalance { + for account in Accounts: + account.balance >= 0 +} +``` + +Expression-bearing invariants (`invariant Name { expression }`) assert properties over entity state. They are logical assertions, not runtime checks. Distinct from prose annotations (`@invariant Name`) in contracts, which use the `@` sigil to mark content the checker does not evaluate. See [Invariants](references/language-reference.md#invariants). + +### Transition graph (v3) + +``` +entity Order { + status: pending | confirmed | shipped | delivered | cancelled + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + pending -> cancelled + confirmed -> cancelled + terminal: delivered, cancelled + } +} +``` + +### State-dependent field presence (v3) + +``` +entity Order { + status: pending | confirmed | shipped | delivered | cancelled + customer: Customer + total: Money + tracking_number: String when status = shipped | delivered + shipped_at: Timestamp when status = shipped | delivered + + transitions status { + pending -> confirmed + confirmed -> shipped + shipped -> delivered + pending -> cancelled + confirmed -> cancelled + terminal: delivered, cancelled + } +} +``` + +### Deferred specs + +``` +deferred InterviewerMatching.suggest -- see: detailed/interviewer-matching.allium +``` + +### Open questions + +``` +open question "Admin ownership - should admins be assigned to specific roles?" +``` + +## Verification + +When the `allium` CLI is installed, a hook validates `.allium` files automatically after every write or edit. Fix any reported issues before presenting the result. If the CLI is not available, verify against the [language reference](references/language-reference.md). + +## References + +- [Language reference](references/language-reference.md) — full syntax for entities, rules, expressions, surfaces, contracts, invariants and validation +- [Test generation](references/test-generation.md) — generating tests from specifications +- [Patterns](references/patterns.md) — 9 worked patterns: auth, RBAC, invitations, soft delete, notifications, usage limits, comments, library spec integration, framework integration contract diff --git a/.pi/skills/allium/references b/.pi/skills/allium/references new file mode 120000 index 0000000..319873a --- /dev/null +++ b/.pi/skills/allium/references @@ -0,0 +1 @@ +../../../allium-main/references \ No newline at end of file diff --git a/allium-port-plan.md b/allium-port-plan.md index 5f375d1..551d076 100644 --- a/allium-port-plan.md +++ b/allium-port-plan.md @@ -46,19 +46,20 @@ pi-allium/ ← this repo ### Phase 1: Setup and smoke test _Goal: get the root `/skill:allium` working in pi with a local model._ -- [ ] Clone allium upstream into `allium-main/` +- [x] Clone allium upstream into `allium-main/` ``` cd ~/research/pi-allium git clone https://github.com/juxt/allium.git allium-main ``` -- [ ] Create `.pi/skills/allium/SKILL.md` with pi-compatible frontmatter - - Source: `allium-main/SKILL.md` +- [x] Create `.pi/skills/allium/SKILL.md` with pi-compatible frontmatter + - Source: `allium-main/skills/allium/SKILL.md` - Change: add `disable-model-invocation: true`, `license: MIT`, `metadata` block - Change: update routing table to use `/skill:elicit`, `/skill:distill`, `/skill:propagate` instead of Claude skill/agent references - - Keep: the full language reference body (copy or include) -- [ ] Symlink references: `ln -s ../../../allium-main/references .pi/skills/allium/references` -- [ ] Smoke test: `pi -p` in this directory, invoke `/skill:allium`, verify it loads and responds with the routing table -- [ ] Verify references load: ask allium about language syntax, confirm it can read `references/language-reference.md` + - Change: update reference paths from `../../references/` to `references/` + - Keep: the full language reference body +- [x] Symlink references: `ln -s ../../../allium-main/references .pi/skills/allium/references` +- [x] Smoke test: `pi -p` in this directory, invoke `/skill:allium`, verify it loads and responds with the routing table +- [x] Verify references load: ask allium about language syntax, confirm it can read `references/language-reference.md` ### Phase 2: Port elicit, distill, propagate skills _Goal: all three sub-skills work via `/skill:elicit` etc._ diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..7f6aeba --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "24.0.1"