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

+ pi-control: capture, change, verify, evidence +

+ # @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. + +[![npm](https://img.shields.io/npm/v/@groeponline/pi-control.svg)](https://www.npmjs.com/package/@groeponline/pi-control) [![downloads](https://img.shields.io/npm/dm/@groeponline/pi-control.svg?label=downloads)](https://www.npmjs.com/package/@groeponline/pi-control) [![Pi package](https://img.shields.io/badge/Pi-package-9b59b6.svg)](https://pi.dev/packages/@groeponline/pi-control) [![verify](https://github.com/GroepOnline/pi-control/actions/workflows/publish-npm.yml/badge.svg)](https://github.com/GroepOnline/pi-control/actions/workflows/publish-npm.yml) ![License](https://img.shields.io/badge/license-MIT-green.svg) + +## At a glance -[![npm](https://img.shields.io/npm/v/@groeponline/pi-control.svg)](https://www.npmjs.com/package/@groeponline/pi-control) [![Pi package](https://img.shields.io/badge/Pi-package-9b59b6.svg)](https://pi.dev/packages/@groeponline/pi-control) ![License](https://img.shields.io/badge/license-MIT-green.svg) +- **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 loopA 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 capturesessions · tools · state +02 changemodels · tools · guards +03 verifyclaims · QA · behavior +04 evidencereport 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`