diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml
index 00b409c..0d29b83 100644
--- a/.github/workflows/publish-npm.yml
+++ b/.github/workflows/publish-npm.yml
@@ -1,6 +1,16 @@
name: Publish npm package
on:
+ pull_request:
+ branches: [main]
+ paths:
+ - package.json
+ - README.md
+ - extensions/**
+ - skills/**
+ - scripts/**
+ - docs/images/**
+ - .github/workflows/publish-npm.yml
push:
branches: [main]
paths:
@@ -11,21 +21,39 @@ on:
- LICENSE
- extensions/**
- skills/**
+ - scripts/**
+ - docs/images/**
- .github/workflows/publish-npm.yml
workflow_dispatch:
concurrency:
- group: npm-publish
- cancel-in-progress: false
+ group: npm-publish-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
- id-token: write
jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ node-version: '22.x'
+ registry-url: 'https://registry.npmjs.org'
+ - name: Pi package release gate
+ run: npm run verify:package
+
publish:
- if: github.repository == 'GroepOnline/pi-control'
+ needs: verify
+ if: github.repository == 'GroepOnline/pi-control' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 742167d..a1418dd 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,23 +1,46 @@
# Architecture
-`pi-control` is a thin in-process Pi extension. It does not run a daemon or hosted control plane.
+`pi-control` is a thin, in-process Pi extension. It runs no daemon, hosts no control plane, and keeps no second copy of Pi's state — every read and write goes through the live Pi host.
```text
Pi host
- -> extensions/pi-control/index.ts
- -> commands/ operator slash workflows
- -> tools.ts structured agent tools
- -> guardrails.ts mutation / shell safety checks
- -> Pi context APIs session, model, tools, state
+ └─ extensions/pi-control/index.ts
+ ├─ commands/ operator slash workflows (/pi-demo, /pi-verify, /pi-qa)
+ ├─ tools.ts five structured agent tools
+ ├─ guardrails.ts lifecycle + tool-call safety hooks
+ └─ Pi context APIs sessions, model, tools, state
+skills/pi-control/SKILL.md packaged operating guidance
```
-## Ownership
+## Module ownership
-- `pi_session` inspects or changes the active Pi session.
-- `pi_model` controls the selected model and thinking level.
-- `pi_tool` inspects or replaces the active tool set.
-- `pi_state` stores small named snapshots used by control workflows.
-- `pi_verify` asserts observable runtime conditions after a change.
-- Guardrails intercept unsafe control/shell patterns before execution.
+| Module | Owns | Never does |
+| --- | --- | --- |
+| `index.ts` | Extension bootstrap; registers commands, tools, guardrails | Holds no state of its own |
+| `tools.ts` | `pi_session`, `pi_model`, `pi_tool`, `pi_state`, `pi_verify` | Bypasses Pi's own session/model/tool APIs |
+| `guardrails.ts` | Denies destructive shell patterns and gates unsafe mutations before execution | Intercepts anything outside control/shell patterns |
+| `commands/` | Operator workflows that compose the tools | Introduces separate state or side effects |
+| `skills/pi-control` | The capture → change → verify → report discipline for agents | Loads tools itself; Pi does that from the manifest |
-State that belongs to durable project work is intentionally outside this package; use `pi-missions`. Multi-agent execution belongs to `pi-agent-orchestrator`. Browser/terminal capture and evidence/showcase workflows belong to `pi-agent-control-extension`.
+## Data flow
+
+1. **Capture** — `pi_session inspect` / `pi_state save` record the current runtime state.
+2. **Change** — `pi_session fork|switch|compact`, `pi_model set|thinking`, `pi_tool set_active`, `pi_state restore` mutate the live process.
+3. **Verify** — `pi_verify session|model|tool|state` asserts observable expectations against the same process.
+4. **Report** — evidence comes from tool outputs and session dumps, not from a parallel model of the world.
+
+Guardrails sit in front of step 2: a denied mutation never reaches Pi's runtime.
+
+## Boundaries
+
+State that belongs to durable project work is intentionally outside this package — use [`@groeponline/pi-missions`](https://github.com/GroepOnline/pi-missions). Browser/terminal capture, QA evidence recipes, and showcase rendering belong to [`@groeponline/pi-agent-control-extension`](https://github.com/GroepOnline/pi-agent-control-extension). Operator cockpit surfaces (status bar, queue, Skill Studio) belong to [`@groeponline/pi-wishcraft`](https://github.com/GroepOnline/pi-wishcraft).
+
+## Packaging
+
+The npm package carries the extension entrypoint, the skill, and the hero assets declared in `package.json` (`pi.extensions`, `pi.skills`, `pi.image`). The [`verify:pi-package`](scripts/verify-pi-package-contract.mjs) gate validates the manifest, resource existence, public metadata, gallery preview format, Pi core peer-dependency rules, and the final packed tarball on every PR and before every publish.
+
+## Testing
+
+- `scripts/package-contract-runtime.test.mjs` — contract-parser regressions
+- `extensions/pi-control/tests/` — extension unit tests (`npm test --prefix extensions/pi-control`)
+- CI (`publish-npm.yml`) runs both plus the full gate, then publishes with provenance when the version is new
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 704e277..dbdfd99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [Unreleased]
+
+## 0.1.3
+
+- Rewrite README, architecture, and packaged skill documentation; align the skill with the current tool surface (`pi_state restore`, `pi_verify session|model|tool|state`) and make all operator documentation English-only for the Pi catalog.
+- Sharpen npm/Pi catalog metadata (description, keywords) for discoverability on pi.dev and npm search.
+
## 0.1.2 - 2026-08-30
- Document concrete structured tool calls and package ownership boundaries.
@@ -9,3 +16,7 @@
## 0.1.1
- Public npm/Pi package metadata, MIT licensing, clean install, and package-content verification.
+
+## 0.1.0
+
+- Initial release: session, model, tool, and state tools; `/pi-demo`, `/pi-verify`, `/pi-qa` commands; guardrails; packaged `pi-control` skill.
diff --git a/README.md b/README.md
index 4eeae65..0e677e4 100644
--- a/README.md
+++ b/README.md
@@ -1,115 +1,218 @@
+
+
+
+
# @groeponline/pi-control
-Pi-native runtime control and QA for operators and coding agents. `pi-control` exposes a small control plane for sessions, models, active tools, saved state, verification workflows, and guardrails without replacing Pi's agent runtime.
+**A Pi extension that gives humans and coding agents a control plane over the live Pi process** — agent sessions, model switching, tool gating, saved runtime state, QA verification, and guardrails — with every change backed by evidence from the same process it controls.
+
+`pi-control` does not replace Pi's agent runtime, spawn daemons, or mirror state into a second store. It operates directly on Pi's own session tree, model registry, tool inventory, and state history, then verifies what actually happened.
+
+[](https://www.npmjs.com/package/@groeponline/pi-control) [](https://www.npmjs.com/package/@groeponline/pi-control) [](https://pi.dev/packages/@groeponline/pi-control) [](https://github.com/GroepOnline/pi-control/actions/workflows/publish-npm.yml) 
+
+## At a glance
-[](https://www.npmjs.com/package/@groeponline/pi-control) [](https://pi.dev/packages/@groeponline/pi-control) 
+- **5 agent tools** — `pi_session`, `pi_model`, `pi_tool`, `pi_state`, `pi_verify`
+- **3 operator commands** — `/pi-demo`, `/pi-verify`, `/pi-qa`
+- **Guardrails** — destructive shell and unsafe mutation patterns are denied before execution
+- **1 packaged skill** — `pi-control` operating discipline (capture → change → verify → report)
+- **No telemetry, no daemon, no second runtime** — in-process against the live Pi host
## Install
+Persistent (all Pi sessions):
+
```bash
pi install npm:@groeponline/pi-control
```
-For one session only:
+One session only:
```bash
pi -e npm:@groeponline/pi-control
```
-[Architecture](ARCHITECTURE.md) · [Changelog](CHANGELOG.md) · [Issues](https://github.com/GroepOnline/pi-control/issues)
-
-## Where it fits
-
-`pi-control` owns **Pi runtime control and verification**: sessions, models, active tools, saved state, and assertions about the current Pi process. It is not the capture/showcase package. For browser/terminal capture, QA evidence recipes, Skill Studio, and showcase rendering use [`@groeponline/pi-agent-control-extension`](https://github.com/GroepOnline/pi-agent-control-extension).
+Pi loads both the extension and the packaged skill from the package manifest — no extra configuration.
-The wider flow is `idea (wishcraft) -> durable mission (missions) -> execution run (orchestrator) -> runtime/evidence verification (pi-control / pi-agent-control-extension)`.
+## Quick start
-## What it gives you
-
-| Surface | Purpose |
-| --- | --- |
-| `/pi-demo` | Demonstrate a concrete Pi workflow or feature with explicit verification. |
-| `/pi-verify` | Test claims about Pi runtime behavior and report evidence. |
-| `/pi-qa` | Run a structured QA flow and report PASS/FAIL evidence. |
-| `pi_session` | List, inspect, fork, switch, compact, label, and rename sessions. |
-| `pi_model` | List/switch models, inspect providers, and change thinking level. |
-| `pi_tool` | Inspect the tool inventory and change the active tool set. |
-| `pi_state` | Save, apply, diff, and inspect runtime state history. |
-| `pi_verify` | Verify session, tool-output, and behavioral expectations. |
-| Guardrails | Lifecycle and tool-call hooks for bounded operator workflows. |
-| `skills/pi-control` | Packaged operating guidance for control/verify/QA workflows. |
-
-## Tool examples
-
-Agent tools accept structured arguments. These examples show the minimum useful shape rather than pseudocode hidden behind a slash command.
+**Verify a claim about the runtime:**
```json
-{"tool":"pi_session","action":"inspect"}
+{"tool":"pi_verify","action":"session","expectations":{"entries.gt":5}}
```
+**Switch model and thinking level, then confirm:**
+
```json
{"tool":"pi_model","action":"thinking","level":"high"}
```
```json
-{"tool":"pi_tool","action":"inspect","toolName":"bash"}
+{"tool":"pi_verify","action":"model","expectations":{"thinkingLevel":"high"}}
```
+**Snapshot state before a risky change, restore it after:**
+
```json
{"tool":"pi_state","action":"save","key":"before-refactor","data":{"phase":"baseline"}}
```
```json
-{"tool":"pi_verify","action":"session","expectations":{"entries.gt":5}}
+{"tool":"pi_state","action":"restore","key":"before-refactor"}
```
-For state-changing operations, inspect first, make the smallest change, then verify. `pi_tool set_active` replaces the complete active-tool set, so it should never be used as an additive toggle by assumption.
+**Gate the toolset for a bounded run:**
-## Operating model
+```json
+{"tool":"pi_tool","action":"set_active","tools":["read","bash"]}
+```
-`pi-control` acts on Pi's live runtime state. It does not create a second session store, model router, or remote control service. A normal workflow is capture → change → verify → report, with evidence coming from the same Pi process being controlled.
+## Commands
-State-changing tools should be used deliberately: switching models, changing active tools, restoring state, or moving between sessions affects the current Pi process. The packaged skill documents the expected capture/verify discipline.
+| Command | Purpose |
+| --- | --- |
+| `/pi-demo` | Demonstrate a concrete Pi workflow or feature with explicit scope, model, and verification commitments. |
+| `/pi-verify` | Test a claim about Pi runtime behavior and report evidence. A well-evidenced "this does not work" is as valuable as a pass. |
+| `/pi-qa` | Run a structured QA flow step by step and report PASS/FAIL with evidence. |
-## Package layout
+## Agent tools
+
+### `pi_session` — manage sessions
+
+| Action | Description |
+| --- | --- |
+| `list` | List available sessions. |
+| `inspect` | Show current session details (entry count, branch, model). |
+| `fork` | Fork from an entry into a new session. |
+| `switch` | Switch to another session. |
+| `compact` | Compact the current session. |
+| `navigate` | Move through the session tree. |
+| `label` | Set or clear a label on an entry. |
+| `rename` | Rename the session. |
+
+### `pi_model` — control model and thinking
+
+| Action | Description |
+| --- | --- |
+| `list` | List available models. |
+| `providers` | Show registered providers. |
+| `set` | Switch the active model. |
+| `thinking` | Change the thinking level. |
+
+### `pi_tool` — gate the active toolset
+
+| Action | Description |
+| --- | --- |
+| `list` | Show all tools and their active/inactive status. |
+| `inspect` | Show details for a specific tool. |
+| `set_active` | Replace the complete active tool set. |
+
+> `set_active` is a **replacement**, not a toggle: it defines the full set of active tools. Inspect first, then set the smallest set you need.
+
+### `pi_state` — snapshot, diff, restore
+
+| Action | Description |
+| --- | --- |
+| `save` | Save a named snapshot of runtime state (label, summary, data). |
+| `restore` | Restore a saved snapshot. |
+| `diff` | Compare two state snapshots. |
+| `history` | Show the change history. |
+
+### `pi_verify` — assert runtime expectations
+
+| Action | Description |
+| --- | --- |
+| `session` | Assert session properties (entry counts, model, settings). |
+| `model` | Assert the active model and thinking level. |
+| `tool` | Assert tool output matched expectations. |
+| `state` | Assert state snapshot properties. |
+
+## Guardrails
+
+Lifecycle and tool-call hooks deny unsafe control patterns **before execution**, including:
+
+- destructive filesystem operations (`rm -rf /`, `rm -rf ~`, `mkfs`, `dd if=`)
+- fork-bomb patterns and remote-to-shell piping (`curl … | sh`, `wget … | sh`)
+- unsafe session mutations, gated behind explicit confirmation hooks
+
+The operating rule the skill enforces: **inspect first, make the smallest change, then verify.**
+
+## The operating loop
```text
-extensions/pi-control/
- index.ts
- tools.ts
- guardrails.ts
- commands/
-skills/pi-control/SKILL.md
+capture (pi_session inspect / pi_state save)
+ → change (fork / switch / set / thinking / set_active)
+ → verify (pi_verify)
+ → report (evidence from the same Pi process)
```
-Pi loads both the extension and the skill from the package manifest. The npm package carries the `pi-package`, `pi-extension`, and `pi-skill` discovery keywords.
+Every state-changing action is deliberate: switching models, replacing tools, restoring state, or moving between sessions affects the current Pi process. The packaged `pi-control` skill documents this discipline for agents.
-## Development
+## Where it fits
-Package boundary check:
+`pi-control` owns **runtime control and verification**. The wider GroepOnline Pi suite:
-```bash
-npm run pack:check
+| Package | Role |
+| --- | --- |
+| [`@groeponline/pi-wishcraft`](https://github.com/GroepOnline/pi-wishcraft) | Operator cockpit: powerline status bar, session queue, Skill Studio, ideas inbox |
+| [`@groeponline/pi-missions`](https://github.com/GroepOnline/pi-missions) | Durable missions that survive context resets |
+| [`@groeponline/pi-agent-control-extension`](https://github.com/GroepOnline/pi-agent-control-extension) | Browser/terminal capture, QA evidence recipes, showcase rendering |
+| [`@groeponline/pi-tools`](https://github.com/GroepOnline/pi-tools) | Shared Pi tooling |
+
+The flow: `idea (pi-wishcraft) → durable mission (pi-missions) → execution → runtime & evidence verification (pi-control / pi-agent-control-extension)`.
+
+## Package layout
+
+```text
+extensions/pi-control/
+ index.ts extension entrypoint — registers commands, tools, guardrails
+ tools.ts the five structured agent tools
+ guardrails.ts lifecycle and tool-call safety hooks
+ commands/ /pi-demo, /pi-verify, /pi-qa
+skills/pi-control/
+ SKILL.md packaged operating guidance
```
-Extension tests:
+## Development
```bash
-cd extensions/pi-control
-npm ci
-npm test
+# package contract (manifest, resources, Pi peer rules, tarball contents)
+npm run verify:package
+
+# extension unit tests
+npm ci --prefix extensions/pi-control
+npm test --prefix extensions/pi-control
```
+The `verify:pi-package` gate validates the npm/Pi package contract end to end: manifest, declared resources, public metadata, gallery preview format, Pi core peer-dependency rules, and the final packed tarball. CI runs it on every PR and before every publish.
+
## Privacy and telemetry
-`pi-control` does not collect telemetry or send runtime data to external services. It operates on the local Pi process and any state or evidence it handles remains under the operator's control.
+`pi-control` collects no telemetry and sends nothing to external services. It operates on the local Pi process; all state and evidence stays under the operator's control.
+
+## FAQ
+
+**Does it change how Pi works by default?**
+No. It adds commands, tools, and guardrails on top of the standard runtime. Anything that mutates state happens only when a tool call or command asks for it.
+
+**Can I use the tools without the commands?**
+Yes. The commands are operator workflows on top of the same five tools; agents can call the tools directly.
+
+**Does it work with any model?**
+`pi_model` operates on whatever models and providers your Pi installation has registered. It switches and verifies; it does not bundle providers.
+
+**Where does state live?**
+In Pi's own runtime state, managed through `pi_state` snapshots. There is no external database or sidecar.
-## Source and issues
+## Links
- Pi catalog:
+- npm:
- Source:
- Issues:
+- Architecture: [ARCHITECTURE.md](ARCHITECTURE.md) · Changelog: [CHANGELOG.md](CHANGELOG.md)
## License
-MIT © GroepOnline
+MIT © [GroepOnline](https://github.com/GroepOnline)
diff --git a/docs/images/pi-control-hero.png b/docs/images/pi-control-hero.png
new file mode 100644
index 0000000..2949720
Binary files /dev/null and b/docs/images/pi-control-hero.png differ
diff --git a/docs/images/pi-control-hero.svg b/docs/images/pi-control-hero.svg
new file mode 100644
index 0000000..d7a2f6f
--- /dev/null
+++ b/docs/images/pi-control-hero.svg
@@ -0,0 +1,14 @@
+
+pi-control operator loop A compact operator control loop: capture runtime state, make a bounded change, verify behavior, and keep evidence.
+
+
+pi-control
+Control the live Pi runtime. Change less. Verify more.
+
+01 capture sessions · tools · state
+02 change models · tools · guards
+03 verify claims · QA · behavior
+04 evidence report what actually ran
+
+Pi-native · local runtime · no second session store · no remote control plane
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 61cad0c..dc8e399 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@groeponline/pi-control",
- "version": "0.1.2",
- "description": "Pi runtime control and QA extension: sessions, models, tools, state, verification, guardrails, and operator workflows.",
+ "version": "0.1.3",
+ "description": "Pi extension and operator control plane: manage agent sessions, switch models, gate tools, snapshot runtime state, enforce guardrails, and verify changes with QA evidence.",
"type": "module",
"author": "GroepOnline",
"repository": {
@@ -27,7 +27,18 @@
"session-management",
"model-management",
"qa",
- "guardrails"
+ "guardrails",
+ "runtime-control",
+ "agent-ops",
+ "verification",
+ "observability",
+ "state-management",
+ "developer-tools",
+ "pi-verify",
+ "agent-control-plane",
+ "qa-evidence",
+ "session-fork",
+ "evidence"
],
"pi": {
"extensions": [
@@ -35,7 +46,8 @@
],
"skills": [
"./skills"
- ]
+ ],
+ "image": "https://raw.githubusercontent.com/GroepOnline/pi-control/main/docs/images/pi-control-hero.png"
},
"files": [
"extensions/pi-control/index.ts",
@@ -46,7 +58,9 @@
"README.md",
"ARCHITECTURE.md",
"CHANGELOG.md",
- "LICENSE"
+ "LICENSE",
+ "docs/images/pi-control-hero.png",
+ "docs/images/pi-control-hero.svg"
],
"peerDependencies": {
"@earendil-works/pi-ai": "*",
@@ -54,7 +68,10 @@
"typebox": "*"
},
"scripts": {
- "pack:check": "npm pack --dry-run --json"
+ "pack:check": "npm pack --dry-run --ignore-scripts --json",
+ "verify:pi-package": "node --test scripts/package-contract-runtime.test.mjs && node scripts/verify-pi-package-contract.mjs",
+ "verify:package": "npm run verify:pi-package",
+ "prepublishOnly": "npm run verify:pi-package"
},
"license": "MIT"
}
diff --git a/scripts/package-contract-runtime.mjs b/scripts/package-contract-runtime.mjs
new file mode 100644
index 0000000..c73eede
--- /dev/null
+++ b/scripts/package-contract-runtime.mjs
@@ -0,0 +1,242 @@
+function isIdentifierStart(char) {
+ return /[A-Za-z_$]/.test(char);
+}
+
+function isIdentifierPart(char) {
+ return /[A-Za-z0-9_$]/.test(char);
+}
+
+function tokenize(source) {
+ const text = String(source);
+ const tokens = [];
+ let i = 0;
+
+ while (i < text.length) {
+ const char = text[i];
+
+ if (/\s/.test(char)) {
+ i += 1;
+ continue;
+ }
+
+ if (char === "/" && text[i + 1] === "/") {
+ i += 2;
+ while (i < text.length && text[i] !== "\n") i += 1;
+ continue;
+ }
+
+ if (char === "/" && text[i + 1] === "*") {
+ i += 2;
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
+ i = Math.min(text.length, i + 2);
+ continue;
+ }
+
+ // A slash can start either division or a regular-expression literal.
+ // Regex literals are valid where an expression can begin; skip their raw
+ // contents so quotes inside character classes cannot become string tokens
+ // and hide a later import declaration.
+ if (char === "/") {
+ const previous = tokens.at(-1);
+ const regexPrefixPunct = new Set([
+ "(", "[", "{", "=", ",", ":", ";", "!", "?", "&", "|",
+ "+", "-", "*", "%", "~", ">", "<", "^",
+ ]);
+ const regexPrefixIds = new Set(["return", "throw", "case", "delete", "void", "typeof", "instanceof", "in", "of", "yield", "await"]);
+ const canStartRegex =
+ previous === undefined ||
+ (previous.type === "punct" && regexPrefixPunct.has(previous.value)) ||
+ (previous.type === "id" && regexPrefixIds.has(previous.value));
+
+ if (canStartRegex) {
+ let cursor = i + 1;
+ let inClass = false;
+ let closed = false;
+ while (cursor < text.length && text[cursor] !== "\n") {
+ if (text[cursor] === "\\" && cursor + 1 < text.length) {
+ cursor += 2;
+ continue;
+ }
+ if (text[cursor] === "[") inClass = true;
+ else if (text[cursor] === "]") inClass = false;
+ else if (text[cursor] === "/" && !inClass) {
+ cursor += 1;
+ while (cursor < text.length && /[A-Za-z]/.test(text[cursor])) cursor += 1;
+ closed = true;
+ break;
+ }
+ cursor += 1;
+ }
+ if (closed) {
+ i = cursor;
+ continue;
+ }
+ }
+ }
+
+ if (char === '"' || char === "'") {
+ const quote = char;
+ let value = "";
+ i += 1;
+ while (i < text.length) {
+ const current = text[i];
+ if (current === "\\" && i + 1 < text.length) {
+ value += text[i + 1];
+ i += 2;
+ continue;
+ }
+ if (current === quote) {
+ i += 1;
+ break;
+ }
+ value += current;
+ i += 1;
+ }
+ tokens.push({ type: "string", value });
+ continue;
+ }
+
+ // Template literals are not valid static module specifiers. Skip their raw
+ // text so examples such as `import("dep")` do not become false positives.
+ if (char === "`") {
+ i += 1;
+ while (i < text.length) {
+ if (text[i] === "\\" && i + 1 < text.length) {
+ i += 2;
+ continue;
+ }
+ if (text[i] === "`") {
+ i += 1;
+ break;
+ }
+ i += 1;
+ }
+ continue;
+ }
+
+ if (isIdentifierStart(char)) {
+ let value = char;
+ i += 1;
+ while (i < text.length && isIdentifierPart(text[i])) {
+ value += text[i];
+ i += 1;
+ }
+ tokens.push({ type: "id", value });
+ continue;
+ }
+
+ tokens.push({ type: "punct", value: char });
+ i += 1;
+ }
+
+ return tokens;
+}
+
+function isDirectCall(tokens, index, name) {
+ return (
+ tokens[index]?.type === "id" &&
+ tokens[index]?.value === name &&
+ tokens[index - 1]?.value !== "." &&
+ tokens[index + 1]?.value === "(" &&
+ tokens[index + 2]?.type === "string"
+ );
+}
+
+function namedClauseIsTypeOnly(tokens) {
+ if (tokens[0]?.value !== "{") return false;
+ const end = tokens.findIndex((token, index) => index > 0 && token.value === "}");
+ if (end < 0) return false;
+ const body = tokens.slice(1, end);
+ if (body.length === 0) return false;
+
+ const specifiers = [];
+ let current = [];
+ for (const token of body) {
+ if (token.value === ",") {
+ if (current.length) specifiers.push(current);
+ current = [];
+ } else {
+ current.push(token);
+ }
+ }
+ if (current.length) specifiers.push(current);
+ if (specifiers.length === 0) return false;
+
+ return specifiers.every((specifier) => {
+ const first = specifier[0];
+ const second = specifier[1];
+ return first?.type === "id" && first.value === "type" && second?.value !== "as";
+ });
+}
+
+function declarationSpecifier(tokens, start, keyword) {
+ const next = tokens[start + 1];
+
+ if (keyword === "import") {
+ if (next?.value === ".") return null; // import.meta
+ if (next?.value === "(" && tokens[start + 2]?.type === "string") {
+ return { specifier: tokens[start + 2].value, runtime: true };
+ }
+ if (next?.type === "string") {
+ return { specifier: next.value, runtime: true };
+ }
+ }
+
+ const clause = [];
+ for (let i = start + 1; i < tokens.length; i += 1) {
+ const token = tokens[i];
+ if (token.value === ";") break;
+ if (token.type === "id" && token.value === "from" && tokens[i + 1]?.type === "string") {
+ const typeOnlyDeclaration = clause[0]?.type === "id" && clause[0].value === "type";
+ const typeOnlyNamed = namedClauseIsTypeOnly(clause);
+ return {
+ specifier: tokens[i + 1].value,
+ runtime: !typeOnlyDeclaration && !typeOnlyNamed,
+ };
+ }
+ clause.push(token);
+ }
+ return null;
+}
+
+/** Return runtime module specifiers while ignoring comments and literal examples. */
+export function runtimeModuleSpecifiers(text) {
+ const tokens = tokenize(text);
+ const found = [];
+
+ for (let i = 0; i < tokens.length; i += 1) {
+ const token = tokens[i];
+ if (token.type !== "id") continue;
+
+ if ((token.value === "import" || token.value === "export") && tokens[i - 1]?.value !== ".") {
+ const result = declarationSpecifier(tokens, i, token.value);
+ if (result?.runtime) found.push(result.specifier);
+ continue;
+ }
+
+ if (isDirectCall(tokens, i, "require")) {
+ found.push(tokens[i + 2].value);
+ }
+ }
+
+ return [...new Set(found)];
+}
+
+export function importsDependency(text, dep) {
+ return runtimeModuleSpecifiers(text).some(
+ (specifier) => specifier === dep || specifier.startsWith(`${dep}/`),
+ );
+}
+
+/** npm 10 returns an array of listings; npm 12 returns `{ [name]: listing }`. */
+export function npmPackListing(parsed) {
+ if (Array.isArray(parsed)) return parsed[0] ?? null;
+ if (parsed && Array.isArray(parsed.files)) return parsed;
+ if (parsed && typeof parsed === "object") {
+ const listings = Object.values(parsed).filter(
+ (value) => value && typeof value === "object" && Array.isArray(value.files),
+ );
+ return listings[0] ?? null;
+ }
+ return null;
+}
diff --git a/scripts/package-contract-runtime.test.mjs b/scripts/package-contract-runtime.test.mjs
new file mode 100644
index 0000000..64654bf
--- /dev/null
+++ b/scripts/package-contract-runtime.test.mjs
@@ -0,0 +1,138 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { importsDependency, npmPackListing, runtimeModuleSpecifiers } from "./package-contract-runtime.mjs";
+
+const dep = "@earendil-works/pi-coding-agent";
+
+test("detects runtime module syntax including compact and multiline forms", () => {
+ for (const source of [
+ 'import { Tool } from "@earendil-works/pi-coding-agent";',
+ 'import{Tool}from"@earendil-works/pi-coding-agent";',
+ 'import {\n Tool,\n} from "@earendil-works/pi-coding-agent";',
+ 'export { Tool } from "@earendil-works/pi-coding-agent";',
+ 'import "@earendil-works/pi-coding-agent";',
+ 'const api = await import("@earendil-works/pi-coding-agent");',
+ 'const api = import("@earendil-works/pi-coding-agent", { with: { type: "json" } });',
+ 'const api = require("@earendil-works/pi-coding-agent");',
+ 'export * from "@earendil-works/pi-coding-agent";',
+ 'export * as api from "@earendil-works/pi-coding-agent";',
+ ]) {
+ assert.equal(importsDependency(source, dep), true, source);
+ }
+});
+
+test("ignores type-only imports and exports", () => {
+ for (const source of [
+ 'import type { Tool } from "@earendil-works/pi-coding-agent";',
+ 'export type { Tool } from "@earendil-works/pi-coding-agent";',
+ 'import { type Tool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";',
+ 'export { type Tool } from "@earendil-works/pi-coding-agent";',
+ ]) {
+ assert.equal(importsDependency(source, dep), false, source);
+ }
+});
+
+test("treats mixed named clauses and empty imports as runtime dependencies", () => {
+ for (const source of [
+ 'import { type Tool, runtimeValue } from "@earendil-works/pi-coding-agent";',
+ 'export { runtimeValue, type Tool } from "@earendil-works/pi-coding-agent";',
+ 'import {} from "@earendil-works/pi-coding-agent";',
+ ]) {
+ assert.equal(importsDependency(source, dep), true, source);
+ }
+});
+
+test("does not mistake a runtime binding named type for a type modifier", () => {
+ for (const source of [
+ 'import { type as RuntimeType } from "@earendil-works/pi-coding-agent";',
+ 'export { type as RuntimeType } from "@earendil-works/pi-coding-agent";',
+ ]) {
+ assert.equal(importsDependency(source, dep), true, source);
+ }
+});
+
+test("ignores comments, literal examples, and member methods", () => {
+ for (const source of [
+ '// import("@earendil-works/pi-coding-agent")',
+ '/* require("@earendil-works/pi-coding-agent") */',
+ 'const text = \'require("@earendil-works/pi-coding-agent")\';',
+ 'const text = `import("@earendil-works/pi-coding-agent")`;',
+ 'loader.import("@earendil-works/pi-coding-agent");',
+ 'module.require("@earendil-works/pi-coding-agent");',
+ 'require?.("@earendil-works/pi-coding-agent");',
+ 'const api = import(specifier);',
+ 'const api = require(specifier);',
+ 'const api = import(`@earendil-works/pi-coding-agent`);',
+ 'const quotient = value / 2 / importExample;',
+ 'const importation = "from @earendil-works/pi-coding-agent";',
+ ]) {
+ assert.equal(importsDependency(source, dep), false, source);
+ }
+});
+
+test("ignores quotes inside regex literals before later imports", () => {
+ const source = `
+ const quoted = /["']/g;
+ const escaped = /foo\\/bar[\"']/i;
+ import { Tool } from "@earendil-works/pi-coding-agent";
+ `;
+ assert.deepEqual(runtimeModuleSpecifiers(source), ["@earendil-works/pi-coding-agent"]);
+ assert.equal(importsDependency(source, dep), true);
+});
+
+test("recognizes regex literals after expression operators before later imports", () => {
+ const source = `
+ const matcher = () => /["']/;
+ const compared = value > /["']/.test(value);
+ import { Tool } from "@earendil-works/pi-coding-agent";
+ `;
+ assert.deepEqual(runtimeModuleSpecifiers(source), ["@earendil-works/pi-coding-agent"]);
+ assert.equal(importsDependency(source, dep), true);
+});
+
+test("keeps local runtime specifiers for graph traversal", () => {
+ assert.deepEqual(
+ runtimeModuleSpecifiers(`
+ import "./guardrails.ts";
+ export { run } from './commands/run.ts';
+ import type { Config } from './types.ts';
+ `),
+ ["./guardrails.ts", "./commands/run.ts"],
+ );
+});
+
+test("returns unique runtime specifiers in first-seen order", () => {
+ assert.deepEqual(
+ runtimeModuleSpecifiers(`
+ import "first";
+ const again = require("first");
+ export * from "second";
+ import("first/subpath");
+ `),
+ ["first", "second", "first/subpath"],
+ );
+});
+
+test("handles empty and non-string scanner inputs", () => {
+ assert.deepEqual(runtimeModuleSpecifiers(""), []);
+ assert.deepEqual(runtimeModuleSpecifiers(null), []);
+ assert.deepEqual(runtimeModuleSpecifiers(undefined), []);
+ assert.equal(importsDependency(false, dep), false);
+});
+
+test("matches dependency subpaths but not prefix collisions", () => {
+ assert.equal(importsDependency(`import "${dep}/internal";`, dep), true);
+ assert.equal(importsDependency(`import "${dep}-extra";`, dep), false);
+});
+
+test("reads npm pack --json from npm 10 arrays and npm 12 name maps", () => {
+ const listing = { files: [{ path: "package.json" }, { path: "extensions/pi-control/index.ts" }] };
+ assert.equal(npmPackListing([listing]), listing);
+ assert.equal(npmPackListing({ "@groeponline/pi-control": listing }), listing);
+ assert.equal(npmPackListing(listing), listing);
+ assert.equal(npmPackListing({ metadata: null, package: listing }), listing);
+ assert.equal(npmPackListing([]), null);
+ assert.equal(npmPackListing({}), null);
+ assert.equal(npmPackListing(null), null);
+ assert.equal(npmPackListing("invalid"), null);
+});
diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs
new file mode 100644
index 0000000..602be55
--- /dev/null
+++ b/scripts/verify-pi-package-contract.mjs
@@ -0,0 +1,269 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { npmPackListing, runtimeModuleSpecifiers } from "./package-contract-runtime.mjs";
+
+const DOCS = "https://pi.dev/docs/latest/packages";
+const packageRoot = path.resolve(process.argv[2] || process.cwd());
+const packageJsonPath = path.join(packageRoot, "package.json");
+const failures = [];
+const notes = [];
+const fail = (message) => failures.push(message);
+const normalize = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
+const globPattern = /[*?{}[\]]/;
+const codeExt = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
+const core = [
+ "@earendil-works/pi-ai",
+ "@earendil-works/pi-agent-core",
+ "@earendil-works/pi-coding-agent",
+ "@earendil-works/pi-tui",
+ "typebox",
+];
+
+if (!fs.existsSync(packageJsonPath)) {
+ console.error(`Pi package contract: package.json not found at ${packageJsonPath}`);
+ process.exit(2);
+}
+
+const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
+const pi = pkg.pi;
+if (!pkg.name) fail("package.json needs a package name");
+if (pkg.private === true) fail("package must not be private");
+if (!Array.isArray(pkg.keywords) || !pkg.keywords.includes("pi-package")) fail('keywords must include "pi-package"');
+if (String(pkg.name || "").startsWith("@groeponline/") && !pkg.keywords?.includes("groeponline")) fail('GroepOnline packages must include the "groeponline" keyword');
+const descriptionLength = typeof pkg.description === "string" ? pkg.description.trim().length : 0;
+if (descriptionLength < 40 || descriptionLength > 240) fail("description must be 40-240 characters of useful gallery copy");
+for (const field of ["author", "license", "repository", "homepage", "bugs"]) {
+ if (!pkg[field]) fail(`missing package metadata: ${field}`);
+}
+if (String(pkg.name || "").startsWith("@") && pkg.publishConfig?.access !== "public") fail('scoped public Pi packages need publishConfig.access = "public"');
+if (!pi || typeof pi !== "object" || Array.isArray(pi)) fail("explicit pi manifest is required by the GroepOnline release standard");
+
+const resourceKeys = ["extensions", "skills", "prompts", "themes"];
+const resourcesByKey = new Map();
+for (const key of resourceKeys) {
+ const values = pi?.[key];
+ if (values !== undefined && !Array.isArray(values)) {
+ fail(`pi.${key} must be an array when present`);
+ continue;
+ }
+ resourcesByKey.set(key, values || []);
+}
+if (![...resourcesByKey.values()].some((values) => values.length)) fail("pi manifest must expose at least one extension, skill, prompt, or theme resource");
+
+const preview = pi?.video || pi?.image;
+if (!preview) fail("GroepOnline gallery standard requires pi.video or pi.image");
+for (const [field, allowed] of [["video", [".mp4"]], ["image", [".png", ".jpg", ".jpeg", ".gif", ".webp"]]]) {
+ const value = pi?.[field];
+ if (!value) continue;
+ let url;
+ try {
+ url = new URL(value);
+ } catch {
+ fail(`pi.${field} must be an absolute HTTPS URL`);
+ continue;
+ }
+ if (url.protocol !== "https:") fail(`pi.${field} must use HTTPS`);
+ const ext = path.extname(url.pathname).toLowerCase();
+ if (!allowed.includes(ext)) fail(`pi.${field} has unsupported format ${ext || "(none)"}; allowed: ${allowed.join(", ")}`);
+}
+
+let repoRoot = null;
+try {
+ repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
+ cwd: packageRoot,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ }).trim();
+} catch {
+ notes.push("git root unavailable; skipped same-repo preview asset existence check");
+}
+if (repoRoot) {
+ for (const field of ["image", "video"]) {
+ const value = pi?.[field];
+ const match = typeof value === "string" && value.match(/^https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/main\/(.+)$/);
+ if (match && !fs.existsSync(path.join(repoRoot, match[1]))) fail(`pi.${field} points at a same-repo raw asset that does not exist: ${match[1]}`);
+ }
+}
+
+const insidePackage = (candidate) => {
+ const resolved = path.resolve(packageRoot, candidate);
+ const relative = path.relative(packageRoot, resolved);
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
+};
+
+const assertResourcePattern = (key, raw) => {
+ if (typeof raw !== "string" || !raw.trim()) {
+ fail(`pi.${key} contains an invalid resource path`);
+ return null;
+ }
+ const negative = raw.startsWith("!");
+ const clean = normalize(negative ? raw.slice(1) : raw);
+ if (!clean || clean.includes("\0") || path.isAbsolute(clean) || path.posix.isAbsolute(clean) || path.win32.isAbsolute(clean) || !insidePackage(clean)) {
+ fail(`pi.${key} resource escapes package root: ${raw}`);
+ return null;
+ }
+ return { clean, negative };
+};
+
+const collectFiles = (relativePath, out) => {
+ const local = path.resolve(packageRoot, relativePath);
+ if (!insidePackage(relativePath) || !fs.existsSync(local)) return;
+ const real = fs.realpathSync(local);
+ const rootReal = fs.realpathSync(packageRoot);
+ const realRelative = path.relative(rootReal, real);
+ if (realRelative === ".." || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
+ fail(`resource resolves through a symlink outside package root: ${relativePath}`);
+ return;
+ }
+ const stat = fs.statSync(local);
+ if (stat.isDirectory()) {
+ for (const entry of fs.readdirSync(local, { withFileTypes: true })) {
+ collectFiles(normalize(path.relative(packageRoot, path.join(local, entry.name))), out);
+ }
+ return;
+ }
+ if (stat.isFile()) out.add(normalize(path.relative(packageRoot, local)));
+};
+
+const expandPattern = (key, entry) => {
+ const matches = new Set();
+ if (globPattern.test(entry.clean)) {
+ if (typeof fs.globSync !== "function") {
+ fail("glob resources require Node.js >= 22");
+ return matches;
+ }
+ for (const match of fs.globSync(entry.clean, { cwd: packageRoot })) collectFiles(normalize(match), matches);
+ if (!entry.negative && matches.size === 0) fail(`pi.${key} resource glob matches nothing: ${entry.clean}`);
+ } else if (!fs.existsSync(path.resolve(packageRoot, entry.clean))) {
+ if (!entry.negative) fail(`pi.${key} resource does not exist after build: ${entry.clean}`);
+ } else {
+ collectFiles(entry.clean, matches);
+ }
+ return matches;
+};
+
+const resourceFiles = new Map();
+for (const [key, values] of resourcesByKey) {
+ const included = new Set();
+ const excluded = new Set();
+ let positives = 0;
+ for (const raw of values) {
+ const entry = assertResourcePattern(key, raw);
+ if (!entry) continue;
+ if (!entry.negative) positives += 1;
+ const matches = expandPattern(key, entry);
+ for (const file of matches) (entry.negative ? excluded : included).add(file);
+ }
+ for (const file of excluded) included.delete(file);
+ if (positives > 0 && included.size === 0) fail(`pi.${key} resolves to no packaged files after exclusions`);
+ resourceFiles.set(key, included);
+}
+if (![...resourceFiles.values()].some((files) => files.size > 0)) {
+ fail("pi manifest must resolve to at least one packaged Pi resource");
+}
+
+const peer = pkg.peerDependencies || {};
+for (const dep of core) {
+ if (peer[dep] !== undefined && peer[dep] !== "*") fail(`Pi core peer ${dep} must use "*", found ${JSON.stringify(peer[dep])}`);
+ if (pkg.dependencies?.[dep] !== undefined) fail(`Pi core package ${dep} must not be in dependencies; use peerDependencies: "*"`);
+ if ((pkg.bundledDependencies || pkg.bundleDependencies || []).includes(dep)) fail(`Pi core package ${dep} must not be bundled`);
+}
+
+let packed = null;
+try {
+ packed = npmPackListing(JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], {
+ cwd: packageRoot,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ })));
+ if (!packed) fail("npm pack --dry-run returned no package listing");
+} catch (error) {
+ fail(`npm pack --dry-run failed: ${error.stderr?.toString().trim() || error.message}`);
+}
+
+const packedFiles = new Set((packed?.files || []).map((file) => normalize(file.path)));
+if (packed) {
+ if (!packedFiles.has("package.json")) fail("npm tarball is missing package.json");
+ if (![...packedFiles].some((file) => /^readme(?:\.|$)/i.test(file))) fail("npm tarball is missing README");
+ for (const [key, files] of resourceFiles) {
+ for (const file of files) {
+ if (!packedFiles.has(file)) fail(`pi.${key} resource file is not present in npm tarball: ${file}`);
+ }
+ }
+ notes.push(`${packed.files?.length || 0} packed files, ${packed.size || 0} bytes`);
+}
+
+function dependencyMatches(specifier, dep) {
+ return specifier === dep || specifier.startsWith(`${dep}/`);
+}
+
+function resolveLocalRuntimeModule(fromFile, specifier) {
+ const fromDir = path.posix.dirname(normalize(fromFile));
+ const raw = normalize(path.posix.normalize(path.posix.join(fromDir, specifier)));
+ const candidates = [raw];
+ if (!codeExt.has(path.posix.extname(raw))) {
+ for (const ext of codeExt) candidates.push(`${raw}${ext}`);
+ for (const ext of codeExt) candidates.push(`${raw}/index${ext}`);
+ } else if (raw.endsWith(".js")) {
+ const stem = raw.slice(0, -3);
+ for (const ext of [".ts", ".tsx", ".mjs", ".cjs", ".jsx"]) candidates.push(`${stem}${ext}`);
+ }
+ return candidates.find((candidate) => packedFiles.has(candidate)) || null;
+}
+
+const runtimeEntrypoints = [...(resourceFiles.get("extensions") || [])]
+ .filter((file) => codeExt.has(path.extname(file)));
+if ((resourcesByKey.get("extensions") || []).length > 0 && runtimeEntrypoints.length === 0) {
+ fail("pi.extensions declares resources but resolves to no runtime module entrypoint");
+}
+
+const runtimeQueue = [...runtimeEntrypoints];
+const runtimeSeen = new Set();
+while (runtimeQueue.length > 0) {
+ const file = runtimeQueue.shift();
+ if (runtimeSeen.has(file)) continue;
+ runtimeSeen.add(file);
+
+ if (!packedFiles.has(file)) {
+ fail(`runtime module is not present in npm tarball: ${file}`);
+ continue;
+ }
+ const local = path.join(packageRoot, file);
+ if (!fs.existsSync(local)) {
+ fail(`runtime module is missing on disk: ${file}`);
+ continue;
+ }
+
+ const source = fs.readFileSync(local, "utf8");
+ for (const specifier of runtimeModuleSpecifiers(source)) {
+ if (specifier.startsWith(".")) {
+ const resolved = resolveLocalRuntimeModule(file, specifier);
+ if (!resolved) {
+ fail(`packed runtime module ${file} imports missing local module ${specifier}`);
+ } else if (!runtimeSeen.has(resolved)) {
+ runtimeQueue.push(resolved);
+ }
+ continue;
+ }
+
+ for (const dep of core) {
+ if (dependencyMatches(specifier, dep) && peer[dep] !== "*") {
+ fail(`packed runtime imports ${dep}; peerDependencies.${dep} must be "*"`);
+ }
+ }
+ if (dependencyMatches(specifier, "@sinclair/typebox") && pkg.dependencies?.["@sinclair/typebox"] === undefined) {
+ fail('packed runtime imports @sinclair/typebox; it is third-party under the current Pi contract and must be in dependencies (Pi core is the separate "typebox" package)');
+ }
+ }
+}
+notes.push(`${runtimeSeen.size} runtime module${runtimeSeen.size === 1 ? "" : "s"} traversed from pi.extensions`);
+
+if (failures.length) {
+ console.error("Pi package contract FAILED");
+ for (const message of failures) console.error(`- ${message}`);
+ console.error(`Docs: ${DOCS}`);
+ process.exit(1);
+}
+console.log(`Pi package contract OK: ${pkg.name}@${pkg.version}`);
+for (const note of notes) console.log(`- ${note}`);
diff --git a/scripts/verify-pi-package-contract.test.mjs b/scripts/verify-pi-package-contract.test.mjs
new file mode 100644
index 0000000..45d6305
--- /dev/null
+++ b/scripts/verify-pi-package-contract.test.mjs
@@ -0,0 +1,348 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+const verifier = fileURLToPath(new URL("./verify-pi-package-contract.mjs", import.meta.url));
+
+function basePackage() {
+ return {
+ name: "pi-contract-fixture",
+ version: "1.0.0",
+ description: "A useful Pi package fixture with enough detail for gallery validation.",
+ keywords: ["pi-package"],
+ author: "Test Author",
+ license: "MIT",
+ repository: "https://example.com/pi-contract-fixture.git",
+ homepage: "https://example.com/pi-contract-fixture",
+ bugs: "https://example.com/pi-contract-fixture/issues",
+ files: ["extensions", "README.md"],
+ pi: {
+ extensions: ["extensions/index.js"],
+ image: "https://example.com/preview.png",
+ },
+ peerDependencies: {
+ "@earendil-works/pi-coding-agent": "*",
+ },
+ };
+}
+
+function createFixture(t) {
+ const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-package-contract-"));
+ const packageRoot = path.join(fixtureRoot, "package");
+ fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true });
+ fs.writeFileSync(path.join(packageRoot, "README.md"), "# Fixture\n");
+ fs.writeFileSync(
+ path.join(packageRoot, "extensions", "index.js"),
+ 'import "./helper.js";\nimport { Tool } from "@earendil-works/pi-coding-agent";\n',
+ );
+ fs.writeFileSync(path.join(packageRoot, "extensions", "helper.ts"), "export const helper = true;\n");
+ fs.writeFileSync(path.join(packageRoot, "package.json"), `${JSON.stringify(basePackage(), null, 2)}\n`);
+ t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
+ return { fixtureRoot, packageRoot };
+}
+
+function readPackage(packageRoot) {
+ return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
+}
+
+function updatePackage(packageRoot, mutate) {
+ const pkg = readPackage(packageRoot);
+ mutate(pkg);
+ fs.writeFileSync(path.join(packageRoot, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
+}
+
+function writeFixtureFile(packageRoot, relativePath, contents) {
+ const target = path.join(packageRoot, relativePath);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, contents);
+}
+
+function runVerifier(packageRoot, options = {}) {
+ const result = spawnSync(process.execPath, [verifier, packageRoot], {
+ encoding: "utf8",
+ env: options.env,
+ timeout: 15_000,
+ });
+ assert.equal(result.error, undefined, result.error?.message);
+ return {
+ status: result.status,
+ output: `${result.stdout}${result.stderr}`,
+ };
+}
+
+function assertFailed(result, ...messages) {
+ assert.equal(result.status, 1, result.output);
+ assert.match(result.output, /Pi package contract FAILED/);
+ for (const message of messages) assert.match(result.output, message);
+}
+
+test("accepts a complete package and traverses packed local runtime modules", (t) => {
+ const { packageRoot } = createFixture(t);
+ const result = runVerifier(packageRoot);
+
+ assert.equal(result.status, 0, result.output);
+ assert.match(result.output, /Pi package contract OK: pi-contract-fixture@1\.0\.0/);
+ assert.match(result.output, /2 runtime modules traversed from pi\.extensions/);
+});
+
+test("accepts descriptions at both inclusive length boundaries", (t) => {
+ const minimum = createFixture(t);
+ updatePackage(minimum.packageRoot, (pkg) => {
+ pkg.description = "x".repeat(40);
+ });
+ assert.equal(runVerifier(minimum.packageRoot).status, 0);
+
+ const maximum = createFixture(t);
+ updatePackage(maximum.packageRoot, (pkg) => {
+ pkg.description = "x".repeat(240);
+ });
+ assert.equal(runVerifier(maximum.packageRoot).status, 0);
+});
+
+test("supports positive and negative globs while resolving directory index modules", (t) => {
+ const { packageRoot } = createFixture(t);
+ fs.writeFileSync(path.join(packageRoot, "extensions", "index.js"), 'import "./nested";\n');
+ writeFixtureFile(packageRoot, "extensions/nested/index.ts", "export const nested = true;\n");
+ writeFixtureFile(
+ packageRoot,
+ "extensions/private.js",
+ 'import "@earendil-works/pi-ai";\n',
+ );
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.extensions = ["extensions/**/*.js", "!extensions/private.js"];
+ pkg.pi.image = "https://example.com/preview.JPG?raw=1";
+ delete pkg.peerDependencies;
+ });
+
+ const result = runVerifier(packageRoot);
+
+ assert.equal(result.status, 0, result.output);
+ assert.match(result.output, /2 runtime modules traversed from pi\.extensions/);
+});
+
+test("returns exit code 2 when package.json is absent", (t) => {
+ const { packageRoot } = createFixture(t);
+ fs.rmSync(path.join(packageRoot, "package.json"));
+
+ const result = runVerifier(packageRoot);
+
+ assert.equal(result.status, 2, result.output);
+ assert.match(result.output, /package\.json not found/);
+});
+
+test("reports package metadata and manifest violations together", (t) => {
+ const { packageRoot } = createFixture(t);
+ updatePackage(packageRoot, (pkg) => {
+ pkg.name = "@groeponline/invalid";
+ pkg.private = true;
+ pkg.description = "too short";
+ pkg.keywords = ["pi-package"];
+ delete pkg.author;
+ delete pkg.license;
+ delete pkg.repository;
+ delete pkg.homepage;
+ delete pkg.bugs;
+ delete pkg.publishConfig;
+ delete pkg.pi;
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /package must not be private/,
+ /GroepOnline packages must include the "groeponline" keyword/,
+ /description must be 40-240 characters/,
+ /missing package metadata: author/,
+ /missing package metadata: license/,
+ /missing package metadata: repository/,
+ /missing package metadata: homepage/,
+ /missing package metadata: bugs/,
+ /publishConfig\.access = "public"/,
+ /explicit pi manifest is required/,
+ /pi manifest must expose at least one/,
+ /requires pi\.video or pi\.image/,
+ );
+});
+
+test("validates preview URL protocols and media formats", (t) => {
+ const { packageRoot } = createFixture(t);
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.image = "http://example.com/preview.svg";
+ pkg.pi.video = "not-an-absolute-url";
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /pi\.image must use HTTPS/,
+ /pi\.image has unsupported format \.svg/,
+ /pi\.video must be an absolute HTTPS URL/,
+ );
+});
+
+test("rejects a missing same-repository raw preview asset", (t) => {
+ const { fixtureRoot, packageRoot } = createFixture(t);
+ const bin = path.join(fixtureRoot, "bin");
+ fs.mkdirSync(bin);
+ fs.writeFileSync(path.join(bin, "git"), '#!/bin/sh\nprintf "%s\\n" "$PI_CONTRACT_TEST_GIT_ROOT"\n');
+ fs.chmodSync(path.join(bin, "git"), 0o755);
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.image = "https://raw.githubusercontent.com/example/project/main/docs/missing.png";
+ });
+
+ assertFailed(
+ runVerifier(packageRoot, {
+ env: {
+ ...process.env,
+ PATH: `${bin}${path.delimiter}${process.env.PATH}`,
+ PI_CONTRACT_TEST_GIT_ROOT: packageRoot,
+ },
+ }),
+ /pi\.image points at a same-repo raw asset that does not exist: docs\/missing\.png/,
+ );
+});
+
+test("rejects invalid, escaping, and unmatched resource patterns", (t) => {
+ const { packageRoot } = createFixture(t);
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.extensions = [null, "../outside.js", "/absolute.js", "missing.js", "extensions/*.tsx"];
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /pi\.extensions contains an invalid resource path/,
+ /pi\.extensions resource escapes package root: \.\.\/outside\.js/,
+ /pi\.extensions resource escapes package root: \/absolute\.js/,
+ /pi\.extensions resource does not exist after build: missing\.js/,
+ /pi\.extensions resource glob matches nothing: extensions\/\*\.tsx/,
+ /pi\.extensions resolves to no packaged files after exclusions/,
+ );
+});
+
+test("rejects resource declarations that are not arrays", (t) => {
+ const { packageRoot } = createFixture(t);
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.extensions = "extensions/index.js";
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /pi\.extensions must be an array when present/,
+ /pi manifest must expose at least one extension, skill, prompt, or theme resource/,
+ );
+});
+
+test("applies negative resource globs and rejects an empty result", (t) => {
+ const { packageRoot } = createFixture(t);
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.extensions = ["extensions/*.js", "!extensions/*.js"];
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /pi\.extensions resolves to no packaged files after exclusions/,
+ /pi manifest must resolve to at least one packaged Pi resource/,
+ );
+});
+
+test("rejects resources that resolve through a symlink outside the package", (t) => {
+ const { fixtureRoot, packageRoot } = createFixture(t);
+ const outside = path.join(fixtureRoot, "outside.js");
+ fs.writeFileSync(outside, "export default true;\n");
+ fs.symlinkSync(outside, path.join(packageRoot, "extensions", "outside.js"));
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.extensions = ["extensions/outside.js"];
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /resource resolves through a symlink outside package root: extensions\/outside\.js/,
+ );
+});
+
+test("requires resolved resources and README to be present in the tarball", (t) => {
+ const { packageRoot } = createFixture(t);
+ fs.rmSync(path.join(packageRoot, "README.md"));
+ updatePackage(packageRoot, (pkg) => {
+ pkg.files = [];
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /npm tarball is missing README/,
+ /pi\.extensions resource file is not present in npm tarball: extensions\/index\.js/,
+ );
+});
+
+test("enforces Pi core dependency placement and wildcard peer ranges", (t) => {
+ const { packageRoot } = createFixture(t);
+ updatePackage(packageRoot, (pkg) => {
+ pkg.peerDependencies["@earendil-works/pi-coding-agent"] = "^1.0.0";
+ pkg.dependencies = { "@earendil-works/pi-ai": "1.0.0" };
+ pkg.bundledDependencies = ["@earendil-works/pi-tui"];
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /Pi core peer @earendil-works\/pi-coding-agent must use "\*"/,
+ /Pi core package @earendil-works\/pi-ai must not be in dependencies/,
+ /Pi core package @earendil-works\/pi-tui must not be bundled/,
+ /packed runtime imports @earendil-works\/pi-coding-agent; peerDependencies/,
+ );
+});
+
+test("detects missing local modules throughout the runtime graph", (t) => {
+ const { packageRoot } = createFixture(t);
+ fs.writeFileSync(path.join(packageRoot, "extensions", "helper.ts"), 'import "./missing.js";\n');
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /packed runtime module extensions\/helper\.ts imports missing local module \.\/missing\.js/,
+ );
+});
+
+test("requires third-party typebox imports to be regular dependencies", (t) => {
+ const { packageRoot } = createFixture(t);
+ fs.writeFileSync(
+ path.join(packageRoot, "extensions", "helper.ts"),
+ 'import { Type } from "@sinclair/typebox/value";\n',
+ );
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /packed runtime imports @sinclair\/typebox;.*must be in dependencies/,
+ );
+});
+
+test("requires extension resources to resolve to a runtime entrypoint", (t) => {
+ const { packageRoot } = createFixture(t);
+ writeFixtureFile(packageRoot, "extensions/notes.txt", "not executable\n");
+ updatePackage(packageRoot, (pkg) => {
+ pkg.pi.extensions = ["extensions/notes.txt"];
+ });
+
+ assertFailed(
+ runVerifier(packageRoot),
+ /pi\.extensions declares resources but resolves to no runtime module entrypoint/,
+ );
+});
+
+test("accepts a skill-only package without runtime entrypoints", (t) => {
+ const { packageRoot } = createFixture(t);
+ writeFixtureFile(packageRoot, "skills/example/SKILL.md", "# Example skill\n");
+ updatePackage(packageRoot, (pkg) => {
+ pkg.files = ["skills", "README.md"];
+ pkg.pi = {
+ skills: ["skills"],
+ image: "https://example.com/preview.webp",
+ };
+ delete pkg.peerDependencies;
+ });
+
+ const result = runVerifier(packageRoot);
+
+ assert.equal(result.status, 0, result.output);
+ assert.match(result.output, /0 runtime modules traversed from pi\.extensions/);
+});
diff --git a/skills/pi-control/SKILL.md b/skills/pi-control/SKILL.md
index 9101b58..a21e82d 100644
--- a/skills/pi-control/SKILL.md
+++ b/skills/pi-control/SKILL.md
@@ -1,160 +1,150 @@
---
name: pi-control
-description: Control Pi agent sessions, models, tools, and workflows. Gebruik dit om Pi's gedrag te beheren, sessies te navigeren, en workflows te automatiseren.
+description: Control the live Pi agent runtime — sessions, models, tools, state, and verification. Use this to navigate Pi sessions, switch models, gate tools, snapshot state, and prove changes with evidence.
---
# Pi Control
-Beheer Pi's eigen runtime. Drie routing beslissingen bepalen welke tools en vaardigheden je laadt.
+Operate Pi's own runtime. Three routing decisions decide which tools and skills you load.
-## Grondregels
+## Ground rules
-1. **Echte sessies, echte toestand.** Pi's sessies, modellen, en tools zijn live. Geen mocks of fixtures.
-2. **Commit to execute.** Als je een plan hebt, voer het uit. Bij fouten: herstel en retry.
-3. **Tools zijn atomisch.** Eén tool per operatie. Geen cross-referentie nodig.
-4. **Isoleer elke operatie.** Gebruik `RUN_ID` voor alle sessions en output paden.
+1. **Real sessions, real state.** Pi's sessions, models, and tools are live. No mocks or fixtures.
+2. **Commit to execute.** When you have a plan, run it. On failure: recover and retry.
+3. **Tools are atomic.** One tool per operation. No cross-references needed.
+4. **Isolate every operation.** Scope all sessions and output paths to a `RUN_ID`.
## Routing
-Drie onafhankelijke lookups. Doe alle drie, laad dan de tools en vaardigheden die ze produceren.
+Three independent lookups. Do all three, then load the tools and skills they produce.
-### 1. Target route — wat wil je controleren?
+### 1. Target route — what do you want to control?
-| Target | Tools | Vaardigheid |
-|---|---|---|
-| Pi sessies | `pi_session` | **pi-control-session** |
+| Target | Tool | Skill |
+| --- | --- | --- |
+| Pi sessions | `pi_session` | **pi-control-session** |
| Pi model | `pi_model` | **pi-control-model** |
| Pi tools | `pi_tool` | **pi-control-tools** |
-| Pi staat | `pi_state` | **pi-control-state** |
-| Pi verifiëren | `pi_verify` | **pi-control-verify** |
+| Pi state | `pi_state` | **pi-control-state** |
+| Pi verification | `pi_verify` | **pi-control-verify** |
-### 2. Stage route — wat heeft de workflow nodig?
+### 2. Stage route — what does the workflow need?
-| Stage | Tools | Wanneer laden |
-|---|---|---|
-| **Capture** (sessie/state vastleggen) | `pi_session list`, `pi_state save` | Altijd — elke workflow begint met huidige toestand |
-| **Compose** (sessie manipuleren) | `pi_session fork`, `pi_session compact`, `pi_state apply` | Als je sessies wijzigt of state herstelt |
-| **Verify** (controleren) | `pi_verify`, `pi_session inspect` | Altijd — elke workflow eindigt met verificatie |
+| Stage | Tools | When to load |
+| --- | --- | --- |
+| **Capture** (record current state) | `pi_session list`, `pi_state save` | Always — every workflow starts from current state |
+| **Compose** (mutate sessions/state) | `pi_session fork`, `pi_session compact`, `pi_state restore` | When changing sessions or restoring state |
+| **Verify** (check results) | `pi_verify`, `pi_session inspect` | Always — every workflow ends with verification |
-### 3. Guard route — welke beveiliging is nodig?
+### 3. Guard route — which safety is needed?
-| Behoefte | Guard |
-|---|---|
-| Blokkeer gevaarlijke `bash` commando's | `tool_call` guard |
-| Bevestig sessie-wijzigingen | `session_before_switch` guard |
-| Automatische state tracking | `turn_start` + `turn_end` hooks |
+| Need | Guard |
+| --- | --- |
+| Block dangerous `bash` commands | `tool_call` guard |
+| Confirm session mutations | `session_before_switch` guard |
+| Automatic state tracking | `turn_start` + `turn_end` hooks |
-## Workflow vorm
+## Workflow shape
```
Command (intent + commitments)
- → Target route (welk aspect van Pi)
- → Capture (huidige toestand vastleggen)
- → Compose (sessie/model/tools wijzigen)
- → Verify (controleren tegen commitments)
+ → Target route (which aspect of Pi)
+ → Capture (record current state)
+ → Compose (change sessions/model/tools/state)
+ → Verify (check against commitments)
→ Report
```
-### Layout default
+| Flow | Type | Shape |
+| --- | --- | --- |
+| New feature demo | Single | `pi_session fork` + `pi_model set` |
+| Behavior verification | Comparison | `pi_verify` across sessions |
+| QA test flow | Stepwise | `pi_session inspect` per step |
-| Flow | Type | Vorm |
-|---|---|---|
-| Nieuwe feature demo | Enkelvoudig | `pi_session fork` + `pi_model set` |
-| Gedragsverificatie | Vergelijking | `pi_verify` op meerdere sessies |
-| QA test flow | Stapsgewijs | `pi_session inspect` per stap |
-
-## Commando's
+## Commands
### `/pi-demo`
-Demonstreer een Pi workflow of feature. Accepteert een sessie referentie, een model wissel, of een vrije tekst beschrijving.
+Demonstrate a Pi workflow or feature. Accepts a session reference, a model switch, or a free-text description.
**Commitments:**
-- [ ] **Scope**: Welk Pi aspect wordt gedemonstreerd? (sessies, modellen, tools, workflows)
-- [ ] **Model**: Welk model wordt gebruikt?
-- [ ] **Verificatie**: Hoe wordt aangetoond dat het werkt?
+- [ ] **Scope**: which Pi aspect is demonstrated? (sessions, models, tools, workflows)
+- [ ] **Model**: which model is used?
+- [ ] **Verification**: how is success demonstrated?
### `/pi-verify`
-Test een claim over Pi's gedrag. Je bent een onderzoeker, geen advocaat. Een conclusie "dit werkt niet" met helder bewijs is even waardevol als "dit werkt".
+Test a claim about Pi's behavior. You are a researcher, not an advocate. A conclusion of "this does not work" with clear evidence is as valuable as "this works".
**Commitments:**
-- [ ] **Claim**: Wat wordt er getest?
-- [ ] **Evidence type**: sessie state | tool output | model response
-- [ ] **Vergelijking**: voor/na of enkele staat
+- [ ] **Claim**: what is being tested?
+- [ ] **Evidence type**: session state | tool output | model response
+- [ ] **Comparison**: before/after or single state
### `/pi-qa`
-Systematische QA test van Pi functionaliteit. Doorloop stappen, rapporteer PASS/FAIL met bewijs.
-
-## Tools referentie
-
-### pi_session
-
-Beheer Pi sessies. Lijst, inspecteer, fork, switch, compact, en navigeer de sessieboom.
-
-| Operatie | Beschrijving |
-|---|---|
-| `list` | Toon alle beschikbare sessies |
-| `inspect` | Toon huidige sessie details (aantal entries, branch, model) |
-| `fork` | Fork vanaf een entry in een nieuwe sessie |
-| `switch` | Schakel naar een andere sessie |
-| `compact` | Compacteer huidige sessie |
-| `label` | Zet of wis een label op een entry |
-| `rename` | Hernoem de sessie |
-
-### pi_model
+Systematic QA test of Pi functionality. Walk the steps and report PASS/FAIL with evidence.
-Beheer Pi's model en provider configuratie.
+## Tool reference
-| Operatie | Beschrijving |
-|---|---|
-| `list` | Toon beschikbare modellen |
-| `set` | Wissel van model |
-| `thinking` | Wijzig thinking level |
-| `providers` | Toon geregistreerde providers |
+### `pi_session` — manage sessions
-### pi_tool
+| Action | Description |
+| --- | --- |
+| `list` | List available sessions. |
+| `inspect` | Show current session details (entry count, branch, model). |
+| `fork` | Fork from an entry into a new session. |
+| `switch` | Switch to another session. |
+| `compact` | Compact the current session. |
+| `navigate` | Move through the session tree. |
+| `label` | Set or clear a label on an entry. |
+| `rename` | Rename the session. |
-Beheer Pi's active tools.
+### `pi_model` — control model and thinking
-| Operatie | Beschrijving |
-|---|---|
-| `list` | Toon alle tools en hun status (actief/inactief) |
-| `set_active` | Activeer of deactiveer tools |
-| `inspect` | Toon details van een specifieke tool |
+| Action | Description |
+| --- | --- |
+| `list` | List available models. |
+| `providers` | Show registered providers. |
+| `set` | Switch the active model. |
+| `thinking` | Change the thinking level. |
-### pi_state
+### `pi_tool` — gate the active toolset
-Bewaar en herstel Pi sessie toestand.
+| Action | Description |
+| --- | --- |
+| `list` | Show all tools and their active/inactive status. |
+| `inspect` | Show details for a specific tool. |
+| `set_active` | Replace the complete active tool set. |
-| Operatie | Beschrijving |
-|---|---|
-| `save` | Bewaar huidige toestand (label, compact, summary) |
-| `apply` | Herstel een bewaarde toestand |
-| `diff` | Vergelijk twee sessie toestanden |
-| `history` | Toon wijzigingsgeschiedenis |
+### `pi_state` — snapshot, diff, restore
-### pi_verify
+| Action | Description |
+| --- | --- |
+| `save` | Save a named snapshot (label, summary, data). |
+| `restore` | Restore a saved snapshot. |
+| `diff` | Compare two state snapshots. |
+| `history` | Show the change history. |
-Verifieer Pi's toestand tegen verwachtingen.
+### `pi_verify` — assert runtime expectations
-| Operatie | Beschrijving |
-|---|---|
-| `session` | Controleer sessie eigenschappen (entries, model, settings) |
-| `tool_output` | Controleer of een tool de verwachte output gaf |
-| `behavior` | Test of Pi een bepaald gedrag vertoont |
+| Action | Description |
+| --- | --- |
+| `session` | Assert session properties (entries, model, settings). |
+| `model` | Assert the active model and thinking level. |
+| `tool` | Assert tool output matched expectations. |
+| `state` | Assert state snapshot properties. |
-## Rapportage
+## Reporting
-Na elke workflow:
-- Wat er gebeurd is (stappen)
-- Wat het bewijs is (tool outputs, session dumps)
-- Of de commitments zijn nagekomen
-- Eventuele issues of afwijkingen
+After every workflow, report:
+- What happened (steps taken)
+- What the evidence is (tool outputs, session dumps)
+- Whether the commitments were met
+- Any issues or deviations
-## Niet doen
+## Do not
-- Ga niet door na een fatale fout zonder duidelijke herstelstrategie
-- Negeer geen bewijs dat de claim tegenspreekt
-- Gebruik geen hardcoded paden; altijd `RUN_DIR`/`RUN_ID` scoping
+- Continue past a fatal error without a clear recovery strategy
+- Ignore evidence that contradicts the claim
+- Use hardcoded paths; always scope to `RUN_DIR`/`RUN_ID`