diff --git a/.omp/hooks/pre/graft.ts b/.omp/hooks/pre/graft.ts new file mode 100644 index 00000000..d5de0716 --- /dev/null +++ b/.omp/hooks/pre/graft.ts @@ -0,0 +1,201 @@ +// graft.ts — pi / omp (oh-my-pi) extension for the Graft context graph. +// +// Graft's Claude Code support has four channels; pi/omp cover two natively: +// 1. orientation -> AGENTS.md section (graft init "agents" host); both pi and +// omp load AGENTS.md, so nothing to do here +// 2. MCP tools -> .mcp.json (graft init "claude" host); omp reads it +// natively, pi via pi-mcp-adapter +// 3. per-prompt retrieval (Claude's UserPromptSubmit hook) -> THIS extension: +// before_agent_start runs `graft ask --json` on the prompt and injects a +// pointers-only pack, gated on the same coverage floors and novelty rules +// as graft's own relevantRetrieval() (STRONG 0.1 / HIGH 0.5, nudges ≤ 2, +// never repeat a pointer). Edit/write marks the graph stale; the next turn +// gets a one-line stale note. +// 4. statusline -> THIS extension: the status text (ctx.ui.setStatus) shows +// the graph's node/edge counts and freshness (graft/.cache/stats.json), +// and the session's saved-tokens counter fed by the retrieval pack. +// Load paths: +// pi: ~/.pi/agent/extensions/graft.ts (global) or /.pi/extensions/graft.ts +// omp: /.omp/hooks/pre/graft.ts or ~/.omp/agent/hooks/pre/graft.ts +// It no-ops in repos without graft/INDEX.md. +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +interface GraftAskHit { kind: string; title: string; pointer: string; snippet: string; code?: string } +interface GraftAskJson { hits: GraftAskHit[]; coverage?: number; coverageStrong?: number; saved?: { files: number; baselineChars: number } } +interface GraftSessionState { injectedPointers: string[]; weakNudges: number; staleNoted: boolean; stale: boolean; savedTokens: number } +interface GraftExtensionContext { cwd: string } +interface BeforeAgentStartEvent { prompt?: string } +interface TurnEndEvent { timestamp?: number } +interface GraftStatusContext { cwd: string; ui?: { setStatus(key: string, text: string | undefined): void } } +interface ToolResultEvent { toolName?: string; isError?: boolean } +type PackResult = { text: string; used: string[] } | { text: null; used: [] }; + +const MIN_PROMPT_CHARS = 12; // graft hooks.ts: shorter prompts are conversational +const STRONG_FLOOR = 0.1; // graft ask/fuse.ts coverageStrong floor +const HIGH_FLOOR = 0.5; // graft ask/fuse.ts broad-coverage floor +const NUDGE_CAP = 2; // graft: weak-match nudges per session +const INJECTED_CAP = 40; // graft: novelty-gate memory +const PACK_CAP = 3; // hits per injected pack +const ASK_TIMEOUT_MS = 8000; // graft: installed hook child budget +const STATE_FILE = "graft/.session-state.json"; +const EDIT_TOOLS: Record = { edit: true, write: true, multiedit: true, notebookedit: true }; + +const EMPTY_STATE: GraftSessionState = { injectedPointers: [], weakNudges: 0, staleNoted: false, stale: false, savedTokens: 0 }; + +function readIndex(dir: string): string | null { + try { return readFileSync(`${dir}/graft/INDEX.md`, "utf8"); } catch { return null; } +} + +function readState(dir: string): GraftSessionState { + try { + const raw: unknown = JSON.parse(readFileSync(`${dir}/${STATE_FILE}`, "utf8")); + if (typeof raw !== "object" || raw === null) return { ...EMPTY_STATE }; + // Shape is written by this file only; a narrow guard, not a schema engine. + const o = raw as Record; // internal file, shape owned here + const pointers = Array.isArray(o.injectedPointers) + ? o.injectedPointers.filter((p): p is string => typeof p === "string") + : []; + return { + injectedPointers: pointers, + weakNudges: typeof o.weakNudges === "number" ? o.weakNudges : 0, + staleNoted: o.staleNoted === true, + stale: o.stale === true, + savedTokens: typeof o.savedTokens === "number" ? o.savedTokens : 0, + }; + } catch { return { ...EMPTY_STATE }; } +} + +function writeState(dir: string, s: GraftSessionState): void { + try { writeFileSync(`${dir}/${STATE_FILE}`, JSON.stringify(s)); } catch { /* best-effort */ } +} + +function askGraft(dir: string, prompt: string): GraftAskJson | null { + try { + const r = spawnSync("graft", ["ask", prompt, ".", "--json", "--source", "-n", String(PACK_CAP)], { + cwd: dir, timeout: ASK_TIMEOUT_MS, encoding: "utf8", + }); + if (r.status !== 0 || !r.stdout) return null; + return JSON.parse(r.stdout) as GraftAskJson; // graft ask --json output, owned by graft + } catch { return null; } // graft missing/stale/slow: never block the turn +} + +function formatPack(hits: GraftAskHit[]): string { + const blocks = hits.map((h, i) => { + const ptr = h.pointer.split(",")[0].trim(); + const snip = h.snippet.replace(/\s+/g, " ").trim().slice(0, 140); + return snip ? ` ${i + 1}. ${h.title}: ${ptr}\n ${snip}` : ` ${i + 1}. ${h.title}: ${ptr}`; + }); + return ( + `[graft] starting points for this task: pull the code inline with \`graft ask "" --source\`, ` + + `trace impact with \`graft callers \`, or search with \`graft grep ""\`:\n${blocks.join("\n")}` + ); +} + +/** Same two-clause gate as graft format.ts relevantRetrieval(): weak lexical + * matches get at most a capped nudge; strong matches get fresh pointers only. */ +function relevantRetrieval(ask: GraftAskJson, s: GraftSessionState): PackResult { + if (!ask.hits?.length) return { text: null, used: [] }; + const seen = new Set(s.injectedPointers); + const fresh = ask.hits.filter((h) => !seen.has(h.pointer)); + const strong = ask.coverageStrong ?? 0; + const broad = ask.coverage ?? 0; + if (strong < STRONG_FLOOR && broad < HIGH_FLOOR) { + if (!fresh.length || s.weakNudges >= NUDGE_CAP) return { text: null, used: [] }; + s.weakNudges += 1; + return { + text: + `[graft] this repo is indexed in graft/; before grepping source, pull context with ` + + `\`graft ask "" --source\` (ranked nodes with file:line), \`graft grep ""\` ` + + `(exhaustive), or \`graft callers \` (who calls it).`, + used: [], + }; + } + if (!fresh.length) return { text: null, used: [] }; + const pack = fresh.slice(0, PACK_CAP); + return { text: formatPack(pack), used: pack.map((h) => h.pointer) }; +} + +/** Graph freshness + size. Preferred source is graft/.cache/stats.json (the + * hook-maintained cache); on pi/omp nothing maintains it, so fall back to the + * graph itself (graft/.graph/wiring.json) the way graft's Claude statusline + * resolveStats() does — the bar reflects reality immediately after a build. */ +function renderStatus(dir: string, savedTokens: number): string { + let stats: { nodeCount?: number; edgeCount?: number; dirty?: boolean; staleCount?: number } = {}; + try { + stats = JSON.parse(readFileSync(`${dir}/graft/.cache/stats.json`, "utf8")) as typeof stats; + } catch { /* cache absent: fall through to the graph */ } + if (typeof stats.nodeCount !== "number" || stats.nodeCount === 0) { + try { + const wiring = JSON.parse(readFileSync(`${dir}/graft/.graph/wiring.json`, "utf8")) as + { meta?: { nodeCount?: number; edgeCount?: number }; nodes?: unknown[]; edges?: unknown[] }; + stats = { + nodeCount: wiring.meta?.nodeCount ?? (wiring.nodes ?? []).length, + edgeCount: wiring.meta?.edgeCount ?? (wiring.edges ?? []).length, + }; + } catch { /* not built */ } + } + const nodes = typeof stats.nodeCount === "number" ? stats.nodeCount : null; + if (nodes === null) return "graft · not built · run `graft build`"; + const freshness = stats.dirty ? "⚠ stale" : "✓ synced"; + const saved = savedTokens > 0 ? ` · ~${savedTokens.toLocaleString()} tok saved` : ""; + return `graft · ${nodes} nodes / ${typeof stats.edgeCount === "number" ? stats.edgeCount : 0} edges · ${freshness}${saved}`; +} + +export default function (pi: { + on(event: "before_agent_start", handler: (event: BeforeAgentStartEvent, ctx: GraftExtensionContext) => Promise<{ message?: { customType: string; content: string; display: boolean } } | void>): void; + on(event: "tool_result", handler: (event: ToolResultEvent, ctx: GraftExtensionContext) => Promise): void; + on(event: "turn_end", handler: (event: TurnEndEvent, ctx: GraftStatusContext) => Promise): void; + on(event: string, handler: (event: never, ctx: never) => unknown): void; +}): void { + // UserPromptSubmit equivalent: gated retrieval pack before the agent loop. + pi.on("before_agent_start", async (event, ctx) => { + const prompt = String(event.prompt ?? "").trim(); + if (prompt.length < MIN_PROMPT_CHARS) return; + const dir = ctx.cwd; + if (!readIndex(dir)) return; // no graph here — nothing to say + const s = readState(dir); + const ask = askGraft(dir, prompt); + if (!ask) { writeState(dir, s); return; } + const pack = relevantRetrieval(ask, s); + let content = pack.text; + if (pack.used.length) { + s.injectedPointers = [...s.injectedPointers, ...pack.used].slice(-INJECTED_CAP); + // Honest floor only: the pack is pointers-only (~100 tok), so the credit + // is what graft's ask said the hits replace (baselineChars/4) minus the + // pack — never the full baseline (that would double-count the pull). + const saved = ask.saved?.baselineChars ?? 0; + const credit = Math.max(0, Math.round(saved / 4) - 100); + s.savedTokens += credit; + } + if (s.stale && !s.staleNoted) { + s.staleNoted = true; + const note = + "[graft] the graph in graft/ is now stale (code was edited after it was built); " + + "refresh with `graft build` before relying on it for changed areas."; + content = content ? `${content}\n${note}` : note; + } + writeState(dir, s); + if (!content) return; + return { message: { customType: "graft-retrieval", content, display: false } }; + }); + + // PostToolUse(edit) equivalent: flip the stale flag; the note ships on the + // next turn's injection instead of clobbering this edit's tool result. + pi.on("tool_result", async (event, ctx) => { + if (!EDIT_TOOLS[String(event.toolName ?? "").toLowerCase()]) return; + if (event.isError) return; + const dir = ctx.cwd; + if (!readIndex(dir)) return; + const s = readState(dir); + if (!s.stale) { s.stale = true; writeState(dir, s); } + }); + + // Statusline: graft's bar lives in the host's footer status area instead of a + // shell script — same content as Claude's statusline, refreshed per turn. + pi.on("turn_end", async (_event, ctx) => { + if (!ctx.ui) return; // print/JSON mode: no footer to paint + const s = readState(ctx.cwd); + ctx.ui.setStatus("graft", renderStatus(ctx.cwd, s.savedTokens)); + }); +} diff --git a/.pi/extensions/graft.ts b/.pi/extensions/graft.ts new file mode 100644 index 00000000..d5de0716 --- /dev/null +++ b/.pi/extensions/graft.ts @@ -0,0 +1,201 @@ +// graft.ts — pi / omp (oh-my-pi) extension for the Graft context graph. +// +// Graft's Claude Code support has four channels; pi/omp cover two natively: +// 1. orientation -> AGENTS.md section (graft init "agents" host); both pi and +// omp load AGENTS.md, so nothing to do here +// 2. MCP tools -> .mcp.json (graft init "claude" host); omp reads it +// natively, pi via pi-mcp-adapter +// 3. per-prompt retrieval (Claude's UserPromptSubmit hook) -> THIS extension: +// before_agent_start runs `graft ask --json` on the prompt and injects a +// pointers-only pack, gated on the same coverage floors and novelty rules +// as graft's own relevantRetrieval() (STRONG 0.1 / HIGH 0.5, nudges ≤ 2, +// never repeat a pointer). Edit/write marks the graph stale; the next turn +// gets a one-line stale note. +// 4. statusline -> THIS extension: the status text (ctx.ui.setStatus) shows +// the graph's node/edge counts and freshness (graft/.cache/stats.json), +// and the session's saved-tokens counter fed by the retrieval pack. +// Load paths: +// pi: ~/.pi/agent/extensions/graft.ts (global) or /.pi/extensions/graft.ts +// omp: /.omp/hooks/pre/graft.ts or ~/.omp/agent/hooks/pre/graft.ts +// It no-ops in repos without graft/INDEX.md. +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +interface GraftAskHit { kind: string; title: string; pointer: string; snippet: string; code?: string } +interface GraftAskJson { hits: GraftAskHit[]; coverage?: number; coverageStrong?: number; saved?: { files: number; baselineChars: number } } +interface GraftSessionState { injectedPointers: string[]; weakNudges: number; staleNoted: boolean; stale: boolean; savedTokens: number } +interface GraftExtensionContext { cwd: string } +interface BeforeAgentStartEvent { prompt?: string } +interface TurnEndEvent { timestamp?: number } +interface GraftStatusContext { cwd: string; ui?: { setStatus(key: string, text: string | undefined): void } } +interface ToolResultEvent { toolName?: string; isError?: boolean } +type PackResult = { text: string; used: string[] } | { text: null; used: [] }; + +const MIN_PROMPT_CHARS = 12; // graft hooks.ts: shorter prompts are conversational +const STRONG_FLOOR = 0.1; // graft ask/fuse.ts coverageStrong floor +const HIGH_FLOOR = 0.5; // graft ask/fuse.ts broad-coverage floor +const NUDGE_CAP = 2; // graft: weak-match nudges per session +const INJECTED_CAP = 40; // graft: novelty-gate memory +const PACK_CAP = 3; // hits per injected pack +const ASK_TIMEOUT_MS = 8000; // graft: installed hook child budget +const STATE_FILE = "graft/.session-state.json"; +const EDIT_TOOLS: Record = { edit: true, write: true, multiedit: true, notebookedit: true }; + +const EMPTY_STATE: GraftSessionState = { injectedPointers: [], weakNudges: 0, staleNoted: false, stale: false, savedTokens: 0 }; + +function readIndex(dir: string): string | null { + try { return readFileSync(`${dir}/graft/INDEX.md`, "utf8"); } catch { return null; } +} + +function readState(dir: string): GraftSessionState { + try { + const raw: unknown = JSON.parse(readFileSync(`${dir}/${STATE_FILE}`, "utf8")); + if (typeof raw !== "object" || raw === null) return { ...EMPTY_STATE }; + // Shape is written by this file only; a narrow guard, not a schema engine. + const o = raw as Record; // internal file, shape owned here + const pointers = Array.isArray(o.injectedPointers) + ? o.injectedPointers.filter((p): p is string => typeof p === "string") + : []; + return { + injectedPointers: pointers, + weakNudges: typeof o.weakNudges === "number" ? o.weakNudges : 0, + staleNoted: o.staleNoted === true, + stale: o.stale === true, + savedTokens: typeof o.savedTokens === "number" ? o.savedTokens : 0, + }; + } catch { return { ...EMPTY_STATE }; } +} + +function writeState(dir: string, s: GraftSessionState): void { + try { writeFileSync(`${dir}/${STATE_FILE}`, JSON.stringify(s)); } catch { /* best-effort */ } +} + +function askGraft(dir: string, prompt: string): GraftAskJson | null { + try { + const r = spawnSync("graft", ["ask", prompt, ".", "--json", "--source", "-n", String(PACK_CAP)], { + cwd: dir, timeout: ASK_TIMEOUT_MS, encoding: "utf8", + }); + if (r.status !== 0 || !r.stdout) return null; + return JSON.parse(r.stdout) as GraftAskJson; // graft ask --json output, owned by graft + } catch { return null; } // graft missing/stale/slow: never block the turn +} + +function formatPack(hits: GraftAskHit[]): string { + const blocks = hits.map((h, i) => { + const ptr = h.pointer.split(",")[0].trim(); + const snip = h.snippet.replace(/\s+/g, " ").trim().slice(0, 140); + return snip ? ` ${i + 1}. ${h.title}: ${ptr}\n ${snip}` : ` ${i + 1}. ${h.title}: ${ptr}`; + }); + return ( + `[graft] starting points for this task: pull the code inline with \`graft ask "" --source\`, ` + + `trace impact with \`graft callers \`, or search with \`graft grep ""\`:\n${blocks.join("\n")}` + ); +} + +/** Same two-clause gate as graft format.ts relevantRetrieval(): weak lexical + * matches get at most a capped nudge; strong matches get fresh pointers only. */ +function relevantRetrieval(ask: GraftAskJson, s: GraftSessionState): PackResult { + if (!ask.hits?.length) return { text: null, used: [] }; + const seen = new Set(s.injectedPointers); + const fresh = ask.hits.filter((h) => !seen.has(h.pointer)); + const strong = ask.coverageStrong ?? 0; + const broad = ask.coverage ?? 0; + if (strong < STRONG_FLOOR && broad < HIGH_FLOOR) { + if (!fresh.length || s.weakNudges >= NUDGE_CAP) return { text: null, used: [] }; + s.weakNudges += 1; + return { + text: + `[graft] this repo is indexed in graft/; before grepping source, pull context with ` + + `\`graft ask "" --source\` (ranked nodes with file:line), \`graft grep ""\` ` + + `(exhaustive), or \`graft callers \` (who calls it).`, + used: [], + }; + } + if (!fresh.length) return { text: null, used: [] }; + const pack = fresh.slice(0, PACK_CAP); + return { text: formatPack(pack), used: pack.map((h) => h.pointer) }; +} + +/** Graph freshness + size. Preferred source is graft/.cache/stats.json (the + * hook-maintained cache); on pi/omp nothing maintains it, so fall back to the + * graph itself (graft/.graph/wiring.json) the way graft's Claude statusline + * resolveStats() does — the bar reflects reality immediately after a build. */ +function renderStatus(dir: string, savedTokens: number): string { + let stats: { nodeCount?: number; edgeCount?: number; dirty?: boolean; staleCount?: number } = {}; + try { + stats = JSON.parse(readFileSync(`${dir}/graft/.cache/stats.json`, "utf8")) as typeof stats; + } catch { /* cache absent: fall through to the graph */ } + if (typeof stats.nodeCount !== "number" || stats.nodeCount === 0) { + try { + const wiring = JSON.parse(readFileSync(`${dir}/graft/.graph/wiring.json`, "utf8")) as + { meta?: { nodeCount?: number; edgeCount?: number }; nodes?: unknown[]; edges?: unknown[] }; + stats = { + nodeCount: wiring.meta?.nodeCount ?? (wiring.nodes ?? []).length, + edgeCount: wiring.meta?.edgeCount ?? (wiring.edges ?? []).length, + }; + } catch { /* not built */ } + } + const nodes = typeof stats.nodeCount === "number" ? stats.nodeCount : null; + if (nodes === null) return "graft · not built · run `graft build`"; + const freshness = stats.dirty ? "⚠ stale" : "✓ synced"; + const saved = savedTokens > 0 ? ` · ~${savedTokens.toLocaleString()} tok saved` : ""; + return `graft · ${nodes} nodes / ${typeof stats.edgeCount === "number" ? stats.edgeCount : 0} edges · ${freshness}${saved}`; +} + +export default function (pi: { + on(event: "before_agent_start", handler: (event: BeforeAgentStartEvent, ctx: GraftExtensionContext) => Promise<{ message?: { customType: string; content: string; display: boolean } } | void>): void; + on(event: "tool_result", handler: (event: ToolResultEvent, ctx: GraftExtensionContext) => Promise): void; + on(event: "turn_end", handler: (event: TurnEndEvent, ctx: GraftStatusContext) => Promise): void; + on(event: string, handler: (event: never, ctx: never) => unknown): void; +}): void { + // UserPromptSubmit equivalent: gated retrieval pack before the agent loop. + pi.on("before_agent_start", async (event, ctx) => { + const prompt = String(event.prompt ?? "").trim(); + if (prompt.length < MIN_PROMPT_CHARS) return; + const dir = ctx.cwd; + if (!readIndex(dir)) return; // no graph here — nothing to say + const s = readState(dir); + const ask = askGraft(dir, prompt); + if (!ask) { writeState(dir, s); return; } + const pack = relevantRetrieval(ask, s); + let content = pack.text; + if (pack.used.length) { + s.injectedPointers = [...s.injectedPointers, ...pack.used].slice(-INJECTED_CAP); + // Honest floor only: the pack is pointers-only (~100 tok), so the credit + // is what graft's ask said the hits replace (baselineChars/4) minus the + // pack — never the full baseline (that would double-count the pull). + const saved = ask.saved?.baselineChars ?? 0; + const credit = Math.max(0, Math.round(saved / 4) - 100); + s.savedTokens += credit; + } + if (s.stale && !s.staleNoted) { + s.staleNoted = true; + const note = + "[graft] the graph in graft/ is now stale (code was edited after it was built); " + + "refresh with `graft build` before relying on it for changed areas."; + content = content ? `${content}\n${note}` : note; + } + writeState(dir, s); + if (!content) return; + return { message: { customType: "graft-retrieval", content, display: false } }; + }); + + // PostToolUse(edit) equivalent: flip the stale flag; the note ships on the + // next turn's injection instead of clobbering this edit's tool result. + pi.on("tool_result", async (event, ctx) => { + if (!EDIT_TOOLS[String(event.toolName ?? "").toLowerCase()]) return; + if (event.isError) return; + const dir = ctx.cwd; + if (!readIndex(dir)) return; + const s = readState(dir); + if (!s.stale) { s.stale = true; writeState(dir, s); } + }); + + // Statusline: graft's bar lives in the host's footer status area instead of a + // shell script — same content as Claude's statusline, refreshed per turn. + pi.on("turn_end", async (_event, ctx) => { + if (!ctx.ui) return; // print/JSON mode: no footer to paint + const s = readState(ctx.cwd); + ctx.ui.setStatus("graft", renderStatus(ctx.cwd, s.savedTokens)); + }); +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d8af4a56 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ + +## Graft — repo context graph + +This repo is indexed in `graft/`: small linked markdown nodes that explain each +system and carry exact file:line spans, kept in sync with the code through git. + +For ANY task here — understanding how something works, finding where code lives, +or scoping a change — get context from the graph before grepping or opening +source files. Re-ask freely (it's cheap) and reuse literal identifiers you +already have (symbol, error string, file name) as the query. New to this repo? +Run `graft map` first — a token-budgeted orientation (dir clusters, hubs, +hotspots), no LLM, no key. + +- Run `graft ask "" --source` → ranked nodes with the relevant + code spans inlined (each hit's ≤8-line crux by default; `--full` for whole + definitions when the crux isn't enough). Match the tool to the task shape: + for understanding or editing, the top node IS the answer — cite its + `covers:` file:line spans and edit straight from `--source`. For + exhaustive tasks ("every occurrence / every caller of this pattern"), ranked + results are top-N, not complete — run `graft grep ""` instead + (exhaustive over indexed files, grouped by enclosing symbol), falling back + to raw `grep -rn` only for unindexed files. +- `graft skeleton ` → every definition's signature + span, ~10× cheaper + than reading the file; use it to skim an API surface. +- `graft callers ` gives precomputed, exact edges — who calls this. + Add `--direction out` for what it calls, or `--depth N` to walk + transitively for the full blast radius. For structural questions, skip + ranking and use this directly. +- Or browse: `graft/INDEX.md` lists every node; follow the links. +- Monorepos and folders of multiple repos rank fairly across sub-projects — + hits carry `[scope/]` labels naming which one they're from. Narrow with + `graft ask "" --in /` once you know where you're working. + +If a returned span is truncated ("+N more lines"), open the file at that exact +range before finalizing. Only open source files when a node genuinely lacks a +needed detail, and then at the exact file:line the node points to — never +re-read whole files. + +After big code changes, refresh the graph with `graft build` (deterministic, +no API key, $0). + diff --git a/src/hosts/init.ts b/src/hosts/init.ts index 12b73d5a..c2a40444 100644 --- a/src/hosts/init.ts +++ b/src/hosts/init.ts @@ -13,6 +13,7 @@ import { installCodexHooks } from './codex-hooks.js'; import { installCursorHooks } from './cursor-hooks.js'; import type { ConfigWrite } from './config-write.js'; import { installAntigravitySkill } from './antigravity.js'; +import { installPiOmp, installPiOmpGlobal } from './pi-omp.js'; export interface HostsInitResult { written: { id: string; path: string; action: string }[]; @@ -94,5 +95,17 @@ export function runHostsInit( opts.global === false || !selected.some((h) => h.id === 'antigravity') ? [] : installAntigravitySkill(home); - return { written, skipped, unknown, mcp, hooks: [...hooks, ...cursorHooks, ...antigravitySkill] }; + // pi/omp get the extension both ways: repo-local (Cursor posture — --no-global + // does NOT suppress it, only --no-hooks) and user-level in the agent dir + // (Codex/Claude posture — --no-global suppresses it). The global copy governs + // every project; the extension itself no-ops without a graft graph in cwd. + const piOmpRepo = + opts.hooks === false || !selected.some((h) => h.id === 'pi' || h.id === 'omp') + ? [] + : installPiOmp(repo, selected.map((h) => h.id)); + const piOmpGlobal = + opts.global === false || opts.hooks === false || !selected.some((h) => h.id === 'pi' || h.id === 'omp') + ? [] + : installPiOmpGlobal(home, selected.map((h) => h.id)); + return { written, skipped, unknown, mcp, hooks: [...hooks, ...cursorHooks, ...antigravitySkill, ...piOmpRepo, ...piOmpGlobal] }; } diff --git a/src/hosts/pi-omp.ts b/src/hosts/pi-omp.ts new file mode 100644 index 00000000..7b974893 --- /dev/null +++ b/src/hosts/pi-omp.ts @@ -0,0 +1,111 @@ +// The pi / oh-my-pi (omp) host wiring for graft. pi (pi.dev) and omp share the +// same extension API — omp's hook runner loads `.omp/hooks/pre/*.ts` factories, +// pi loads `.pi/extensions/*.ts` — so one extension file serves both hosts. +// +// What graft gets on pi/omp, by channel: +// 1. orientation -> the `agents` host already writes the AGENTS.md section; +// both hosts load AGENTS.md natively, nothing to add. +// 2. MCP tools -> pi reads project `.mcp.json` via pi-mcp-adapter (and omp +// reads it natively), so the Claude host's `.mcp.json` +// write already covers them. No separate target needed. +// 3. per-prompt retrieval + edit staleness + statusline -> THIS file: graft's +// Claude UserPromptSubmit/PostToolUse hook work and the +// statusline's bar, mapped onto the extension API +// (before_agent_start / tool_result / turn_end setStatus). +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { PlannedWrite } from './plan.js'; +import { writeOwned, type ConfigWrite } from './config-write.js'; + +/** Repo-local path pi auto-discovers (after the project is trusted). */ +export const PI_EXTENSION_REL = join('.pi', 'extensions', 'graft.ts'); +/** Repo-local path omp's hook scanner loads (hooks/pre — hooks/ itself loads nothing). */ +export const OMP_HOOK_REL = join('.omp', 'hooks', 'pre', 'graft.ts'); + +/** + * The agent dir each host reads user-level extensions from: `$PI_CODING_AGENT_DIR` + * when set, else `~/.pi/agent` (pi) / `~/.omp/agent` (omp — its config dir is the + * only difference). Both hosts watch these dirs in every project, so an extension + * there governs everywhere; the extension itself gates on `graft/INDEX.md` in the + * project cwd, which is what makes a global install safe. + */ +export function piAgentDir(home: string, env: NodeJS.ProcessEnv = process.env): string { + const override = env.PI_CODING_AGENT_DIR?.trim(); + return override ? override : join(home, '.pi', 'agent'); +} +export function ompAgentDir(home: string, env: NodeJS.ProcessEnv = process.env): string { + const override = env.PI_CODING_AGENT_DIR?.trim(); + return override ? override : join(home, '.omp', 'agent'); +} + +/** User-level extension paths, by host id. */ +export function piOmpGlobalPaths(home: string, env: NodeJS.ProcessEnv = process.env): Record { + return { + pi: join(piAgentDir(home, env), 'extensions', 'graft.ts'), + omp: join(ompAgentDir(home, env), 'hooks', 'pre', 'graft.ts'), + }; +} + +const HOSTS_DIR = dirname(fileURLToPath(import.meta.url)); + +/** The bundled extension source: `/dist/pi/graft-extension.ts` ships + * beside this module the way dist/claude/*.js does. */ +export function piExtensionSourcePath(): string { + return join(HOSTS_DIR, '..', 'pi', 'graft-extension.ts'); +} + +/** The planned writes pi/omp need. Repo paths are scope 'repo'; the agent-dir + * extension is scope 'global' — it governs every project, gated by the + * extension's own graft/INDEX.md check, and --no-global suppresses it. */ +export function piOmpTargets(repo: string, home: string, env: NodeJS.ProcessEnv = process.env): PlannedWrite[] { + const globalPaths = piOmpGlobalPaths(home, env); + return [ + { hostId: 'pi', id: 'pi', path: join(repo, PI_EXTENSION_REL), scope: 'repo', kind: 'hook', what: 'graft extension (per-prompt retrieval + stale note)' }, + { hostId: 'omp', id: 'omp', path: join(repo, OMP_HOOK_REL), scope: 'repo', kind: 'hook', what: 'graft hook (same extension, omp hook scanner)' }, + { hostId: 'pi', id: 'pi-global', path: globalPaths.pi, scope: 'global', kind: 'hook', what: 'graft extension (user level — every project; no-ops without a graft graph)' }, + { hostId: 'omp', id: 'omp-global', path: globalPaths.omp, scope: 'global', kind: 'hook', what: 'graft hook (user level — every project; no-ops without a graft graph)' }, + ]; +} + +/** + * Write the extension for the hosts that were selected. Copying the same bytes + * to both paths is deliberate: omp's scanner only reads .omp/hooks/pre, pi only + * reads .pi/extensions, and a symlink confuses repo packaging. + */ +export function installPiOmp(repo: string, ids: string[]): ConfigWrite[] { + const written: ConfigWrite[] = []; + const source = piExtensionSourcePath(); + const content = readFileSync(source, 'utf8'); + const wants = (id: string) => ids.includes(id); + if (wants('pi') || wants('omp')) { + written.push(writeOwned('pi', join(repo, PI_EXTENSION_REL), content)); + } + if (wants('omp')) { + written.push(writeOwned('omp', join(repo, OMP_HOOK_REL), content)); + } + return written; +} + +/** + * The user-level install, mirroring installClaudeGlobal: the extension into each + * host's agent dir, where it governs every project. Safe globally precisely + * because the extension no-ops in a project without graft/INDEX.md. Best-effort + * by contract: a failure is reported as an action, never raised. + */ +export function installPiOmpGlobal(home: string, ids: string[], env: NodeJS.ProcessEnv = process.env): ConfigWrite[] { + const written: ConfigWrite[] = []; + const content = readFileSync(piExtensionSourcePath(), 'utf8'); + const paths = piOmpGlobalPaths(home, env); + if (ids.includes('pi')) written.push(writeOwned('pi-global', paths.pi, content)); + if (ids.includes('omp')) written.push(writeOwned('omp-global', paths.omp, content)); + return written; +} + +/** pi/omp detection mirrors the other CLIs: machine config dir presence. */ +export function detectPiOmp(probe: { home: string; repo: string; dirExists(p: string): boolean }): { pi: boolean; omp: boolean } { + return { + pi: probe.dirExists(join(probe.home, '.pi')) || probe.dirExists(join(probe.repo, '.pi')), + omp: probe.dirExists(join(probe.home, '.omp')) || probe.dirExists(join(probe.repo, '.omp')), + }; +} diff --git a/src/hosts/plan.ts b/src/hosts/plan.ts index 38b225b3..8211ee0f 100644 --- a/src/hosts/plan.ts +++ b/src/hosts/plan.ts @@ -17,6 +17,7 @@ import { cursorHookTargets } from './cursor-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; import { claudeTargets } from '../claude/init.js'; import { claudeGlobalTargets } from './claude-global.js'; +import { piOmpTargets } from './pi-omp.js'; /** Where a write lands. 'global' = outside the repo, affects every project. */ export type WriteScope = 'repo' | 'global'; @@ -85,6 +86,7 @@ export function planInit(repo: string, opts: { home?: string; ids?: string[] } = ...(host.id === 'agents' ? hookTargets(home) : []), ...(host.id === 'cursor' ? cursorHookTargets(repo) : []), ...(host.id === 'antigravity' ? antigravitySkillTargets(home) : []), + ...(host.id === 'pi' || host.id === 'omp' ? piOmpTargets(repo, home) : []), ], })), ]; diff --git a/src/hosts/registry.ts b/src/hosts/registry.ts index 9bc4e4a7..df6f280e 100644 --- a/src/hosts/registry.ts +++ b/src/hosts/registry.ts @@ -121,6 +121,25 @@ export const HOSTS: HostTarget[] = [ content: windsurfRule, detect: (p) => p.dirExists(join(p.home, '.codeium', 'windsurf')) || p.dirExists(join(p.repo, '.windsurf')), }, + { + // pi reads AGENTS.md natively (the `agents` host covers orientation); + // the extension file carries per-prompt retrieval — see hosts/pi-omp.ts. + id: 'pi', + name: 'pi (pi.dev)', + kind: 'section', + relPath: 'AGENTS.md', + content: instructionBody, + detect: (p) => p.dirExists(join(p.home, '.pi')) || p.dirExists(join(p.repo, '.pi')), + }, + { + // omp shares pi's extension API and reads AGENTS.md the same way. + id: 'omp', + name: 'Oh My Pi (omp)', + kind: 'section', + relPath: 'AGENTS.md', + content: instructionBody, + detect: (p) => p.dirExists(join(p.home, '.omp')) || p.dirExists(join(p.repo, '.omp')), + }, ]; export function hostIds(): string[] { diff --git a/src/pi/graft-extension.ts b/src/pi/graft-extension.ts new file mode 100644 index 00000000..d5de0716 --- /dev/null +++ b/src/pi/graft-extension.ts @@ -0,0 +1,201 @@ +// graft.ts — pi / omp (oh-my-pi) extension for the Graft context graph. +// +// Graft's Claude Code support has four channels; pi/omp cover two natively: +// 1. orientation -> AGENTS.md section (graft init "agents" host); both pi and +// omp load AGENTS.md, so nothing to do here +// 2. MCP tools -> .mcp.json (graft init "claude" host); omp reads it +// natively, pi via pi-mcp-adapter +// 3. per-prompt retrieval (Claude's UserPromptSubmit hook) -> THIS extension: +// before_agent_start runs `graft ask --json` on the prompt and injects a +// pointers-only pack, gated on the same coverage floors and novelty rules +// as graft's own relevantRetrieval() (STRONG 0.1 / HIGH 0.5, nudges ≤ 2, +// never repeat a pointer). Edit/write marks the graph stale; the next turn +// gets a one-line stale note. +// 4. statusline -> THIS extension: the status text (ctx.ui.setStatus) shows +// the graph's node/edge counts and freshness (graft/.cache/stats.json), +// and the session's saved-tokens counter fed by the retrieval pack. +// Load paths: +// pi: ~/.pi/agent/extensions/graft.ts (global) or /.pi/extensions/graft.ts +// omp: /.omp/hooks/pre/graft.ts or ~/.omp/agent/hooks/pre/graft.ts +// It no-ops in repos without graft/INDEX.md. +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +interface GraftAskHit { kind: string; title: string; pointer: string; snippet: string; code?: string } +interface GraftAskJson { hits: GraftAskHit[]; coverage?: number; coverageStrong?: number; saved?: { files: number; baselineChars: number } } +interface GraftSessionState { injectedPointers: string[]; weakNudges: number; staleNoted: boolean; stale: boolean; savedTokens: number } +interface GraftExtensionContext { cwd: string } +interface BeforeAgentStartEvent { prompt?: string } +interface TurnEndEvent { timestamp?: number } +interface GraftStatusContext { cwd: string; ui?: { setStatus(key: string, text: string | undefined): void } } +interface ToolResultEvent { toolName?: string; isError?: boolean } +type PackResult = { text: string; used: string[] } | { text: null; used: [] }; + +const MIN_PROMPT_CHARS = 12; // graft hooks.ts: shorter prompts are conversational +const STRONG_FLOOR = 0.1; // graft ask/fuse.ts coverageStrong floor +const HIGH_FLOOR = 0.5; // graft ask/fuse.ts broad-coverage floor +const NUDGE_CAP = 2; // graft: weak-match nudges per session +const INJECTED_CAP = 40; // graft: novelty-gate memory +const PACK_CAP = 3; // hits per injected pack +const ASK_TIMEOUT_MS = 8000; // graft: installed hook child budget +const STATE_FILE = "graft/.session-state.json"; +const EDIT_TOOLS: Record = { edit: true, write: true, multiedit: true, notebookedit: true }; + +const EMPTY_STATE: GraftSessionState = { injectedPointers: [], weakNudges: 0, staleNoted: false, stale: false, savedTokens: 0 }; + +function readIndex(dir: string): string | null { + try { return readFileSync(`${dir}/graft/INDEX.md`, "utf8"); } catch { return null; } +} + +function readState(dir: string): GraftSessionState { + try { + const raw: unknown = JSON.parse(readFileSync(`${dir}/${STATE_FILE}`, "utf8")); + if (typeof raw !== "object" || raw === null) return { ...EMPTY_STATE }; + // Shape is written by this file only; a narrow guard, not a schema engine. + const o = raw as Record; // internal file, shape owned here + const pointers = Array.isArray(o.injectedPointers) + ? o.injectedPointers.filter((p): p is string => typeof p === "string") + : []; + return { + injectedPointers: pointers, + weakNudges: typeof o.weakNudges === "number" ? o.weakNudges : 0, + staleNoted: o.staleNoted === true, + stale: o.stale === true, + savedTokens: typeof o.savedTokens === "number" ? o.savedTokens : 0, + }; + } catch { return { ...EMPTY_STATE }; } +} + +function writeState(dir: string, s: GraftSessionState): void { + try { writeFileSync(`${dir}/${STATE_FILE}`, JSON.stringify(s)); } catch { /* best-effort */ } +} + +function askGraft(dir: string, prompt: string): GraftAskJson | null { + try { + const r = spawnSync("graft", ["ask", prompt, ".", "--json", "--source", "-n", String(PACK_CAP)], { + cwd: dir, timeout: ASK_TIMEOUT_MS, encoding: "utf8", + }); + if (r.status !== 0 || !r.stdout) return null; + return JSON.parse(r.stdout) as GraftAskJson; // graft ask --json output, owned by graft + } catch { return null; } // graft missing/stale/slow: never block the turn +} + +function formatPack(hits: GraftAskHit[]): string { + const blocks = hits.map((h, i) => { + const ptr = h.pointer.split(",")[0].trim(); + const snip = h.snippet.replace(/\s+/g, " ").trim().slice(0, 140); + return snip ? ` ${i + 1}. ${h.title}: ${ptr}\n ${snip}` : ` ${i + 1}. ${h.title}: ${ptr}`; + }); + return ( + `[graft] starting points for this task: pull the code inline with \`graft ask "" --source\`, ` + + `trace impact with \`graft callers \`, or search with \`graft grep ""\`:\n${blocks.join("\n")}` + ); +} + +/** Same two-clause gate as graft format.ts relevantRetrieval(): weak lexical + * matches get at most a capped nudge; strong matches get fresh pointers only. */ +function relevantRetrieval(ask: GraftAskJson, s: GraftSessionState): PackResult { + if (!ask.hits?.length) return { text: null, used: [] }; + const seen = new Set(s.injectedPointers); + const fresh = ask.hits.filter((h) => !seen.has(h.pointer)); + const strong = ask.coverageStrong ?? 0; + const broad = ask.coverage ?? 0; + if (strong < STRONG_FLOOR && broad < HIGH_FLOOR) { + if (!fresh.length || s.weakNudges >= NUDGE_CAP) return { text: null, used: [] }; + s.weakNudges += 1; + return { + text: + `[graft] this repo is indexed in graft/; before grepping source, pull context with ` + + `\`graft ask "" --source\` (ranked nodes with file:line), \`graft grep ""\` ` + + `(exhaustive), or \`graft callers \` (who calls it).`, + used: [], + }; + } + if (!fresh.length) return { text: null, used: [] }; + const pack = fresh.slice(0, PACK_CAP); + return { text: formatPack(pack), used: pack.map((h) => h.pointer) }; +} + +/** Graph freshness + size. Preferred source is graft/.cache/stats.json (the + * hook-maintained cache); on pi/omp nothing maintains it, so fall back to the + * graph itself (graft/.graph/wiring.json) the way graft's Claude statusline + * resolveStats() does — the bar reflects reality immediately after a build. */ +function renderStatus(dir: string, savedTokens: number): string { + let stats: { nodeCount?: number; edgeCount?: number; dirty?: boolean; staleCount?: number } = {}; + try { + stats = JSON.parse(readFileSync(`${dir}/graft/.cache/stats.json`, "utf8")) as typeof stats; + } catch { /* cache absent: fall through to the graph */ } + if (typeof stats.nodeCount !== "number" || stats.nodeCount === 0) { + try { + const wiring = JSON.parse(readFileSync(`${dir}/graft/.graph/wiring.json`, "utf8")) as + { meta?: { nodeCount?: number; edgeCount?: number }; nodes?: unknown[]; edges?: unknown[] }; + stats = { + nodeCount: wiring.meta?.nodeCount ?? (wiring.nodes ?? []).length, + edgeCount: wiring.meta?.edgeCount ?? (wiring.edges ?? []).length, + }; + } catch { /* not built */ } + } + const nodes = typeof stats.nodeCount === "number" ? stats.nodeCount : null; + if (nodes === null) return "graft · not built · run `graft build`"; + const freshness = stats.dirty ? "⚠ stale" : "✓ synced"; + const saved = savedTokens > 0 ? ` · ~${savedTokens.toLocaleString()} tok saved` : ""; + return `graft · ${nodes} nodes / ${typeof stats.edgeCount === "number" ? stats.edgeCount : 0} edges · ${freshness}${saved}`; +} + +export default function (pi: { + on(event: "before_agent_start", handler: (event: BeforeAgentStartEvent, ctx: GraftExtensionContext) => Promise<{ message?: { customType: string; content: string; display: boolean } } | void>): void; + on(event: "tool_result", handler: (event: ToolResultEvent, ctx: GraftExtensionContext) => Promise): void; + on(event: "turn_end", handler: (event: TurnEndEvent, ctx: GraftStatusContext) => Promise): void; + on(event: string, handler: (event: never, ctx: never) => unknown): void; +}): void { + // UserPromptSubmit equivalent: gated retrieval pack before the agent loop. + pi.on("before_agent_start", async (event, ctx) => { + const prompt = String(event.prompt ?? "").trim(); + if (prompt.length < MIN_PROMPT_CHARS) return; + const dir = ctx.cwd; + if (!readIndex(dir)) return; // no graph here — nothing to say + const s = readState(dir); + const ask = askGraft(dir, prompt); + if (!ask) { writeState(dir, s); return; } + const pack = relevantRetrieval(ask, s); + let content = pack.text; + if (pack.used.length) { + s.injectedPointers = [...s.injectedPointers, ...pack.used].slice(-INJECTED_CAP); + // Honest floor only: the pack is pointers-only (~100 tok), so the credit + // is what graft's ask said the hits replace (baselineChars/4) minus the + // pack — never the full baseline (that would double-count the pull). + const saved = ask.saved?.baselineChars ?? 0; + const credit = Math.max(0, Math.round(saved / 4) - 100); + s.savedTokens += credit; + } + if (s.stale && !s.staleNoted) { + s.staleNoted = true; + const note = + "[graft] the graph in graft/ is now stale (code was edited after it was built); " + + "refresh with `graft build` before relying on it for changed areas."; + content = content ? `${content}\n${note}` : note; + } + writeState(dir, s); + if (!content) return; + return { message: { customType: "graft-retrieval", content, display: false } }; + }); + + // PostToolUse(edit) equivalent: flip the stale flag; the note ships on the + // next turn's injection instead of clobbering this edit's tool result. + pi.on("tool_result", async (event, ctx) => { + if (!EDIT_TOOLS[String(event.toolName ?? "").toLowerCase()]) return; + if (event.isError) return; + const dir = ctx.cwd; + if (!readIndex(dir)) return; + const s = readState(dir); + if (!s.stale) { s.stale = true; writeState(dir, s); } + }); + + // Statusline: graft's bar lives in the host's footer status area instead of a + // shell script — same content as Claude's statusline, refreshed per turn. + pi.on("turn_end", async (_event, ctx) => { + if (!ctx.ui) return; // print/JSON mode: no footer to paint + const s = readState(ctx.cwd); + ctx.ui.setStatus("graft", renderStatus(ctx.cwd, s.savedTokens)); + }); +} diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index 10663153..f0d85a62 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -82,7 +82,7 @@ export type Surface = 'cli' | 'mcp' | 'hook'; /** Which editor/agent graft is running under. Derived from the surface and the * wiring on disk — never from a hostname, a username, or an env var's value. */ -export type AgentHost = 'claude-code' | 'cursor' | 'mcp' | 'cli'; +export type AgentHost = 'claude-code' | 'cursor' | 'mcp' | 'cli' | 'pi' | 'omp'; /** * The commands worth counting. A command absent here is simply not reported — diff --git a/test/hosts-init.test.ts b/test/hosts-init.test.ts index cbbd3c37..5ff02be2 100644 --- a/test/hosts-init.test.ts +++ b/test/hosts-init.test.ts @@ -34,7 +34,7 @@ test('explicit agents list overrides detection and flags unknown ids', () => { test('all writes every host and re-run converges (idempotent)', () => { const home = fresh(); const repo = fresh(); const first = runHostsInit(repo, { home, all: true }); - assert.equal(first.written.length, 10); + assert.equal(first.written.length, 12); const second = runHostsInit(repo, { home, all: true }); assert.ok(second.written.every((w) => w.action === 'unchanged')); // `agents` and `antigravity` share AGENTS.md, but the fenced section is written once @@ -250,3 +250,26 @@ test('CLI: --dry-run respects an explicit --agents list', () => { assert.doesNotMatch(out, /AGENTS\.md/); assert.doesNotMatch(out, /affects ALL repos/); }); + +test('pi/omp write the extension repo-locally AND user-level, and re-run converges', () => { + const home = fresh(); const repo = fresh(); + const first = runHostsInit(repo, { home, agents: ['pi', 'omp'] }); + const paths = first.hooks.map((h) => h.path); + // Four extension copies: two repo-local (pi, omp each read only their own) and + // two user-level (the agent dirs, where the hook governs every project). + assert.equal(paths.length, 4); + assert.ok(paths.some((p) => p.endsWith(join('.pi', 'extensions', 'graft.ts')))); + assert.ok(paths.some((p) => p.endsWith(join('.omp', 'hooks', 'pre', 'graft.ts')))); + assert.ok(paths.some((p) => p.includes(join(home, '.pi', 'agent', 'extensions')))); + assert.ok(paths.some((p) => p.includes(join(home, '.omp', 'agent', 'hooks', 'pre')))); + const second = runHostsInit(repo, { home, agents: ['pi', 'omp'] }); + assert.ok(second.hooks.every((h) => h.action === 'unchanged')); +}); + +test('global: false keeps the pi/omp repo extension but skips the agent-dir copy', () => { + const home = fresh(); const repo = fresh(); + const r = runHostsInit(repo, { home, agents: ['pi', 'omp'], global: false }); + const paths = r.hooks.map((h) => h.path); + assert.ok(paths.some((p) => p.endsWith(join('.pi', 'extensions', 'graft.ts')))); + assert.ok(paths.every((p) => !p.startsWith(home))); +}); diff --git a/test/hosts-registry.test.ts b/test/hosts-registry.test.ts index 76153ad4..60b1015f 100644 --- a/test/hosts-registry.test.ts +++ b/test/hosts-registry.test.ts @@ -15,7 +15,7 @@ function probeFor(home: string, repo: string): DetectProbe { function fresh(): string { return mkdtempSync(join(tmpdir(), 'graft-registry-')); } test('registry exposes the known hosts', () => { - assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'gemini', 'grok', 'hermes', 'kiro', 'windsurf']); + assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'gemini', 'grok', 'hermes', 'kiro', 'omp', 'pi', 'windsurf']); for (const h of HOSTS) { assert.ok(h.relPath.length > 0); assert.ok(h.content().length > 0);