Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/concise/docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ Last updated: 2026-10-08

| Doc | Read it to | Created | Last updated |
|---|---|---|---|
| [configuration.md](configuration.md) | Find a config key, its default, the layer order, the merge rules, `mode`, and the dictionary. | 2026-09-04 | 2026-10-06 |
| [configuration.md](configuration.md) | Find a config key, its default, the layer order, the merge rules, `mode`, and the dictionary. | 2026-09-04 | 2026-10-08 |
| [tools.md](tools.md) | Change settings with the `concise-config` CLI or the MCP tools, or tune them from writing samples. | 2026-10-05 | 2026-10-05 |
| [environment.md](environment.md) | Control a session with `BEC_` variables. | 2026-09-04 | 2026-10-05 |
| [environment.md](environment.md) | Control a session with `BEC_` variables. | 2026-09-04 | 2026-10-08 |
| [categories.md](categories.md) | Look up an AI writing category, its presets, and its scopes. | 2026-09-04 | 2026-10-08 |
| [packs.md](packs.md) | Write or check a pattern pack. | 2026-09-04 | 2026-10-08 |
| [host-features.md](host-features.md) | Check which Claude Code, Codex, and omp hook, skill, and MCP features the manifests and the omp extension use, with the host doc sources and the live test results. | 2026-10-05 | 2026-10-07 |
Expand Down
6 changes: 4 additions & 2 deletions plugins/concise/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@ The hook reads config from 5 layers, lowest first. A higher layer replaces a sca

1. The built-in defaults.
2. `BEC_CONFIG_JSON`, then the baseline variables `BEC_FEATURE_ENABLE`, `BEC_FEATURE_DISABLE`, `BEC_ENABLE_PATTERNS`, `BEC_DISABLE_PATTERNS`, and `BEC_LOAD_LIB_PATHS`.
3. User config: the first of `$XDG_CONFIG_HOME/concise/concise.json`, `~/.config/concise/concise.json`, `~/.claude/concise.json`, `~/.codex/concise.json`.
4. Project config: `BEC_CONFIG_PATH` when it is set, else `<cwd>/.claude/concise.json`, else `<cwd>/.codex/concise.json`.
3. The `BEC_CONFIG_PATH` file, then the user config. The user config is the first of `$XDG_CONFIG_HOME/concise/concise.json`, `~/.config/concise/concise.json`, `~/.claude/concise.json`, `~/.codex/concise.json`.
4. Project config: `<cwd>/.claude/concise.json`, else `<cwd>/.codex/concise.json`.
5. The override variables: the `ALWAYS` forms, `BEC_HOOK_SOFT_FAIL`, `BEC_DISABLE_STOP_HOOK`, the `BEC_LOG_*` set, the `BEC_ALLOW_*` set, and the `BEC_BYPASS_*` set.

A project file overrides `BEC_FEATURE_ENABLE`. `BEC_FEATURE_ALWAYS_ENABLE` overrides the project file. The variables are listed in [environment.md](environment.md).

Of the three config files, the `BEC_CONFIG_PATH` file loads first. The user file merges over it, then the project file merges over both, so the project file wins a conflict. A relative `BEC_CONFIG_PATH` resolves against the cwd. When `BEC_CONFIG_PATH` names the user file or the project file, the hook reads that file once, in the user or project place. `BEC_CONFIG_PATH_ONLY=1` skips the user file and the project file.

## Merge rules per key

- Unioned across layers, duplicates dropped, order kept: `styleIgnoreGlobs`, `allowList.phrases`, `allowList.patterns`, `bypass.phrases`, `bypass.patterns`, `features.aiWriting.allow`, `features.aiWriting.packs`, `features.aiWriting.excludePacks`.
Expand Down
5 changes: 3 additions & 2 deletions plugins/concise/docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ Value formats:
- Path lists split on the platform path delimiter (`:` on Linux and macOS, `;` on Windows).
- A `BEC_ALLOW_*` or `BEC_BYPASS_*` value that starts with `[` is parsed as a JSON array, and any other value as a comma list.

