From ccc9f9f7190c261e659e02b8bdeb2ae55fbd5a7b Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Mon, 5 Oct 2026 05:06:11 +0000 Subject: [PATCH 1/2] feat(configuration): support be-concise 0.10 dictionary, tuner, and missing settings Add Configuration sections for checks, style, dictionary, exceptions, logging, and the 0.10 writing tuner, gated on the installed defaults. Add the concise.tune RPC, show session rules as "Rules sent" in Activity, show a per-hook Playground result, and refresh two README banners. --- README.md | 14 +- backend.test.mts | 62 ++++++ client/activity.tsx | 22 ++- client/composer.tsx | 1 + client/config-form.tsx | 161 +++++++++++++++ client/config-sections.tsx | 94 +++++++++ client/configuration.tsx | 176 ++++++++--------- client/dictionary.tsx | 155 +++++++++++++++ client/main.tsx | 19 +- client/playground.tsx | 94 +++++++-- client/tuner.tsx | 70 +++++++ client/ui.tsx | 53 ++++- docs/INDEX.md | 1 + docs/banners/banner.html | 253 ++++++++++++++++++++++++ docs/banners/captures/configuration.png | Bin 0 -> 263600 bytes docs/banners/captures/playground.png | Bin 0 -> 286093 bytes docs/banners/render.mjs | 90 +++++++++ docs/be-concise-0.10.md | 54 +++++ docs/composer-pill.md | 2 +- docs/images/configuration-light.png | Bin 269138 -> 367923 bytes docs/images/playground.png | Bin 448532 -> 448996 bytes index.server.ts | 3 +- server/activity.ts | 5 +- server/adapter.ts | 41 +++- shared/contracts.ts | 14 ++ test/composer.test.mts | 2 +- test/config-ui.test.mts | 96 +++++++++ test/server-entry.test.mts | 2 +- 28 files changed, 1350 insertions(+), 134 deletions(-) create mode 100644 client/config-form.tsx create mode 100644 client/config-sections.tsx create mode 100644 client/dictionary.tsx create mode 100644 client/tuner.tsx create mode 100644 docs/banners/banner.html create mode 100644 docs/banners/captures/configuration.png create mode 100644 docs/banners/captures/playground.png create mode 100644 docs/banners/render.mjs create mode 100644 docs/be-concise-0.10.md create mode 100644 test/config-ui.test.mts diff --git a/README.md b/README.md index 8640393..d7a7820 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ A native companion to [be-concise](https://github.com/yannelli/be-concise) for C - Live hook decisions, searchable by tool, file, session, project, and reason. 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 threshold controls, check switches, writing presets, an advanced JSON editor, and test filter settings. -- An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews inspect hook responses without executing the pasted command or writing the target file. +- 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. +- 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. ## Screenshots @@ -62,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. 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. See [be-concise 0.10 support](docs/be-concise-0.10.md). Paseo supplies all runtime dependencies. ```sh paseo plugin add npm:@yannelli/paseo-be-concise @@ -96,6 +98,8 @@ The view refreshes every two seconds. It follows the project registry and saved Configuration controls edit the selected file and preserve its other keys. **Save changes** validates through be-concise and checks the file revision. An intervening edit preserves your draft and requires an explicit reload. The effective view uses the daemon’s environment; agent-specific environment overrides apply separately. The interface labels active and inactive layers because creating a higher-priority file changes which configuration is selected. +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. ## Development @@ -106,11 +110,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, and preview isolation. Integration tests require an installed be-concise; 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, 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. 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, the be-concise installer commands, and npm publishing. Consult it when you change pill labels, update screenshots, 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 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 f6074cd..27f511b 100644 --- a/backend.test.mts +++ b/backend.test.mts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; 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"; const record = (extra: Record = {}) => ({ cwd: "/project", hook: "check-edit", ts: "2026-09-05T10:30:00.000Z", decision: "allow", @@ -68,12 +69,26 @@ test("stats count interventions, sessions, durations, and thirty minute buckets" assert.equal(activityStats([]).averageMs, 0); }); +test("injected session rules count as context while config warnings stay flagged", () => { + const lifecycle = (response: Record, decision = "flag") => normalizeEvent(record({ + hook: "session-context", event: "SessionStart", decision, request: { hook_event_name: "SessionStart", session_id: "s" }, response, + }), "id")!.event; + const rules = lifecycle({ hookSpecificOutput: { additionalContext: "[concise] Active rules:" } }); + assert.equal(rules.decision, "context"); + assert.equal(rules.tool, "SessionStart"); + assert.equal(lifecycle({ systemMessage: "[concise] config problem", hookSpecificOutput: { additionalContext: "rules" } }).decision, "flag"); + assert.equal(lifecycle({}, "bypass").decision, "bypass"); + assert.equal(normalizeEvent(record({ decision: "flag", response: { systemMessage: "kept" } }), "id")!.event.decision, "flag"); + assert.equal(activityStats([rules]).interventions, 0); +}); + test("environment output includes valid supported flags and excludes arbitrary values", () => { assert.deepEqual(visibleEnvironment({ BEC_MONITOR_PERSIST: "0", BEC_FEATURE_ALWAYS_ENABLE: "aiWriting,comments", BEC_LOG_MAX_SIZE: "5m", BEC_CONFIG_JSON: '{"secret":"hidden"}', BEC_CONFIG_PATH: "/private/path", BEC_API_KEY: "secret", 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" }); }); async function fixture(t: test.TestContext) { @@ -205,3 +220,50 @@ test("preview isolates sessions and leaves pasted commands and project files une await assert.rejects(stat(join(cwd, "paseo-preview-marker")), { code: "ENOENT" }); assert.equal((await backend.getSnapshot()).events.length, 0); }); + +test("the tuner requires be-concise 0.10.0 and proposes settings from pasted samples", async (t) => { + const { root, cwd, env } = await fixture(t); + await fakeInstallation(join(root, "missing"), "0.9.0"); + const older = createBackend(env); + t.after(older.close); + await assert.rejects(older.runTune({ cwd, kind: "docs", texts: ["Text."] }), /requires be-concise 0\.10\.0 or newer\. This host has 0\.9\.0/); + const installed = await discoverInstallation(process.env); + if (!installed || compareVersions(installed.version, "0.10.0") < 0) return t.skip("be-concise >=0.10.0 is required for tuner coverage"); + const backend = createBackend({ ...env, PASEO_CONCISE_ROOT: installed.root }); + t.after(backend.close); + const sample = "The parser reads each file \u2014 then it writes the index. ".repeat(40); + const result = await backend.runTune({ cwd, kind: "docs", texts: [sample, sample] }); + assert.equal(result.samples, 2); + assert.ok(result.words > 300); + assert.ok(result.evidence.every((item) => typeof item.key === "string" && typeof item.reason === "string")); + assert.equal(typeof result.delta, "object"); + await assert.rejects(stat(join(cwd, ".claude/concise.json")), { code: "ENOENT" }); + await assert.rejects(backend.runTune({ cwd, kind: "essay" as "docs", texts: ["Text."] }), /^Error: unknown kind "essay"/); +}); + +test("settings written by the 0.10 controls pass upstream validation and name the bad entry", async (t) => { + const installed = await discoverInstallation(process.env); + if (!installed || compareVersions(installed.version, "0.10.0") < 0) return t.skip("be-concise >=0.10.0 is required for 0.10 settings coverage"); + const { cwd, env } = await fixture(t); + env.PASEO_CONCISE_ROOT = installed.root; + const backend = createBackend(env); + t.after(backend.close); + const layer = { + context: { perTurn: true }, subagentStop: { exemptAgentTypes: ["Explore"] }, testFilter: { codexPostToolUse: true }, + features: { dictionary: { mode: "deny", entries: [ + { id: "blacklist", match: "exact", value: "blacklist", fix: "denylist" }, + { id: "ticket", match: "regex", value: "\\bJIRA-\\d+\\b", fix: "link the ticket", flags: "i", scopes: ["commit"] }, + ] } }, + ignoreGlobs: ["**/dist/**"], allowList: { phrases: ["load-bearing"] }, log: { rotate: "daily", maxSize: "1m", maxFiles: 2 }, + }; + const saved = await backend.saveConfiguration({ cwd, id: "project-claude", text: JSON.stringify(layer), revision: null }); + const effective = saved.effective as typeof layer & { problems: unknown[] }; + assert.equal(effective.context.perTurn, true); + assert.deepEqual(effective.subagentStop.exemptAgentTypes, ["Explore"]); + assert.deepEqual(effective.features.dictionary.entries.map(({ id }) => id), ["blacklist", "ticket"]); + assert.deepEqual(effective.ignoreGlobs, ["**/dist/**"]); + assert.deepEqual(effective.problems, []); + const revision = saved.layers.find(({ id }) => id === "project-claude")!.revision; + 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/); +}); diff --git a/client/activity.tsx b/client/activity.tsx index 6f9ca82..77bd42c 100644 --- a/client/activity.tsx +++ b/client/activity.tsx @@ -7,11 +7,21 @@ import { Pressable, Text, TextInput, View } from "react-native"; import { type ActivityEvent, type ActivityStats, eventDetail } from "../shared/contracts"; import { Button, Card, decisionColor, Label, Row } from "./ui"; -const filters = [ +const ATTENTION = ["deny", "ask", "block", "error"]; +const knownFilters = [ { value: "all", label: "All decisions" }, { value: "attention", label: "Needs attention" }, { value: "allow", label: "Allowed" }, - { value: "flag", label: "Flagged" }, { value: "rewrite", label: "Rewritten" }, { value: "bypass", label: "Bypassed" }, + { value: "flag", label: "Flagged" }, { value: "rewrite", label: "Rewritten" }, { value: "filter", label: "Filtered" }, + { value: "bypass", label: "Bypassed" }, { value: "context", label: "Rules sent" }, ]; +function decisionFilters(stats: ActivityStats) { + const count = (names: string[]) => stats.decisions.reduce((sum, item) => sum + (names.includes(item.name) ? item.count : 0), 0); + const known = new Set([...knownFilters.map((item) => item.value), ...ATTENTION]); + const extra = stats.decisions.filter((item) => !known.has(item.name)).map((item) => ({ value: item.name, label: item.name })); + return [...knownFilters, ...extra].map((item) => ({ ...item, + count: item.value === "all" ? stats.total : count(item.value === "attention" ? ATTENTION : [item.value]) })); +} + export function Activity({ theme, compact, events, stats, paused, onPause }: { theme: PluginTheme; compact: boolean; events: ActivityEvent[]; stats: ActivityStats; paused: boolean; onPause: () => void; }) { @@ -20,8 +30,9 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: { const [limit, setLimit] = useState(60); const [statsOpen, setStatsOpen] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false); + const filters = decisionFilters(stats); const visible = useMemo(() => events.filter((event) => - (filter === "all" || (filter === "attention" ? ["deny", "ask", "block", "error"].includes(event.decision) : event.decision === filter)) && + (filter === "all" || (filter === "attention" ? ATTENTION.includes(event.decision) : event.decision === filter)) && `${event.tool} ${event.hook} ${event.target} ${event.summary} ${event.session} ${event.projectName}`.toLowerCase().includes(search.toLowerCase()) ), [events, filter, search]); const maximum = Math.max(1, ...stats.minutes.map((minute) => minute.count)); @@ -64,7 +75,7 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: { setFiltersOpen(!filtersOpen)} style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, minHeight: 30, paddingHorizontal: 8, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 6 }}> - + {filtersOpen && @@ -73,7 +84,8 @@ export function Activity({ theme, compact, events, stats, paused, onPause }: { style={({ pressed }) => ({ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, minHeight: 30, borderRadius: 4, backgroundColor: pressed || filter === item.value ? theme.colors.surface2 : "transparent" })}> {filter === item.value && } - + + {item.count} )} } diff --git a/client/composer.tsx b/client/composer.tsx index 2704809..90c4692 100644 --- a/client/composer.tsx +++ b/client/composer.tsx @@ -24,6 +24,7 @@ const decisionGroups = [ { label: "rejected", names: ["deny", "block"], decision: "deny" }, { label: "flagged", names: ["ask", "flag"], decision: "flag" }, { label: "rewritten", names: ["rewrite"], decision: "rewrite" }, + { label: "filtered", names: ["filter"], decision: "filter" }, { label: "bypassed", names: ["bypass"], decision: "bypass" }, { label: "errors", names: ["error"], decision: "error" }, ]; diff --git a/client/config-form.tsx b/client/config-form.tsx new file mode 100644 index 0000000..ba89564 --- /dev/null +++ b/client/config-form.tsx @@ -0,0 +1,161 @@ +import type { PluginTheme } from "@getpaseo/plugin"; +import { Icon } from "@getpaseo/plugin/client/react-native"; +import React, { type ReactNode, useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { Button, Chips, Field, Label, Notice, Row, Toggle } from "./ui"; + +export type JsonObject = Record; +export type Form = { + theme: PluginTheme; compact: boolean; value: JsonObject; effective: JsonObject; defaults: JsonObject; top: boolean; + set(path: string, value: unknown): void; replace(value: JsonObject): void; +}; + +export const isObject = (value: unknown): value is JsonObject => value !== null && typeof value === "object" && !Array.isArray(value); +export const at = (value: unknown, path: string): unknown => path.split(".").reduce((node, key) => isObject(node) ? node[key] : undefined, value); +export const supports = (form: Form, path: string) => at(form.defaults, path) !== undefined; +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const show = (value: unknown) => value === null ? "default" : typeof value === "string" ? value + : Array.isArray(value) ? `${value.length} ${value.length === 1 ? "item" : "items"}` : JSON.stringify(value); +const strings = (value: unknown) => Array.isArray(value) ? value.map(String) : []; + +export function update(source: JsonObject, path: string, value: unknown): JsonObject { + const [key, ...rest] = path.split("."); + const next = { ...source }; + if (rest.length) { + const child = update(isObject(next[key]) ? next[key] : {}, rest.join("."), value); + if (Object.keys(child).length) next[key] = child; + else delete next[key]; + } else if (value === undefined) delete next[key]; + else next[key] = value; + return next; +} + +/** Mirrors be-concise mergeDelta: objects merge, lists gain new items, other values replace. */ +export function mergeDelta(target: JsonObject, delta: JsonObject): JsonObject { + const next = { ...target }; + for (const [key, value] of Object.entries(delta)) { + const current = next[key]; + if (isObject(value)) next[key] = mergeDelta(isObject(current) ? current : {}, value); + else if (Array.isArray(value) && Array.isArray(current)) next[key] = [...current, ...value.filter((item) => !current.some((prior) => same(prior, item)))]; + else next[key] = value; + } + return next; +} + +function Origin({ form, path, compare }: { form: Form; path: string; compare: boolean }) { + const own = at(form.value, path); + const effective = at(form.effective, path); + const text = own === undefined ? "Inherited" : !compare || same(own, effective) ? "Set in this file" : `Set in this file · Effective value: ${show(effective)}`; + return ; +} + +export function Setting({ form, path, label, hint, children, below, compare = true }: { + form: Form; path: string; label: string; hint?: string; children?: ReactNode; below?: ReactNode; compare?: boolean; +}) { + const own = at(form.value, path) !== undefined; + return + + + + {!!hint && } + + + {own &&