Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/allium/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allium",
"version": "3.6.0",
"version": "3.7.0",
"description": "Velocity through clarity.",
"author": {
"name": "JUXT",
Expand Down
2 changes: 1 addition & 1 deletion plugins/allium/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allium",
"version": "3.6.0",
"version": "3.7.0",
"description": "Velocity through clarity.",
"author": {
"name": "JUXT",
Expand Down
28 changes: 25 additions & 3 deletions plugins/allium/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ npx skills add juxt/allium

**Other editors:** If your editor doesn't read from `.agents/skills/`, symlink the installed skills into wherever it does look (e.g. `ln -s .agents/skills/allium .continue/rules/allium`, or `mklink /J` on Windows). Use a symlink rather than copying; the skill files contain relative links to reference material that a copy would break.

Once installed, type `/allium` to get started. Allium examines your project and guides you toward the right skill, whether that's distilling a spec from existing code or building one through conversation. Once you're familiar with the individual skills, you'll likely invoke them directly.
Once installed, type `/allium` to get started. Allium examines your project and points you at the best next move — usually driving the whole loop end to end, or a single skill like distilling a spec from existing code or building one through conversation. Once you're familiar with the individual skills, you'll likely invoke them directly.

Jump to what [Allium looks like in practice](#what-this-looks-like-in-practice).

Expand Down Expand Up @@ -112,14 +112,14 @@ Allium provides five skills, an entry point and two autonomous agents.

| Skill | Purpose |
|---|---|
| `/allium <prompt>` | Entry point. Examines your project or the prompt and routes you to the right skill. |
| `/allium <goal or prompt>` | Entry point. Give it a goal and it drives the whole loop to convergence — gather context, take action, verify, repeat — running the other skills as phases; give it a single task and it routes you to the right skill. |
| `/elicit <feature idea>` (or `/allium:elicit`) | Build a spec through structured conversation. |
| `/distill <codebase area>` (or `/allium:distill`) | Extract a spec from existing code. |
| `/propagate <optional constraints>` (or `/allium:propagate`) | Generate tests from a spec. |
| `/tend <optional constraints>` (or `/allium:tend`) | Targeted changes to existing specs. |
| `/weed <optional constraints>` (or `/allium:weed`) | Find and fix divergences between spec and code. |

How skills appear depends on your editor. Some show the fully qualified form (`/allium:weed`), others show the short form (`/weed`), and some support both. If one form isn't recognised, try the other. Skills also auto-trigger when you open or edit `.allium` files.
How skills appear depends on your editor. Some show the fully qualified form (`/allium:weed`), others show the short form (`/weed`), and some support both. If one form isn't recognised, try the other. The entry point is `/allium` — because its name matches the plugin, its fully qualified form is `/allium:allium` (the same `plugin:skill` rule as every other skill); either invokes it. Skills also auto-trigger when you open or edit `.allium` files.

Tend and weed are also available as autonomous **agents** that run in their own context, keeping Allium syntax out of your main session. Claude Code picks up agents from `agents/`, Copilot from `.github/agents/`. How editors discover skills and agents is still settling; we make these available in the most portable formats we can and expect to consolidate as conventions stabilise. If your editor doesn't pick something up, [raise an issue](https://github.com/juxt/allium/issues).

Expand Down Expand Up @@ -260,6 +260,28 @@ A developer starts a new session days later, working on a different feature.

The developer never mentioned invoicing or payment method capture. The Allium did, from a session that ended days ago. Without it, the LLM would have implemented the trial with no awareness of these constraints, and the gaps would have surfaced in production.

### The loop in motion

A developer asks for a feature and lets the loop run to convergence.

---

> ***> Add gift cards to checkout.***
>
> ✻ I'll capture this as a spec first (`/elicit`). One thing to pin down before building: can a gift card be combined with a promo code, or are they mutually exclusive?
>
> ***> Mutually exclusive.***
>
> ✻ Spec updated. Generated tests with `/propagate` and ran them — 4 new tests, all failing as expected (red). Implementing against them now.
>
> ✻ Tests green. `/weed` flagged a divergence: the code lets a gift card pay for shipping, but the spec scopes it to goods only. Fixing the code to match the spec.
>
> ✻ Re-ran: tests pass, `/weed` clean, no open questions. Spec, tests and code agree — converged.

---

The loop did the work: elicitation forced the promo-code decision up front, the red step proved the tests were real, and verification (`/weed`) caught a drift the passing tests didn't. The agent stops when the three artefacts agree, not when the code merely runs.

## Verification

When the CLI is installed, `.allium` files are validated automatically after every write or edit. Diagnostics appear inline and the model fixes issues in the same turn.
Expand Down
183 changes: 180 additions & 3 deletions plugins/allium/scripts/test-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
* node scripts/test-skills.mjs structure # run one group
* node scripts/test-skills.mjs portability links # run multiple groups
*
* Groups: structure, codex, portability, links, routing, generation, discovery, crosstalk
* Groups: structure, codex, consistency, portability, links, routing, generation, loopdocs, hooks, discovery, crosstalk
*
* The first six groups are offline (free, fast). The last two require --live
* All groups except discovery and crosstalk are offline (free, fast); those two require --live
* and make Claude API calls.
*/

import { readFileSync, existsSync } from "fs";
import { readFileSync, existsSync, readdirSync } from "fs";
import { execFileSync, execSync } from "child_process";
import path from "path";

Expand Down Expand Up @@ -102,6 +102,22 @@ function resolveRelativeLinks(body, fileDir) {
}));
}

