diff --git a/README.md b/README.md index 177fc45..6dd058d 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ # Be concise for Paseo -A native companion to [be-concise](https://github.com/yannelli/be-concise) for Claude Code and Codex. Open **Be concise** in Paseo's sidebar, workspace panels, or Command Center. The composer pill shows the rejected and flagged total when enforcement is on and **Off** when disabled. Open it for decision details and the workspace enforcement toggle. +A native companion to [be-concise](https://github.com/yannelli/be-concise) for Claude Code, Codex, and omp (oh-my-pi). Open **Be concise** in Paseo's sidebar, workspace panels, or Command Center. The composer pill shows the rejected and flagged total when enforcement is on and **Off** when disabled. Open it for decision details and the workspace enforcement toggle. -- Live hook decisions, searchable by tool, file, session, project, and reason. Pause the feed or open an event’s request and response. +- Live hook decisions, searchable by tool, file, session, project, and reason. The project picker groups projects by repository and hides projects whose folder is gone. Pause the feed or open an event’s request and response. - A compact composer pill with the Be concise logo and one number. The number combines rejected and flagged decisions; the popup shows each decision count and a 30-minute chart. Turning enforcement off displays **Off** and sets the workspace's `softFail` override; flagged actions are allowed while hooks and test-output filtering remain active. Turn it on to restore enforcement. Agent environment overrides apply separately. - Call counts, interventions, sessions, average hook duration, and a 30-minute activity chart. -- User and project configuration with sections for limits, checks, style, dictionary, exceptions, and logging, plus a JSON view and test filter settings. List controls follow the be-concise merge rule for each key. +- User, project, and `BEC_CONFIG_PATH` configuration with sections for limits, checks, scanned text, style, dictionary, exceptions, and logging, plus a JSON view and test filter settings. List controls follow the be-concise merge rule for each key. - A dictionary editor for terms to flag, with match rules, fixes, and hook and scope limits. Entries from other files can be turned off or copied in. - A writing tuner that scans pasted samples and proposes settings they pass. Apply the proposal to a draft, review it, then save. -- An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews show the decision, reason, and findings of each hook without executing the pasted command or writing the target file. -- A Plugin tab that installs, updates, downgrades, and removes be-concise for Claude Code and Codex from the be-concise GitHub releases. +- An isolated playground for file writes, Codex and omp patches, shell commands, and final replies. Previews show the decision, reason, and findings of each hook without executing the pasted command or writing the target file. +- A Plugin tab that installs, updates, downgrades, and removes be-concise for Claude Code, Codex, and omp from the be-concise GitHub releases. ## Screenshots @@ -64,7 +64,7 @@ All images are 1920×1080. They come from the Paseo 0.11.0-beta.3 web app in lig ## Install -Requires Paseo 0.9.0 or newer (tested against the 0.10.2 plugin SDK) and be-concise 0.7.0 or newer on the daemon machine. The adapter uses the configuration, monitoring, and preview APIs shipped with be-concise 0.7.0. The dictionary and the writing tuner need be-concise 0.10.0; controls for settings that the installed version lacks are hidden. See [be-concise 0.10 support](docs/be-concise-0.10.md). Paseo supplies all runtime dependencies. +Requires Paseo 0.9.0 or newer (tested against the 0.10.2 plugin SDK) and be-concise 0.7.0 or newer on the daemon machine. The adapter uses the configuration, monitoring, and preview APIs shipped with be-concise 0.7.0. The dictionary and the writing tuner need be-concise 0.10.0; controls for settings that the installed version lacks are hidden. Repository groups and hidden missing projects need 0.11.0. omp needs 0.12.0. The scan controls and the `code` dictionary scope need 0.14.0. Tested with be-concise 0.15.0. See [be-concise 0.10 to 0.15 support](docs/be-concise-0.10.md). Paseo supplies all runtime dependencies. Install the npm release: @@ -77,7 +77,7 @@ paseo plugin ls Plugins must be enabled in Paseo Settings → Plugins. Installation runs trusted code with the daemon user’s access. -The plugin detects installed be-concise versions in the Claude and Codex caches, respecting `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. For a source checkout or custom installation, set `PASEO_CONCISE_ROOT` in the daemon environment to the repository or `plugins/concise` directory, then reload this plugin. +The plugin detects installed be-concise versions in the Claude, Codex, and omp caches, respecting `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and `XDG_DATA_HOME`. It reads the omp cache in `~/.omp`, in each `~/.omp/profiles/`, and in `$XDG_DATA_HOME/omp`. For a source checkout or custom installation, set `PASEO_CONCISE_ROOT` in the daemon environment to the repository or `plugins/concise` directory, then reload this plugin. ### Older Paseo versions @@ -103,7 +103,7 @@ paseo plugin ls ### Install be-concise from Paseo -Open **Be concise → Plugin**, or use the panel that appears when be-concise is missing. Choose a release and press **Install**, **Update**, or **Downgrade** for Claude Code or Codex. The daemon host needs Git and the `claude` or `codex` CLI on `PATH` or in `~/.local/bin`. The plugin downloads the release from [yannelli/be-concise](https://github.com/yannelli/be-concise/releases) into `~/.local/share/paseo-be-concise/releases` and registers it as the `be-concise` marketplace. Claude Code changes use user scope. Restart Claude Code sessions after a change. For Codex, start a new session and review the hooks with `/hooks`. If the marketplace already uses another source, **Switch to GitHub releases** asks for confirmation before it points the marketplace at the release folder. Changes run in the background, and the tab shows the result when the host commands finish. See [be-concise installer](docs/plugin-installer.md) for the exact commands. +Open **Be concise → Plugin**, or use the panel that appears when be-concise is missing. Choose a release and press **Install**, **Update**, or **Downgrade** for Claude Code, Codex, or omp. The daemon host needs Git and the `claude`, `codex`, or `omp` CLI on `PATH`, in `~/.local/bin`, or in `~/.bun/bin`. The plugin downloads the release from [yannelli/be-concise](https://github.com/yannelli/be-concise/releases) into `~/.local/share/paseo-be-concise/releases` and registers it as the `be-concise` marketplace. Claude Code changes use user scope. Restart Claude Code sessions after a change. For Codex, start a new session and review the hooks with `/hooks`. omp copies the plugin into its own cache and loads the hooks at session start, so start a new omp session after a change. omp needs be-concise 0.12.0 or newer, and it does not check subagent replies. If the marketplace already uses another source, **Switch to GitHub releases** asks for confirmation before it points the marketplace at the release folder. Changes run in the background, and the tab shows the result when the host commands finish. See [be-concise installer](docs/plugin-installer.md) for the exact commands. ## Activity and settings @@ -113,7 +113,7 @@ Configuration controls edit the selected file and preserve its other keys. **Sav Session rules that be-concise sends at startup appear as **Rules sent** and do not count as flagged. The tuner reads pasted text only and writes nothing until you save. -Each preview starts with separate retry state. Preview results do not enter live activity. Only the Plugin tab changes the Claude or Codex installation, and only when you press one of its buttons. The plugin does not start another web console. +Each preview starts with separate retry state. Preview results do not enter live activity. Only the Plugin tab changes the Claude Code, Codex, or omp installation, and only when you press one of its buttons. The plugin does not start another web console. ## Development @@ -123,11 +123,11 @@ paseo plugin reload paseo-be-concise paseo plugin logs paseo-be-concise ``` -Tests use Node.js 24 and temporary home/project directories. They cover activity normalization, retention, stats, installation discovery, config conflicts, file permissions, preview isolation, form and dictionary edits, and the tuner. Integration tests require an installed be-concise; the tuner and dictionary tests need 0.10.0. Set `PASEO_CONCISE_ROOT` to a checkout to select it explicitly. +Tests use Node.js 24 and temporary home/project directories. They cover activity normalization, retention, stats, installation discovery, config conflicts, file permissions, preview isolation, form and dictionary edits, the tuner, omp records, and the omp installer commands. Integration tests require an installed be-concise; the tuner and dictionary tests need 0.10.0. Set `PASEO_CONCISE_ROOT` to a checkout to select it explicitly. Paseo API reference: [v0.9 plugins](https://paseo.sh/docs/plugins/v0.9/reference). -See the [documentation index](docs/INDEX.md) for the current composer button contract, screenshot capture details, be-concise 0.10 support, the be-concise installer commands, and npm publishing. Consult it when you change pill labels, update screenshots, add a configuration control, change install behavior, or change the release workflow. +See the [documentation index](docs/INDEX.md) for the current composer button contract, screenshot capture details, be-concise 0.10 to 0.15 support, the be-concise installer commands, and npm publishing. Consult it when you change pill labels, update screenshots, add a configuration control, change install behavior, or change the release workflow. ## Releases diff --git a/backend.test.mts b/backend.test.mts index 27f511b..b4a1942 100644 --- a/backend.test.mts +++ b/backend.test.mts @@ -6,6 +6,7 @@ import test from "node:test"; import { ActivityStore, activityStats, normalizeEvent } from "./server/activity.ts"; import { createBackend, discoverInstallation, visibleEnvironment } from "./server/adapter.ts"; import { compareVersions } from "./shared/versions.ts"; +import { projectGroups, projectLabel, visibleProjects } from "./shared/projects.ts"; const record = (extra: Record = {}) => ({ cwd: "/project", hook: "check-edit", ts: "2026-09-05T10:30:00.000Z", decision: "allow", @@ -89,6 +90,8 @@ test("environment output includes valid supported flags and excludes arbitrary v BEC_LOG_ENABLED: "secret", BEC_FEATURE_DISABLE: "api-token", BEC_ALLOW_PHRASES: "private phrase", }), { BEC_MONITOR_PERSIST: "0", BEC_FEATURE_ALWAYS_ENABLE: "aiWriting,comments", BEC_LOG_MAX_SIZE: "5m" }); assert.deepEqual(visibleEnvironment({ BEC_FEATURE_ENABLE: "dictionary,emDash" }), { BEC_FEATURE_ENABLE: "dictionary,emDash" }); + assert.deepEqual(visibleEnvironment({ BEC_FEATURE_DISABLE: "shellWrites, tasks", BEC_CONFIG_PATH_ONLY: "1", BEC_FEATURE_ENABLE: "scan" }), + { BEC_FEATURE_DISABLE: "shellWrites, tasks", BEC_CONFIG_PATH_ONLY: "1" }); }); async function fixture(t: test.TestContext) { @@ -100,11 +103,11 @@ async function fixture(t: test.TestContext) { return { root, cwd, home, env: { HOME: home, USERPROFILE: home, PATH: process.env.PATH, PASEO_CONCISE_ROOT: join(root, "missing") } }; } -async function fakeInstallation(root: string, version = "0.7.0") { +async function fakeInstallation(root: string, version = "0.7.0", projects: unknown[] = []) { const files: Record = { ".claude-plugin/plugin.json": JSON.stringify({ name: "concise", version }), "web/configuration.mjs": "export function configuration() {}\nexport function saveConfiguration() {}\n", - "web/hub.mjs": "export function createHub() { return { list: () => [], close() {} }; }\n", + "web/hub.mjs": `export function createHub() { return { list: () => ${JSON.stringify(projects)}, close() {} }; }\n`, "web/testing/runner.mjs": "export async function runTest() {}\nexport async function disposeTests() {}\n", }; for (const [path, content] of Object.entries(files)) { @@ -267,3 +270,98 @@ test("settings written by the 0.10 controls pass upstream validation and name th const broken = { features: { dictionary: { entries: [{ id: "ok", match: "exact", value: "a", fix: "b" }, { id: "bad", match: "regex", value: "(", fix: "c" }] } } }; await assert.rejects(backend.saveConfiguration({ cwd, id: "project-claude", text: JSON.stringify(broken), revision }), /features\.dictionary\.entries\[1\]: value does not compile/); }); + +// Records written by the be-concise 0.12.0 omp extension (omp/extension.mjs) and its hooks, with paths shortened. +const ompRecords = [ + { ts: "2026-10-07T18:48:22.381Z", hook: "session-context", event: "SessionStart", tool: null, session: "019a2b3c-omp-session", cwd: "/work/proj", decision: "flag", durationMs: 3, error: null, + request: { hook_event_name: "SessionStart", session_id: "019a2b3c-omp-session", cwd: "/work/proj" }, + response: { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "[concise] Active rules:" } }, source: "live" }, + { ts: "2026-10-07T18:48:22.443Z", hook: "check-edit", event: "PreToolUse", tool: "Write", session: "019a2b3c-omp-session", cwd: "/work/proj", decision: "flag", durationMs: 21, error: null, + request: { hook_event_name: "PreToolUse", session_id: "019a2b3c-omp-session", cwd: "/work/proj", tool_name: "Write", tool_input: { file_path: "/work/proj/notes.md", content: "This is a robust — seamless tool.\n" } }, + response: { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: "[concise] soft-fail: 1 em dash, 2 AI writing patterns in /work/proj/notes.md." } }, source: "live" }, + { ts: "2026-10-07T18:48:22.565Z", hook: "check-edit", event: "PreToolUse", tool: "apply_patch", session: "019a2b3c-omp-session", cwd: "/work/proj", decision: "flag", durationMs: 21, error: null, + request: { hook_event_name: "PreToolUse", session_id: "019a2b3c-omp-session", cwd: "/work/proj", tool_name: "apply_patch", tool_input: { input: "*** Begin Patch\n*** Add File: a.md\n+Hello — world\n*** End Patch" } }, + response: { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: "[concise] soft-fail: 1 em dash in /work/proj/a.md." } }, source: "live" }, +]; + +test("omp records normalize like Claude Code and Codex records, including patch targets in tool_input.input", () => { + const [rules, write, patch] = ompRecords.map((value, index) => normalizeEvent(value, String(index))!.event); + assert.deepEqual([rules.tool, rules.decision, rules.session], ["SessionStart", "context", "019a2b3c-omp-session"]); + assert.deepEqual([write.tool, write.decision, write.target], ["Write", "flag", "/work/proj/notes.md"]); + assert.equal(patch.tool, "apply_patch"); + assert.equal(patch.target, "*** Begin Patch *** Add File: a.md +Hello — world *** End Patch"); + assert.match(patch.summary, /1 em dash in \/work\/proj\/a\.md/); + assert.equal(activityStats([rules, write, patch]).sessions, 1); +}); + +test("discovery finds omp caches in the default, profile, and XDG data roots", async (t) => { + const { root, home } = await fixture(t); + const xdg = join(root, "xdg"); + const cache = (base: string, version: string) => join(base, "plugins/cache/plugins", `be-concise___concise___${version}`); + await fakeInstallation(cache(join(home, ".omp"), "0.12.0"), "0.12.0"); + await fakeInstallation(cache(join(home, ".omp/profiles/work"), "0.12.1"), "0.12.1"); + await fakeInstallation(join(home, ".omp/plugins/cache/plugins/other___concise___0.13.0"), "0.13.0"); + const env = { HOME: home, XDG_DATA_HOME: xdg, CLAUDE_CONFIG_DIR: join(root, "claude"), CODEX_HOME: join(root, "codex") }; + assert.equal((await discoverInstallation(env))!.version, "0.12.1"); + const newest = cache(join(xdg, "omp"), "0.12.2"); + await fakeInstallation(newest, "0.12.2"); + assert.equal((await discoverInstallation(env))!.root, await realpath(newest)); +}); + +test("snapshots keep the 0.11.0 project fields and the picker hides missing projects by repository", async (t) => { + const { root, env } = await fixture(t); + const repo = { name: "app", root: "/src/app", worktree: null, subdir: "" }; + await fakeInstallation(join(root, "installed"), "0.11.0", [ + { key: "a", name: "app", cwd: "/src/app", lastSeen: "3", missing: false, repo }, + { key: "b", name: "fix-bug", cwd: "/trees/fix-bug", lastSeen: "2", missing: true, repo: { ...repo, worktree: "fix-bug" } }, + { key: "c", name: "notes", cwd: "/notes", lastSeen: "1", missing: false, repo: null }, + { key: "d", name: "docs", cwd: "/src/app/docs", lastSeen: "0", repo: { name: 7 } }, + ]); + const backend = createBackend({ ...env, PASEO_CONCISE_ROOT: join(root, "installed") }); + t.after(backend.close); + const { projects } = await backend.getSnapshot(); + assert.deepEqual(projects.map(({ key, missing, repo: value }) => [key, missing, value?.worktree ?? value?.name ?? value]), [["a", false, "app"], ["b", true, "fix-bug"], ["c", false, null], ["d", false, null]]); + assert.deepEqual(projects.map(projectLabel), ["app", "fix-bug (worktree) (missing)", "notes", "docs"]); + assert.deepEqual(projectGroups(visibleProjects(projects, false, "")).map(({ label, projects: items }) => [label, items.map(({ key }) => key)]), [["app", ["a"]], ["No git repository", ["c", "d"]]]); + assert.deepEqual(visibleProjects(projects, false, "/trees/fix-bug").map(({ key }) => key), ["a", "b", "c", "d"]); + assert.deepEqual(projectGroups([{ key: "e", name: "old", cwd: "/old", lastSeen: "" }]), [{ id: "", label: null, projects: [{ key: "e", name: "old", cwd: "/old", lastSeen: "" }] }]); +}); + +test("records from the be-concise 0.14.0 tool text and notebook hooks name their target", () => { + const target = (tool: string, input: Record) => normalizeEvent(record({ hook: "check-tool-text", request: { tool_name: tool, session_id: "s", tool_input: input } }), "id")!.event; + assert.equal(target("NotebookEdit", { notebook_path: "/project/n.ipynb", new_source: "x" }).target, "/project/n.ipynb"); + assert.equal(target("ExitPlanMode", { plan: "Step one.\nStep two." }).target, "Step one. Step two."); + assert.equal(target("TaskCreate", { subject: "Fix the build", description: "Long text" }).target, "Fix the build"); + const post = target("mcp__github__create_issue", { title: "Bug", body: "Details" }); + assert.deepEqual([post.tool, post.target], ["mcp__github__create_issue", "Bug"]); +}); + +test("settings written by the 0.14 scan controls and the code scope pass upstream validation", async (t) => { + const installed = await discoverInstallation(process.env); + if (!installed || compareVersions(installed.version, "0.14.0") < 0) return t.skip("be-concise >=0.14.0 is required for scan settings coverage"); + const { cwd, env } = await fixture(t); + const backend = createBackend({ ...env, PASEO_CONCISE_ROOT: installed.root }); + t.after(backend.close); + const before = await backend.getConfiguration({ cwd }); + assert.deepEqual(Object.keys((before.defaults as { scan: object }).scan).sort(), ["codeFiles", "heredocWrites", "mcp", "notebooks", "plans", "questions", "shellWrites", "tasks"]); + const layer = { scan: { shellWrites: false, tasks: false }, features: { dictionary: { entries: [{ id: "todo", match: "contains", value: "TODO", fix: "file an issue", scopes: ["code"] }] } } }; + const saved = await backend.saveConfiguration({ cwd, id: "project-claude", text: JSON.stringify(layer), revision: null }); + const effective = saved.effective as { scan: Record; problems: unknown[] }; + assert.deepEqual([effective.scan.shellWrites, effective.scan.tasks, effective.scan.plans], [false, false, true]); + assert.deepEqual(effective.problems, []); +}); + +test("from be-concise 0.13.1, BEC_CONFIG_PATH is its own layer under the user and project files", async (t) => { + const installed = await discoverInstallation(process.env); + if (!installed || compareVersions(installed.version, "0.13.1") < 0) return t.skip("be-concise >=0.13.1 is required for the env-config layer"); + const { root, cwd, env } = await fixture(t); + await writeFile(join(root, "shared.json"), '{"maxCommentLines":7,"maxFileLines":100}'); + await mkdir(join(cwd, ".claude")); + await writeFile(join(cwd, ".claude/concise.json"), '{"maxFileLines":200}'); + const backend = createBackend({ ...env, PASEO_CONCISE_ROOT: installed.root, BEC_CONFIG_PATH: "../shared.json" }); + t.after(backend.close); + const state = await backend.getConfiguration({ cwd }); + assert.deepEqual(state.layers.filter(({ active }) => active).map(({ id }) => id), ["env-config", "project-claude"]); + assert.equal(state.layers[0].path, join(root, "shared.json")); + assert.deepEqual([state.effective.maxCommentLines, state.effective.maxFileLines], [7, 200]); +}); diff --git a/client/activity.tsx b/client/activity.tsx index 77bd42c..564355e 100644 --- a/client/activity.tsx +++ b/client/activity.tsx @@ -97,7 +97,7 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: { {visible.length ? visible.slice(0, limit).map((event) => ) : - } + } {visible.length > limit &&