The layer column refers to the 5 config layers in [configuration.md](configuration.md). Layer 2 sits under the user file and the project file. Layer 5 sits over both.
The layer column refers to the 5 config layers in [configuration.md](configuration.md). Layer 2 sits under the `BEC_CONFIG_PATH` file, the user file, and the project file. Layer 5 sits over all three.

| Variable | Value | Effect | Layer |
| --- | --- | --- | --- |
| `BEC_CONFIG_JSON` | JSON object | a whole config layer | 2 |
| `BEC_CONFIG_PATH` | path | the project config file to read | 4 |
| `BEC_CONFIG_PATH` | path | a config file read under the user file and the project file; a relative path resolves against the cwd | 3 |
| `BEC_CONFIG_PATH_ONLY` | boolean | skips the user file and the project file, so only the `BEC_CONFIG_PATH` file applies | 3, 4 |
| `BEC_FEATURE_ENABLE` | id list | turns features and checks on | 2 |
| `BEC_FEATURE_DISABLE` | id list | turns features and checks off | 2 |
| `BEC_FEATURE_ALWAYS_ENABLE` | id list | turns features and checks on over the config files | 5 |
Expand Down
23 changes: 17 additions & 6 deletions plugins/concise/hooks/lib/config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { readFileSync, existsSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { readEnv } from "./env.mjs";
import { applyLayer, envBaselineLayer, envOverrideLayer } from "./config-layers.mjs";

Expand Down Expand Up @@ -116,19 +116,30 @@ export function userConfigPath(env) {
return candidates.find((path) => existsSync(path)) || null;
}

export function projectConfigPath(cwd, vars) {
if (vars.configPath) return vars.configPath;
export function projectConfigPath(cwd) {
const base = cwd || ".";
return CONFIG_DIRS.map((dir) => join(base, dir, "concise.json")).find((path) => existsSync(path)) || null;
}

const realPath = (path) => (existsSync(path) ? realpathSync(path) : path);

// The files in merge order: env (BEC_CONFIG_PATH, resolved against cwd), user, project.
// env is null when that file is also the user or project file, so it loads once.
export function configFiles(cwd, env = process.env, vars = readEnv(env)) {
const pinned = vars.configPath ? resolve(cwd || ".", vars.configPath) : null;
const user = vars.configPathOnly ? null : userConfigPath(env);
const project = vars.configPathOnly ? null : projectConfigPath(cwd);
const shared = pinned && [user, project].some((path) => path && realPath(path) === realPath(pinned));
return { env: shared ? null : pinned, user, project };
}

export function loadConfig(cwd, env = process.env) {
const vars = readEnv(env || {});
const problems = [...vars.problems];
let config = applyLayer(defaultConfig(), vars.configJson);
config = applyLayer(config, envBaselineLayer(vars));
config = applyLayer(config, readLayer(userConfigPath(env || {}), problems));
config = applyLayer(config, readLayer(projectConfigPath(cwd, vars), problems));
const files = configFiles(cwd, env || {}, vars);
for (const path of [files.env, files.user, files.project]) config = applyLayer(config, readLayer(path, problems));
config = applyLayer(config, envOverrideLayer(vars));
for (const [group, key] of [["context", "enabled"], ["context", "perTurn"], ["subagentStop", "enabled"], ["testFilter", "codexPostToolUse"]]) {
if (typeof config[group][key] === "boolean") continue;
Expand Down
1 change: 1 addition & 0 deletions plugins/concise/hooks/lib/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function readEnv(env = process.env) {
return {
configJson: parseConfigJson(raw("BEC_CONFIG_JSON"), problems),
configPath: raw("BEC_CONFIG_PATH")?.trim() || null,
configPathOnly: bool("BEC_CONFIG_PATH_ONLY") === true,
featureEnable: ids("BEC_FEATURE_ENABLE"),
featureDisable: ids("BEC_FEATURE_DISABLE"),
alwaysEnableFeatures: ids("BEC_FEATURE_ALWAYS_ENABLE"),
Expand Down
31 changes: 27 additions & 4 deletions plugins/concise/test/config-tests.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env node
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { ok, bad, summary } from "./lib.mjs";
import { loadConfig, defaultConfig } from "../hooks/lib/config.mjs";
import { configFiles, loadConfig, defaultConfig } from "../hooks/lib/config.mjs";
import { readEnv, parseSize } from "../hooks/lib/env.mjs";

const dirs = [];
Expand Down Expand Up @@ -66,6 +66,7 @@ console.log("config: env parsing");
const vars = readEnv(env);
eq("BEC_CONFIG_JSON parses to an object", vars.configJson, { maxRetries: 4 });
eq("BEC_CONFIG_PATH kept as a path", vars.configPath, "/tmp/x.json");
eq("BEC_CONFIG_PATH_ONLY is off when unset", vars.configPathOnly, false);
eq("BEC_FEATURE_ENABLE splits on commas", vars.featureEnable, ["aiWriting", "emDash"]);
eq("BEC_FEATURE_DISABLE parses", vars.featureDisable, ["comments"]);
eq("BEC_FEATURE_ALWAYS_ENABLE parses", vars.alwaysEnableFeatures, ["prBody"]);
Expand Down Expand Up @@ -119,11 +120,33 @@ console.log("\nconfig: layers");
eq("env overrides set softFail", config.softFail, true);
eq("no problems on clean layers", config.problems, []);

const pinned = writeJson(join(project, "other.json"), { maxFileLines: 55 });
eq("BEC_CONFIG_PATH replaces the project file", loadConfig(project, { ...env, BEC_CONFIG_PATH: pinned }).maxFileLines, 55);
const pinned = writeJson(join(project, "other.json"), { maxFileLines: 55, maxCommentLines: 3, maxRetries: 6 });
const withPath = loadConfig(project, { ...env, BEC_CONFIG_PATH: pinned });
eq("the project file merges over the BEC_CONFIG_PATH file", withPath.maxFileLines, 120);
eq("the user file merges over the BEC_CONFIG_PATH file", withPath.maxCommentLines, 5);
eq("the BEC_CONFIG_PATH file merges over BEC_CONFIG_JSON", withPath.maxRetries, 6);
eq("a relative BEC_CONFIG_PATH resolves against the cwd", loadConfig(project, { BEC_CONFIG_PATH: "other.json" }).maxRetries, 6);
const only = loadConfig(project, { ...env, BEC_CONFIG_PATH: pinned, BEC_CONFIG_PATH_ONLY: "1" });
eq("BEC_CONFIG_PATH_ONLY skips the user and project files", [only.maxFileLines, only.maxCommentLines], [55, 3]);
eq("loadConfig with one argument still reads the project file", loadConfig(project).maxFileLines, 120);
}

{
const home = temp("concise-same-home-");
const project = temp("concise-same-proj-");
const user = writeJson(join(home, ".config", "concise", "concise.json"), { features: { aiWriting: { enablePatterns: ["filler"] } } });
writeJson(join(project, ".claude", "concise.json"), { features: { aiWriting: { disablePatterns: ["filler"] } } });
const env = { HOME: home, BEC_CONFIG_PATH: user };
eq("a project file merges over a BEC_CONFIG_PATH that names the user file", ai(loadConfig(project, env)).enablePatterns, []);
eq("a BEC_CONFIG_PATH that names the user file loads in the user place", configFiles(project, env).env, null);
eq("a missing BEC_CONFIG_PATH is still reported as the env file", configFiles(project, { BEC_CONFIG_PATH: "gone.json" }).env, join(project, "gone.json"));
const broken = temp("concise-same-broken-");
const local = writeJson(join(broken, ".claude", "concise.json"), "{ not json");
symlinkSync(local, join(broken, "linked.json"));
const problems = loadConfig(broken, { BEC_CONFIG_PATH: "linked.json" }).problems.map((problem) => problem.source);
eq("a BEC_CONFIG_PATH link to the project file loads that file once", problems, [local]);
}

console.log("\nconfig: features and patterns");

{
Expand Down
20 changes: 17 additions & 3 deletions plugins/concise/test/coverage-tools-ops-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ console.log("\ncoverage: dictionary-ops");

{
const entries = [{ id: "env-term", match: "exact", value: "zorb", fix: "zap" }, { id: "broken", match: "regex", value: "(", fix: "x" }];
const env = { BEC_CONFIG_PATH: "missing.json", BEC_CONFIG_JSON: JSON.stringify({ features: { dictionary: { entries } } }) };
const env = { BEC_CONFIG_JSON: JSON.stringify({ features: { dictionary: { entries } } }) };
const listed = listDictionary({ cwd: project(), env });
const term = listed.entries.find((entry) => entry.id === "env-term");
const broken = listed.entries.find((entry) => entry.id === "broken");
Expand All @@ -126,6 +126,20 @@ console.log("\ncoverage: dictionary-ops");
check("a layer file with no entries list owns no entries", noEntries.entries.find((entry) => entry.id === "env-term")?.layer === "environment", noEntries.entries);
}

{
const term = (id, fix) => ({ id, match: "exact", value: id, fix });
const dir = project({ features: { dictionary: { entries: [term("shared", "project")] } } });
writeFileSync(join(dir, "env.json"), JSON.stringify({ features: { dictionary: { entries: [term("shared", "env"), term("pinned", "env")] } } }));
const listed = listDictionary({ cwd: dir, env: { BEC_CONFIG_PATH: "env.json" } }).entries;
const owner = (id) => listed.find((entry) => entry.id === id);
check("the project file owns an entry it shares with the BEC_CONFIG_PATH file", owner("shared")?.layer === "project-claude" && owner("shared").fix === "project", listed);
check("the BEC_CONFIG_PATH file owns its own entries", owner("pinned")?.layer === "env-config", listed);
const fresh = project();
writeFileSync(join(fresh, "env.json"), "{}");
const plan = planEdit({ cwd: fresh, env: { BEC_CONFIG_PATH: "env.json" }, edits: [{ op: "set", key: "maxRetries", value: 3 }] });
check("the project layer is a new project file, not the BEC_CONFIG_PATH file", plan.layer.id === "project-claude" && !plan.layer.exists, plan.layer);
}

{
const dir = project({ features: { dictionary: { enabled: false } } });
const env = {};
Expand Down Expand Up @@ -199,9 +213,9 @@ console.log("\ncoverage: settings");

{
const home = tempDir();
const state = { layers: [{ id: "user-xdg", active: false }, { id: "user-claude", active: false }, { id: "project-override", active: false }, { id: "project-claude", active: false }] };
const state = { layers: [{ id: "env-config", active: true }, { id: "user-xdg", active: false }, { id: "user-claude", active: false }, { id: "project-claude", active: false }] };
check("user resolves to the first user layer when none is in effect", resolveLayer(state, "user").id === "user-xdg", state);
check("project resolves to an inactive override before the Claude file", resolveLayer(state, "project", home).id === "project-override", state);
check("project resolves to the Claude file, not the BEC_CONFIG_PATH file", resolveLayer(state, "project", home).id === "project-claude", state);
}

{
Expand Down
1 change: 0 additions & 1 deletion plugins/concise/tools/dictionary-ops.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const object = (value) => value !== null && typeof value === "object" && !Array.
const LIST_FIELDS = { hooks: HOOKS, scopes: SCOPES };

function layerEntries(layer) {
if (!layer.exists) return [];
try {
const entries = readValue(JSON.parse(layer.text), "features.dictionary.entries");
return Array.isArray(entries) ? entries : [];
Expand Down
2 changes: 0 additions & 2 deletions plugins/concise/tools/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ export function resolveLayer(state, name = "project", cwd = process.cwd()) {
const active = group.find((layer) => layer.active);
if (active) return active;
if (name === "user") return group[0];
const override = group.find((layer) => layer.id === "project-override");
if (override) return override;
const codexOnly = existsSync(join(cwd, ".codex")) && !existsSync(join(cwd, ".claude"));
return group.find((layer) => layer.id === (codexOnly ? "project-codex" : "project-claude"));
}
Expand Down
18 changes: 10 additions & 8 deletions plugins/concise/web/configuration.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { existsSync, readFileSync, realpathSync, statSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { dirname, join } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { defaultConfig, loadConfig, projectConfigPath, userConfigPath } from "../hooks/lib/config.mjs";
import { readEnv, parseSize } from "../hooks/lib/env.mjs";
import { configFiles, defaultConfig, loadConfig } from "../hooks/lib/config.mjs";
import { parseSize } from "../hooks/lib/env.mjs";
import { entryProblem } from "../hooks/lib/dictionary.mjs";

export const problem = (message, status = 400) => Object.assign(new Error(message), { status });
Expand Down Expand Up @@ -64,22 +64,24 @@ function layer(id, label, path, active = false) {

export function configuration(cwd, env) {
const home = env.HOME || env.USERPROFILE;
const user = userConfigPath(env);
const project = projectConfigPath(cwd, readEnv(env));
const candidates = [
const files = configFiles(cwd, env);
const others = [
...(env.XDG_CONFIG_HOME ? [["user-xdg", "User (XDG)", join(env.XDG_CONFIG_HOME, "concise", "concise.json")]] : []),
...(home ? [
["user", "User", join(home, ".config", "concise", "concise.json")],
["user-claude", "User (Claude)", join(home, ".claude", "concise.json")],
["user-codex", "User (Codex)", join(home, ".codex", "concise.json")],
] : []),
...(readEnv(env).configPath ? [["project-override", "Project (BEC_CONFIG_PATH)", resolve(cwd, readEnv(env).configPath)]] : []),
["project-claude", "Project (Claude)", join(cwd, ".claude", "concise.json")],
["project-codex", "Project (Codex)", join(cwd, ".codex", "concise.json")],
];
// The BEC_CONFIG_PATH file comes first, as it loads first. A missing file yields its path to a user or project id.
const ownLayer = files.env && (existsSync(files.env) || !others.some(([, , path]) => path === files.env));
const candidates = [...(ownLayer ? [["env-config", "BEC_CONFIG_PATH", files.env]] : []), ...others];
const loaded = [files.env, files.user, files.project];
const seen = new Set();
const layers = candidates.filter(([, , path]) => !seen.has(path) && seen.add(path)).map(([id, label, path]) =>
layer(id, label, path, path === user || path === (project ? resolve(cwd, project) : null)));
layer(id, label, path, loaded.includes(path) && existsSync(path)));
const filterLayers = home ? ["claude", "codex"].map((host) =>
layer(`filter-${host}`, `Test filter (${host})`, join(home, `.${host}`, "test-filter.conf"), true)) : [];
return { defaults: defaultConfig(), effective: loadConfig(cwd, env), layers, filterLayers,
Expand Down
2 changes: 1 addition & 1 deletion plugins/concise/web/testing/runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function environment(env, directory, configPath) {
const home = join(directory, "home");
const state = join(directory, "state");
return { ...out, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"),
TMPDIR: state, TMP: state, TEMP: state, BEC_CONFIG_PATH: configPath,
TMPDIR: state, TMP: state, TEMP: state, BEC_CONFIG_PATH: configPath, BEC_CONFIG_PATH_ONLY: "1",
BEC_MONITOR_DISABLED: "1", CLAUDE_PLUGIN_ROOT: PLUGIN };
}

Expand Down
10 changes: 10 additions & 0 deletions test/playground.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ test("playground uses the config snapshot and isolates user environment, logging
assert.equal(existsSync(shellMarker), false);
});

test("playground previews ignore the project config file", async (t) => {
const cwd = await fixture(t);
const projectLog = join(cwd, "project.log");
await mkdir(join(cwd, ".claude"));
await writeFile(join(cwd, ".claude", "concise.json"), JSON.stringify({ features: { emDash: { enabled: false } }, log: { enabled: true, path: projectLog } }));
const result = await runTest({ cwd, config: dashConfig(), text: "This \u2014 wording." });
assert.equal(result.hooks[0].decision, "deny");
assert.equal(existsSync(projectLog), false);
});

test("playground rejects unknown session IDs and replaces raw session and cwd fields", async (t) => {
const cwd = await fixture(t);
await assert.rejects(runTest({ cwd, session: "../../outside" }), /Unknown playground session/);
Expand Down
Loading
Loading