// Broader link check for prose docs (README, references, design notes):
// any markdown link to a local path, skipping external URLs and pure anchors.
function resolveDocLinks(body, fileDir) {
const linkPattern = /\[[^\]]*\]\(([^)]+)\)/g;
const out = [];
let m;
while ((m = linkPattern.exec(body)) !== null) {
const raw = m[1].trim().split(/\s+/)[0]; // drop any "title" suffix
if (/^(https?:|mailto:|#)/.test(raw)) continue; // external or pure anchor
const noAnchor = raw.replace(/#.*$/, "");
if (!noAnchor) continue;
out.push({ link: raw, exists: existsSync(path.resolve(fileDir, noAnchor)) });
}
return out;
}

function claudeQuery(prompt, { cwd } = {}) {
const output = execFileSync(
getClaudePath(),
Expand Down Expand Up @@ -222,6 +238,8 @@ if (shouldRun("structure")) {
pass(`${label} naming`);
}
}

console.log("");
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -361,6 +379,38 @@ if (shouldRun("portability")) {
// Links — all relative markdown links resolve to real files
// ---------------------------------------------------------------------------

if (shouldRun("consistency")) {
console.log("\n── consistency: manifests & registration ──\n");

const claudePluginPath = path.join(ROOT, ".claude-plugin", "plugin.json");
const claude = readJson(claudePluginPath);
const codex = readJson(codexPluginPath);

// Version parity across the two plugin manifests.
if (claude && codex && claude.version && claude.version === codex.version) {
pass(`version parity (${claude.version})`);
} else {
fail("version parity", `claude=${claude?.version} codex=${codex?.version}`);
}

// Registration: skills/ dirs == test skillNames == .claude-plugin skills[].
const skillsRoot = path.join(ROOT, "skills");
const actualDirs = existsSync(skillsRoot)
? readdirSync(skillsRoot).filter((d) => existsSync(path.join(skillsRoot, d, "SKILL.md")))
: [];
const claudeArray = Array.isArray(claude?.skills) ? claude.skills.map((s) => path.basename(s)) : [];
const sortUniq = (a) => [...new Set(a)].sort();
const dirs = sortUniq(actualDirs);
const named = sortUniq(skillNames);
const registered = sortUniq(claudeArray);
const eq = (x, y) => x.length === y.length && x.every((v, i) => v === y[i]);
if (eq(dirs, named) && eq(dirs, registered)) {
pass(`skill registration consistent (${dirs.length} skills)`);
} else {
fail("skill registration", `dirs=[${dirs}] skillNames=[${named}] claude-plugin=[${registered}]`);
}
}

if (shouldRun("links")) {
console.log("\n── links: relative link resolution ──\n");

Expand All @@ -378,6 +428,35 @@ if (shouldRun("links")) {
pass(`${rel(filePath)} (${links.length} link${links.length !== 1 ? "s" : ""})`);
}
}

// Prose docs (README + reference docs + design notes) — broader link check
// that also covers bare relative paths, not just ./ and ../ links.
const proseDocs = [path.join(ROOT, "README.md")];
for (const n of skillNames) {
const refDir = path.join(ROOT, "skills", n, "references");
if (existsSync(refDir)) {
for (const f of readdirSync(refDir)) {
if (f.endsWith(".md")) proseDocs.push(path.join(refDir, f));
}
}
}
const designDir = path.join(ROOT, "design");
if (existsSync(designDir)) {
for (const f of readdirSync(designDir)) {
if (f.endsWith(".md")) proseDocs.push(path.join(designDir, f));
}
}
for (const filePath of proseDocs) {
if (!existsSync(filePath)) continue;
const links = resolveDocLinks(readFileSync(filePath, "utf-8"), path.dirname(filePath));
const broken = links.filter((l) => !l.exists);
for (const { link } of broken) {
fail(`${rel(filePath)}`, `broken link: ${link}`);
}
if (broken.length === 0) {
pass(`${rel(filePath)} (${links.length} link${links.length !== 1 ? "s" : ""})`);
}
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -429,6 +508,104 @@ if (shouldRun("generation")) {
}
}

// ---------------------------------------------------------------------------
// Loopdocs — the loop constants (caps + phase phrase) stay consistent across
// the docs that restate them. Canonical values live here in the test.
// ---------------------------------------------------------------------------

if (shouldRun("loopdocs")) {
console.log("\n── loopdocs: loop constant drift ──\n");

const HARD_CAP = 6;
const NO_PROGRESS = 2;
const PHASE_PHRASE = "gather context → take action → verify → repeat";

// Files that state the numeric caps.
const capFiles = [
"skills/allium/references/driving-the-loop.md",
"skills/allium/references/recommended-loops.md",
"design/loop-mode.md",
];
for (const rp of capFiles) {
const fp = path.join(ROOT, rp);
if (!existsSync(fp)) continue; // design note may be absent post-release
const src = readFileSync(fp, "utf-8");
const hard = src.match(/hard cap[^\n.]*?\b(\d+)\b/i);
const noProg = src.match(/no-progress[^\n.]*?\b(\d+)\b/i);
if (hard && Number(hard[1]) === HARD_CAP) pass(`${rp} hard cap = ${HARD_CAP}`);
else fail(`${rp} hard cap`, `expected ${HARD_CAP}, found ${hard ? hard[1] : "none"}`);
if (noProg && Number(noProg[1]) === NO_PROGRESS) pass(`${rp} no-progress cap = ${NO_PROGRESS}`);
else fail(`${rp} no-progress cap`, `expected ${NO_PROGRESS}, found ${noProg ? noProg[1] : "none"}`);
}

// Files that state the phase phrase in arrow form.
const phaseFiles = [
"skills/allium/references/driving-the-loop.md",
"skills/allium/references/recommended-loops.md",
"skills/allium/SKILL.md",
"design/loop-mode.md",
];
for (const rp of phaseFiles) {
const fp = path.join(ROOT, rp);
if (!existsSync(fp)) continue;
if (readFileSync(fp, "utf-8").includes(PHASE_PHRASE)) pass(`${rp} phase phrase`);
else fail(`${rp} phase phrase`, `missing "${PHASE_PHRASE}"`);
}

// README states the phases in verb form — check the four appear in order.
const readmePath = path.join(ROOT, "README.md");
if (existsSync(readmePath)) {
const src = readFileSync(readmePath, "utf-8");
const stems = [/gather/i, /take[s]? action/i, /verif/i, /repeat/i];
const idx = stems.map((s) => src.search(s));
if (idx.every((i) => i >= 0) && idx.every((v, i) => i === 0 || v > idx[i - 1])) {
pass("README.md phases in order");
} else {
fail("README.md phases", `not all present and in order: ${idx}`);
}
}
}

// ---------------------------------------------------------------------------
// Hooks — the PostToolUse hook config is valid and points at a real script.
// ---------------------------------------------------------------------------

if (shouldRun("hooks")) {
console.log("\n── hooks: hook config integrity ──\n");

const hooksPath = path.join(ROOT, "hooks", "hooks.json");
if (!existsSync(hooksPath)) {
fail("hooks/hooks.json", "not found");
} else {
const cfg = readJson(hooksPath);
if (!cfg) {
fail("hooks/hooks.json", "invalid JSON");
} else {
pass("hooks/hooks.json valid JSON");
const post = cfg.hooks?.PostToolUse;
if (!Array.isArray(post) || post.length === 0) {
fail("hooks PostToolUse", "missing or empty");
} else {
pass("hooks PostToolUse present");
let matchersOk = true;
let scriptsOk = true;
for (const entry of post) {
if (!entry || !entry.matcher) matchersOk = false;
const cmds = Array.isArray(entry?.hooks) ? entry.hooks : [];
for (const h of cmds) {
const m =
typeof h.command === "string" &&
h.command.match(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^"\s]+)/);
if (m && !existsSync(path.join(ROOT, m[1]))) scriptsOk = false;
}
}
matchersOk ? pass("hooks have matchers") : fail("hooks matcher", "an entry is missing a matcher");
scriptsOk ? pass("hook command scripts exist") : fail("hook command", "referenced script not found");
}
}
}
}

// ---------------------------------------------------------------------------
// Discovery — live Claude Code skill and agent loading
// ---------------------------------------------------------------------------
Expand Down
16 changes: 15 additions & 1 deletion plugins/allium/skills/allium/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,23 @@ Allium does NOT specify programming language or framework choices, database sche
| Modifying an existing spec | `tend` skill | User wants targeted changes to `.allium` files |
| Checking spec-to-code alignment | `weed` skill | User wants to find or fix divergences between spec and implementation |
| Generating tests from a spec | `propagate` skill | User wants to generate tests, PBT properties or state machine tests from a specification |
| Driving the whole loop to convergence | this skill (see [driving the loop](./references/driving-the-loop.md)) | User wants to build or reconcile a feature end to end — `/allium <goal>` runs the gather→act→verify→repeat loop autonomously until spec, tests and code agree |

## Responding to `/allium` (loop-first)

`/allium` is the entry point. Bias toward the autonomous path — the whole-loop value is exactly what occasional single-skill use misses:

- **Clear single task** → route straight to that skill (per the routing table); don't make the user wade through a menu.
- **A goal or feature** (e.g. "add gift cards", "get password reset working") → drive the whole loop end to end yourself, rather than running one phase. Follow [driving the loop](./references/driving-the-loop.md).
- **Bare or ambiguous** → orient the user loop-first: offer to drive the loop as the default, then list the individual skills as the control path with a one-line hint each, and suggest a concrete starting point from the project state (existing `.allium` specs? code but no spec? drift to reconcile?). For example:

> Tell me a goal and I'll drive the whole loop — spec → tests → code, until they agree. Or run one step yourself: `elicit` (spec from intent), `distill` (spec from existing code), `propagate` (tests from a spec), `tend` (edit a spec), `weed` (fix spec↔code drift). You have code but no `.allium` yet, so I'd start by distilling — or just give me the goal and I'll take it end to end.

Lead with the loop; keep the individual skills one step away for users who want manual control. And once a single skill finishes, proactively suggest the next phase rather than waiting to be asked.

## The Allium loop (recommended sequencing)

The skills are not one-shot commands; they compose into an autonomous-style loop — **gather context → take action → verify → repeat** — that drives three artefacts to agreement: the **spec** (intent), the **tests** (contract), and the **code** (implementation). Gather context with `/elicit` or `/distill` (the spec is durable context); take action with `/propagate` then implementation (in spec-first work, confirm the new tests fail first — a test already green before you implement is already-covered or vacuous); verify by running the tests, then `/weed`, then CLI structural checks; repeat until converged. Verification is the phase that matters most, and the spec-plus-tests-plus-weed signal is what makes the loop trustworthy. After invoking one skill, proactively suggest the next step rather than waiting to be asked.
The skills are not one-shot commands; they compose into an autonomous-style loop — **gather context → take action → verify → repeat** — that drives three artefacts to agreement: the **spec** (intent), the **tests** (contract), and the **code** (implementation). Gather context with `/elicit` or `/distill` (the spec is durable context); take action with `/propagate` then implementation (in spec-first work, confirm the new tests fail first — a test already green before you implement is already-covered or vacuous); verify by running the tests, then `/weed`, then CLI structural checks; repeat until converged. Verification is the phase that matters most, and the spec-plus-tests-plus-weed signal is what makes the loop trustworthy. After invoking one skill, proactively suggest the next step rather than waiting to be asked. To run the whole loop to convergence in one go, just give `/allium` a goal — it drives the loop for you, following [driving the loop](./references/driving-the-loop.md).

Two entry points, one convergence loop:

Expand Down Expand Up @@ -322,4 +335,5 @@ When the `allium` CLI is installed, a hook validates `.allium` files automatical
- [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
- [Recommended loops](./references/recommended-loops.md) — the gather-context → take-action → verify → repeat loop, with spec-first and code-first walkthroughs
- [Driving the loop](./references/driving-the-loop.md) — the procedure `/allium` follows to drive a goal to convergence (entry detection, the tick, stop conditions, the ledger)
- [Patterns](./references/patterns.md) — 9 worked patterns: auth, RBAC, invitations, soft delete, notifications, usage limits, comments, library spec integration, framework integration contract
Loading
Loading