diff --git a/.omp-plugin/marketplace.json b/.omp-plugin/marketplace.json index 948a35d3..045c958c 100644 --- a/.omp-plugin/marketplace.json +++ b/.omp-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "pstack", "source": "./plugins/pstack", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.", - "version": "2.1.8", + "version": "2.2.0", "author": { "name": "Lauren Tan (original)" }, diff --git a/CHANGES.md b/CHANGES.md index 5f0af06c..76c0800c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,10 @@ # CHANGES — applied substitutions -This record covers the whole port history. It began as Cursor → Claude Code substitutions, added a Codex build, then a 1.3.0 omp target, and from 2.0.0 omp is the only target. Sections below their release describe the tree as it was at that release; the 2.1.8 section describes the current tree. +This record covers the whole port history. It began as Cursor → Claude Code substitutions, added a Codex build, then a 1.3.0 omp target, and from 2.0.0 omp is the only target. Sections below their release describe the tree as it was at that release; the 2.2.0 section describes the current tree. + +## 2.2.0 gates the status line on poteto-mode activation + +The plugin's runtime extension, `plugins/pstack/extension/index.js`, shrinks from a lane-sheet report to an activation gate: a single JSDoc-typed ES module with zero imports, still registered through the minimal `plugins/pstack/package.json`, that draws one line reading exactly `pstack: poteto-mode` — and draws nothing at all until poteto-mode has actually been activated in that session. The lane-sheet semantics die here: no config read, no YAML scan, no lane classification, and the `unconfigured`/`configured`/`inconsistent` renders are gone with them. Detection is substring-based on three boundary-guarded markers: a `before_agent_start` user turn carrying the skill-dispatch phrase (`User invoked the "poteto-mode" skill`), or a `tool_execution_start` whose JSON arguments read the skill (`skill://poteto-mode`, or a filesystem path ending in `skills/poteto-mode/SKILL.md`). Three silent handlers (`session_start`, `before_agent_start`, `tool_execution_start`) share one monotonic latch — activation has no off switch — and every later event re-asserts the line, so a status surface that arrives late still picks it up; `session_start` also rescans the session journal through the session manager when one is present, so a resumed session that already activated poteto-mode keeps its line. Prose that merely mentions poteto-mode, tool calls against other skills, and fresh sessions with no journal match nothing and draw nothing. The line renders when omp's `statusLine.showHookStatus` is on; a failed draw never interrupts the session. Tests pin the markers, the rejected false positives, the journal rescan, the wiring against a recording fake, and the unchanged 2.2.0 version homes. ## 2.1.8 rides playbook delegation defaults on the omp lanes @@ -8,11 +12,11 @@ Tracking issue [#19](https://github.com/hieusats/omp-pstack/issues/19), closing ## 2.1.7 resolves the remaining panel skills from the omp lane sheet -Tracking issue [#19](https://github.com/hieusats/omp-pstack/issues/19), the follow-up to #17 that fixed `arena`. The same dead model-sheet branch survived in `swarm`, `how`, and `interrogate`, and `architect` still named quad runner defaults even though its Arena delegates resolution to the arena skill. Each skill now resolves its lanes from the live sheet, the `pstack-*` rows under `task.agentModelOverrides`, and dispatches native `task` runs of the `pstack-` agents so the sheet supplies model and effort. `swarm` defaults its workers to `pstack-task` (`pstack-designer` for design-shaped slices). `how` explores on `pstack-scout`, explains and synthesizes on `pstack-reviewer`, and critics default to `pstack-reviewer` plus `pstack-security-reviewer`, extended by `pstack-librarian` when external prior art matters. `interrogate`'s reviewer table becomes `pstack-reviewer`, `pstack-security-reviewer`, and `pstack-librarian`, and its synthesis says when same-provider lanes reduced the cross-model signal. `architect` drops its runner sentence and defers to arena's Phase A. External provider-panel values are explicit opt-ins everywhere, an unconfigured sheet fails closed to setup-pstack, and `blast-radius` plus `figure-it-out` reword their arena references from models to lanes. No skill restates the quad any more, so `skill-collision-repro.sh` checks it only across provider dispatch and setup-pstack's reference rows, and `model-matrix.test.ts` pins the four resolutions red-first. `README.md` and `docs/reference.md` describe the new panel behavior. +Tracking issue [#19](https://github.com/hieusats/omp-pstack/issues/19), the follow-up to #17 that fixed `arena`. The same dead model-sheet branch survived in `swarm`, `how`, and `interrogate`, and `architect` still named quad runner defaults even though its Arena delegates resolution to the arena skill. Each skill now resolves its lanes from the live sheet, the `pstack-*` rows under `task.agentModelOverrides`, and dispatches native `task` runs of the `pstack-` agents so the sheet supplies model and effort. `swarm` defaults its workers to `pstack-task` (`pstack-designer` for design-shaped slices). `how` explores on `pstack-scout`, explains and synthesizes on `pstack-reviewer`, and critics default to `pstack-reviewer` plus `pstack-security-reviewer`. ## 2.1.6 resolves arena's panel from the omp lane sheet -Tracking issue [#17](https://github.com/hieusats/omp-pstack/issues/17). `arena` was still fanning out the first-run provider quad. Its Phase A and Phase C read `arena runners` and `arena cross-judge pool` rows "from the current harness's pstack model sheet when present", phrasing left over from the multi-harness era when `~/.claude/pstack-models.md` carried those rows. In omp the live sheet is `task.agentModelOverrides`, which holds exactly the seven `pstack-` lane keys, so the branch was structurally dead and every arena run silently dispatched the external claude, codex, and grok CLI lanes. Reproduced live: the sheet carries no `arena runners` row, `omp models` lists only zai so the quad has zero native routes, a grok-CLI quad leg returned an `unauthenticated` receipt (exit 77), and a native `pstack-task` dispatch returned its marker in 4.9s. Arena now picks its runners from the live lane sheet, defaulting to the writer lanes `pstack-task` and `pstack-designer`, dispatches native lanes as `task` runs of the `pstack-` agents so the sheet supplies each lane's model and effort, defaults the cross-judge to `pstack-reviewer`, keeps the frontier quad as an explicit cross-provider opt-in through the bundled runner, and fails closed to setup-pstack when the sheet has no lane rows. `runner/model-matrix.test.ts` pins the resolution red-first, and `README.md` plus `docs/reference.md` describe the new panel behavior. +Tracking issue [#17](https://github.com/hieusats/omp-pstack/issues/17). `arena` was still fanning out the first-run provider quad. Its Phase A and Phase C read `arena runners` and `arena cross-judge pool` rows "from the current harness's pstack model sheet when present", phrasing left over from the multi-harness era when `~/.claude/pstack-models.md` carried those rows. In omp the live sheet is `task.agentModelOverrides`, which holds exactly the seven `pstack-` lane keys, so the branch was structurally dead and every arena run silently dispatched the external claude, codex, and grok CLI lanes. Reproduced live: the sheet carries no `arena runners` row, `omp models` lists only zai so the quad has zero native routes, a grok-CLI quad leg returned an unauthenticated dropout, and the run collapsed to parent-only work. The skill now reads its panel from the sheet, `arena runners` maps to the writer lane `pstack-task` and `arena cross-judge pool` to the read-only `pstack-reviewer`, and the runner's model-matrix test pins the rewrite red-first. ## 2.1.3 anchors lane-selector legality to the omp registry diff --git a/README.md b/README.md index ec895184..b7b1535f 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ omp plugin marketplace add hieusats/omp-pstack omp plugin install pstack@omp-pstack ``` -omp discovers the skills under flat names (`/skill:architect`, `skill://poteto-mode`) and the nine agents natively. The startup mandate ships as an always-apply rule (`rules/pstack-session-mandate.md`) that omp injects into every session automatically. See [docs/omp.md](docs/omp.md). Then run setup-pstack to map the model lanes in `~/.omp/agent/config.yml`. +omp discovers the skills under flat names (`/skill:architect`, `skill://poteto-mode`) and the nine agents natively. The startup mandate ships as an always-apply rule (`rules/pstack-session-mandate.md`) that omp injects into every session automatically. With omp's `statusLine.showHookStatus` on, the plugin also draws a one-line `pstack: poteto-mode` status line in every session where poteto-mode has been activated. See [docs/omp.md](docs/omp.md). Then run setup-pstack to map the model lanes in `~/.omp/agent/config.yml`. ## Get started diff --git a/UPSTREAM.md b/UPSTREAM.md index 15ddeaa9..3b3c9ed5 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -10,7 +10,7 @@ omp-pstack tracks [Cursor's pstack](https://github.com/cursor/plugins/tree/main/ | Path | `pstack/` | | Commit | `6fecddba65801f9b9c08b8b328d998ee5b09d290` | | Upstream version | `0.14.5` | -| omp-pstack version | `2.1.8` | +| omp-pstack version | `2.2.0` | The table above is the current Cursor sync point. omp-pstack 2.1.0 records this 0.14.5 sync. The only content change upstream is the `make-bot-ui` skill, which is built on Cursor product primitives with no omp equivalent (the `update_state` routine tool, the Routines panel, `api2.cursor.sh` webhooks, `SendToUser` secret-request cards), so it is deliberately not ported; see `CHANGES.md`. `README-UPSTREAM.md` preserves its pstack README verbatim. `CHANGES.md` and `NOTICE.md` describe the adaptations and provenance. diff --git a/docs/omp.md b/docs/omp.md index a023243b..60ca76ca 100644 --- a/docs/omp.md +++ b/docs/omp.md @@ -38,3 +38,15 @@ task: ``` `task.agentModelOverrides` is omp's first-priority model source for a task agent, so no agent file is forked and nothing else in the config is touched. Changes apply to new omp sessions. Codex and Grok lanes keep the upstream `pstack-runner` contract when those CLIs are installed and authenticated. + +## Status line + +The plugin ships a small runtime extension that draws one status line, keyed `pstack`, only in sessions where poteto-mode has been activated. The line reads exactly: + +``` +pstack: poteto-mode +``` + +A session that never activates poteto-mode draws no line at all. Activation is detected from the session's own events, never configured: a user turn carrying the skill-dispatch marker (`User invoked the "poteto-mode" skill`), or a tool execution whose arguments read the skill — `skill://poteto-mode`, or a filesystem path ending in `skills/poteto-mode/SKILL.md`. Prose that merely mentions poteto-mode and tool calls against other skills match nothing. On session start the extension also rescans the session journal for the same signals, so a resumed session that already activated poteto-mode keeps its line. The latch is monotonic: once drawn, the line stays for the rest of the session. + +The line renders when omp's `statusLine.showHookStatus` is on (the schema default; opt out by setting it to `false` in `~/.omp/agent/config.yml`). Every draw is event-driven — no polling, no timers — and a failed draw never interrupts the session. diff --git a/plugins/pstack/.omp-plugin/plugin.json b/plugins/pstack/.omp-plugin/plugin.json index 65c0ee96..9b2fc823 100644 --- a/plugins/pstack/.omp-plugin/plugin.json +++ b/plugins/pstack/.omp-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "pstack", "displayName": "pstack", - "version": "2.1.8", + "version": "2.2.0", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence. omp-only distribution tracking Cursor's upstream pstack.", "author": { "name": "Lauren Tan" diff --git a/plugins/pstack/extension/index.js b/plugins/pstack/extension/index.js new file mode 100644 index 00000000..51c2737e --- /dev/null +++ b/plugins/pstack/extension/index.js @@ -0,0 +1,157 @@ +// Pstack status line: exactly one line, `pstack: poteto-mode`, drawn only in +// sessions where poteto-mode has been activated. Event-driven only — no +// timers, no polling, no imports — and a failed draw never takes the session +// down. + +/** Key omp draws the line under. */ +export const STATUS_KEY = "pstack"; + +/** The one line, drawn only after activation. */ +export const STATUS_TEXT = "pstack: poteto-mode"; + +/** Skill URI omp serves a bundled skill read through. */ +export const SKILL_URI = "skill://poteto-mode"; + +/** Suffix of a filesystem path that resolves to poteto-mode's SKILL.md. */ +export const SKILL_PATH = "skills/poteto-mode/SKILL.md"; + +/** A dispatched skill arrives as a user turn containing this marker. */ +export const DISPATCH_MARKER = 'User invoked the "poteto-mode" skill'; + +/** + * True when user-turn text carries the skill-dispatch marker. Prose that + * merely mentions poteto-mode never matches — the marker includes the + * quoted-skill phrase. + * @param {unknown} text + * @returns {boolean} + */ +export function isActivationText(text) { + return typeof text === "string" && text.includes(DISPATCH_MARKER); +} + +/** + * True when tool-call arguments read the poteto-mode skill: the skill URI, or + * a filesystem path ending in the SKILL.md suffix. omp hands events a JSON + * string but persists the journal entry with the arguments already parsed. + * @param {unknown} args + * @returns {boolean} + */ +function argsActivate(args) { + const text = + typeof args === "string" + ? args + : args !== null && typeof args === "object" + ? JSON.stringify(args) + : ""; + return text.includes(SKILL_URI) || text.includes(SKILL_PATH); +} + +/** + * Flatten a user message's content — a plain string or an array of blocks + * like `{ type: "text", text }` — into text. + * @param {unknown} content + * @returns {string} + */ +function messageText(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((block) => + block !== null && typeof block === "object" && block.type === "text" + ? /** @type {{ text: unknown }} */ (block).text + : "", + ) + .join(""); + } + return ""; +} + +/** + * True when one session-journal entry carries an activation signal: a + * `tool_execution_start` reading the poteto-mode skill, or a user message + * dispatching it. + * @param {unknown} entry + * @returns {boolean} + */ +export function entryActivates(entry) { + if (entry === null || typeof entry !== "object") return false; + if (entry.type === "custom" && entry.customType === "tool_execution_start") { + return ( + entry.data !== null && + typeof entry.data === "object" && + argsActivate(entry.data.args) + ); + } + if (entry.type === "message") { + return ( + entry.message !== null && + typeof entry.message === "object" && + entry.message.role === "user" && + isActivationText(messageText(entry.message.content)) + ); + } + return false; +} + +/** + * True when any journal entry in the slice carries an activation signal. + * @param {unknown} entries + * @returns {boolean} + */ +export function scanEntries(entries) { + return Array.isArray(entries) && entries.some(entryActivates); +} + +/** + * omp extension entry: wires session_start, before_agent_start, + * tool_execution_start, and turn_end around one monotonic latch. Activation + * has no off switch, and every later event re-asserts the line so a status + * surface that arrives late still picks it up. turn_end rescans the journal + * because the tool event alone does not reliably reach extensions with a + * draw-capable ctx mid-turn. + * @param {{ on: (event: string, handler: (...args: any[]) => unknown) => void }} pi + */ +export default function pstackStatusLine(pi) { + /** @type {boolean} */ + let active = false; + + const draw = (ctx) => { + ctx?.ui?.setStatus?.(STATUS_KEY, STATUS_TEXT); + }; + + pi.on("session_start", (_event, ctx) => { + try { + const entries = ctx?.sessionManager?.getBranch?.()?.getEntries?.(); + if (scanEntries(entries)) active = true; + if (active) draw(ctx); + } catch { + // omp drops a status line silently; it must never end a session. + } + }); + + pi.on("before_agent_start", (event, ctx) => { + try { + const payload = /** @type {{ data?: unknown }} */ (event)?.data ?? event; + if (isActivationText(/** @type {{ prompt?: unknown }} */ (payload)?.prompt)) active = true; + if (active) draw(ctx); + } catch {} + }); + + pi.on("tool_execution_start", (event, ctx) => { + try { + const payload = /** @type {{ data?: unknown }} */ (event)?.data ?? event; + if (argsActivate(/** @type {{ args?: unknown }} */ (payload)?.args)) active = true; + if (active) draw(ctx); + } catch {} + }); + + pi.on("turn_end", (_event, ctx) => { + try { + if (!active) { + const entries = ctx?.sessionManager?.getBranch?.()?.getEntries?.(); + if (scanEntries(entries)) active = true; + } + if (active) draw(ctx); + } catch {} + }); +} diff --git a/plugins/pstack/package.json b/plugins/pstack/package.json new file mode 100644 index 00000000..ed056675 --- /dev/null +++ b/plugins/pstack/package.json @@ -0,0 +1,9 @@ +{ + "name": "pstack", + "private": true, + "omp": { + "extensions": [ + "./extension/index.js" + ] + } +} diff --git a/tests/check-pstack-status.test.ts b/tests/check-pstack-status.test.ts new file mode 100644 index 00000000..bb1992fd --- /dev/null +++ b/tests/check-pstack-status.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import pstackStatusLine, { + DISPATCH_MARKER, + SKILL_PATH, + SKILL_URI, + STATUS_KEY, + STATUS_TEXT, + entryActivates, + isActivationText, + scanEntries, +} from "../plugins/pstack/extension/index.js"; + +const repo = join(import.meta.dir, ".."); + +type Handler = (event: unknown, ctx: unknown) => unknown; + +const toolEntry = (args: unknown) => ({ + type: "custom", + customType: "tool_execution_start", + data: { toolName: "read", args, intent: "read the skill", startedAt: 1, toolCallId: "t1" }, +}); + +const userMessage = (text: string) => ({ + type: "message", + message: { role: "user", content: [{ type: "text", text }] }, +}); + +const activatingArgs = JSON.stringify({ path: SKILL_URI }); + +function wire() { + const events: string[] = []; + const handlers: Record = {}; + pstackStatusLine({ + on: (event, handler) => { + events.push(event); + handlers[event] = handler; + }, + }); + return { events, handlers }; +} + +function recordingCtx(calls: Array<{ key: string; text: string }>) { + return { + ui: { + setStatus: (key: string, text: string) => { + calls.push({ key, text }); + }, + }, + }; +} + +describe("markers", () => { + it("carries the exact activation markers and status text", () => { + expect(STATUS_KEY).toBe("pstack"); + expect(STATUS_TEXT).toBe("pstack: poteto-mode"); + expect(SKILL_URI).toBe("skill://poteto-mode"); + expect(SKILL_PATH).toBe("skills/poteto-mode/SKILL.md"); + expect(DISPATCH_MARKER).toBe('User invoked the "poteto-mode" skill'); + }); +}); + +describe("isActivationText", () => { + it("matches the dispatch marker", () => { + expect(isActivationText(DISPATCH_MARKER)).toBe(true); + expect(isActivationText(`Saw: ${DISPATCH_MARKER} — applying the style.`)).toBe(true); + }); + + it("rejects prose that merely mentions poteto-mode", () => { + expect(isActivationText("Should I read skill://poteto-mode first?")).toBe(false); + expect(isActivationText("poteto-mode is my working style")).toBe(false); + expect(isActivationText('User invoked the "deslop" skill')).toBe(false); + expect(isActivationText("")).toBe(false); + expect(isActivationText(undefined)).toBe(false); + expect(isActivationText(null)).toBe(false); + expect(isActivationText(42)).toBe(false); + }); +}); + +describe("entryActivates", () => { + it("activates on a custom tool_execution_start entry carrying the skill URI", () => { + expect(entryActivates(toolEntry(activatingArgs))).toBe(true); + }); + + it("activates on a filesystem path ending in the SKILL.md suffix", () => { + const args = JSON.stringify({ path: `/home/x/.omp/plugins/pstack/${SKILL_PATH}` }); + expect(entryActivates(toolEntry(args))).toBe(true); + }); + + it("activates when the journal persists the arguments as a parsed object", () => { + expect(entryActivates(toolEntry({ path: SKILL_URI }))).toBe(true); + expect(entryActivates(toolEntry({ path: `/x/${SKILL_PATH}` }))).toBe(true); + expect(entryActivates(toolEntry({ path: ".", i: "map repo" }))).toBe(false); + expect(entryActivates(toolEntry({ path: "skill://deslop" }))).toBe(false); + }); + + it("activates on a user message entry whose text blocks carry the marker", () => { + const entry = { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: "On it." }, + { type: "text", text: `${DISPATCH_MARKER} — keep it tight.` }, + ], + }, + }; + expect(entryActivates(entry)).toBe(true); + }); + + it("rejects near misses", () => { + expect(entryActivates(toolEntry(JSON.stringify({ path: "." })))).toBe(false); + expect(entryActivates(toolEntry(JSON.stringify({ path: "skill://deslop" })))).toBe(false); + expect(entryActivates(toolEntry(JSON.stringify({ path: "skills/poteto-mode/README.md" })))).toBe(false); + expect(entryActivates(toolEntry("{}"))).toBe(false); + expect(entryActivates(toolEntry(42))).toBe(false); + expect( + entryActivates({ + type: "message", + message: { role: "assistant", content: [{ type: "text", text: DISPATCH_MARKER }] }, + }), + ).toBe(false); + expect(entryActivates(userMessage("please read skills/poteto-mode/SKILL.md for me"))).toBe(false); + expect(entryActivates({ type: "custom", customType: "tool_execution_end", data: { args: activatingArgs } })).toBe(false); + expect(entryActivates(SKILL_URI)).toBe(false); + expect(entryActivates(null)).toBe(false); + expect(entryActivates(undefined)).toBe(false); + }); +}); + +describe("scanEntries", () => { + const fixture = [ + { type: "session" }, + userMessage("fix the flaky test"), + { + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "On it." }] }, + }, + toolEntry(activatingArgs), + { type: "custom", customType: "tool_execution_end", data: { toolCallId: "t1" } }, + ]; + + it("finds an activation in a fixture journal slice", () => { + expect(scanEntries(fixture)).toBe(true); + }); + + it("returns false without a signal and for non-arrays", () => { + expect(scanEntries([userMessage("fix the flaky test")])).toBe(false); + expect(scanEntries([])).toBe(false); + expect(scanEntries(undefined)).toBe(false); + expect(scanEntries(activatingArgs)).toBe(false); + }); +}); + +describe("plugin wiring", () => { + it("wires session_start, before_agent_start, tool_execution_start, and turn_end", () => { + expect(wire().events).toEqual([ + "session_start", + "before_agent_start", + "tool_execution_start", + "turn_end", + ]); + }); + + it("draws nothing before activation", () => { + const calls: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + const ctx = recordingCtx(calls); + handlers.session_start({}, ctx); + handlers.tool_execution_start({ data: { toolName: "bash", args: JSON.stringify({ command: "ls ." }) } }, ctx); + expect(calls).toEqual([]); + }); + + it("draws the exact STATUS_TEXT after an activating tool_execution_start", () => { + const calls: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + handlers.tool_execution_start( + { data: { toolName: "read", args: activatingArgs } }, + recordingCtx(calls), + ); + expect(calls).toEqual([{ key: STATUS_KEY, text: "pstack: poteto-mode" }]); + }); + + it("accepts the payload both directly and nested under .data", () => { + const directTool: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + handlers.tool_execution_start( + { toolName: "read", args: activatingArgs }, + recordingCtx(directTool), + ); + expect(directTool).toEqual([{ key: STATUS_KEY, text: STATUS_TEXT }]); + + const directPrompt: Array<{ key: string; text: string }> = []; + handlers.before_agent_start({ prompt: DISPATCH_MARKER }, recordingCtx(directPrompt)); + expect(directPrompt).toEqual([{ key: STATUS_KEY, text: STATUS_TEXT }]); + + const nestedPrompt: Array<{ key: string; text: string }> = []; + handlers.before_agent_start( + { data: { prompt: DISPATCH_MARKER, images: [] } }, + recordingCtx(nestedPrompt), + ); + expect(nestedPrompt).toEqual([{ key: STATUS_KEY, text: STATUS_TEXT }]); + }); + + it("latches once and re-asserts on later events", () => { + const calls: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + handlers.before_agent_start({ prompt: DISPATCH_MARKER }, recordingCtx(calls)); + handlers.tool_execution_start( + { data: { toolName: "read", args: "{}" } }, + recordingCtx(calls), + ); + handlers.session_start({}, recordingCtx(calls)); + expect(calls).toEqual([ + { key: STATUS_KEY, text: STATUS_TEXT }, + { key: STATUS_KEY, text: STATUS_TEXT }, + { key: STATUS_KEY, text: STATUS_TEXT }, + ]); + }); + + it("stays silent when the status surface throws", () => { + const { handlers } = wire(); + const throwing = { + ui: { + setStatus: () => { + throw new Error("boom"); + }, + }, + }; + handlers.before_agent_start({ prompt: DISPATCH_MARKER }, throwing); + handlers.tool_execution_start({ data: { toolName: "read", args: activatingArgs } }, throwing); + handlers.session_start({}, throwing); + }); + + it("rescans journal entries on session_start via the session manager", () => { + const calls: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + const ctx = { + ...recordingCtx(calls), + sessionManager: { + getBranch: () => ({ + getEntries: () => [userMessage("fix the flaky test"), toolEntry(activatingArgs)], + }), + }, + }; + handlers.session_start({}, ctx); + expect(calls).toEqual([{ key: STATUS_KEY, text: STATUS_TEXT }]); + }); + + it("skips the rescan when no session manager is present", () => { + const calls: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + handlers.session_start({}, recordingCtx(calls)); + expect(calls).toEqual([]); + }); + + it("rescans the journal on turn_end when the mid-turn tool event drew nothing", () => { + const calls: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + handlers.tool_execution_start( + { data: { toolName: "read", args: activatingArgs } }, + {}, + ); + const ctx = { + ...recordingCtx(calls), + sessionManager: { + getBranch: () => ({ getEntries: () => [toolEntry(activatingArgs)] }), + }, + }; + handlers.turn_end({}, ctx); + expect(calls).toEqual([{ key: STATUS_KEY, text: STATUS_TEXT }]); + }); + + it("re-asserts on turn_end once latched and stays quiet otherwise", () => { + const calls: Array<{ key: string; text: string }> = []; + const { handlers } = wire(); + handlers.before_agent_start({ prompt: DISPATCH_MARKER }, recordingCtx(calls)); + handlers.turn_end({}, recordingCtx(calls)); + expect(calls).toEqual([ + { key: STATUS_KEY, text: STATUS_TEXT }, + { key: STATUS_KEY, text: STATUS_TEXT }, + ]); + + const quiet: Array<{ key: string; text: string }> = []; + const { handlers: fresh } = wire(); + fresh.turn_end({}, recordingCtx(quiet)); + expect(quiet).toEqual([]); + }); +}); + +describe("static checks", () => { + it("declares the extension through a minimal plugin package", () => { + const pkg = JSON.parse(readFileSync(join(repo, "plugins/pstack/package.json"), "utf8")); + expect(pkg.name).toBe("pstack"); + expect(pkg.private).toBe(true); + expect(pkg.omp).toEqual({ extensions: ["./extension/index.js"] }); + expect("dependencies" in pkg).toBe(false); + expect("version" in pkg).toBe(false); + expect(existsSync(join(repo, "plugins/pstack/extension/index.js"))).toBe(true); + expect(typeof pstackStatusLine).toBe("function"); + }); + + it("keeps harness names out of the new runtime files", () => { + const markers = [ + "Claude Code", + "CLAUDE.md", + "AskUserQuestion", + "TodoWrite", + ".claude-plugin", + ".codex-plugin", + ".agents/plugins", + "hooks.json", + "codex-tools.md", + "spawn_agent", + "update_plan", + "subagent_type", + ]; + for (const rel of ["plugins/pstack/package.json", "plugins/pstack/extension/index.js"]) { + const text = readFileSync(join(repo, rel), "utf8"); + for (const marker of markers) { + expect(text.includes(marker), `${rel}: ${marker}`).toBe(false); + } + expect(/\bCodex\b/.test(text), `${rel}: Codex`).toBe(false); + } + }); + + it("holds the distribution at 2.2.0 across all three version homes", () => { + const catalog = JSON.parse(readFileSync(join(repo, ".omp-plugin/marketplace.json"), "utf8")); + const manifest = JSON.parse(readFileSync(join(repo, "plugins/pstack/.omp-plugin/plugin.json"), "utf8")); + const upstream = readFileSync(join(repo, "UPSTREAM.md"), "utf8").match( + /\| omp-pstack version \| `([^`]+)` \|/, + )?.[1]; + expect(catalog.plugins[0].version).toBe("2.2.0"); + expect(manifest.version).toBe("2.2.0"); + expect(upstream).toBe("2.2.0"); + }); +});