From faaf541425d4fc0a74bef30b7b54f30cdf1166b8 Mon Sep 17 00:00:00 2001 From: ChefGroep Date: Sun, 23 Aug 2026 01:18:29 +0200 Subject: [PATCH 01/21] docs: polish Pi package catalog metadata --- README.md | 8 ++++++-- docs/images/pi-control-hero.svg | 14 ++++++++++++++ package.json | 20 ++++++++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 docs/images/pi-control-hero.svg diff --git a/README.md b/README.md index ae1a15c..1ff79a9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ +

+ 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. +Operate Pi without building a second runtime. `pi-control` gives humans and agents a compact control plane over the **live Pi process** — sessions, models, tools, saved state, verification, and guardrails — then makes every change prove itself with evidence. -[![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) +[![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) ![License](https://img.shields.io/badge/license-MIT-green.svg) ## Install 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 2014d73..d3c07fe 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "@groeponline/pi-control", - "version": "0.1.1", - "description": "Pi runtime control and QA extension: sessions, models, tools, state, verification, guardrails, and operator workflows.", + "version": "0.1.2", + "description": "Pi operator control plane for sessions, models, tools, runtime state, QA verification, guardrails, and reproducible agent workflows.", "type": "module", "author": "GroepOnline", "repository": { "type": "git", "url": "https://github.com/GroepOnline/pi-control.git" }, - "homepage": "https://pi.dev/packages/@groeponline/pi-control", + "homepage": "https://github.com/GroepOnline/pi-control#readme", "bugs": { "url": "https://github.com/GroepOnline/pi-control/issues" }, @@ -27,7 +27,13 @@ "session-management", "model-management", "qa", - "guardrails" + "guardrails", + "runtime-control", + "agent-ops", + "verification", + "observability", + "state-management", + "developer-tools" ], "pi": { "extensions": [ @@ -35,7 +41,8 @@ ], "skills": [ "./skills" - ] + ], + "image": "https://raw.githubusercontent.com/GroepOnline/pi-control/main/docs/images/pi-control-hero.svg" }, "files": [ "extensions/pi-control/index.ts", @@ -43,7 +50,8 @@ "extensions/pi-control/tools.ts", "extensions/pi-control/commands", "skills", - "README.md" + "README.md", + "docs/images/pi-control-hero.svg" ], "peerDependencies": { "@earendil-works/pi-ai": "*", From 3eb9c1f400d33e47f00fd281a19d6b962efb2ca6 Mon Sep 17 00:00:00 2001 From: ChefGroep Date: Sun, 23 Aug 2026 01:29:15 +0200 Subject: [PATCH 02/21] chore: enforce Pi package release contract --- .github/workflows/publish-npm.yml | 4 +- docs/images/pi-control-hero.png | Bin 0 -> 60134 bytes package.json | 7 +- scripts/verify-pi-package-contract.mjs | 135 +++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 docs/images/pi-control-hero.png create mode 100644 scripts/verify-pi-package-contract.mjs diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index c157b4d..06f2ea1 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -31,8 +31,8 @@ jobs: with: node-version: '22.x' registry-url: 'https://registry.npmjs.org' - - name: Verify package contents - run: npm run pack:check + - name: Pi package release gate + run: npm run verify:package - name: Read package identity id: package run: | diff --git a/docs/images/pi-control-hero.png b/docs/images/pi-control-hero.png new file mode 100644 index 0000000000000000000000000000000000000000..294972069114e08156dd25a9ab52ddd15f8bb1ec GIT binary patch literal 60134 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 failures.push(message); +const normalize = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, ""); + +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'); +if (typeof pkg.description !== "string" || pkg.description.trim().length < 40 || pkg.description.length > 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 resources = []; +if (pi) { + for (const key of resourceKeys) { + if (pi[key] !== undefined && !Array.isArray(pi[key])) fail(`pi.${key} must be an array when present`); + for (const value of pi[key] || []) resources.push([key, value]); + } +} +if (!resources.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 = packageRoot; +try { repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: packageRoot, encoding: "utf8" }).trim(); } catch {} +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]}`); +} + +for (const [key, raw] of resources) { + if (typeof raw !== "string" || !raw.trim()) { fail(`pi.${key} contains an invalid resource path`); continue; } + if (raw.startsWith("!")) continue; + const clean = normalize(raw); + if (clean.startsWith("../")) { fail(`pi.${key} resource escapes package root: ${raw}`); continue; } + if (!/[?*{}[\]]/.test(clean) && !fs.existsSync(path.join(packageRoot, clean))) fail(`pi.${key} resource does not exist after build: ${raw}`); +} + +const core = [ + "@earendil-works/pi-ai", + "@earendil-works/pi-agent-core", + "@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui", + "typebox", +]; +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`); +} + +const skipDirs = new Set([".git", "node_modules", "dist", "target", "coverage", ".next", "build"]); +const codeExt = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]); +const sourceFiles = []; +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() && skipDirs.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (codeExt.has(path.extname(entry.name))) sourceFiles.push(full); + } +} +walk(packageRoot); +const sourceText = sourceFiles.map((file) => fs.readFileSync(file, "utf8")).join("\n"); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +for (const dep of core) { + const d = escapeRegExp(dep); + const imported = new RegExp(`(?:from\\s+|import\\s*\\(|require\\s*\\()\\s*[\"']${d}[\"']`).test(sourceText); + if (imported && peer[dep] !== "*") fail(`runtime source imports ${dep}; peerDependencies.${dep} must be \"*\"`); +} + +let packed = null; +try { + packed = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { cwd: packageRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }))[0]; +} catch (error) { + fail(`npm pack --dry-run failed: ${error.stderr?.toString().trim() || error.message}`); +} +if (packed) { + const packedFiles = new Set((packed.files || []).map((f) => normalize(f.path))); + if (!packedFiles.has("package.json")) fail("npm tarball is missing package.json"); + if (![...packedFiles].some((f) => /^readme(?:\.|$)/i.test(f))) fail("npm tarball is missing README"); + for (const [key, raw] of resources) { + if (raw.startsWith("!")) continue; + const clean = normalize(raw); + if (/[?*{}[\]]/.test(clean)) continue; + const local = path.join(packageRoot, clean); + if (!fs.existsSync(local)) continue; + const stat = fs.statSync(local); + const included = stat.isDirectory() ? [...packedFiles].some((f) => f.startsWith(clean.replace(/\/$/, "") + "/")) : packedFiles.has(clean); + if (!included) fail(`pi.${key} resource is not present in npm tarball: ${raw}`); + } + notes.push(`${packed.files?.length || 0} packed files, ${packed.size || 0} bytes`); +} + +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}`); From 74ecc538034ca8b20a1cf8ca3b77e67e3e0f8819 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:42:39 +0000 Subject: [PATCH 03/21] fix: Align pack check with package verifier --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de5a2d4..664aafb 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "typebox": "*" }, "scripts": { - "pack:check": "npm pack --dry-run --json", + "pack:check": "npm pack --dry-run --ignore-scripts --json", "verify:pi-package": "node scripts/verify-pi-package-contract.mjs", "verify:package": "npm run pack:check && npm run verify:pi-package", "prepublishOnly": "npm run verify:pi-package" From 8944a920530f7a9f1e23ce3b84b5bc295139aa10 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 23 Aug 2026 04:53:58 +0200 Subject: [PATCH 04/21] fix(package): harden Pi catalog release gate --- package.json | 2 +- scripts/verify-pi-package-contract.mjs | 220 +++++++++++++++++-------- 2 files changed, 156 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index 664aafb..595f3d4 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "scripts": { "pack:check": "npm pack --dry-run --ignore-scripts --json", "verify:pi-package": "node scripts/verify-pi-package-contract.mjs", - "verify:package": "npm run pack:check && npm run verify:pi-package", + "verify:package": "npm run verify:pi-package", "prepublishOnly": "npm run verify:pi-package" }, "license": "MIT" diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs index e9a8fc3..8b67263 100644 --- a/scripts/verify-pi-package-contract.mjs +++ b/scripts/verify-pi-package-contract.mjs @@ -1,24 +1,31 @@ -#!/usr/bin/env node +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -const DOCS = "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md"; +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"'); @@ -31,14 +38,16 @@ if (String(pkg.name || "").startsWith("@") && pkg.publishConfig?.access !== "pub 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 resources = []; -if (pi) { - for (const key of resourceKeys) { - if (pi[key] !== undefined && !Array.isArray(pi[key])) fail(`pi.${key} must be an array when present`); - for (const value of pi[key] || []) resources.push([key, value]); +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 (!resources.length) fail("pi manifest must expose at least one extension, skill, prompt, or theme resource"); +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"); @@ -46,85 +55,166 @@ for (const [field, allowed] of [["video", [".mp4"]], ["image", [".png", ".jpg", 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; } + 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 = packageRoot; -try { repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: packageRoot, encoding: "utf8" }).trim(); } catch {} -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]}`); +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"); } - -for (const [key, raw] of resources) { - if (typeof raw !== "string" || !raw.trim()) { fail(`pi.${key} contains an invalid resource path`); continue; } - if (raw.startsWith("!")) continue; - const clean = normalize(raw); - if (clean.startsWith("../")) { fail(`pi.${key} resource escapes package root: ${raw}`); continue; } - if (!/[?*{}[\]]/.test(clean) && !fs.existsSync(path.join(packageRoot, clean))) fail(`pi.${key} resource does not exist after build: ${raw}`); +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 core = [ - "@earendil-works/pi-ai", - "@earendil-works/pi-agent-core", - "@earendil-works/pi-coding-agent", - "@earendil-works/pi-tui", - "typebox", -]; -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`); -} +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 skipDirs = new Set([".git", "node_modules", "dist", "target", "coverage", ".next", "build"]); -const codeExt = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]); -const sourceFiles = []; -function walk(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory() && skipDirs.has(entry.name)) continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (codeExt.has(path.extname(entry.name))) sourceFiles.push(full); +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); } -walk(packageRoot); -const sourceText = sourceFiles.map((file) => fs.readFileSync(file, "utf8")).join("\n"); -const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const peer = pkg.peerDependencies || {}; for (const dep of core) { - const d = escapeRegExp(dep); - const imported = new RegExp(`(?:from\\s+|import\\s*\\(|require\\s*\\()\\s*[\"']${d}[\"']`).test(sourceText); - if (imported && peer[dep] !== "*") fail(`runtime source imports ${dep}; peerDependencies.${dep} must be \"*\"`); + 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 = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { cwd: packageRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }))[0]; + packed = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { + cwd: packageRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }))[0]; } 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) { - const packedFiles = new Set((packed.files || []).map((f) => normalize(f.path))); if (!packedFiles.has("package.json")) fail("npm tarball is missing package.json"); - if (![...packedFiles].some((f) => /^readme(?:\.|$)/i.test(f))) fail("npm tarball is missing README"); - for (const [key, raw] of resources) { - if (raw.startsWith("!")) continue; - const clean = normalize(raw); - if (/[?*{}[\]]/.test(clean)) continue; - const local = path.join(packageRoot, clean); - if (!fs.existsSync(local)) continue; - const stat = fs.statSync(local); - const included = stat.isDirectory() ? [...packedFiles].some((f) => f.startsWith(clean.replace(/\/$/, "") + "/")) : packedFiles.has(clean); - if (!included) fail(`pi.${key} resource is not present in npm tarball: ${raw}`); + 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`); } +const runtimePath = (file) => { + const parts = normalize(file).split("/"); + if (parts.some((part) => ["test", "tests", "scripts", "docs", "examples", "fixtures", "coverage"].includes(part))) return false; + if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) return false; + return codeExt.has(path.extname(file)); +}; +const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const importsDependency = (text, dep) => { + const d = escapeRegExp(dep); + return [ + new RegExp(`\\b(?:import|export)\\s+(?:type\\s+)?(?:[^;\\n]*?\\s+from\\s+)?["']${d}(?:\\/[^"']*)?["']`), + new RegExp(`\\b(?:import|require)\\s*\\(\\s*["']${d}(?:\\/[^"']*)?["']`), + ].some((pattern) => pattern.test(text)); +}; +const runtimeFiles = [...packedFiles].filter(runtimePath); +const runtimeText = runtimeFiles.map((file) => { + const local = path.join(packageRoot, file); + return fs.existsSync(local) ? stripComments(fs.readFileSync(local, "utf8")) : ""; +}).join("\n"); +for (const dep of core) { + if (importsDependency(runtimeText, dep) && peer[dep] !== "*") fail(`packed runtime imports ${dep}; peerDependencies.${dep} must be "*"`); +} +if (importsDependency(runtimeText, "@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)'); +} + if (failures.length) { console.error("Pi package contract FAILED"); for (const message of failures) console.error(`- ${message}`); From 48227e63ed7e86a0e814a44c4be94a088313463b Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 23 Aug 2026 12:12:56 +0200 Subject: [PATCH 05/21] review: address CodeRabbit findings (#4) - importsDependency now matches compact static imports (import{x}from"y") by allowing zero whitespace after import/export and around from Skipped with reason: - double npm pack with mismatched flags (qodo): package.json already runs verify:package = verify:pi-package only, and pack:check already uses --ignore-scripts; no double pack on this branch - unused fileURLToPath import: not present on this branch --- scripts/verify-pi-package-contract.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs index 8b67263..2c1a5d3 100644 --- a/scripts/verify-pi-package-contract.mjs +++ b/scripts/verify-pi-package-contract.mjs @@ -199,7 +199,7 @@ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const importsDependency = (text, dep) => { const d = escapeRegExp(dep); return [ - new RegExp(`\\b(?:import|export)\\s+(?:type\\s+)?(?:[^;\\n]*?\\s+from\\s+)?["']${d}(?:\\/[^"']*)?["']`), + new RegExp(`\\b(?:import|export)\\s*(?:type\\s+)?(?:[^;\\n]*?\\s*from\\s*)?["']${d}(?:\\/[^"']*)?["']`), new RegExp(`\\b(?:import|require)\\s*\\(\\s*["']${d}(?:\\/[^"']*)?["']`), ].some((pattern) => pattern.test(text)); }; From 89074aa17f85bb3c8c79e73370ade5cdf3265a92 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 23 Aug 2026 14:47:46 +0200 Subject: [PATCH 06/21] review: handle multiline static imports (#4) importsDependency flattens newlines before matching so multiline import/export declarations are detected; regression tests skipped (repo has no test runner, the verifier itself is the release gate). --- scripts/verify-pi-package-contract.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs index 2c1a5d3..e056877 100644 --- a/scripts/verify-pi-package-contract.mjs +++ b/scripts/verify-pi-package-contract.mjs @@ -198,10 +198,11 @@ const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const importsDependency = (text, dep) => { const d = escapeRegExp(dep); + const flat = String(text).replace(/\r?\n/g, " "); return [ - new RegExp(`\\b(?:import|export)\\s*(?:type\\s+)?(?:[^;\\n]*?\\s*from\\s*)?["']${d}(?:\\/[^"']*)?["']`), + new RegExp(`\\b(?:import|export)\\s*(?:type\\s+)?(?:[^;]*?\\s*from\\s*)?["']${d}(?:\\/[^"']*)?["']`), new RegExp(`\\b(?:import|require)\\s*\\(\\s*["']${d}(?:\\/[^"']*)?["']`), - ].some((pattern) => pattern.test(text)); + ].some((pattern) => pattern.test(flat)); }; const runtimeFiles = [...packedFiles].filter(runtimePath); const runtimeText = runtimeFiles.map((file) => { From 2a4a482750503fa42abd8f212528bd2467e81bad Mon Sep 17 00:00:00 2001 From: MisterWanted Date: Mon, 24 Aug 2026 09:00:35 +0200 Subject: [PATCH 07/21] review: fix type-only vs multiline import detection (unify gate) pi-control gate treated 'import type' as runtime (false positive on valid type-only packages) while pi-tools missed multiline runtime imports. Unify importsDependency: flatten to single line, handle side-effect / dynamic imports, exempt 'import type' and '{ type T }' via allNamedSpecifiersAreTypeOnly. --- scripts/verify-pi-package-contract.mjs | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs index e056877..7fd5173 100644 --- a/scripts/verify-pi-package-contract.mjs +++ b/scripts/verify-pi-package-contract.mjs @@ -196,13 +196,32 @@ const runtimePath = (file) => { }; const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function allNamedSpecifiersAreTypeOnly(clause) { + const trimmed = clause.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false; + const body = trimmed.slice(1, -1).trim(); + if (!body) return false; + return body.split(",").every((specifier) => /^type\b/.test(specifier.trim())); +} const importsDependency = (text, dep) => { const d = escapeRegExp(dep); + const target = `${d}(?:\\/[^"']*)?`; const flat = String(text).replace(/\r?\n/g, " "); - return [ - new RegExp(`\\b(?:import|export)\\s*(?:type\\s+)?(?:[^;]*?\\s*from\\s*)?["']${d}(?:\\/[^"']*)?["']`), - new RegExp(`\\b(?:import|require)\\s*\\(\\s*["']${d}(?:\\/[^"']*)?["']`), - ].some((pattern) => pattern.test(flat)); + if ( + new RegExp(`\\bimport\\s+["']${target}["']`).test(flat) || + new RegExp(`\\bimport\\s*\\(\\s*["']${target}["']`).test(flat) || + new RegExp(`\\brequire\\s*\\(\\s*["']${target}["']`).test(flat) + ) { + return true; + } + const declarations = new RegExp(`\\b(?:import|export)\\s+([^;]*?)\\s+from\\s+["']${target}["']`, "g"); + for (const match of flat.matchAll(declarations)) { + const clause = match[1].trim(); + if (/^type\b/.test(clause)) continue; + if (allNamedSpecifiersAreTypeOnly(clause)) continue; + return true; + } + return false; }; const runtimeFiles = [...packedFiles].filter(runtimePath); const runtimeText = runtimeFiles.map((file) => { From 35f93986577091895c44f61828f0e8f867ac107c Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Mon, 24 Aug 2026 10:49:13 +0200 Subject: [PATCH 08/21] fix(package): share compact-import runtime scanner with pi-tools Extract importsDependency so pi-control and pi-tools use the same type-only and space-free import detection in the catalog contract gate. --- scripts/package-contract-runtime.mjs | 33 ++++++++++++++++++++++++++ scripts/verify-pi-package-contract.mjs | 29 +--------------------- 2 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 scripts/package-contract-runtime.mjs diff --git a/scripts/package-contract-runtime.mjs b/scripts/package-contract-runtime.mjs new file mode 100644 index 0000000..10f7c0c --- /dev/null +++ b/scripts/package-contract-runtime.mjs @@ -0,0 +1,33 @@ +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +function allNamedSpecifiersAreTypeOnly(clause) { + const trimmed = clause.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false; + const body = trimmed.slice(1, -1).trim(); + if (!body) return false; + return body.split(",").every((specifier) => /^type\b/.test(specifier.trim())); +} + +export function importsDependency(text, dep) { + const d = escapeRegExp(dep); + const target = `${d}(?:\\/[^"']*)?`; + const flat = String(text).replace(/\r?\n/g, " "); + if ( + new RegExp(`\\bimport\\s*["']${target}["']`).test(flat) || + new RegExp(`\\bimport\\s*\\(\\s*["']${target}["']`).test(flat) || + new RegExp(`\\brequire\\s*\\(\\s*["']${target}["']`).test(flat) + ) { + return true; + } + const declarations = new RegExp( + `\\b(?:import|export)\\s*([^;]*?)\\s*from\\s*["']${target}["']`, + "g", + ); + for (const match of flat.matchAll(declarations)) { + const clause = match[1].trim(); + if (/^type\b/.test(clause)) continue; + if (allNamedSpecifiersAreTypeOnly(clause)) continue; + return true; + } + return false; +} diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs index 7fd5173..61d3451 100644 --- a/scripts/verify-pi-package-contract.mjs +++ b/scripts/verify-pi-package-contract.mjs @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { importsDependency } from "./package-contract-runtime.mjs"; const DOCS = "https://pi.dev/docs/latest/packages"; const packageRoot = path.resolve(process.argv[2] || process.cwd()); @@ -195,34 +196,6 @@ const runtimePath = (file) => { return codeExt.has(path.extname(file)); }; const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -function allNamedSpecifiersAreTypeOnly(clause) { - const trimmed = clause.trim(); - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false; - const body = trimmed.slice(1, -1).trim(); - if (!body) return false; - return body.split(",").every((specifier) => /^type\b/.test(specifier.trim())); -} -const importsDependency = (text, dep) => { - const d = escapeRegExp(dep); - const target = `${d}(?:\\/[^"']*)?`; - const flat = String(text).replace(/\r?\n/g, " "); - if ( - new RegExp(`\\bimport\\s+["']${target}["']`).test(flat) || - new RegExp(`\\bimport\\s*\\(\\s*["']${target}["']`).test(flat) || - new RegExp(`\\brequire\\s*\\(\\s*["']${target}["']`).test(flat) - ) { - return true; - } - const declarations = new RegExp(`\\b(?:import|export)\\s+([^;]*?)\\s+from\\s+["']${target}["']`, "g"); - for (const match of flat.matchAll(declarations)) { - const clause = match[1].trim(); - if (/^type\b/.test(clause)) continue; - if (allNamedSpecifiersAreTypeOnly(clause)) continue; - return true; - } - return false; -}; const runtimeFiles = [...packedFiles].filter(runtimePath); const runtimeText = runtimeFiles.map((file) => { const local = path.join(packageRoot, file); From 3bb5265b2a18260bc437b836b921ea73d643ebfa Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Tue, 25 Aug 2026 07:30:08 +0200 Subject: [PATCH 09/21] fix(contract): use token-aware runtime dependency scanning --- scripts/package-contract-runtime.mjs | 206 +++++++++++++++++++++++---- 1 file changed, 180 insertions(+), 26 deletions(-) diff --git a/scripts/package-contract-runtime.mjs b/scripts/package-contract-runtime.mjs index 10f7c0c..516c494 100644 --- a/scripts/package-contract-runtime.mjs +++ b/scripts/package-contract-runtime.mjs @@ -1,33 +1,187 @@ -const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function isIdentifierStart(char) { + return /[A-Za-z_$]/.test(char); +} -function allNamedSpecifiersAreTypeOnly(clause) { - const trimmed = clause.trim(); - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false; - const body = trimmed.slice(1, -1).trim(); - if (!body) return false; - return body.split(",").every((specifier) => /^type\b/.test(specifier.trim())); +function isIdentifierPart(char) { + return /[A-Za-z0-9_$]/.test(char); } -export function importsDependency(text, dep) { - const d = escapeRegExp(dep); - const target = `${d}(?:\\/[^"']*)?`; - const flat = String(text).replace(/\r?\n/g, " "); - if ( - new RegExp(`\\bimport\\s*["']${target}["']`).test(flat) || - new RegExp(`\\bimport\\s*\\(\\s*["']${target}["']`).test(flat) || - new RegExp(`\\brequire\\s*\\(\\s*["']${target}["']`).test(flat) - ) { - return true; +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; + } + + 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; } - const declarations = new RegExp( - `\\b(?:import|export)\\s*([^;]*?)\\s*from\\s*["']${target}["']`, - "g", + + 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" ); - for (const match of flat.matchAll(declarations)) { - const clause = match[1].trim(); - if (/^type\b/.test(clause)) continue; - if (allNamedSpecifiersAreTypeOnly(clause)) continue; - return true; +} + +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 }; + } } - return false; + + 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") { + 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}/`), + ); } From d4b46d0ae315609850f83809dab5f24c3472238e Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Tue, 25 Aug 2026 07:30:29 +0200 Subject: [PATCH 10/21] fix(contract): ignore member calls named import or export --- scripts/package-contract-runtime.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-contract-runtime.mjs b/scripts/package-contract-runtime.mjs index 516c494..bf4e094 100644 --- a/scripts/package-contract-runtime.mjs +++ b/scripts/package-contract-runtime.mjs @@ -166,7 +166,7 @@ export function runtimeModuleSpecifiers(text) { const token = tokens[i]; if (token.type !== "id") continue; - if (token.value === "import" || token.value === "export") { + 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; From 788af2ed586c2d703f6640c841fedcd7d45d4ae8 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Tue, 25 Aug 2026 07:30:41 +0200 Subject: [PATCH 11/21] test(contract): cover token-aware module detection --- scripts/package-contract-runtime.test.mjs | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 scripts/package-contract-runtime.test.mjs diff --git a/scripts/package-contract-runtime.test.mjs b/scripts/package-contract-runtime.test.mjs new file mode 100644 index 0000000..6f5acb4 --- /dev/null +++ b/scripts/package-contract-runtime.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importsDependency, 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 = require("@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("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");', + 'const importation = "from @earendil-works/pi-coding-agent";', + ]) { + assert.equal(importsDependency(source, dep), false, source); + } +}); + +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("matches dependency subpaths but not prefix collisions", () => { + assert.equal(importsDependency(`import "${dep}/internal";`, dep), true); + assert.equal(importsDependency(`import "${dep}-extra";`, dep), false); +}); From 14ceb53c10dedb56393034aaf9b372150c5d6989 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Tue, 25 Aug 2026 07:31:18 +0200 Subject: [PATCH 12/21] fix(contract): traverse packaged runtime modules from Pi entrypoints --- scripts/verify-pi-package-contract.mjs | 85 ++++++++++++++++++++------ 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs index 61d3451..993db3c 100644 --- a/scripts/verify-pi-package-contract.mjs +++ b/scripts/verify-pi-package-contract.mjs @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { importsDependency } from "./package-contract-runtime.mjs"; +import { runtimeModuleSpecifiers } from "./package-contract-runtime.mjs"; const DOCS = "https://pi.dev/docs/latest/packages"; const packageRoot = path.resolve(process.argv[2] || process.cwd()); @@ -31,7 +31,8 @@ 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'); -if (typeof pkg.description !== "string" || pkg.description.trim().length < 40 || pkg.description.length > 240) fail("description must be 40-240 characters of useful gallery copy"); +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}`); } @@ -158,6 +159,9 @@ for (const [key, values] of resourcesByKey) { 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) { @@ -189,24 +193,69 @@ if (packed) { notes.push(`${packed.files?.length || 0} packed files, ${packed.size || 0} bytes`); } -const runtimePath = (file) => { - const parts = normalize(file).split("/"); - if (parts.some((part) => ["test", "tests", "scripts", "docs", "examples", "fixtures", "coverage"].includes(part))) return false; - if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) return false; - return codeExt.has(path.extname(file)); -}; -const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -const runtimeFiles = [...packedFiles].filter(runtimePath); -const runtimeText = runtimeFiles.map((file) => { - const local = path.join(packageRoot, file); - return fs.existsSync(local) ? stripComments(fs.readFileSync(local, "utf8")) : ""; -}).join("\n"); -for (const dep of core) { - if (importsDependency(runtimeText, dep) && peer[dep] !== "*") fail(`packed runtime imports ${dep}; peerDependencies.${dep} must be "*"`); +function dependencyMatches(specifier, dep) { + return specifier === dep || specifier.startsWith(`${dep}/`); } -if (importsDependency(runtimeText, "@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)'); + +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")) { + candidates.push(`${raw.slice(0, -3)}.ts`, `${raw.slice(0, -3)}.tsx`); + } + 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"); From 5515c1d80d817a3c22647bea1db0d8ef6a8e7434 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Tue, 25 Aug 2026 07:31:28 +0200 Subject: [PATCH 13/21] ci(contract): run parser regressions before publish --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 595f3d4..3538c7f 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ }, "scripts": { "pack:check": "npm pack --dry-run --ignore-scripts --json", - "verify:pi-package": "node scripts/verify-pi-package-contract.mjs", + "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" }, From cd46a2e06f9ad82a1bb68a95630dc3d26592283d Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Tue, 25 Aug 2026 07:31:52 +0200 Subject: [PATCH 14/21] ci: validate Pi package contract on pull requests --- .github/workflows/publish-npm.yml | 36 ++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 06f2ea1..07df29d 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: @@ -8,20 +18,20 @@ on: - README.md - 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: - publish: - if: github.repository == 'GroepOnline/pi-control' + verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -33,6 +43,22 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Pi package release gate run: npm run verify:package + + publish: + 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: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '22.x' + registry-url: 'https://registry.npmjs.org' - name: Read package identity id: package run: | From eca4c754ae24b228b4c48b170f7e22c71d70901b Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 25 Aug 2026 21:18:58 +0200 Subject: [PATCH 15/21] fix(contract): accept npm 12 pack JSON and pack the gallery PNG npm 12 emits a name-keyed object from npm pack --json, so treating the payload as an array missed the tarball listing. Also skip Linear sync on public repos that cannot see private-only org secrets. Co-authored-by: Cursor --- .github/workflows/chef-linear-notion-sync.yml | 2 ++ package.json | 1 + scripts/package-contract-runtime.mjs | 13 +++++++++++++ scripts/package-contract-runtime.test.mjs | 10 +++++++++- scripts/verify-pi-package-contract.mjs | 10 ++++++---- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/chef-linear-notion-sync.yml b/.github/workflows/chef-linear-notion-sync.yml index 92f261f..3aff001 100644 --- a/.github/workflows/chef-linear-notion-sync.yml +++ b/.github/workflows/chef-linear-notion-sync.yml @@ -14,6 +14,8 @@ concurrency: jobs: sync: + # Public repos do not receive org secrets with visibility: private. + if: ${{ secrets.LINEAR_API_KEY != '' }} uses: GroepOnline/OrgBeheer/.github/workflows/chef-dev-sync-reusable.yml@main secrets: LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} diff --git a/package.json b/package.json index 3538c7f..e996922 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "extensions/pi-control/commands", "skills", "README.md", + "docs/images/pi-control-hero.png", "docs/images/pi-control-hero.svg" ], "peerDependencies": { diff --git a/scripts/package-contract-runtime.mjs b/scripts/package-contract-runtime.mjs index bf4e094..19af0a1 100644 --- a/scripts/package-contract-runtime.mjs +++ b/scripts/package-contract-runtime.mjs @@ -185,3 +185,16 @@ export function importsDependency(text, dep) { (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 index 6f5acb4..b6eaaae 100644 --- a/scripts/package-contract-runtime.test.mjs +++ b/scripts/package-contract-runtime.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importsDependency, runtimeModuleSpecifiers } from "./package-contract-runtime.mjs"; +import { importsDependency, npmPackListing, runtimeModuleSpecifiers } from "./package-contract-runtime.mjs"; const dep = "@earendil-works/pi-coding-agent"; @@ -67,3 +67,11 @@ 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({}), null); +}); diff --git a/scripts/verify-pi-package-contract.mjs b/scripts/verify-pi-package-contract.mjs index 993db3c..602be55 100644 --- a/scripts/verify-pi-package-contract.mjs +++ b/scripts/verify-pi-package-contract.mjs @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { runtimeModuleSpecifiers } from "./package-contract-runtime.mjs"; +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()); @@ -172,11 +172,12 @@ for (const dep of core) { let packed = null; try { - packed = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { + packed = npmPackListing(JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { cwd: packageRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], - }))[0]; + }))); + 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}`); } @@ -205,7 +206,8 @@ function resolveLocalRuntimeModule(fromFile, specifier) { for (const ext of codeExt) candidates.push(`${raw}${ext}`); for (const ext of codeExt) candidates.push(`${raw}/index${ext}`); } else if (raw.endsWith(".js")) { - candidates.push(`${raw.slice(0, -3)}.ts`, `${raw.slice(0, -3)}.tsx`); + 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; } From 45662e016a40a063c3af5a0abb5751426f111dd3 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 25 Aug 2026 21:21:26 +0200 Subject: [PATCH 16/21] ci: disable Linear sync on this public package repo Reusable-job if: secrets.* is invalid YAML, and LINEAR_API_KEY is an org secret with visibility private so this public repo cannot start the OrgBeheer caller anyway. Co-authored-by: Cursor --- .github/workflows/chef-linear-notion-sync.yml | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/chef-linear-notion-sync.yml b/.github/workflows/chef-linear-notion-sync.yml index 3aff001..0deed6e 100644 --- a/.github/workflows/chef-linear-notion-sync.yml +++ b/.github/workflows/chef-linear-notion-sync.yml @@ -1,6 +1,8 @@ -# On PR events: sync this repo → Linear CHEF child issues (+ Notion when configured). -# Copy to product repos via scripts/deploy_chef_pr_workflows.sh - +# On PR events this file used to call OrgBeheer chef-dev-sync-reusable. +# Disabled here: this public package does not receive org LINEAR_API_KEY +# (visibility: private), and reusable-job `if: secrets.*` is invalid +# ("Unrecognized named-value: secrets"). Keep a skipped job so the copied +# workflow name does not fail the PR. name: CHEF Linear + Notion Sync on: @@ -14,12 +16,7 @@ concurrency: jobs: sync: - # Public repos do not receive org secrets with visibility: private. - if: ${{ secrets.LINEAR_API_KEY != '' }} - uses: GroepOnline/OrgBeheer/.github/workflows/chef-dev-sync-reusable.yml@main - secrets: - LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} - NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }} - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - with: - repo: ${{ github.event.repository.name }} \ No newline at end of file + if: false + runs-on: ${{ fromJSON('["self-hosted","Linux","X64","pr-isolated"]') }} + steps: + - run: echo "Linear sync disabled on this public package repo" From e2ca294feb903df528a3588d4227180abc98b434 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Wed, 26 Aug 2026 02:20:42 +0200 Subject: [PATCH 17/21] fix(ci): keep public sync compatibility workflow valid --- .github/workflows/chef-linear-notion-sync.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/chef-linear-notion-sync.yml b/.github/workflows/chef-linear-notion-sync.yml index 0deed6e..11cb40e 100644 --- a/.github/workflows/chef-linear-notion-sync.yml +++ b/.github/workflows/chef-linear-notion-sync.yml @@ -14,9 +14,14 @@ concurrency: group: chef-linear-notion-${{ github.event.pull_request.number || 'manual' }} cancel-in-progress: true +permissions: + contents: read + jobs: sync: - if: false + # This package is public today, so keep the compatibility workflow dormant + # without using an invalid constant or a forbidden secrets context. + if: github.event.repository.private == true runs-on: ${{ fromJSON('["self-hosted","Linux","X64","pr-isolated"]') }} steps: - run: echo "Linear sync disabled on this public package repo" From 5c0f2b0a7fe670106434b4f6321d2e4419eadbbc Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Wed, 26 Aug 2026 02:23:04 +0200 Subject: [PATCH 18/21] fix(contract): skip regex literals during runtime import scan --- scripts/package-contract-runtime.mjs | 39 +++++++++++++++++++++++ scripts/package-contract-runtime.test.mjs | 10 ++++++ 2 files changed, 49 insertions(+) diff --git a/scripts/package-contract-runtime.mjs b/scripts/package-contract-runtime.mjs index 19af0a1..4b33bae 100644 --- a/scripts/package-contract-runtime.mjs +++ b/scripts/package-contract-runtime.mjs @@ -32,6 +32,45 @@ function tokenize(source) { 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 = ""; diff --git a/scripts/package-contract-runtime.test.mjs b/scripts/package-contract-runtime.test.mjs index b6eaaae..ac66d97 100644 --- a/scripts/package-contract-runtime.test.mjs +++ b/scripts/package-contract-runtime.test.mjs @@ -52,6 +52,16 @@ test("ignores comments, literal examples, and member methods", () => { } }); +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("keeps local runtime specifiers for graph traversal", () => { assert.deepEqual( runtimeModuleSpecifiers(` From 6dd2c5f0ddda008038aa1dfa406e400520cc6e89 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Mon, 31 Aug 2026 00:38:59 +0200 Subject: [PATCH 19/21] fix(contract): recognize regex after expression operators --- scripts/package-contract-runtime.mjs | 5 ++++- scripts/package-contract-runtime.test.mjs | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/package-contract-runtime.mjs b/scripts/package-contract-runtime.mjs index 4b33bae..c73eede 100644 --- a/scripts/package-contract-runtime.mjs +++ b/scripts/package-contract-runtime.mjs @@ -38,7 +38,10 @@ function tokenize(source) { // and hide a later import declaration. if (char === "/") { const previous = tokens.at(-1); - const regexPrefixPunct = new Set(["(", "[", "{", "=", ",", ":", ";", "!", "?", "&", "|", "+", "-", "*", "%", "~"]); + const regexPrefixPunct = new Set([ + "(", "[", "{", "=", ",", ":", ";", "!", "?", "&", "|", + "+", "-", "*", "%", "~", ">", "<", "^", + ]); const regexPrefixIds = new Set(["return", "throw", "case", "delete", "void", "typeof", "instanceof", "in", "of", "yield", "await"]); const canStartRegex = previous === undefined || diff --git a/scripts/package-contract-runtime.test.mjs b/scripts/package-contract-runtime.test.mjs index ac66d97..feaf20e 100644 --- a/scripts/package-contract-runtime.test.mjs +++ b/scripts/package-contract-runtime.test.mjs @@ -62,6 +62,16 @@ test("ignores quotes inside regex literals before later imports", () => { 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(` From d9b2cf5dd4fad5b752f9e3b7109b9f5bb1640f1b Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:58:04 +0000 Subject: [PATCH 20/21] Expand Pi package contract verification test coverage --- scripts/package-contract-runtime.test.mjs | 41 +++ scripts/verify-pi-package-contract.test.mjs | 348 ++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 scripts/verify-pi-package-contract.test.mjs diff --git a/scripts/package-contract-runtime.test.mjs b/scripts/package-contract-runtime.test.mjs index feaf20e..64654bf 100644 --- a/scripts/package-contract-runtime.test.mjs +++ b/scripts/package-contract-runtime.test.mjs @@ -12,7 +12,10 @@ test("detects runtime module syntax including compact and multiline forms", () = '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); } @@ -29,6 +32,16 @@ test("ignores type-only imports and exports", () => { } }); +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";', @@ -46,6 +59,11 @@ test("ignores comments, literal examples, and member methods", () => { '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); @@ -83,6 +101,25 @@ test("keeps local runtime specifiers for graph traversal", () => { ); }); +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); @@ -93,5 +130,9 @@ test("reads npm pack --json from npm 10 arrays and npm 12 name maps", () => { 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.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/); +}); From cc1e6b0954e952d4cba2628f22bef39198cf8585 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 6 Sep 2026 00:04:12 +0200 Subject: [PATCH 21/21] docs: full README, architecture, and skill rewrite; sharpen catalog metadata - README rebuilt: value proposition, at-a-glance, quick start, complete command and tool reference (actions verified against tools.ts), the capture-change-verify loop, GroepOnline Pi suite cross-links, FAQ - ARCHITECTURE.md expanded: module ownership table, data flow, package boundaries; dropped the nonexistent pi-agent-orchestrator reference - skills/pi-control/SKILL.md rewritten in English (operator docs are English-only across the suite) and aligned with the current tool surface: pi_state restore (was apply), pi_verify session|model|tool|state - package.json: 0.1.3, description leads with the primary search terms (Pi extension, sessions, models, tools, guardrails, QA evidence) and keywords extended for npm/pi.dev discovery - CHANGELOG: keep-a-changelog format with 0.1.3 entry --- ARCHITECTURE.md | 51 ++++++--- CHANGELOG.md | 11 ++ README.md | 205 +++++++++++++++++++++++++++---------- package.json | 11 +- skills/pi-control/SKILL.md | 200 +++++++++++++++++------------------- 5 files changed, 303 insertions(+), 175 deletions(-) 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 a30a884..0e677e4 100644 --- a/README.md +++ b/README.md @@ -4,116 +4,215 @@ # @groeponline/pi-control -Operate Pi without building a second runtime. `pi-control` gives humans and agents a compact control plane over the **live Pi process** — sessions, models, tools, saved state, verification, and guardrails — then makes every change prove itself with evidence. +**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. -[![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) ![License](https://img.shields.io/badge/license-MIT-green.svg) +`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 + +- **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) +Pi loads both the extension and the packaged skill from the package manifest — no extra configuration. -## 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). +## Quick start -The wider flow is `idea (wishcraft) -> durable mission (missions) -> execution run (orchestrator) -> runtime/evidence verification (pi-control / pi-agent-control-extension)`. - -## 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/package.json b/package.json index 3a99c99..dc8e399 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@groeponline/pi-control", - "version": "0.1.2", - "description": "Pi operator control plane for sessions, models, tools, runtime state, QA verification, guardrails, and reproducible agent 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": { @@ -33,7 +33,12 @@ "verification", "observability", "state-management", - "developer-tools" + "developer-tools", + "pi-verify", + "agent-control-plane", + "qa-evidence", + "session-fork", + "evidence" ], "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`