mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
* feat: add thin Pi adapter mounting ECC's canonical skills and commands Adds first-class Pi (@earendil-works/pi-coding-agent) support as a thin adapter layer, following the maintainer review on #2352. ECC's canonical assets stay the single source of truth: nothing is copied or generated under .pi/. The `pi` manifest in package.json points Pi directly at `skills/` and `commands/`. No transformation is needed — ECC's SKILL.md files already follow the Agent Skills standard Pi implements, and ECC's command frontmatter is already Pi's prompt-template format. .pi/extensions/index.ts is the only adapter logic. It: - uses Pi's documented `pi.on(...)` lifecycle, not an undocumented event bus - resolves hook scripts from the installed package via `__dirname`, never `process.cwd()`, so global installs work from any project directory - runs hooks with `execFile(process.execPath, [...])` and no shell, so paths containing spaces or shell metacharacters are safe - invokes hooks through ECC's own `run-with-flags.js`, so `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` keep gating hooks under Pi - runs hooks in the user's project directory so project detection stays correct, while resolving the scripts themselves package-relative - injects the SessionStart hook's `additionalContext` into the system prompt on the next `before_agent_start` - isolates hook failures behind a timeout and an output limit - registers `/ecc-doctor` for install diagnostics Registers `.pi` in the platform-configs install module and adds a Pi row to the harness adapter compliance matrix. Verified against Pi 0.84.1: a global `pi install` exposes 285 skills and 94 commands resolved from `skills/` and `commands/`, plus `/ecc-doctor`, with no generated copies. Scope deliberately excludes subagents, chains, approval gates, todos, profiles, and MCP; ECC works in Pi without any companion package. * fix: address review findings on the Pi adapter Bot review on #2759 surfaced two real runtime defects and several hardening gaps. Runtime fixes: - Attach an `error` listener to the hook child's stdin. `stdin.end()` writes asynchronously, so a hook that exits, short-circuits, or is killed by the timeout before reading the payload raises EPIPE as an `error` event that the surrounding try/catch cannot see. Unhandled, that event would terminate the Pi session and break the isolation guarantee the adapter documents. - Clear `pendingContext` at the top of the `session_start` handler. Pi can start a new session (/new, /resume, /fork) before `before_agent_start` consumes the previous value; if the newer hook then failed, the next agent start received context describing a different session's project state. - Replace `require.resolve` companion detection with a read of Pi's own `packages` list, honoring `PI_CODING_AGENT_DIR`. Pi installs packages under its config directory, which is not on Node's module resolution path from the extension, so the previous check reported every companion as missing no matter what was installed. Compliance matrix: remove internal semicolons and a trailing period from the Pi record's list entries. The renderer joins entries with "; ", so those characters split one entry into several in the rendered cell. Tests: run profile gating against the temp skeleton instead of the real checkout so it cannot leave marker artifacts behind; count files under .pi/ by walking disk rather than git, so untracked copies cannot bypass the regression guard; allow negated phrasing in the README heuristic; pin the adapter's real parser guards with source assertions so the local mirrors cannot silently diverge; add coverage for EPIPE isolation, stale context clearing, and companion detection. * docs: point users at existing companion Pi packages instead of bundling them Every capability listed as out of scope is already provided by a maintained community Pi package: pi-subagents, @juicesharp/rpiv-ask-user-question, @juicesharp/rpiv-todo, and pi-mcp-adapter for MCP. Pi supports pulling other pi packages in via dependencies plus bundledDependencies, but this adapter deliberately does not. Bundling would ship third-party code that executes with full user permissions in every ECC install, turn optional capabilities into mandatory ones, and add four fast-moving pins to maintain. Instead /ecc-doctor now prints the exact `pi install npm:<name>` command for each companion it does not find, so adopting one stays a deliberate user choice. Also corrects the MCP claim: Pi core has no MCP surface by design, but the community pi-mcp-adapter package adds one. This adapter neither installs nor verifies it, and ECC's MCP reference configs are not known to be compatible. * docs: ECC's MCP configs work in Pi through pi-mcp-adapter, verbatim Tested rather than assumed. The community pi-mcp-adapter package reads the standard mcpServers format from .mcp.json and ~/.config/mcp/mcp.json, which is exactly the format ECC already uses in .mcp.json and mcp-configs/mcp-servers.json. Verified against pi-mcp-adapter 2.21.2 in an isolated PI_CODING_AGENT_DIR: copying mcp-configs/mcp-servers.json to a project's .mcp.json registers Pi's `mcp` tool and `/mcp` command with all 35 ECC servers discovered, coexisting with this adapter's /ecc-doctor. No translation layer and no ECC change are needed, so this stops being a limitation and becomes documentation. Recorded caveats: the adapter's first run against a new config performs initialization that blocks in non-interactive mode, and only discovery was verified, not live tool invocation. ECC still neither installs nor depends on the package. * feat: inject ECC's canonical engineering rules into Pi's system prompt ECC's rules were the one durable asset the adapter did not deliver: skills and commands reached Pi in full, but the 122 rule files that carry ECC's coding style, testing, security, git workflow, and code-review standards did not, so ECC in Pi was a library of skills rather than a set of enforced standards. Rules are read at runtime from the canonical rules/common/ directory of the installed package and appended to the system prompt inside an <ecc-engineering-rules> block. Nothing is copied or generated under .pi/, which keeps the single-source-of-truth constraint this PR exists to satisfy. Injection reuses the before_agent_start path already built for session context, so no new lifecycle mapping is introduced. Rules are re-applied every turn because they are standing policy, while the session context stays one-shot and is consumed on first use. agents.md, hooks.md, and performance.md are excluded: they describe Claude Code primitives Pi does not have (Task/TodoWrite delegation, Claude hook event types, thinking-budget toggles), so injecting them would point the model at tools that are not there. A test asserts they stay excluded, and a leakage test asserts none of those primitives appear in the injected text. Language-specific rules under rules/<language>/ are out of scope for this first adapter. Injection is bounded by MAX_RULES_BYTES and can be disabled with ECC_PI_RULES, following ECC's existing off-switch convention. /ecc-doctor reports the state and injected size. Measured on this repo: 7 files, 12,361 characters, roughly 3k tokens. Also replaces a Function() call in the test helper with direct arithmetic, and repins a stale assertion that pinned one spelling of the context handoff rather than the guarantee (read before clear, clear before return). * fix: /ecc-doctor misreported filtered packages and partial rule installs Two reporting defects in /ecc-doctor, the command whose whole job is telling a user what is actually installed. Pi's settings accept a `packages` entry in two shapes: the bare source string ("npm:pi-subagents") and an object carrying that source alongside resource filters ({ source: "npm:pi-subagents", skills: [] }). normalizePiPackageName only recognized the string, so a user who narrowed which resources a companion contributes was told the companion was not installed, along with an install command for something already present. The source type still decides whether a name is comparable, so an object wrapping a git source or a path stays unrecognized exactly as before. loadPortableRules drops rule files it cannot read, drops empty ones, and stops at MAX_RULES_BYTES, but describeRulesStatus reported PORTABLE_RULE_FILES.length regardless. A partial install that loaded 3 of 7 files reported "7 rule file(s)" to the one command a user runs to find a partial install. The loaded count is now tracked next to the cache and reported as a ratio, with the shortfall named. Also reconciles the Notes bullet in .pi/README.md, which still called MCP out of scope after the MCP section landed documenting that ECC's configs load in Pi through pi-mcp-adapter. Both defects were reported by CodeRabbit and verified against Pi's own packages.md before fixing. Adapter tests go from 24 to 26; the two source contracts that pinned the previous spellings now pin the new guards, so the object-form unwrapping and the loaded-count reporting cannot be silently reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
647 lines
20 KiB
TypeScript
647 lines
20 KiB
TypeScript
/**
|
|
* ECC adapter for the Pi coding agent.
|
|
*
|
|
* This is the ONLY adapter logic ECC ships for Pi. ECC's canonical assets stay
|
|
* the single source of truth: `skills/` and `commands/` are mounted directly by
|
|
* the `pi` manifest in the repo's root `package.json`. Nothing is copied or
|
|
* generated under `.pi/`.
|
|
*
|
|
* What this file adapts:
|
|
* - Pi lifecycle events -> ECC's existing hook runner (`run-with-flags.js`),
|
|
* so ECC hook profiles and disable flags keep working under Pi.
|
|
* - ECC's SessionStart `additionalContext` payload -> Pi's system prompt.
|
|
* - A `/ecc-doctor` command for install diagnostics.
|
|
*
|
|
* Design constraints (see .pi/README.md):
|
|
* - Hooks resolve relative to THIS file, never `process.cwd()`, so a global
|
|
* `pi install` works from any project directory.
|
|
* - Hooks execute via `execFile(process.execPath, [...])` with no shell, so
|
|
* paths containing spaces or shell metacharacters are safe.
|
|
* - Hook failures are isolated: a broken, missing, or slow hook degrades to a
|
|
* warning and never terminates the Pi session.
|
|
*/
|
|
|
|
import { execFile } from "node:child_process"
|
|
import * as fs from "node:fs"
|
|
import * as os from "node:os"
|
|
import * as path from "node:path"
|
|
|
|
/**
|
|
* Minimal structural types mirroring `@earendil-works/pi-coding-agent`.
|
|
*
|
|
* Declared locally on purpose: Pi loads extensions through jiti, which strips
|
|
* types without type-checking, so importing the package would add a dependency
|
|
* and a lockfile entry that buy nothing at runtime. Field names and signatures
|
|
* match the upstream `ExtensionAPI` / `ExtensionContext` declarations; install
|
|
* the package as a devDependency if you want editor-level checking.
|
|
*/
|
|
interface PiUiContext {
|
|
notify(message: string, type?: "info" | "warning" | "error"): void
|
|
}
|
|
|
|
interface PiSessionManager {
|
|
getSessionId(): string
|
|
getSessionFile(): string | undefined
|
|
}
|
|
|
|
interface ExtensionContext {
|
|
ui: PiUiContext
|
|
cwd: string
|
|
sessionManager: PiSessionManager
|
|
}
|
|
|
|
interface SessionStartEvent {
|
|
reason: "startup" | "reload" | "new" | "resume" | "fork"
|
|
}
|
|
|
|
interface SessionShutdownEvent {
|
|
reason: "quit" | "reload" | "new" | "resume" | "fork"
|
|
}
|
|
|
|
interface BeforeAgentStartEvent {
|
|
systemPrompt: string
|
|
}
|
|
|
|
interface BeforeAgentStartResult {
|
|
systemPrompt?: string
|
|
}
|
|
|
|
interface ExtensionAPI {
|
|
on(
|
|
event: "session_start",
|
|
handler: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void> | void
|
|
): void
|
|
on(
|
|
event: "session_shutdown",
|
|
handler: (event: SessionShutdownEvent, ctx: ExtensionContext) => Promise<void> | void
|
|
): void
|
|
on(
|
|
event: "before_agent_start",
|
|
handler: (
|
|
event: BeforeAgentStartEvent,
|
|
ctx: ExtensionContext
|
|
) => Promise<BeforeAgentStartResult | void> | BeforeAgentStartResult | void
|
|
): void
|
|
registerCommand(
|
|
name: string,
|
|
options: {
|
|
description?: string
|
|
handler: (args: string, ctx: ExtensionContext) => Promise<void>
|
|
}
|
|
): void
|
|
sendMessage(
|
|
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }
|
|
): void
|
|
}
|
|
|
|
/**
|
|
* ECC package root. This file lives at `<root>/.pi/extensions/index.ts`, so the
|
|
* root is two levels up. Pi loads extensions via jiti in CommonJS mode, which
|
|
* is why `__dirname` is the correct primitive here rather than
|
|
* `import.meta.url` (verified against Pi 0.84.1).
|
|
*/
|
|
const ECC_ROOT = path.resolve(__dirname, "..", "..")
|
|
|
|
/** ECC's universal hook runner. It applies hook-profile and disable flags. */
|
|
const HOOK_RUNNER = path.join(ECC_ROOT, "scripts", "hooks", "run-with-flags.js")
|
|
|
|
const HOOK_TIMEOUT_MS = 30_000
|
|
const MAX_HOOK_OUTPUT_BYTES = 1024 * 1024
|
|
|
|
/**
|
|
* ECC rules injected into Pi's system prompt, read from the canonical
|
|
* `rules/common/` directory at runtime. Nothing is copied or generated.
|
|
*
|
|
* Excluded on purpose: `agents.md`, `hooks.md`, and `performance.md`. Those
|
|
* describe Claude Code primitives Pi does not have (Task/TodoWrite delegation,
|
|
* Claude hook event types, thinking-budget toggles), so injecting them would
|
|
* instruct the model to use tools that are not there.
|
|
*/
|
|
const PORTABLE_RULE_FILES = [
|
|
"coding-style.md",
|
|
"testing.md",
|
|
"security.md",
|
|
"git-workflow.md",
|
|
"patterns.md",
|
|
"development-workflow.md",
|
|
"code-review.md",
|
|
] as const
|
|
|
|
/** Upper bound on injected rule text, so a large edit cannot flood the prompt. */
|
|
const MAX_RULES_BYTES = 32 * 1024
|
|
|
|
/** Values ECC treats as "off" across its existing environment switches. */
|
|
const DISABLED_VALUES = new Set(["0", "false", "off", "none", "disabled"])
|
|
|
|
/**
|
|
* Optional Pi companion packages. ECC works without every one of these; they
|
|
* are reported by `/ecc-doctor` so users can see which extras are available.
|
|
*/
|
|
const COMPANION_PACKAGES = [
|
|
"pi-subagents",
|
|
"@juicesharp/rpiv-ask-user-question",
|
|
"@juicesharp/rpiv-todo",
|
|
] as const
|
|
|
|
interface HookSpec {
|
|
/** ECC hook id, used for profile gating and disable flags. */
|
|
id: string
|
|
/** Hook script path relative to the ECC package root. */
|
|
script: string
|
|
/** Hook profiles the hook participates in. */
|
|
profiles: string
|
|
}
|
|
|
|
/** Mirrors the SessionStart wiring in `hooks/hooks.json`. */
|
|
const SESSION_START_HOOK: HookSpec = {
|
|
id: "session:start",
|
|
script: "scripts/hooks/session-start.js",
|
|
profiles: "minimal,standard,strict",
|
|
}
|
|
|
|
/** Mirrors the SessionEnd wiring in `hooks/hooks.json`. */
|
|
const SESSION_END_HOOK: HookSpec = {
|
|
id: "session:end:marker",
|
|
script: "scripts/hooks/session-end-marker.js",
|
|
profiles: "minimal,standard,strict",
|
|
}
|
|
|
|
interface HookResult {
|
|
stdout: string
|
|
failure?: string
|
|
}
|
|
|
|
/**
|
|
* Run an ECC hook through ECC's own runner.
|
|
*
|
|
* Never rejects: a missing runner, a non-zero exit, a timeout, or a spawn error
|
|
* all resolve to a `failure` string that the caller surfaces as a warning.
|
|
*/
|
|
function runEccHook(
|
|
spec: HookSpec,
|
|
payload: unknown,
|
|
env: NodeJS.ProcessEnv,
|
|
cwd: string
|
|
): Promise<HookResult> {
|
|
return new Promise(resolve => {
|
|
if (!fs.existsSync(HOOK_RUNNER)) {
|
|
resolve({ stdout: "", failure: `hook runner not found at ${HOOK_RUNNER}` })
|
|
return
|
|
}
|
|
|
|
const child = execFile(
|
|
process.execPath,
|
|
[HOOK_RUNNER, spec.id, spec.script, spec.profiles],
|
|
{
|
|
// Hooks inspect the user's project, so they run there. Only the script
|
|
// path is package-relative, and the runner resolves that from
|
|
// CLAUDE_PLUGIN_ROOT rather than from the working directory.
|
|
cwd,
|
|
env,
|
|
timeout: HOOK_TIMEOUT_MS,
|
|
maxBuffer: MAX_HOOK_OUTPUT_BYTES,
|
|
encoding: "utf8",
|
|
},
|
|
(error, stdout) => {
|
|
const text = typeof stdout === "string" ? stdout : ""
|
|
if (error) {
|
|
resolve({ stdout: text, failure: `${spec.id}: ${error.message}` })
|
|
return
|
|
}
|
|
resolve({ stdout: text })
|
|
}
|
|
)
|
|
|
|
child.on("error", error => {
|
|
resolve({ stdout: "", failure: `${spec.id}: ${error.message}` })
|
|
})
|
|
|
|
// stdin.end() writes asynchronously. A hook that exits, short-circuits, or
|
|
// is killed by the timeout before reading the payload makes the write fail
|
|
// with EPIPE, which Node reports as an `error` event rather than a throw.
|
|
// Without this listener that event is unhandled and would take the Pi
|
|
// session down, breaking the isolation guarantee documented above.
|
|
child.stdin?.on("error", error => {
|
|
resolve({ stdout: "", failure: `${spec.id}: could not write hook payload (${error.message})` })
|
|
})
|
|
|
|
try {
|
|
child.stdin?.end(JSON.stringify(payload))
|
|
} catch (error) {
|
|
resolve({
|
|
stdout: "",
|
|
failure: `${spec.id}: could not write hook payload (${(error as Error).message})`,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Working directory for hook execution: the user's project. Falls back to the
|
|
* ECC package root if Pi reports a directory that no longer exists, so a stale
|
|
* cwd degrades to a working hook rather than a spawn failure.
|
|
*/
|
|
function resolveHookCwd(ctx: ExtensionContext): string {
|
|
try {
|
|
if (ctx.cwd && fs.existsSync(ctx.cwd)) {
|
|
return ctx.cwd
|
|
}
|
|
} catch {
|
|
// Fall through to the package root.
|
|
}
|
|
return ECC_ROOT
|
|
}
|
|
|
|
function readSessionId(ctx: ExtensionContext): string | undefined {
|
|
try {
|
|
return ctx.sessionManager.getSessionId() || undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the environment ECC hooks expect.
|
|
*
|
|
* `CLAUDE_PLUGIN_ROOT` / `ECC_PLUGIN_ROOT` are how every ECC hook locates the
|
|
* package; setting them from `ECC_ROOT` is what makes a global install resolve
|
|
* correctly instead of probing the user's project. The `CLAUDE_*` session vars
|
|
* are the names ECC's shared hook scripts already read across harnesses.
|
|
*/
|
|
function buildHookEnv(ctx: ExtensionContext): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
CLAUDE_PLUGIN_ROOT: ECC_ROOT,
|
|
ECC_PLUGIN_ROOT: ECC_ROOT,
|
|
CLAUDE_PROJECT_DIR: ctx.cwd,
|
|
}
|
|
|
|
const sessionId = readSessionId(ctx)
|
|
if (sessionId) {
|
|
env.CLAUDE_SESSION_ID = sessionId
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
/**
|
|
* Map Pi's session reason onto the `source` values ECC's SessionStart hook
|
|
* understands. Pi's `new` and `reload` have no Claude Code equivalent, so they
|
|
* report as a fresh startup.
|
|
*/
|
|
function mapSessionSource(reason: SessionStartEvent["reason"]): string {
|
|
switch (reason) {
|
|
case "resume":
|
|
case "fork":
|
|
return "resume"
|
|
default:
|
|
return "startup"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract `hookSpecificOutput.additionalContext` from a hook's stdout.
|
|
*
|
|
* ECC hooks emit a JSON envelope, but the runner passes stdin straight through
|
|
* when a hook is disabled by profile, so non-JSON stdout is expected and must
|
|
* not be treated as an error.
|
|
*/
|
|
function extractAdditionalContext(stdout: string): string | undefined {
|
|
const trimmed = stdout.trim()
|
|
if (!trimmed.startsWith("{")) {
|
|
return undefined
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as {
|
|
hookSpecificOutput?: { additionalContext?: unknown }
|
|
}
|
|
const context = parsed.hookSpecificOutput?.additionalContext
|
|
return typeof context === "string" && context.trim() ? context : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function isDisabledByEnv(value: string | undefined): boolean {
|
|
return typeof value === "string" && DISABLED_VALUES.has(value.trim().toLowerCase())
|
|
}
|
|
|
|
/** Memoized so the rule files are read once per session, not once per turn. */
|
|
let cachedRules: string | null | undefined
|
|
|
|
/**
|
|
* How many of `PORTABLE_RULE_FILES` actually made it into `cachedRules`.
|
|
*
|
|
* Kept alongside the cache because `loadPortableRules` silently drops files it
|
|
* cannot read, files that are empty, and every file past the size cap — so the
|
|
* allowlist length would overstate a partial install in `/ecc-doctor`, which is
|
|
* the one place a user looks to find exactly that.
|
|
*/
|
|
let cachedRuleFileCount = 0
|
|
|
|
/**
|
|
* ECC's portable engineering rules, concatenated from the canonical
|
|
* `rules/common/` directory of the installed package.
|
|
*
|
|
* Returns null when disabled via `ECC_PI_RULES` or when no rule file could be
|
|
* read, so a partial install degrades to "no rules" instead of failing.
|
|
*/
|
|
function loadPortableRules(): string | null {
|
|
if (cachedRules !== undefined) {
|
|
return cachedRules
|
|
}
|
|
|
|
if (isDisabledByEnv(process.env.ECC_PI_RULES)) {
|
|
cachedRules = null
|
|
cachedRuleFileCount = 0
|
|
return cachedRules
|
|
}
|
|
|
|
const sections: string[] = []
|
|
let total = 0
|
|
|
|
for (const file of PORTABLE_RULE_FILES) {
|
|
let text: string
|
|
try {
|
|
text = fs.readFileSync(path.join(ECC_ROOT, "rules", "common", file), "utf8").trim()
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
if (!text) {
|
|
continue
|
|
}
|
|
|
|
if (total + text.length > MAX_RULES_BYTES) {
|
|
break
|
|
}
|
|
|
|
total += text.length
|
|
sections.push(text)
|
|
}
|
|
|
|
cachedRules = sections.length > 0 ? sections.join("\n\n---\n\n") : null
|
|
cachedRuleFileCount = sections.length
|
|
return cachedRules
|
|
}
|
|
|
|
/**
|
|
* Pi's config directory, honoring the documented `PI_CODING_AGENT_DIR` override.
|
|
*/
|
|
function resolvePiConfigDir(): string {
|
|
const override = process.env.PI_CODING_AGENT_DIR
|
|
if (override && override.trim()) {
|
|
return override.trim()
|
|
}
|
|
return path.join(os.homedir(), ".pi", "agent")
|
|
}
|
|
|
|
/**
|
|
* Package names Pi currently has installed, read from the same `packages`
|
|
* lists Pi itself uses: the user config directory plus the project-local
|
|
* `.pi/settings.json`.
|
|
*
|
|
* `require.resolve` cannot answer this. Pi installs packages under its own
|
|
* config directory (`<config>/npm`, `<config>/git`), which is not on Node's
|
|
* module resolution path from this file, so resolving would report every
|
|
* companion as missing no matter what the user has installed.
|
|
*/
|
|
function listInstalledPiPackages(projectDir: string): Set<string> {
|
|
const names = new Set<string>()
|
|
|
|
const settingsFiles = [
|
|
path.join(resolvePiConfigDir(), "settings.json"),
|
|
path.join(projectDir, ".pi", "settings.json"),
|
|
]
|
|
|
|
for (const file of settingsFiles) {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { packages?: unknown }
|
|
if (!Array.isArray(parsed.packages)) {
|
|
continue
|
|
}
|
|
for (const entry of parsed.packages) {
|
|
const name = normalizePiPackageName(entry)
|
|
if (name) {
|
|
names.add(name)
|
|
}
|
|
}
|
|
} catch {
|
|
// Missing or unreadable settings are simply "nothing installed here".
|
|
}
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
/**
|
|
* Reduce a `packages` entry to a bare package name.
|
|
*
|
|
* An entry is either the source string itself or an object carrying that
|
|
* string under `source` alongside resource filters (`{ source: "npm:x",
|
|
* skills: [] }`). Pi accepts both forms, and a filtered package is just as
|
|
* installed as a plain one, so both must resolve to the same name.
|
|
*
|
|
* Sources look like `npm:pi-subagents`, `npm:@scope/name@1.2.3`, a git source,
|
|
* or a filesystem path. Only npm sources carry a comparable package name.
|
|
*/
|
|
function normalizePiPackageName(entry: unknown): string | undefined {
|
|
const source = entry && typeof entry === "object" ? (entry as { source?: unknown }).source : entry
|
|
|
|
if (typeof source !== "string" || !source.startsWith("npm:")) {
|
|
return undefined
|
|
}
|
|
|
|
const spec = source.slice("npm:".length)
|
|
// Strip a trailing @version without breaking the leading @ of a scoped name.
|
|
const versionAt = spec.lastIndexOf("@")
|
|
return versionAt > 0 ? spec.slice(0, versionAt) : spec
|
|
}
|
|
|
|
function countDirectories(dir: string): number {
|
|
try {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).filter(entry => entry.isDirectory()).length
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function countMarkdownFiles(dir: string): number {
|
|
try {
|
|
return fs.readdirSync(dir).filter(name => name.endsWith(".md")).length
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function readEccVersion(): string {
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(ECC_ROOT, "package.json"), "utf8")) as {
|
|
version?: string
|
|
}
|
|
return manifest.version || "unknown"
|
|
} catch {
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
function describeRulesStatus(): string {
|
|
if (isDisabledByEnv(process.env.ECC_PI_RULES)) {
|
|
return "disabled via ECC_PI_RULES"
|
|
}
|
|
|
|
const rules = loadPortableRules()
|
|
if (!rules) {
|
|
return `NOT FOUND (${path.join(ECC_ROOT, "rules", "common")})`
|
|
}
|
|
|
|
const skipped = PORTABLE_RULE_FILES.length - cachedRuleFileCount
|
|
const shortfall = skipped > 0 ? ` (${skipped} unreadable, empty, or past the size cap)` : ""
|
|
return `${cachedRuleFileCount}/${PORTABLE_RULE_FILES.length} rule file(s), ${rules.length} chars, from rules/common/${shortfall}`
|
|
}
|
|
|
|
function buildDoctorReport(ctx: ExtensionContext): string {
|
|
const skillsDir = path.join(ECC_ROOT, "skills")
|
|
const commandsDir = path.join(ECC_ROOT, "commands")
|
|
const skillCount = countDirectories(skillsDir)
|
|
const commandCount = countMarkdownFiles(commandsDir)
|
|
|
|
const lines = [
|
|
"ECC adapter for Pi",
|
|
"",
|
|
` ECC version: ${readEccVersion()}`,
|
|
` Package root: ${ECC_ROOT}`,
|
|
` Project cwd: ${ctx.cwd}`,
|
|
"",
|
|
"Canonical resources",
|
|
` skills/ ${skillCount > 0 ? `${skillCount} skill(s)` : "NOT FOUND"} (${skillsDir})`,
|
|
` commands/ ${commandCount > 0 ? `${commandCount} command(s)` : "NOT FOUND"} (${commandsDir})`,
|
|
"",
|
|
"Engineering rules (injected into the system prompt)",
|
|
` ${describeRulesStatus()}`,
|
|
"",
|
|
"Hook runner",
|
|
` ${fs.existsSync(HOOK_RUNNER) ? "found" : "NOT FOUND"} (${HOOK_RUNNER})`,
|
|
` profile: ${process.env.ECC_HOOK_PROFILE || "standard (default)"}`,
|
|
` disabled: ${process.env.ECC_DISABLED_HOOKS || "none"}`,
|
|
"",
|
|
"Optional companion packages (from Pi's installed package list)",
|
|
]
|
|
|
|
const installed = listInstalledPiPackages(ctx.cwd)
|
|
for (const name of COMPANION_PACKAGES) {
|
|
const present = installed.has(name)
|
|
lines.push(` ${present ? "installed " : "not installed"} ${name}`)
|
|
if (!present) {
|
|
lines.push(` install with: pi install npm:${name}`)
|
|
}
|
|
}
|
|
|
|
lines.push(
|
|
"",
|
|
"Companion packages are optional; ECC skills, commands, and session hooks",
|
|
"work without them. See .pi/README.md for what each one unlocks.",
|
|
"Detection reads Pi's `packages` list, so a companion vendored some other",
|
|
"way may work while reporting as not installed."
|
|
)
|
|
|
|
return lines.join("\n")
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI): void {
|
|
/**
|
|
* ECC's SessionStart hook returns context for the model, but Pi has no
|
|
* equivalent of Claude Code's `additionalContext` field. It is held here and
|
|
* folded into the system prompt on the next agent start, which is the
|
|
* documented Pi injection point that does not fabricate a user turn.
|
|
*/
|
|
let pendingContext: string | undefined
|
|
|
|
pi.on("session_start", async (event, ctx) => {
|
|
const payload = {
|
|
hook_event_name: "SessionStart",
|
|
source: mapSessionSource(event.reason),
|
|
cwd: ctx.cwd,
|
|
session_id: readSessionId(ctx),
|
|
}
|
|
|
|
// Drop any context captured by an earlier session start that has not been
|
|
// injected yet. Pi can start a new session (/new, /resume, /fork) before
|
|
// `before_agent_start` consumes the previous value, and replaying context
|
|
// built for a different session would describe the wrong project state.
|
|
pendingContext = undefined
|
|
|
|
const result = await runEccHook(
|
|
SESSION_START_HOOK,
|
|
payload,
|
|
buildHookEnv(ctx),
|
|
resolveHookCwd(ctx)
|
|
)
|
|
|
|
if (result.failure) {
|
|
ctx.ui.notify(`ECC session-start hook skipped (${result.failure})`, "warning")
|
|
return
|
|
}
|
|
|
|
pendingContext = extractAdditionalContext(result.stdout)
|
|
})
|
|
|
|
pi.on("before_agent_start", event => {
|
|
const additions: string[] = []
|
|
|
|
// Rules describe standing engineering policy, so they are re-applied on
|
|
// every turn. The session context is a one-shot handoff and is consumed.
|
|
const rules = loadPortableRules()
|
|
if (rules) {
|
|
additions.push(`<ecc-engineering-rules>\n${rules}\n</ecc-engineering-rules>`)
|
|
}
|
|
|
|
if (pendingContext) {
|
|
additions.push(`<ecc-session-context>\n${pendingContext}\n</ecc-session-context>`)
|
|
pendingContext = undefined
|
|
}
|
|
|
|
if (additions.length === 0) {
|
|
return
|
|
}
|
|
|
|
return { systemPrompt: [event.systemPrompt, ...additions].join("\n\n") }
|
|
})
|
|
|
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
const payload = {
|
|
hook_event_name: "SessionEnd",
|
|
reason: event.reason,
|
|
cwd: ctx.cwd,
|
|
session_id: readSessionId(ctx),
|
|
}
|
|
|
|
const result = await runEccHook(
|
|
SESSION_END_HOOK,
|
|
payload,
|
|
buildHookEnv(ctx),
|
|
resolveHookCwd(ctx)
|
|
)
|
|
|
|
if (result.failure) {
|
|
ctx.ui.notify(`ECC session-end hook skipped (${result.failure})`, "warning")
|
|
}
|
|
})
|
|
|
|
pi.registerCommand("ecc-doctor", {
|
|
description: "Report ECC adapter status: package root, canonical resources, hooks, companions",
|
|
handler: async (_args, ctx) => {
|
|
pi.sendMessage(
|
|
{
|
|
customType: "ecc-doctor",
|
|
content: buildDoctorReport(ctx),
|
|
display: true,
|
|
},
|
|
{ deliverAs: "nextTurn" }
|
|
)
|
|
},
|
|
})
|
|
}
|