domaine-oleksandr-kever/claude-plugins/tree/main/plugins/fnd
fnd
## Foundation — fnd プラグイン Domaine の **Agentic Assisted Development** スキルをプラグインとしてまとめたものです。Foundation のワークフロースキル(技術方針 → 開発 → QA → PR に加え、翻訳、破壊的変更、プレビュー用テーマなど)を Shopify テーマ作業向けに同梱します。
この mod について
fnd(Foundation)プラグインをホストする Claude Code marketplace repository です。技術方針、開発、QA、PR 作成、出荷、テーマ、コミット、翻訳を扱う 18 個のワークフロースキル、7 個のサブエージェント(change-reviewer、bug-hunter、jira-reader、jira-writer、figma-reader、doc-reader、theme-explorer)、ステータスバンド、進捗ペイン、コミットガード、コンテキスト監視、MCP 結果圧縮用の Claude Code hooks モジュール、Shopify Admin GraphQL、theme JSON、Jira 添付ファイル、外部スクリーンショット、Figma REST 用の同梱 runner を提供します。さらに生成されたアダプター層により、同じ内容を Cursor、OpenAI Codex CLI、OpenCode でも実行できます。/plugin marketplace add domaine-oleksandr-kever/claude-plugins の後に /plugin install fnd@domaine を実行してインストールします。macOS/Linux(Windows は WSL 経由)、bash >= 3.2、git、jq が必要です。doctor.cjs と smoke-test でインストールを検証でき、hooks は FND_HOST_TRACE で動作を確認できます。Claude 以外のホストは未検証として文書化されており、クラウドサンドボックスでは Figma、Atlassian、スクリーンショット、Shopify スクリプト用に記載された egress 許可リスト項目が必要です。
インストール
まず作者の README で marketplace とプラグイン名を確認してください。コマンドはリポジトリの構成によって変わる場合があります。
claude plugin marketplace add domaine-oleksandr-kever/claude-plugins claude plugin install fnd
原文 / README
Foundation — the fnd plugin
Domaine's Agentic Assisted Development skills, packaged as a plugin. It bundles the Foundation workflow skills (technical approach → develop → QA → PR, plus translations, breaking-changes, preview themes, etc.) for Shopify theme work.
Claude Code is the canonical host; the same content also runs on Cursor, OpenAI Codex CLI and OpenCode through thin, committed adapters generated from it — one repo, no fork. See Install — four hosts.
What's inside
The map of how the pieces fit — hosts, adapters, hooks, the compression pipeline, the ship pipeline — is docs/ARCHITECTURE.md (Mermaid, renders on GitHub).
This repo is a marketplace (a catalog) that hosts one or more plugins, each
in its own subfolder under plugins/:
.
├── .claude-plugin/
│ └── marketplace.json # marketplace catalog (lists the plugins)
├── plugins/
│ └── fnd/ # the Foundation plugin (self-contained)
│ ├── .claude-plugin/
│ │ └── plugin.json # plugin manifest (+ bundled mcpServers) — canonical
│ ├── .cursor-plugin/plugin.json # Cursor manifest (same version stamp)
│ ├── .codex-plugin/plugin.json # Codex CLI manifest (same version stamp)
│ ├── skills/ # 18 workflow skills (see table below)
│ │ ├── develop-feature-or-fix/SKILL.md
│ │ └── ...
│ ├── agents/ # subagents the skills delegate to
│ │ ├── change-reviewer.md # reviews a diff (hygiene + conformance)
│ │ ├── bug-hunter.md # adversarial bug hunt on a diff (correctness)
│ │ ├── jira-reader.md # reads a ticket → structured fields
│ │ ├── jira-writer.md # writes one approved field (ADF) / comment (md) → Jira
│ │ ├── figma-reader.md # reads one Figma frame → build spec
│ │ ├── doc-reader.md # reads one linked doc (Notion/Confluence/web) → extract
│ │ └── theme-explorer.md # scouts the theme → impact map
│ ├── hooks/ # injected conventions + git guards + context monitor
│ │ ├── mods/ # Claude Code hooks module: status band, progress pane, guard, slim, pasted JSON
│ │ ├── comment-discipline.md
│ │ ├── lean-code.md # "lazy senior dev" ladder (FND_LEAN=0 to disable)
│ │ ├── writing-style.md # how-to-explain convention, ASD-STE100-style (FND_STE=0 to disable)
│ │ ├── mcp-whale-claude.md # short whale convention Claude Code gets instead of mcp-whale.md
│ │ ├── session-start.sh # SessionStart composer both shell wirings spawn
│ │ ├── subagent-conventions.sh # outside-content rail → every subagent; the above → code-writing ones
│ │ ├── no-verify-bypass.sh # PreToolUse guard: no hook-bypassing commits
│ │ ├── spill-access.sh # PreToolUse recorder: which tool read an MCP spill
│ │ └── ... # see "Hooks" below for the full set
│ ├── types/index.d.ts # the hooks module's state contract (plugin.json "types")
│ ├── scripts/ # bundled runners the skills call
│ │ ├── shopify-admin-gql.sh # Admin GraphQL (store execute → token)
│ │ ├── theme-json.sh # theme JSON / customizer state
│ │ ├── jira-attachments.sh # Jira attachments → task workspace (read-only token; videos → frames)
│ │ ├── external-screenshots.sh # screenshots a ticket LINKS (prnt.sc, imgur, …) → the same dir, allow-listed hosts only
│ │ ├── figma-rest.sh # one Figma node via the REST API (FIGMA_TOKEN) when no Figma MCP answers
│ │ ├── figma-node-slim.cjs # that REST node tree → compact markdown build tree
│ │ ├── _shopify-common.sh # sourced by the theme scripts + the two token runners (never run directly)
│ │ ├── gen-host-adapters.cjs # writes every generated dir below
│ │ ├── doctor.cjs # static install verification, any host
│ │ └── ...
│ ├── references/ # shared docs the skills read
│ │ ├── jira-custom-fields.md
│ │ ├── review-flow.md # shared contract for the review/marker flow
│ │ ├── host-model-map.md # GENERATED — tier → model, per host
│ │ └── ...
│ ├── rules/ # Cursor rule bundle (vendored foundation .mdc + fnd rules,
│ │ # the fnd-* session conventions GENERATED from hooks/)
│ ├── agents-cursor/ # GENERATED per-host agent variants — never hand-edit;
│ ├── agents-codex/ # change agents/ (or the generator) and re-run
│ ├── agents-opencode/ # scripts/gen-host-adapters.cjs
│ ├── commands-opencode/ # GENERATED /name shims (OpenCode invokes skills by model only)
│ ├── mcp.json # GENERATED Cursor MCP config (+ mcp.pruned.json profile)
│ ├── mcp-codex.json # GENERATED Codex MCP config (not `.mcp.json` — that
│ │ # name is a Claude Code plugin component)
│ └── opencode/ # OpenCode adapter + GENERATED model-profile examples
│ # and mcp-fragment.json
├── scripts/
│ ├── bootstrap.sh # one-command clone + per-host install / update / uninstall
│ └── install.sh # Cursor / OpenCode / Codex-subagent installer
├── docs/ # per-host quickstarts (install · update · what differs)
│ ├── README.cursor.md
│ ├── README.codex.md
│ └── README.opencode.md
├── tests/ # committed test suites for the hooks + scripts
│ ├── no-verify-bypass-matrix.sh # FP/FN contract of the two commit guards
│ ├── hooks-sim.sh # SessionStart / monitor-gate / context-stats sims
│ ├── scripts-sim.sh # runner + theme-json + converter-caller sims
│ ├── jira-attachments-sim.sh # jira-attachments.sh: creds, gateway, downloads, transient video frames
│ ├── external-screenshots-sim.sh # external-screenshots.sh: host allow-list, og:image, caps, downscale, cache
│ ├── figma-rest-sim.sh # figma-rest.sh: url parsing, token, degradation, out-dir gate
│ ├── figma-node-slim-fixtures.mjs # REST node tree → build tree: lossless folds, size gate
│ ├── bootstrap-sim.sh # bootstrap: arg gates, clone, pty picker, uninstall
│ ├── adf-md-fixtures.mjs # ADF ↔ markdown converter fixtures
│ ├── json-slim-fixtures.mjs # mcp-slim pipeline + CLI + hook fixtures
│ ├── mods-sim.sh # hooks module: validate --strict + plugin test (SKIP without claude)
│ ├── readme-checks.sh # README/docs: commands, paths, links, version markers
│ ├── fixtures/ # real captured payloads (secrets scrubbed)
│ └── parity/ # upstream-port parity fixtures + license NOTICE
├── CLAUDE.md # repo conventions (AGENTS.md is a symlink to it — the name Codex reads)
├── LICENSE
└── README.md
To add another plugin later: create plugins/<name>/ (with its own
.claude-plugin/plugin.json) and add an entry to marketplace.json → plugins[].
Note on rules. Project coding conventions (
css-conventions,liquid-conventions,protected-core, …) are not shipped in this plugin. They live in the target repo under.claude/rules/*.mdand auto-attach natively by theirpaths:globs when you edit matching files. The skills just say "follow the repo's coding rules" — the rules themselves come from the project. See Skills + project rules.
Skills
| Skill | Invoke |
|-------|--------|
| write-technical-approach | /fnd:write-technical-approach |
| develop-feature-or-fix | /fnd:develop-feature-or-fix |
| qa-feature-or-fix | /fnd:qa-feature-or-fix |
| qa-preflight | /fnd:qa-preflight |
| write-steps-to-test | /fnd:write-steps-to-test |
| create-pull-request | /fnd:create-pull-request |
| ship | /fnd:ship |
| preview-theme | /fnd:preview-theme |
| worktree | /fnd:worktree |
| pre-commit-review | /fnd:pre-commit-review |
| commit | /fnd:commit |
| preflight-checks | /fnd:preflight-checks |
| save-task-context | /fnd:save-task-context |
| fix-accessibility-issue | /fnd:fix-accessibility-issue |
| get-breaking-changes | /fnd:get-breaking-changes |
| fix-breaking-changes | /fnd:fix-breaking-changes |
| update-translations | /fnd:update-translations |
| report-plugin-issue | /fnd:report-plugin-issue |
| smoke-test | /fnd:smoke-test |
Skills are also auto-invoked: Claude reads each skill's description and
runs the relevant one when your request matches — you don't have to type the
slash command.
The Invoke column is the Claude Code form. Elsewhere: /<name> on Cursor, $<name> on Codex
CLI, and on OpenCode either by description (its native skill tool) or /<name> through the
generated command shims. Auto-invocation by description works on all four.
Install — four hosts
The same plugin content runs on Claude Code (canonical), Cursor, OpenAI Codex CLI
and OpenCode. Every route ends the same way: the installer (or /plugin install) leaves you
with a checkout, doctor.cjs says whether the host will actually load it, and one smoke-test
run in a live session proves the layers a script cannot reach.
The table is the one-line summary; the Details column links the full numbered walkthrough for that host — starting from "the host CLI is not even installed yet" (prerequisites, sign-in, which branch to check out), through every config paste, to the smoke test. If you are installing for the first time, go straight to your host's doc and follow it top to bottom; the sections below the table are the same story compressed for someone who has done it before.
Supported platforms. macOS and Linux; on Windows, WSL only. The .sh hooks, the guard
scripts and the theme scripts need a POSIX shell (bash >= 3.2, git, jq), so run the plugin
from inside WSL and point the host there — doctor.cjs says so in one row on a native Windows
run. The Node pieces (the ADF converters, json-slim) need nothing but Node and run anywhere.
Fast path — one command. On a clean machine, scripts/bootstrap.sh does the clone, asks which
hosts to install for, runs install.sh for each, and prints what is left to do by hand:
curl -fsSL https://raw.githubusercontent.com/domaine-oleksandr-kever/claude-plugins/main/scripts/bootstrap.sh | bash
It clones into ~/tools/claude-plugins and picks hosts interactively; --dir <path> moves the
clone, and --copy tells install.sh to copy the plugin instead of symlinking it. A run with
nowhere to prompt — CI, or output piped to a file — needs the hosts spelled out instead:
… | bash -s -- --targets cursor,opencode --yes.
Update — re-running it from the clone is the update: install.sh pulls ff-only and relinks.
A --copy install refreshes only on such a re-run, and only with --copy passed again —
bootstrap does not remember the mode, and an update without it converts the install to symlinks:
./scripts/bootstrap.sh --targets cursor,opencode --yes
Uninstall — also from the clone:
./scripts/bootstrap.sh --targets cursor,opencode --uninstall
That removes only what install.sh created and keeps the clone. The per-host walkthroughs below
remain the full story — the fast path is those same steps with the typing removed.
| Host | Install | Verify | Details |
|---|---|---|---|
| Claude Code | /plugin marketplace add … + /plugin install fnd@domaine | /fnd:smoke-test | below |
| Cursor | Customize → Plugins → Add Marketplace (dev channel: ./scripts/install.sh --target cursor; a Cursor bug currently ignores subagent model pins on every route — see the doc) | /smoke-test | docs/README.cursor.md |
| Codex CLI | codex plugin marketplace add … plus ./scripts/install.sh --target codex (subagents; Codex reads roles from ~/.codex/agents, never from the plugin cache) | $smoke-test | docs/README.codex.md |
| OpenCode | ./scripts/install.sh --target opencode | /smoke-test (command shim) | docs/README.opencode.md |
smoke-test is a run-once post-install check, not a per-session routine: it runs the
doctor from inside the session, makes one cheap read-only call per configured MCP server,
spawns one subagent, attempts a --no-verify commit in a scratch repo expecting the host to
block it, and reports a pass/fail matrix with remediation. preflight-checks keeps the
recurring per-project role.
Claude Code — from the published Git marketplace (team use)
# 1. Add the marketplace (you'll get a trust prompt — confirm it)
/plugin marketplace add domaine-oleksandr-kever/claude-plugins
# 2. Install the plugin from it
/plugin install fnd@domaine
# 3. Activate without restarting the session
/reload-plugins
# 4. Prove the install once, in a session
/fnd:smoke-test
/plugin marketplace add shows a trust dialog the first time, because a
marketplace can ship hooks, commands, and MCP servers that run on your machine.
Review the source, then confirm to add it to your trusted marketplaces. To make
it trusted for a whole team without each person confirming, an admin can
predeclare it in managed settings under extraKnownMarketplaces.
Claude Code — local development (from this folder on disk)
/plugin marketplace add /path/to/claude-plugins
/plugin install fnd@domaine
/reload-plugins
Edits to skill files in a local marketplace are picked up on the next session
(or after /reload-plugins). See Updating.
Cursor
To use the plugin, no clone is needed: in Cursor, Customize → Plugins → Add Marketplace with this repo's URL, then Add on the fnd card — Cursor installs into its own cache and follows GitHub for updates (live-verified 2026-08-22). To develop it, or run unpushed work:
git clone https://github.com/domaine-oleksandr-kever/claude-plugins.git
cd claude-plugins
./scripts/install.sh --target cursor # symlinks ~/.cursor/plugins/local/fnd
The installer pulls, links, and runs doctor.cjs --target cursor at the end — read those rows.
On either route, reload the Cursor window (Developer: Reload Window) so the manifest, hooks
and mcp.json load, and run /smoke-test once in a chat. Full walkthrough, update path and
host deltas: docs/README.cursor.md.
Codex CLI
Two channels, both required: the marketplace install carries skills, hooks and MCP, and the
installer links the TOML subagents into ~/.codex/agents/: Codex reads custom roles from there,
never from the plugin cache (measured 2026-09-08, CLI 0.153.4; the plugin manifest format has no
key for agents).
codex plugin marketplace add domaine-oleksandr-kever/claude-plugins
/plugins # → install fnd
Then, from a clone of this repo (keep the clone — the links point into it):
./scripts/install.sh --target codex # link the subagents into ~/.codex/agents
Then two steps the install cannot perform for you — [features] hooks = true in
~/.codex/config.toml, and the per-content-hash trust review in /hooks — then a new session
and $smoke-test once (Codex invokes skills with $). The full walkthrough, including what
re-triggers the trust review, is in
docs/README.codex.md.
OpenCode
From a clone of this repo:
./scripts/install.sh --target opencode # skills, agents, commands, plugin adapter
The installer links into ~/.config/opencode/ and runs doctor.cjs --target opencode. Two
fragments stay yours to paste into your own opencode.json — the mcp block from
plugins/fnd/opencode/mcp-fragment.json and the permission.bash backstop from
plugins/fnd/opencode/permission-fragment.example.json — then start a new session and run
/smoke-test once (the command shim). Details, including the optional model-profile fragments:
docs/README.opencode.md.
Verifying any install
install.sh runs the doctor for you at the end of every run; re-run it standalone whenever an
install stops behaving:
node plugins/fnd/scripts/doctor.cjs --target <cursor|codex|opencode>
It checks the install mode and symlink targets, that all three manifests carry the same
version stamp, that the generated dirs still match the generator, and that the hook scripts are
executable — the whole class of "the clone succeeded but the host will never load it" failures,
before any session. smoke-test then covers what a script cannot reach.
Proving the hooks on a host. The doctor says whether the host will load the plugin;
FND_HOST_TRACE says whether the hooks actually fired and what each decided — from a log, not
from a model reporting on itself. Arm it globally with
node plugins/fnd/scripts/domaine-env.cjs set FND_HOST_TRACE=1 (global-only switch), open a new
session on the host, run one throwaway git commit --no-verify in a scratch repo plus one MCP
call, then read it back:
node plugins/fnd/scripts/doctor.cjs --trace --since 2h
node plugins/fnd/scripts/doctor.cjs --report --since 7d # the compression statistics, same log root
One row per event/hook, one column per host, each cell a count and its decision breakdown — the
commit-guard row must carry a deny, and a hook that is silent there did not run. smoke-test
row 8 runs exactly this from inside a session. --report is the other half of the proof — what the
compression hook actually saved, per tool and per project, rendered by json-slim.cjs --report
from the FND_MCP_SLIM_DEBUG log in the same root; the two flags combine.
Cloud sandboxes — egress allowlist
A sandboxed host (Claude Code cloud environments, Cowork cloud sessions) runs the bundled scripts behind an egress allowlist, and a host that is not on it reads as a network error rather than as a missing feature. Allow:
| Host | Needed by |
|---|---|
| api.figma.com | figma-rest.sh — the node tree and the variables |
| figma-alpha-api.s3.us-west-2.amazonaws.com | the same script's renders and asset exports — Figma answers those with a pre-signed S3 url, so without this host the tree arrives and every image comes back kind=image status=failed |
| api.atlassian.com plus your <site>.atlassian.net | jira-attachments.sh — the gateway every authenticated call goes to, and the one unauthenticated tenant_info lookup on the site itself |
| prnt.sc, prntscr.com, img.lightshot.app, imgur.com, i.imgur.com, gyazo.com, i.gyazo.com, share.cleanshot.com, snipboard.io | external-screenshots.sh — the screenshot pages a ticket links and the CDNs their og:image points at; the script never fetches any other host |
| the store's <store>.myshopify.com | shopify-admin-gql.sh, theme-json.sh and the preview-theme scripts |
MCP traffic needs none of it — a connector server is reached through Anthropic's infrastructure, not through the container's egress, so the Figma, Atlassian and Shopify MCPs keep answering in a sandbox where the REST fallbacks cannot.
The global env file does not travel. ~/.config/domaine/env is read inside the container's
own $HOME, and nothing copies a workstation's file into it, so a global-only switch
(FND_HOST_TRACE, FND_FIGMA_SOURCE, the compression and guard gates) has to be set as an
environment variable of the cloud environment itself. The project file
<repo>/.claude/domaine.env rides with the checkout and keeps working unchanged.
What's different per host
The content is identical; the wiring is not. Claude Code is the baseline every column is read against — nothing about it changed in the port — and each row is spelled out in full in the per-host doc it belongs to:
| | Cursor | Codex CLI | OpenCode |
|---|---|---|---|
| Session statics | always-applied rules/*.mdc | SessionStart hook, as on Claude Code | your instructions config / AGENTS.md |
| MCP result handling | observe-only — afterMCPExecution exposes no rewrite field, so nothing is compressed, stubbed or spilled (the shim logs skip) | replacement through the PostToolUse block channel (compress and spill-and-stub), reason capped at 16 KB; over-cap ⇒ stub, non-text block ⇒ additionalContext | in-place rewrite (compress and spill-and-stub) |
| MCP servers | all 6; mcp.pruned.json if the ~40-tool cap bites | all 6, no documented cap | all 6, pasted from mcp-fragment.json |
| Guard hooks | beforeShellExecution + beforeMCPExecution, deny + reason | PreToolUse; needs [features] hooks + /hooks trust; absent on Windows | tool.execute.before throw + permission.bash backstop, no MCP-level guard |
| Subagents | 1 level → hoisted ship shape | hoisted, genuinely concurrent | hoisted unless subagent_depth raised |
| Models | pinned per the Domaine guidelines | pinned (PROPOSED map, sign-off pending) | no pins — session model; optional profile fragment |
| Skill invocation | /name | $name | model-invoked + /name shims |
| Mods (status band, progress pane) | none — the manifest names hooks/hooks-cursor.json | none — the manifest names hooks/hooks-codex.json | none — only opencode/fnd-plugin.js loads |
Losses that hold on all three: no adversarial verify fan-out, no per-phase workflow telemetry,
and the context-usage monitor is inert wherever the host hands a prompt hook no transcript
(Cursor, OpenCode). plugins/fnd/references/host-orchestration.md is the single home of the
hoisted fallback; plugins/fnd/references/pipeline-phases.md → Orchestration is the branch point.
Host verification status — Cursor, Codex CLI and OpenCode are unverified
Frozen 2026-09-07, by decision. Claude Code is the host the team works in daily and the only one
every release is live-verified on. The other three columns ship the same content and the same
wiring they had when they were last measured, and no release since has been smoke-tested on
them in an interactive session. What is proven, from disk rather than from a model reporting
on itself: the hook layer fires on all four hosts (FND_HOST_TRACE matrix, 2026-09-06), the
Cursor deny path and both key spellings were accepted by a headless cursor-agent run
(2026-09-06), and the Codex and OpenCode install routes were exercised live once in August
(the Codex marketplace add and the OpenCode installer, plus both host-trace columns). No full
smoke-test session has ever been recorded on any of the three.
Known gaps that stay open with the freeze:
- Cursor —
afterMCPExecutionis observe-only: the host documents no output-rewrite field for that event, so nothing is compressed, stubbed or spilled there (the shim logsskip);subagentStartconventions injection is unconfirmed (no headless run spawned a subagent); and subagent model pins are dropped by a Cursor bug with no fix date (seedocs/README.cursor.md). - Codex CLI — hooks need
[features] hooksplus a/hookstrust that is lost on some updates; the model map is PROPOSED, sign-off pending. - OpenCode — no MCP-level guard and no verified MCP payload shape; screenshots are unguarded.
Treat those columns as best-effort: install, run doctor.cjs, then the smoke-test skill, and
file what breaks with /fnd:report-plugin-issue. Re-verifying a host is a deliberate, separate
piece of work — one live session per host plus whatever it turns up — not part of the release
cadence, and the per-host docs carry the same notice at the top.
Marketplace posture
- Claude Code — the git marketplace in
.claude-plugin/marketplace.json, unchanged; this is how the team installs today. - Codex CLI — this repo is a Codex marketplace:
codex plugin marketplace add <repo>reads the same.claude-plugin/marketplace.jsonas a legacy-compatible location, so nothing separate is published. - Cursor — this repo imports as a Cursor marketplace (Customize → Add Marketplace;
live-verified 2026-08-22, installs into
~/.cursor/plugins/cache/…); a root.cursor-plugin/marketplace.jsonis committed and mirrors the Claude one's identity. The local-symlink route (~/.cursor/plugins/local/fnd) stays as the development channel. - OpenCode — no marketplace primitive exists;
install.shis the whole distribution channel.
Plugin layer vs project layer
The plugin installs once, in user space, and works in any project; a project's own skills/rules/agents sit on top and the two layers are additive on every host — a project layer never disables the plugin, and the plugin never blocks project content. Three rules keep that honest:
- Plugin content self-gates — bundled rules stay glob-scoped or detection-gated, so the plugin is inert in a non-Shopify project instead of shouting theme guidance at it.
- Don't reuse fnd names project-side. Skills are namespaced on Claude Code (
fnd:*); the other hosts have no namespace, so a same-named project skill or agent is at best ambiguous. - Hook wiring lives in exactly one layer — the plugin. All layers fire on every host, so a project that re-declares an fnd hook gets a double run, not an override.
Conflicting prose is a docs problem, not a mechanism: no host offers hard precedence between layers, so keep plugin rules generic-Foundation and let projects own their deltas.
Managing it on Claude Code
/plugin disable fnd@domaine # keep installed, turn off
/plugin enable fnd@domaine
/plugin uninstall fnd@domaine # remove the plugin
/plugin marketplace remove domaine # remove the marketplace
Recommended Claude Code settings (copy-paste)
Two files, two scopes — both plain JSON, and every value inside "env" is a string:
~/.claude/settings.json— yours, all projects. The CLI and the desktop app's Code tab read the same files, so one edit covers both.<repo>/.claude/settings.local.json— this project only, and gitignored by convention: the right home for anything that names a path inside the repo.
Paste this into the user file (merge the "env" block if you already have one) — or just ask
Claude Code to add these keys to ~/.claude/settings.json; it can edit its own settings:
{
"env": {
"MAX_MCP_OUTPUT_TOKENS": "50000",
"FND_MCP_SLIM_DEBUG": "1"
}
}
MAX_MCP_OUTPUT_TOKENS— optional, and your call. The platform default is 25000 tokens; above it Claude Code spills the result to a file instead of handing it over, so the fnd compressor never sees it. Raising it sends bigger results throughmcp-slimfirst — safe with the spill-and-stub guard in place, but still a decision best made after a week ofjson-slim --reportdata.FND_MCP_SLIM_DEBUG— optional diagnostics (one JSONL line per compression). Pair it withFND_MCP_SLIM_DIR, which names a directory — that pair belongs in the project file,<repo>/.claude/settings.local.json, rather than here:
{
"env": {
"FND_MCP_SLIM_DEBUG": "1",
"FND_MCP_SLIM_DIR": "/absolute/path/to/repo/.claude/fnd-tmp"
}
}
Every FND_* switch also works from the Domaine env files — see
Environment switches, which is the single home for what each one means.
Updating
No host silently updates on push, and there is no proactive "new version available" notification anywhere. Two models cover all four hosts:
Version-cache hosts — Claude Code and Codex. The host clones from the git remote into a version-keyed cache, so updating is an explicit command plus a new session:
/plugin marketplace update domaine # Claude Code, then /reload-plugins
codex plugin marketplace upgrade # Codex, then a new session
- On Claude Code with auto-update on, the pull happens at startup and you are prompted to
run
/reload-plugins; toggle it per-marketplace in/plugin→ Marketplaces. Third-party marketplaces default to off, hence the command above. - Unpushed commits are invisible on both: they pull from the remote, never from your local folder. Push first, then update.
- The
versionfield gates the update — an unbumped version can read as "nothing to update".bump-version.cjs(below) stamps every copy of it in one run. - Codex re-asks for hook trust after any update that changes a hook command or its content,
because the trust is per content hash. Until you re-approve in
/hooks, the guard layer is dormant.
Live-checkout hosts — Cursor and OpenCode (and the Codex subagent half). The install points at your clone, so re-running the installer is the update — it pulls and re-links in one pass:
./scripts/install.sh --target <cursor|opencode|codex>
- Skills, references, agents and scripts apply on the next read; manifest, hook-wiring, MCP and rules changes need a window reload (Cursor) or a new session (OpenCode).
- There is no "reinstall", and entries a rename or deletion removed upstream are pruned by the same run that pulled them.
--copyinstalls do not followgit pull— re-run the installer to refresh one. The install report says which mode is active, anddoctor.cjsfails a copy install whose recorded version has drifted from the checkout's.
The two models coexist on one machine without conflict — they read from different sources.
Nothing tells you an update exists, so preflight-checks carries the nudge: it compares the
installed version against what the host could install and — on Cursor and Codex — whether the
model ids pinned in that host's generated agents still resolve. Both rows are advisory; they
never gate a workflow.
Releasing — one command stamps every version
Current release: fnd v0.124.1.
The version is duplicated across per-host packaging files, and a stamp that drifts reads to a host as "nothing to update". One script owns all of them — run it instead of hand-editing any manifest:
node plugins/fnd/scripts/bump-version.cjs minor # or major | patch | 0.60.0
node plugins/fnd/scripts/bump-version.cjs 0.60.0 --dry-run # report, write nothing
It stamps, all-or-nothing (a failure on any target writes nothing):
| Target | What is stamped |
|---|---|
| plugins/fnd/.claude-plugin/plugin.json | version — canonical, the base for major/minor/patch |
| plugins/fnd/.cursor-plugin/plugin.json | version |
| plugins/fnd/.codex-plugin/plugin.json | version |
| README.md | the markers fnd v<semver> and a double-quoted FND_VERSION= assignment |
| scripts/install.sh | every double-quoted FND_VERSION= assignment |
Only those two literal marker forms are recognized — a version written any other way
(fnd 0.60.0, version: 0.60.0) is silently skipped forever, so use them verbatim
when adding a version string to the docs. docs/README.*.md are deliberately version-free
for the same reason: they are not stamped targets, so a version literal there would drift on the
next release (tests/readme-checks.sh fails one). The flip side: every quoted FND_VERSION=
assignment in a stamped file becomes a constant, so a shell variable of that name must
be assigned unquoted.
Then verify the packaging before committing:
node plugins/fnd/scripts/doctor.cjs # manifests present, versions equal, hooks runnable
bash tests/layout-assertions.sh
bash tests/readme-checks.sh # install commands, referenced paths, links, stamps
bash tests/mods-sim.sh # hooks module: validate --strict + kit tests (local only; SKIP without claude)
How global skills use project rules
A common question: if the plugin is installed globally, do its skills still pick up the project's rules?
Yes. The skills do not hardcode paths to the rule files — they reference "the repo's coding rules" in prose. The actual rules are loaded by the project context, not by the skill:
- The plugin (global) provides the workflow — the steps of each skill.
- The target repo's
.claude/rules/*.mdprovide the conventions — and Claude Code auto-attaches each rule when you touch a file matching itspaths:glob (e.g.css-conventionswhen you edit a*.css).
So when you run a skill inside elc-theme, you get both at once: the global
workflow + the project's native rules. Run the same skill in a repo without
those rules, and the skill simply proceeds on general best practices. Bundled
references/ docs (Jira field IDs, TA format) travel with the plugin and are
read via ${CLAUDE_PLUGIN_ROOT}, so they always resolve regardless of install
scope.
Concepts: commands vs skills vs agents
Commands vs skills
Both are Markdown files with frontmatter; the difference is who triggers them and where they run:
| | Command (commands/*.md) | Skill (skills/<name>/SKILL.md) |
|---|---|---|
| Trigger | You type /name explicitly | You type /name or Claude auto-invokes it by matching description |
| Best for | A fixed action you run on demand | A capability Claude should reach for when the task fits |
| Extra files | Single .md | A folder — can bundle REFERENCE.md, scripts/, etc. |
| Runs in | The main conversation | The main conversation |
Rule of thumb: if you want Claude to decide when to use it, write a skill
(it has a discoverable description and can carry supporting files). If you only
ever fire it manually, a command is the lighter option. This plugin ships
skills because the Foundation steps are things Claude should select on its own.
Agents (subagents)
An agent is a separate Claude instance with its own context window, its own system prompt, and its own restricted toolset. The main conversation delegates a self-contained task to it; the agent works in isolation and returns only its final result. Use them to (a) keep heavy/noisy work out of the main context, and (b) run focused, read-only analysis with a tailored prompt.
A plugin ships agents as agents/<name>.md:
---
name: change-reviewer
description: Reviews the branch's changed files (Liquid / TS / CSS) against Foundation conventions. Invoke before a commit or PR to catch core-file violations, stale comments, and schema mistakes.
model: opus
effort: medium
tools: Read, Grep, Glob, Bash
---
You are a Shopify theme reviewer for the Foundation codebase.
Given a set of changed files, check them against Foundation conventions:
- Never modify `src/entry/core/*` or `blocks/core-*.liquid` directly — flag any direct edits.
- Verify snippet params have LiquidDoc + defaults.
- Verify schemas are authored in `schemas/` (TS), not hand-edited in compiled output.
Return a concise findings list grouped by file, each with severity and a fix.
Your final message IS the result handed back — return data, not chatter.
How it's used:
- Auto-delegation — when your request matches the agent's
description("review my changes"), Claude spawns it automatically. - Explicit — ask directly: "use the change-reviewer agent on my staged changes."
- Isolation — it can only
Read/Grep/Glob/Bashhere (noWrite), so it analyzes without touching files. Addisolation: worktreeif an agent must edit files in parallel without colliding with the main session.
Agents differ from skills: a skill runs inline in the main conversation and steers your Claude; an agent is a separate Claude you hand a task to, with its own context — ideal for parallel, sandboxed, or token-heavy subtasks.
Shipped agents. This plugin ships six subagents that are read-only toward their
sources (doc-reader writes only its own workspace extract), plus the write-side
jira-writer — all used to keep heavy/noisy work out of the main context:
change-reviewer— reviews a diff (stale comments, refactors, project-rules conformance). The review flow fans it out one agent per file-group on large diffs. Its Foundation core rules (protected-core:src/entry/core/*a blocker, the Liquid core a hand-sync warning) fire only when the brief'sprofile:saysfoundation; on a plain Shopify theme it reports the project's own rules alone.bug-hunter— adversarial correctness review of a diff: reads the base classes, event listeners, and sibling paths the change interacts with, and returns verified findings with concrete failure scenarios (races, merchant-invariant bypasses, state divergence). Spawned in parallel withchange-reviewer(pre-commit), as the PR backstop, and alongside live QA in the ship pipeline.jira-reader— fetches a Jira ticket via the Atlassian MCP — fields, comments and attachments — and returns them as clean structured data, keeping the raw ADF and the downloaded bytes out of context (see "Jira comments + attachments").jira-writer— the write-side mirror: writes one approved value with a single call — a rich-text custom field viaeditJiraIssue, or a comment viaaddCommentToJiraIssue(contentFormat: "adf"), the markdown converted to ADF first — so the large payload stays in its disposable context, not the main loop. Writer skills delegate here after the ✋ approval (the gate stays in the skill; the agent never authorizes).figma-reader— reads one Figma frame and returns a compact build spec, picking its source itself in a fixed order: the remote/connector Figma MCP → the local Dev Mode bridge → the REST API with the repo's ownFIGMA_TOKEN, so a closed desktop app and an unattached connector no longer stop a design read. The rung that answered comes back assource: mcp-connector | mcp-desktop | rest; on the REST rung the payloads land in the workspacetmp/figma/andscripts/figma-node-slim.cjsturns them into a compact build tree — no raw node JSON ever enters the context. Nothing reachable and no token ⇒ one hint line inneeds_clarification, never a guess (plugins/fnd/references/figma-rest.md). Spawned one per URL, in parallel when a ticket has several.doc-reader— reads one linked doc (Notion with sub-page follow-through, Confluence, or any web URL) and returns a task-focused extract, saving it to the workspacedoc-<slug>-<hash>.mditself. Spawned one per link, in parallel — the raw pages never enter the main context (references/reading-linked-docs.md).theme-explorer— a planning scout: reads the project's.claude/rules+ theme layout and returns an impact map (relevant files, patterns, new files, rule constraints). Finds breadth; the main loop reads the load-bearing files itself. Likechange-reviewerit is briefed with the checkout'sprofile:, so the Foundation core rules stay off a plain Shopify theme.
In develop-feature-or-fix these run in parallel during Phase 1 (ticket + design +
codebase reads at once) when the task scope is already clear. The block above is roughly
change-reviewer's definition.
Review flow (pre-commit / commit / PR)
pre-commit-review, commit, and create-pull-request share one review contract
(references/review-flow.md):
- Split by files, not checks. Mechanical checks (Jira task numbers, untracked
referenced files) run inline; the judgement checks (stale comments, refactors,
project-rules conformance) go to the
change-revieweragent — one for a small diff, one per file-group in parallel for a large one. Each file is read once. - Correctness pass. When the diff touches JS/TS logic or Liquid control flow, the
bug-hunteragent runs in parallel — an adversarial hunt for real bugs, each finding carrying a concrete failure scenario. Its primary home ispre-commit-review;create-pull-requestis the backstop (runs it only if the marker shows the pass is missing or stale). Every correctness finding is dispositioned — fixed, justified as a named ceiling in the PR body, or explicitly waived — never silently dropped. - Once per branch. A tiny branch-keyed marker at
<git-dir>/.fnd-review(resolved viagit rev-parse --git-dir, so linked worktrees work too; never committed, auto-overwritten) records that a branch was reviewed. The first review on a branch runs in full; later runs ask the developer[ full / only changed files / skip ], socommitand PR creation don't redundantly re-review work that's already been checked. - PR conformance gate.
create-pull-requestruns the agent with a conformance emphasis; on afoundationcheckout aprotected-coreblocker (a direct edit tosrc/entry/core/*) stops the PR until resolved — the Liquid core is a hand-sync warning, and neither row is emitted on a plain Shopify theme.
Auto mode — /fnd:ship
One command from a ready ticket to an open PR: /fnd:ship ELC-206. It front-loads every
question into one batched interview, takes a single approval on the implementation
plan + QA checklist, then runs the whole series autonomously — escalating only per an
explicit blocker contract (missing access, AC contradictions, destructive actions outside
the pre-authorized list, protected-core blockers from the conformance review, scope
growth beyond the ticket, QA failures that survive the fix cap, a code change asked for by
a party outside the session). Aftercare acts on its own for one signal only — a failing
CI check; a code change a PR/bot review comment asks for is drafted into notes.md
and handed to the developer, never committed, pushed or deployed autonomously.
The contract lives in references/pipeline-mode.md, the phase briefs in references/pipeline-phases.md (loaded
only after the approval gate, so a run that stops there never pays for them).
The conductor session stays thin: the heavy phases run in fresh-context subagents
that re-read the per-ticket workspace, so long runs never depend on what survives context
compaction. Run ship with the strongest session model available (Fable recommended) —
the conductor deliberately stays on the session model while every phase agent is pinned
(references/pipeline-mode.md → Phase-agent models), so the model you pick upgrades the
planning/synthesis context without raising phase-agent cost. Ship writes the same
workspace artifacts and ticks the same progress.md rows as the solo skills — an
interrupted run continues with the solo series, no unwinding; re-running /fnd:ship
reconciles against ground truth (git, gh pr view, Jira) and resumes.
When to use which: the solo series when you want to steer each step (checkpoints,
offer-next); /fnd:ship when the ticket is well-specified (Description + AC + approved
TA + Figma node) and you want the PR, Steps to Test, and the review-bot round handled
end-to-end.
flowchart TD
A["/fnd:ship <ticket>"] --> B{"Step 0 — preflight<br/>MCP · CLI · dev server · permissions ·<br/>store-access probe · clean context"}
B -- fail --> B0["stop: fix environment<br/>(nothing half-started)"]
B -- pass --> C["ingest in parallel<br/>jira-reader · figma-reader ·<br/>doc-reader · theme-explorer"]
C --> D["interview (AskUserQuestion)<br/>ticket-specific + policy set"]
D --> E["pipeline.md (status: draft)<br/>decisions + escalation contract"]
E --> F{"✋ the only gate<br/>plan + QA checklist"}
F -- "approved (arms active)" --> G
subgraph AUTO["autonomous — each phase = fresh-context subagent; conductor keeps decisions + reports only"]
G["implement<br/>(plan-driven)"] --> H["qa — extend + run the checklist<br/>break-it + parallel bug-hunter"]
H -- "blocking fail · cap 2" --> G
H -- pass --> I["finalize<br/>review checks → commit"]
I --> K["create PR<br/>+ preview theme"]
K --> L["write steps to test<br/>(fills the bot wait)"]
L --> M{"PR aftercare<br/>CI checks · bot feedback"}
M -- "findings · cap 2" --> N["CI check fails → fix → refresh preview →<br/>verify → commit + push;<br/>comment-driven change → draft in notes.md →<br/>reply + resolve, hand to developer"]
N --> M
end
M -- "clean / timebox" --> O["Jira hand-off comment<br/>clickable PR link · edge cases ·<br/>why-nots · open questions"]
O --> P["final report<br/>pipeline.md → done"]
AUTO -. "blocker classes only" .-> Q(["escalate via AskUserQuestion<br/>answer → continue"])
Parallel ship via git worktree
A ship run owns the repo it starts in — a dirty tree, the dev server on port 9292, the
theme = line in shopify.theme.toml that the session-theme pin rewrites — and it owns
the session for the whole run. plugins/fnd/scripts/worktree-setup.sh moves the run into
a linked git worktree (shared .git, no clone, instant setup) so the main checkout and
the main session stay free for everything else:
worktree-setup.sh <WORK-ID> [<base-branch>] # default base: develop
worktree-setup.sh --remove <WORK-ID> [--force]
<WORK-ID> is a Jira ticket key (ELC-206) or a kebab-case slug
(header-refactor) — the same work-id the task workspace uses, because not every
worktree is ticket-shaped.
Create mode does the whole setup in one pass: the worktree as a sibling directory
(../<repo>-<WORK-ID>) on branch feat/<WORK-ID> off origin/<base> (a local <base>
when no remote-tracking ref exists) — an existing local
or remote branch of that name is checked out, never duplicated; npm ci when the repo has
a package.json; copies of the gitignored config a fresh checkout cannot have —
shopify.theme.toml, so the session preview theme id the pin step later writes into it
lands in the worktree and leaves the main checkout's dev environment alone (and a pin the
source checkout already carried is undone in the copy — toml_unpinned=yes — so this stream
starts from the shared dev theme rather than inheriting another stream's preview), and .env,
without which every Admin API read from the worktree would fail on a missing token; the
worktree's .claude/tasks symlinked to the main repo's, so the task workspace is shared
between the two checkouts and survives the worktree's removal (and git-excluded, so the
link never reaches a commit);
.claude/settings.local.json copied rather than shared (two sessions approving permissions
into one file would race); and a free dev port from 9293 upward, recorded as a dev-port:
line in the shared workspace so the ship session finds it — a port counts as taken when
something is listening on it or another work-id's workspace already recorded it, because
the dev servers only start later, by hand, in the new sessions; the line is written right
after the worktree is registered, before npm ci, so a parallel setup started during the
install already sees the claim. Re-running for the same
work-id is idempotent — an existing worktree just re-prints the hand-off block.
Its own port is half the isolation; the other half is its own session preview theme,
offered once at setup and used by the dev server, QA and the PR table alike — without it
both sessions would sync their branches into the single theme id the copied
shopify.theme.toml points at, and each would clobber the other's preview. See
Session preview theme. A theme per stream does not raise the
≈2-runs-per-store ceiling below: Shopify's rate limit is per store + token, not per theme.
--remove refuses a dirty worktree or a detached HEAD unless you pass --force
(the detached case prints the HEAD sha for recovery), and deliberately touches
neither the shared task workspace nor the preview theme (create-preview-theme.sh reaps
its own orphans).
/fnd:worktree is the thin skill in front of all this — it resolves the work-id from your
message or the conversation, runs the script, and relays its output verbatim.
The launch flow is three steps, and only the first happens in the session you're already in:
# 1. in the main checkout — the hand-off block it prints includes
# the session theme id and the dev-server line, ready to paste
/fnd:worktree ELC-206
# 2. a NEW terminal
cd ../my-theme-ELC-206 && claude
# 3. inside that session
/fnd:ship ELC-206
npm run dev -- --theme <session-theme-id> --port 9293 # from step 1's hand-off
Step 2 is illustrative — paste the cd line the script prints (an absolute, quoted path)
rather than retyping it. The dev-server line is the one step 1's hand-off gave you, with the
id and port filled in — the hand-off prints the npm run dev form above in a foundation
checkout and shopify theme dev in any other (same scripts/project-profile.sh probe as the
session's fnd project profile: line; an unreadable probe falls back to the npm run dev
form). Without --theme the server syncs the branch into the shared dev theme the copied
config still names.
A session cannot relocate itself into another directory, so the second terminal is yours
to open — nothing is auto-spawned. /fnd:ship knows about the split: started in a
worktree it says nothing, started in the main checkout it offers the isolation once
(and can run the setup script for you, then stops so you can move over) and takes "no" for
an answer.
How many at once:
- ≈2 ships per store. Shopify's theme rate limit is per store + token, and a running
shopify theme devdraws on the same budget as every preview-theme push — that contention is what produces half-broken previews. Two concurrent runs on one store is the practical cap, and it helps to stagger the preview-theme phase rather than starting both runs the same minute. Nothing new guards this: the preview script's throttle retries and its created-theme orphan reap remain the mitigation, and they already run. - Different stores don't contend — there, parallelism is bounded only by your machine.
- Usage limits are shared. Every parallel session bills the same Claude subscription, so N ships burn the plan's limit ≈N× as fast. Worktrees buy isolation, not extra quota.
Session preview theme
One work stream, one theme. shopify theme dev -e dev targets the theme id in
shopify.theme.toml, which is shared config — so two sessions on one repo push two
branches into the same remote theme and each overwrites the other's preview. /fnd:ship
and /fnd:worktree therefore ask once, up front, which theme the stream owns: create one
now (create --name "[ELC-206] Kever | Domaine" --reuse, the same naming convention the PR
table uses) or hand over an existing theme id. The answer is pinned into that session's
shopify.theme.toml and recorded as a session-theme: <id> line in the shared task
workspace's notes.md, next to the dev-port: line — a later /fnd:ship or
/fnd:worktree finds that line, stops asking, and silently re-runs pin --theme <id> so the
checkout it is standing in is pinned too (the workspace is shared between checkouts, so a
recorded id is not by itself a pinned one; re-pinning is a byte-level no-op).
From then on everything targets that one theme: the start command the skills hand you
becomes npm run dev -- --theme <id> [--port <N>] in a foundation checkout —
shopify theme dev --theme <id> [--port <N>], or the repo's own dev script when its
package.json defines one, in any other (explicit flag and pinned toml — belt
and braces), the qa phase refreshes the session theme for its preview-theme rows
instead of building a second one, and create-pull-request puts it in the theme-preview
table rather than auto-creating (a recorded session theme now outranks auto-creation in the
"Args win" precedence).
Pinning is a create-preview-theme.sh job; both forms validate the id against the store
and refuse the live theme. When the store listing is unavailable, the standalone pin
refuses outright (error=theme_unverifiable, config untouched — a pin persists, so it is
never applied unvetted; retry), while a fresh create --pin-toml proceeds — refresh --pin-toml
only for a recorded session theme or with --allow-unverified, --reuse --pin-toml only with
--allow-unverified — flagged with warn=pin_unvetted:
create-preview-theme.sh pin --theme <ID> [--env <name>] # pin only, no push
create-preview-theme.sh create --name "<NAME>" --reuse --pin-toml
create-preview-theme.sh refresh --theme <ID> --pin-toml
Under that outage (a silent listing, or one naming no theme at all) refresh proceeds only for
an id some workspace under .claude/tasks records as session-theme:; any other id, and every
create --reuse (a name lookup has no such exemption), is refused
(error=refresh_unverifiable / error=reuse_unverifiable) unless a developer passes
--allow-unverified, which overrides those two refusals only — never the live-theme guard and
never the dev-theme guard — and is never passed by the pipeline. The dev-theme guard is the
script's other refusal: a refresh / --reuse whose target is an id the toml names as the
shared dev theme — its settings source (unless pinned by hand) or an id a pin superseded — is refused
(error=dev_theme_write_refused) unless a workspace records that id as a session theme;
--allow-dev-theme is its sole, developer-only override. Both are command flags, not
environment switches.
The rewrite is scoped to one environment block, because that is how the Shopify CLI
reads this file: shopify theme dev -e dev resolves theme = inside [environments.dev],
not the first one in the file. The block written is the block the run READ its store, dev
theme id and token from — one resolution, so a preview can never be pushed to one
environment's store and pinned into another's. That resolution is --env <name>, else
$SHOPIFY_FLAG_ENVIRONMENT (the CLI's own selector — an exported value redirects the write
as much as the read, [environments.production] included), else the block named dev, else
development — by name only, so with none of those present even a lone block under any
other name is refused (error=ambiguous_env) rather than guessed at, since writing a preview
id over [environments.production] unasked would both lose that theme's id and leave the dev
server unpinned. A toml with no [environments.*] blocks at all but uncommented top-level
theme = / store = keys is pinned at the top level (reported as pin_env=-). Blocks it
doesn't target are never touched.
Inside the chosen block the first uncommented theme = line takes the session id and the
value it replaced is kept right above it on a commented # … # fnd:superseded line (and
reported as superseded_theme_id=) — this file is gitignored, so an overwritten dev theme
id would otherwise be recorded nowhere. Duplicate theme = lines in the same block are
commented out with their values intact (their count reported as commented_dupes=), a block
with none gets one appended tagged # fnd:session-theme — session-owned, so a later pin
just replaces its value and unpinning deletes it — and re-pinning
the same id leaves the file byte-identical. Nothing else moves — the password = Theme
Access token included, which the script never prints or hands back. On create / refresh
the pin runs last, after the push succeeded, the id is re-vetted before it is written, and a
pin that fails is reported (pin=failed) rather than allowed to swallow the id of a theme
that now exists on the store.
Two consequences worth knowing. create takes the customizer settings from whatever
theme the toml points at (code always comes from your branch), so once the session theme is
pinned a later create reads its settings from the session theme itself rather than from
the shared dev theme — which is why the flow creates once and refreshes from then on,
and a refresh pushes code only, leaving the settings untouched. And a new worktree
deliberately starts unpinned: worktree-setup.sh copies the source checkout's config and
reverts every pin it finds there, both shapes — fnd:superseded markers restored,
fnd:session-theme lines deleted (toml_unpinned=yes; =no means the source was never
pinned — unless warn=toml_unpin_failed says the revert did not land) — so a second work stream inherits the shared dev theme instead of the first
stream's in-progress preview. (A hand-written theme id that pin reported pin=unchanged
on carries no tag and is not reverted.) Restoring a pin by hand is that same edit: uncomment
the # … # fnd:superseded line and drop the pinned line below it — an appended line tagged
# fnd:session-theme is simply deleted. Pin and un-pin are one file —
plugins/fnd/scripts/session-theme.sh — so the writer and the reverter of that grammar cannot
disagree.
Bundled MCP servers
The plugin declares the MCP servers the skills/agents use (plugin.json →
mcpServers): atlassian (Jira), figma-dev-mode, shopify-dev-mcp,
chrome-devtools-mcp, playwright, notion-mcp.
- Install the plugin at user (global) scope and these servers are available in
every project — you don't need a
.mcp.jsonin each repo. - Authentication is per-user and cached (keychain): authenticate Atlassian / Notion
once via
/mcp; it persists across projects and sessions — no re-auth when you switch repos. playwrightis launched with--output-dir .claude/fnd-tmp/playwright(relative to the project, which is where the server is spawned). Everything it writes without being told a path — abrowser_take_screenshotwith nofilename, the.ymlsnapshot dumps, console logs, session and trace files — lands there instead of<cwd>/.playwright-mcp/, which is untracked litter in the checkout. Themcp-slimTTL sweep prunes that directory on the same clock as the spills (FND_MCP_SLIM_TTL) and, once it exists, stamps/.claude/fnd-tmp/into the repo's.git/info/exclude— local-only, so your tracked.gitignoreis untouched — unless git already ignores it. Two working directories have to name the same place for that to hold: the server resolves the relative flag against its spawn cwd, the sweep uses the hook event's cwd, and every host spawns the server in the project dir (live evidence: one output dir per checkout, worktrees included). Entering a worktree mid-session is the one divergence — the already-running server keeps writing into the checkout it was spawned in, which that checkout's next session sweeps, and the exclude stamp lands in the common git dir, so both worktrees hide it.figma-dev-modeis the local Dev Mode SSE server — it works whenever the Figma desktop app is open in Dev Mode (no auth). It is one rung of three, not the only path:figma-readerprefers a remote/connector Figma server (mcp__figma__…, URL-driven, no desktop app) when one is attached at user/project scope, and falls back to the Figma REST API with a per-developerFIGMA_TOKENfrom the repo's gitignored.envwhen neither MCP answers — so any one of the three alone is enough.FND_FIGMA_SOURCEforces a rung; the token setup, the degradation and the compactor are inplugins/fnd/references/figma-rest.md.- If you already have any of these configured at user/project scope, that scope wins; the plugin's declaration is harmlessly ignored.
OAuth servers need a browser sign-in, so they're unavailable in headless/non-interactive runs.
The same list on the other hosts
plugin.json → mcpServers is the only hand-maintained server list. Each other host
reads a generated translation of it, written by scripts/gen-host-adapters.cjs and committed
alongside the rest of the generated adapters — never hand-edit one, add the server to
plugin.json and re-run the generator:
| File | Host | Shape |
|---|---|---|
| plugins/fnd/mcp.json | Cursor | auto-discovered at the plugin root; type: "stdio" spelled out |
| plugins/fnd/mcp.pruned.json | Cursor | the priority profile below — a reference file, not loaded |
| plugins/fnd/mcp-codex.json | Codex CLI | mcpServers, carried as-is except the SSE→streamable-HTTP rewrite for figma-dev-mode; the manifest points at it. Deliberately not .mcp.json: Claude Code reads that name as a plugin MCP component, so a Codex-only edit would change what Claude Code loads |
| plugins/fnd/opencode/mcp-fragment.json | OpenCode | argv-array command, environment, type: local\|remote — paste the mcp block into your own opencode.json |
Cursor tool cap. Cursor is community-reported to stop exposing tools past ~40 across all
connected servers; the six bundled servers are roughly three times that. mcp.json still
ships all six — the cap is unmeasured on this host, and a cap we haven't seen is no
reason to hand you a smaller plugin. If you do hit it, mcp.pruned.json is the priority
list to fall back to: atlassian (every workflow starts at a ticket) → shopify-dev-mcp
(five tools for the whole Shopify docs + validator surface) → chrome-devtools-mcp (one
browser stack, picked over playwright for console/network/performance access) → figma-dev-mode;
playwright and notion-mcp are the two it drops. Applying it is a per-server toggle in
Cursor Settings → MCP — nothing in the checkout changes, and re-enabling a dropped server
is the same toggle back. (Overwriting mcp.json with the pruned copy works too, but it is
deliberate local drift: doctor.cjs and gen-host-adapters.cjs --check both report it, and
re-running the generator undoes it.) To change the cut for everyone, move CURSOR_KEEP_RANKS
in the generator and re-run.
Codex and OpenCode document no tool-count limit, so both get the full list. The Figma SSE
transport is resolved on Codex and still open on OpenCode. Codex ships no SSE client (measured
2026-08-23 — it drives every remote url through its streamable-HTTP client, so an /sse
endpoint answers 404 at initialize), so mcp-codex.json points figma-dev-mode at the
streamable /mcp path the same server serves; the per-user fallback is
codex mcp add figma-dev-mode --url http://127.0.0.1:3845/mcp. On OpenCode it is still carried
as a plain remote url and says so in its own file.
Live store access
Skills that need real store data call two bundled runners (plugins/fnd/scripts/):
shopify-admin-gql.sh (Admin GraphQL — shopify store execute on CLI ≥ 4.x with stored
shopify store auth, falling back to SHOPIFY_ADMIN_TOKEN from the project's gitignored
.env) and theme-json.sh (reads/writes a theme's JSON content layer — the customizer state —
hard-refuses writes to the live theme, reads every write back before reporting it landed
(FND_THEME_JSON_VERIFY), and falls back to the project's Theme Access token when
Admin API credentials are absent). A session-start hook tells Claude these exist, so it
inspects real store state whenever that answers a question — research and debugging included,
not just AC verification. Details: plugins/fnd/references/metafield-metaobject-setup.md and
plugins/fnd/references/theme-customizer-state.md.
QA preflight — the store registry
/fnd:qa-preflight is the QA engineer's entry point, ahead of hands-on testing:
given one or more ticket keys it reads the tickets, finds the PR, and asks the QA engineer which
theme they test on per store — the live theme, the theme the ticket names (Steps to Test leads
with it) when that link is not evidently the developer's PR preview, their own saved theme, or a
preview link they paste. The PR's
theme is never offered or opened, since it may be gone by the time QA looks. It unlocks the storefront in the browser, proves Shopify.theme is that one theme —
the only theme the run examines, and a theme that turns out not to carry the change sends the run
back to the engineer's choice instead of hunting for a theme that does — pre-runs the Steps to
Test (the numbered steps that carry an expectation and each edge case — item 2's setup and data become
Needs-data recipes for the engineer, never rows the run performs — in either field shape, the numbered
list new tickets carry or the headed scenarios older ones still hold) plus each AC at desktop (1440x900) and mobile (375x812) with a screenshot
each, and writes the brief
in Domaine's Jira house style — a copy-paste block for the ticket plus agent-only preflight
notes whose for-human-eyes rows carry the absolute page URLs the run opened, one per page. A
product or collection Steps to Test names as an example (e.g. /products/…, with its properties)
that the QA store lacks is not a Needs-data row: the run finds a stand-in with the same stated
properties — the storefront's product JSON, or an Admin API read when the repo has credentials —
tests on it, and names the swap in Observations. It is
read-only toward Jira, Admin and the storefront; posting the block as a comment needs an explicit
yes. Store facts come from a per-developer registry at ~/.config/domaine/qa-stores.json (dir
0700, file 0600), managed by plugins/fnd/scripts/qa-stores.cjs:
list [--json], get <store>, find <text>, set <domain> [--alias …] [--password …] [--theme <id>[:<label>]] [--default-theme <id>] [--note …], unset <store> and path.
A <store> selector is the exact domain (in any URL shape — scheme, userinfo, port and path
are dropped) or the exact alias, case-insensitively; set takes a host only, refuses an alias
another store already carries, and serialises its read-modify-write under a lock file, so two
parallel runs cannot lose each other's store. --password '' records an open storefront by
dropping the field, and exit codes are 0 ok · 1 no such store or an ambiguous alias · 2
usage · 3 unreadable, unlockable or corrupt registry (never overwritten).
A store the registry has never seen costs one round of questions — domain, password, theme id, plus
an optional alias and notes — and the skill runs the set line itself, recording that id as the
engineer's saved theme; that one line is the only command the run composes a password may appear on.
Passwords never leave that file: get is the only command that prints one, and the skill feeds
it straight into the storefront's password form — never into the brief, the chat, Jira, a
workspace file or a screenshot frame. Detail: plugins/fnd/skills/qa-preflight/REFERENCE.md.
Jira comments + attachments
jira-reader reads a ticket's comments on every run — that is where the QA verdicts,
clarifications and reopen reasons live — and saves them in full to the workspace's
comments.md, returning one line each. That read asks the MCP for
responseContentFormat: "adf" (without it the bodies arrive as markdown strings whose images
are empty-alt blob: links) and decodes the response with adf-to-md.cjs <file> --comments,
which unwraps the MCP's {"issues":{"nodes":[…]}} envelope and renders each inline image as a
markdown image reference whose target is jira-media:<id> and whose label is the attachment's
filename — that filename being the join to the attachment rows. Its attachments need a second route: the Atlassian
MCP returns their metadata but exposes no tool that returns the bytes, so the reader runs the
bundled plugins/fnd/scripts/jira-attachments.sh, which downloads every image into
.claude/tasks/<KEY>/tmp/attachments/. A video does not stay: it is downloaded, cut with
ffmpeg into 8–24 timecoded PNG frames beside it (<file>.frames/05-00m20s.png, sampled at bin
centres so the end of the recording is covered too), and then deleted — the frames are what a model
can look at, the tens of megabytes are not, and its row comes back with an empty path and a
frames dir. No ffmpeg on PATH and screen recordings are skipped outright, images unaffected;
--keep-video / --no-frames keep the file when it is the file you want. The bytes never reach a
commit: under
.claude/tasks/ the script stamps the repo's info/exclude line itself, and any other --out
git would track is refused before a single request goes out. The reader never Reads those
files itself; it returns their paths and the caller opens the ones the task is about.
The download authenticates as the developer with a scoped read-only Atlassian API token
(JIRA_EMAIL + JIRA_API_TOKEN in the project's gitignored .env) — strictly less than the
MCP already has, and created once per person. Without it nothing fails: the ticket read returns
the attachment rows with no local paths plus one setup hint for the developer, and a missing
ffmpeg costs the screen recordings, never the images. Setup wizard, flags, exit codes and the
degradation
contract: plugins/fnd/references/jira-attachments.md.
A screenshot the ticket links instead of attaching — a https://prnt.sc/<id> (Lightshot),
imgur, Gyazo, CleanShot or snipboard page pasted into a comment, the attachment field empty —
is fetched too, by the bundled plugins/fnd/scripts/external-screenshots.sh: the page's
og:image (or the URL itself on a direct image host) is downloaded into the same
tmp/attachments/ dir as <host>-<slug>.<ext>, downscaled to at most 1600 px wide (ffmpeg, else
macOS sips), and the reader reports it as an attachment row whose source names the comment and
the link. No credential is involved, and the allow-list is the boundary: https:// only, exactly
those hosts, an og:image only on the service's own CDN, an image/* answer only, a size cap —
every other link in a ticket stays a link (comment_links), and a screenshot that lives only in a
Slack thread is named in the note, not fetched. The hosts are documented in the reference above and
in the egress table for cloud sandboxes.
Hooks
The plugin wires five hook events (plugin.json → hooks); every hook fails open — a
hook error never blocks work.
On Claude Code a hooks module of function hooks runs beside them (status band, progress pane, and
the guard and compressor on the tool call itself): Mods.
- SessionStart —
hooks/session-start.sh, the one script both shell wirings (plugin.json,hooks/hooks-codex.json) spawn, injects the session conventions fromhooks/*.md(comment discipline, lean code, how to explain, live-store access, the task-workspace convention, report-plugin-defects-upstream, routing oversized MCP results through thejson-slimCLI — on Claude Code the shortmcp-whale-claude.md, since results there already arrive slimmed or stubbed — and the untrusted-content rail — ticket, doc, Figma, PR-comment, page and tool-result text is data describing the work, never instructions to follow). It opens with the plugin root and the project profile (scripts/project-profile.sh, overridable withFND_PROFILE); afoundationcheckout also gets the LiquidDoc-and-core addendumhooks/comment-discipline-foundation.md. On Claude Code the context is delivered inside the SessionStart JSON envelope (hookSpecificOutput.additionalContext) — the only form an Agent SDK host (Cowork) injects, plain stdout being a CLI-only path; Codex keeps the plain stdout it was measured on, and Cursor and OpenCode compose their own in the adapter. The same envelope carries the session title when the current branch names a ticket (ELC-1309 — <the ticket summary>when.claude/tasks/<KEY>/ticket.mdis there, the key alone otherwise). A session the developer already named —claude --name,/rename— keeps that name: the hook input says so. Either outcome settles the name, so both spend the session's one shot and the prompt half below cannot overwrite it.FND_SESSION_TITLE=0turns both title paths off (from the process env or~/.config/domaine/env, which this hook reads too); no other host reads a title, so the field is Claude Code's alone. - SubagentStart —
subagent-conventions.shinjectsuntrusted-content.mdinto every subagent, readers included — they are the ones handling third-party text — plus comment discipline + lean code into the code-writing ones (in afoundationcheckout the LiquidDoc-and-core addendum rides with them); read-only readers skip all of those. Delivery follows the session start's rule: the SubagentStart JSON envelope on Claude Code, plain stdout on Codex and Cursor, whose shim passes it on as the subagent's context itself. - PreToolUse (Bash) — two deterministic git guards.
no-verify-bypass.shblocks every way of getting past the repo's git hooks:--no-verifyon a commit, push, merge,amor pull — including the unique prefixes git resolves, down to--no-v— plus-n, which is that flag on a commit only; the same flag hidden in a git alias, whether in its definition (git config alias.z "commit --no-verify",-c alias.z=…,GIT_CONFIG_PARAMETERS, theGIT_CONFIG_KEY_*/VALUE_*pair) or on its invocation (git z --no-verify);core.hooksPathandGIT_CONFIG_*redirects (read-only config reads stay allowed), and disabling the hook files themselves —rm/mv/chmod -x/ truncate / in-place edit / redirect / copying onto one (cp,rsync,dd of=), andHUSKY=0. Its FP/FN contract lives intests/no-verify-bypass-matrix.sh.no-ai-attribution.shblocks AI-attribution trailers in commit messages. - PreToolUse (the browser tools that take a file path) — scratch-path guard.
scratch-path-guard.cjsjudges the path of atake_screenshot/browser_take_screenshot, a chrome-devtoolstake_snapshot(filePath) orget_network_request(requestFilePath/responseFilePath), and the script a playwrightbrowser_run_code_unsafeloads (filename). It denies one that resolves outside the project — the host's scratchpad, another checkout: both servers accept only files inside this project (chrome-devtools also its OS temp dir, which passes), so the call would hard-fail anyway — and one that resolves inside the project working tree but outside a leading.claude/— the untracked QA litterreferences/task-workspace.mdalready forbids in prose — with a reason that hands the model the absolute path to use instead (<project>/.claude/tasks/<work-id>/tmp/<name>, or<project>/.claude/tmp/<name>with no ticket; both stay inside the project, which the playwright server requires of any file it writes, and the guard creates them as it denies, since nothing else does and the retry would otherwise fail with ENOENT). Absolute matters: a relative filename is resolved by the playwright server against its own output dir, not the project, so a relative remediation would land nested inside the very directory the deny was about. Which server it is decides the verdict. The bundledplaywright— the manifest pins its--output-dirto.claude/fnd-tmp/playwright, a swept directory git never sees — passes with a barefilenameand with nofilenameat all. Any other spelling of the tool (a per-userclaude mcp add playwright, and Codex's / Cursor's unprefixed names) may be a server running with the default<cwd>/.playwright-mcp, so it is judged as one: a relative filename is denied, and so is a call with nofilename, since that one does not skip the write. chrome-devtools without a path returns the image / snapshot / body inline and passes, and so doesbrowser_run_code_unsafewith inlinecodeor an in-tree script (it reads the file, writes none). Anything under a leading.claude/passes as well. An in-projecttmp/does not pass: a theme checkout neither ships nor gitignores one, so it is just the next place the litter lands. The project root is the session's project dir (CLAUDE_PROJECT_DIR, else the checkout the cwd's.claude/sits in); in a git worktree a path resolving into the symlinked.claude/tasksor into the main checkout is denied, and the remediation is<worktree>/.claude/tmp/<work-id>/<name>.FND_SCRATCH_GUARD=0disables it. Wired on Claude Code, Codex and Cursor (beforeMCPExecution); OpenCode's tool hook has no verified MCP payload shape, so screenshots are unguarded there. - PreToolUse (Bash / Read / Grep) — spill-access recorder.
spill-access.shnotes, in the compressor's own debug log, that a tool touched one of the two MCP spill families (ourfnd-mcp-slim-<hash>.json, or any file under the platform's owntool-results/directory — those names are opaque, andmcp-slim.cjs'sOVERFLOW_PATHis the single source of truth for the shape) — oneentry:"access"JSONL line per distinct path that is a file on disk (at most 8 per call, harvested from the first 16 KB of the unescaped tool_input slice), carrying which reader got there (jq/grep/shell/node/Read/Grep/other, ornamedfor a command that only touched the NAME:rm,mv,ls,echo…). It is measurement only: it never blocks, prints nothing, and changes no tool input. Without it--reportcalled a whale missed whenever the agent recovered it with three targetedjqqueries instead of ajson-slimrun — the right move, counted as a gap. It writes only whileFND_MCP_SLIM_DEBUGis on and is disabled entirely byFND_SPILL_ACCESS=0; it is POSIX sh + grep (no node), because it fires on every Bash / Read / Grep call. - UserPromptSubmit — one node process (
user-prompt.cjs) running three independently gated halves; a block from the guard wins over everything else.context-stats.cjsmonitors context-window usage and warns (recommending/compact) past a threshold. Its numbers are the last answer's usage record (hooks see neither/contextnor the active model), so a/modelswitch or a compaction nobody has answered yet is read from the command's own transcript record instead:claude-opus-5 → Fable 5.1,≥14.6k/1M after /compact (was 147.9k). Knobs, set likeFND_LEANinsettings.json→env:FND_CTX_MONITOR=0turns it off,FND_CTX_WARNsets the warn threshold in % (default 40),FND_CTX_WINDOWoverrides the assumed window size (e.g. for 1M-token sessions).prompt-json-guard.cjskeeps a large pasted JSON blob out of the conversation: a prompt over ~10 KB that carries a parseable JSON blob over ~8 KB is blocked (the prompt is erased, never reaching the model), the blob is spilled to a file (the active task workspacetmp/, else a private temp file), and the developer is shown that path to resubmit against — so the JSON is read with jq/Read on demand instead of sitting in context every turn.FND_PROMPT_JSON=0disables it. On Claude Code the hooks module rewrites the prompt in place instead of blocking it (see Pasted JSON on the prompt); the block remains the fallback, and it is the behaviour on Codex and Cursor (OpenCode rewrites with a handle, seeFND_PROMPT_JSON).session-title.cjsnames the session after the ticket, once: the first prompt of the session carrying a corroborated key setssessionTitleto<KEY> — <summary>(from.claude/tasks/<KEY>/ticket.md) or to<KEY>, and a per-session marker file keeps a re-run, or any later prompt, from re-titling. Corroborated = the key arrived as a Jira/browse/<KEY>URL, or this checkout already has a.claude/tasks/workspace for the same project — key shape alone is not evidence (UTF-8,SHA-256,ISO-8601andAES-256all match it), and a wrong title would spend the one shot the real ticket needs. It rides inside the monitor's object when both speak, and alone when the monitor is silent; a blocked prompt titles nothing and spends no shot. Claude Code only — the half is gated on the host, since this file also runs on Codex — andFND_SESSION_TITLE=0disables it. Session titles are verified on the CLI; in the desktop app's Code tab and in Cowork they are expected to work and unverified.- PostToolUse (
mcp__.*) —mcp-slim.cjscompresses large MCP tool results before they enter context (scripts/json-slim.cjs: ADF→markdown, noise-drop, long-string truncate, same-shape-array crush). Results ≤ 4 KB and error envelopes (isError/errors[]) pass through untouched; the original is spilled to a file and referenced by a<<full=…>>handle so nothing is lost. Whenever the hook actually changes what is delivered it says so in the session, on one line above that handle (inside the stub, on its second line):fnd-mcp-slim: compressed 118,203 B → 29,412 B (−75.1%)— the decision word iscompressedorstub, and the figures are the result the hook was handed against the value it emitted, that line and the handle included. That copy lives inside the tool result, which the desktop app collapses, so the same line also leaves out of band as the hook'ssystemMessage— one surface, every host: the terminal CLI prints it inline, the desktop app renders it under its collapsible "Claude Code notice", and Cowork, cloud and the SDK get the same field. The model is told nothing anywhere — it is never asked to print or repeat the figure, because a standing "repeat this verbatim" is the exact shape an injected tool result wears. Claude Code only: the Codex and Cursor adapters re-read this emission against their own channels and byte budgets, so the extra field never reaches them, and neither does a subagent: a reader calling the MCP tool in its own context has no way to say anything to the developer, so there only the in-body line is written (Claude Code files a subagent's transcript under<session>/subagents/, which is what tells the two apart). A passthrough says nothing (nothing changed), and this is not a new switch: there is nothing to turn on, andFND_MCP_SLIM=0silences it with the compressor. Stale spills are swept by an mtime TTL (FND_MCP_SLIM_TTL) — and the bundled playwright server's output dir (.claude/fnd-tmp/playwright, in the project) rides along on the same clock, so QA screenshots and snapshot dumps expire instead of accumulating.FND_MCP_SLIM=0disables it;FND_MCP_SLIM_DIRsets the spill directory. A result over the platform limit (MAX_MCP_OUTPUT_TOKENS, ~25k tokens) bypasses this hook — Claude Code spills it to a file and hands over the path; the session convention and the reader agents route that file through the same compressor on demand (node scripts/json-slim.cjs <path> --stats; the reader recipes pass--statson every run, and its stderr line —json-slim: <in> → <out> bytes (<pct>% reduction), the same line a log or JSONL run prints, sincelog-slimis reached through this CLI — is what the readers return verbatim as theircompression:field and write into the workspace file's frontmatter, so the figure survives a/compact. A reader runs in its own context, so a second hook carries that field out: PostToolUse on the subagent-spawn tool (^(Agent|Task)$→hooks/reader-compression.cjs) reads the reader's return, and relays the figure on the same surface as above, prefixed with the agent that measured it (fnd:jira-reader → fnd-mcp-slim: …). It fires on every reader spawn, including the ad-hoc ones no skill drives — a pasted ticket key that pulls injira-readerby itself — which is exactly what the old skill-level relay could not do; the skills no longer say anything aboutcompressionat all. A BACKGROUND spawn — the host's default — answers the tool with launch metadata only and delivers the return later as a<task-notification>prompt, which fires UserPromptSubmit instead; so the relay's second half rides inhooks/user-prompt.cjs(hooks/reader-notification.cjs), reads the same field off that prompt, and proves the agent's identity from the host's ownagent-<id>.meta.jsonbeside the session transcript (no file → no relay). Both halves share one judgement (hooks/compression-notice.cjs): anone/absent field, a value that does not parse whole as one of those printed lines, an agent that is not one of the three readers, or an event it cannot parse → nothing, exit 0, always.FND_READER_COMPRESSION=0disables both halves.figma-node-slim --statsdoes the same for a Figma REST tree.--statsis left OFF a narrowing--jqrun, which measures a sub-path rather than a compression). If that file isn't JSON — or holds an error envelope (never compressed) too big to print inline — the CLI hands the path back instead of re-dumping it, so the caller reads it directly; a smaller error envelope prints, and a narrowing--jqalways prints (the file path does not answer a sub-value). A payload a tool wrapped in a markdown fence (prose preamble +```json…```, e.g. chrome-devtoolsevaluate_script) is unwrapped first when the fenced body is the dominant content (≥ 80 % of bytes) and its body re-run through the pipeline with the preamble kept on top; a real doc with a small code block stays below that bar and passes through byte-identical. A spilled MCP result keeps its content-block envelope ([{"type":"text","text":"<the payload, JSON-encoded>"}]) — a one-element array whose single escaped string no pipeline stage and no--jqwalk can descend into. The CLI unwraps a pure, single-block text envelope (a block carrying_meta/structuredContent/annotationsis left alone — unwrapping would drop it; so is a MULTI-block envelope, whose blocks are independent results, not one document) whose inner payload is JSON, slims that, and prints the INNER body with one stderr line saying so; an inner that does not slim declines with the whole original.--jqwalks the envelope FIRST (--jq 0/--jq 0.textstill resolve to the block) and re-runs the whole EXPRESSION inside the payload only when the first segment misses — so--jq '.fields | keys'on a spilled envelope lists the payload's field names; a miss at both levels reports the payload's keys, notlength: 1.--jq .(identity) still dumps the document it was given. A JSONL payload inside an envelope profiles like any JSONL, against a spill of the unwrapped body — the line recipes must address rows that really exist as lines. A JSONL file (one JSON object per line, e.g. a Shopify bulk-operation dump) is handled apart: the CLI never compresses it and never prints its rows — at ANY size it returns a PROFILE (row + parse-failure counts, per-key{present, null, type, distinct}— plusdistinctCappedonce a key's value set hits the retention cap, sodistinctreads as a floor — and head/tail/reservoir sample rows) plus guidance to query the ORIGINAL file by line — areadlinefilter (the sample rows show the shape to write it against) orsed -n '<N>p'/grep. Thereadlinetemplate rides on the first profile of a given file per session; a repeat profile of the same file keeps the file, the row count, any fence offset and thesed/grepforms but drops the template (FND_WHALE_GUIDE). Never raw-Reada big JSONL: a sampled subset can't answer analytical questions whose answers live in rows the sample skips. On a file with a fresh key name on every row (an id-keyed map dumped one row per line) the profiler stops tracking new keys past a build cap and says so:keysCapped: true, and the dropped-key count comes back askeysTruncatedAtLeast(a FLOOR) instead of the exactkeysTruncated. Files ≤ 8 MB profile the parsed rows; larger ones stream viareadline(never loaded whole) — the same PROFILE either way.--jq <jq-path>extracts a single value from a ≤ 8 MB JSONL; on a larger one it is refused (it would re-read the whole file), and the two spellings that select the whole file (--jq .,--jq '.[]') PROFILE it like a plain run instead.--jqspeaks a small jq subset, evaluated here with no jq dependency: dot paths (products.0.title, with a leading.and[N]indices accepted as the same thing —.products[0].title),[]iteration at any segment (.a[].b, flattened across nesting, an object iterating to its values),,multi-select (jq emits one document per term, this CLI has one stdout — so the terms come back as ONE array,nullin a missed slot) and the filters| keys(sorted names /0..n-1) and| length. Quote the expression as ONE argument — an unquoted,is split by the shell, and the empty slot left behind (.a,) is refused rather than read as "everything". Anything else —select/map/any function call,?,//,.., quoted keys, slices, literals, comparisons — is a usage error: empty stdout, one stderr line naming the offending token and the supported grammar, exit 2, and nothing read or spilled; pipe a supported path into realjqfor the rest. An unrecognized argument writesjson-slim: unknown option <token>on stderr and exits 2 — nothing is read and nothing is printed, so a typo (--jqq,--notables) can never run as a plain compression or be resolved as the input path.--help(or-h) prints the supported grammar on stdout and exits 0 without reading stdin or a file — it is not a run, so it writes no debug line and never shows up in--report. A path that doesn't resolve still printsnullon stdout, but names the failing segment on stderr with what WAS addressable there (--jq: 'produtcs' not found at top level; keys: products) — a value that is genuinelynullstays silent, and an iteration where EVERY element missed says so once ('creted' not found at 'comments[]') instead of printing a silent column of nulls. Log-shaped text (build/test output, console spam, stack traces) is compressed by signal selection instead — errors, stack-trace heads, and summary lines are kept while repeated INFO/WARN spam is deduped×Nand everything else is dropped to a[N lines omitted: …]trailer (on a file the CLI names the on-disk original for recovery); prose, markdown, and XML fall below the log detector's threshold and pass through unchanged. A Figma design-context payload (dev-modeget_design_context— generated React/Tailwind JSX) is compacted losslessly instead: repeatedclassNamestrings move to a legend on top (class=C17at the use sites),data-node-idattributes become#n17refs whose full-id map is spilled to its own file so the ids stay usable for follow-up Figma calls, and identical repeated sibling subtrees fold to one exemplar plus a list of what differed per repeat (~77 % on a real 211 KB section — 219 KB as the captured envelope → 77.3 %) — the original is in the spill, and generic HTML/XML/Liquid never matches the detector. A non-JSONL JSON document keeps the normal slim behavior, plus a guard: if its slimmed body still exceeds ~48 KB it is spilled to afnd-slim-out-*file and handed back as a one-line summary + the first element + both paths (--jq <jq-path>to narrow), never dumped inline. A result the pipeline cannot shrink under ~32 KB — incompressible text/HTML, already-minimal JSON, or a compressed body that is still huge — is not handed to the model raw: it is spilled and stubbed, i.e. replaced by a ~1 KB note naming the file, its format and shape, and thenode scripts/json-slim.cjs <path>line to run on it (the same contract the session convention states for platform-spilled whales). The spill holds the payload itself, so that command really works on it. A content array whose blocks carry anything beyondtype/text(anannotationsor per-block_metacursor) cannot be collapsed into one stub without losing those fields, so each over-limit block is replaced on its own instead: the array keeps its length, every block keeps its own fields, and each replacement names the spill holding that block's text. A block in that array that came back compressed and under the threshold rides along with its ownfull=handle, so nothing there reaches context lossy without a copy on disk. Never stubbed — these pass through as before: error results (including an error envelope sitting anywhere in a content array, at any size); anything carrying binary bytes — an image, audio or embedded-resource block, or any block not typedtextthat carries adata/blobstring (a screenshot must still render, and a text spill would not hold it; atype:"text"block with an unrelateddatasibling is still text and still stubs); the platform's own overflow notices; shapes the compressor did not understand; a failed spill.FND_MCP_SLIM_STUB=0turns the guard off;FND_MCP_SLIM_STUB_BYTESmoves the threshold (never below the stub's own size). The whole flow also runs under a wall-clock ceiling (FND_MCP_SLIM_BUDGET_MS, default 5 s), and an expiry hands the ORIGINAL back — never a half-transformed value — per block: on a content array the blocks already slimmed keep their compression and their recovery handle while the rest ride verbatim, logged ascompressedplusbudget_partial: true. Only a whole-result expiry (a single text payload, or an array where nothing got through) is loggedbudget-exceeded— and above the stub threshold that bail is stubbed like any other whale, unless it turns out to be an overflow notice. With this guard in place, raisingMAX_MCP_OUTPUT_TOKENSin your ownsettings.jsonenv(so bigger results reach this hook instead of the platform's file spill) is safe — but it stays your decision, ideally after a week of--reportdata. Why wasn't a result compressed? setFND_MCP_SLIM_DEBUG=1, re-run, and read<FND_MCP_SLIM_DIR>/fnd-mcp-slim-debug.log— one JSONL line per call records thedecision(compressed/stubbed/passthrough) and, on a passthrough, thereason(error-shape,non-json,unrecognized-shape,no-gain,marker-overhead(the win was smaller than thefull=recovery handle, so the original was handed back),budget-exceeded(the wall-clock ceiling above),platform-overflow— the platform's own over-limit notice, whosespillfield holds the file it saved the whale to; plussize-gatefor a result under the 4 KB gate, which is sub-gate noise — three quarters of a real week's lines — and is therefore recorded only atFND_MCP_SLIM_DEBUG=2, the everything level).no-gain— the most common one — means the pipeline ran and produced byte-identical output, most often on a uniform array of unique entities: same keys on every row, per-row distinct ids / handles / URLs / titles, no error row, no low-cardinality status column, no numeric outlier or change-point. Sampling such an array is refused by design (unique_entities_no_signal, the same gate the upstream crusher ships): every row a 15-row sample dropped would be unique content, so the sample would misrepresent the result — and nothing else is compressible without re-encoding the table. The identical shape does crush once a signal appears (an error row, a status enum, a numeric column with an outlier), which is why one dump can report 0 % and its next page 99 %. AboveFND_MCP_SLIM_STUB_BYTESthe spill-and-stub guard is what protects context here — and because a second whole-file run would only print the same bytes back, that stub names the narrowing command (--jq <jq-path>) instead of a bare re-run; below the threshold the result lands in full and--jqnarrows it. On astubbedline thereasoninstead names the branch the stub replaced (non-json,no-gain,weak-gain— a compression that stayed over the threshold — orbudget-exceeded) or, on Codex alone, the delivery ceiling that forced one (block-cap— a compressed body past the block-reason cap, stubbed so the whale still travels through that channel). Lines that wrote files also list them inspills— the call's ownfull=copy plus the crush / node-id-map spills written inside it, and on a CLI run Gate A'sfnd-slim-out-*body plus, for a stdin run that came back lossy, the copy of the original it spilled and named on stderr (a file run hands the file itself back instead) (spills_nwhen that list is capped at 8); a line with nospillskey left nothing on disk. In the hook, every branch that discards the compressed body (a stub, ano-gainormarker-overheadpassthrough) also deletes the spills it created for that body — nothing names them any more — and splices them out ofspills, so the list only ever holds files that are still on disk; a spill that already existed is left alone, since an earlier call's live handle may name it. A CLI run does no such cleanup: what a declined branch there left behind (a crush spill written before the run degraded to a passthrough) waits for the TTL sweep. Spill filenames are content hashes, so the same payload passing through twice reuses one file instead of adding another. Each line also records thelvlit was collected at (seeFND_MCP_SLIM_DEBUG). What did it all add up to?node scripts/json-slim.cjs --report [logfile] [--since <ISO>](default: the log above) prints totals, counts per decision/reason/stage, the top hook tools by bytes saved (CLI runs, keyed by file path rather than tool name, get one aggregate line — with the whole-file runs that reduced nothing counted separately, since those read a file into context for no gain (ajq-unsupportedorunknown-flagrefusal is not one of them — neither read a body at all); a--jqrun is excluded, it answered a sub-path), per-project subtotals, how many DISTINCT spill files the window left behind (paths are deduped across events, so the content-hash reuse above shows up as one file, not one per call), and the missed whales —platform-overflowresults no tool ever opened. That last number used to read "no laterjson-slimrun", which called a whale missed whenever the agent recovered it the sensible way: three targetedjqqueries straight on the 236 KB spill. Thespill-accesshook above records those reads asentry:"access"lines, and--reportpairs them with the overflow / stub they follow exactly as it pairs a CLI run — same path or basename, not earlier — so a read by ANY tool counts as the recovery. Two limits of that evidence, both by construction: the line is written on PreToolUse, i.e. it records the read that was attempted (a call the user denies, or one that fails, still counts as a read); and a command that only NAMES the spill (rm,mv,ls,echo— recorded asvia: named) is deliberately not paired, so a post-session cleanup cannot clear a session's misses. Access lines are not compression events: they carry no bytes and are excluded from the totals, the decision / reason / stage counts, the per-tool and per-project aggregates, the CLI-run line and the spill-file inventory (thelog:header counts the whole window and says how many of its events are spill reads). They get two lines of their own instead —spill reads (access hook): N (via: …)and, when a whale was recovered,whale recoveries via: json-slim N · jq N · Read N …(each whale counted once per distinct reader). A log with no access lines reports exactly the numbers it always did. Results that were stubbed (above) get their own line and count as bytes saved, never as missed whales; a stub whose follow-up run gained nothing is called out there too. Totals collected at level 1 are labelled as such (the sub-gate lines are missing from them, which inflates the %), and a log that straddles a switch between levels is flagged as not comparable instead of averaged — missed whales, stubs and stage counts are complete at every level. Anon-jsonline also carries aformattag (html/xml/broken-json/text) so you can see WHAT slipped past the JSON-only pipeline (grep '"format":"xml"' fnd-mcp-slim-debug.log); every line carries aprojecttag — the nearest.gitancestor of the call's own cwd, elseCLAUDE_PROJECT_DIR, else that cwd's basename — since the log is shared per-user across projects (grep '"project":"my-repo"'). The.gitwalk leads because Claude Code exportsCLAUDE_PROJECT_DIRto hooks but not to the Bash tool, so it is the one rule both the hook and the CLI can follow; a CLI run from a scratch dir outside any repo still tags that dir. Coexistence: if you also runsqueez, both its PostToolUse hook andmcp-slimfire onmcp__*results — expect them to stack.
- PostToolUse (
Environment switches
Single home for every knob the plugin reads, on every host. Every new switch must be added to
this table — with its per-host behavior whenever the switch does not mean the same thing
everywhere. Host variables the plugin only reads sit at the bottom of the table. Recommended
values for Claude Code's own settings.json (MAX_MCP_OUTPUT_TOKENS, FND_MCP_SLIM_DEBUG) come
with a pasteable file in Recommended Claude Code settings.
Where to set them. Two Domaine env files work identically on all four hosts — every
fnd entry point (Node hooks, the json-slim CLI, the two Shopify shell scripts, the OpenCode
plugin) reads them first thing, so a GUI-launched Cursor no longer needs launchctl gymnastics:
- global —
~/.config/domaine/env(Windows:%APPDATA%\domaine\env;$XDG_CONFIG_HOMEhonored) - per-project —
<repo>/.claude/domaine.env(found by walking up from the working directory)
Precedence is process env > project file > global file > default — a value already in the
real environment is never overridden, so Claude Code's settings.json → "env": { … } (global
or per-project) keeps working and keeps winning. The file format is a strict KEY=VALUE subset:
one pair per line, # full-line comments, value = everything after the first =, no quoting
and no $VAR expansion. Only FND_* keys (plus SHOPIFY_ADMIN_GQL_QUIET) are read — the file
can never smuggle PATH or NODE_OPTIONS into a hook.
The project layer carries tuning keys only. A <repo>/.claude/domaine.env is a file a client
repository can commit, so exactly these fourteen switches are read from it — FND_LEAN,
FND_STE, FND_PROFILE, FND_CTX_MONITOR, FND_CTX_WARN, FND_CTX_WINDOW, FND_MCP_SLIM_DEBUG,
FND_WHALE_GUIDE, FND_NOGAIN_MEMO, FND_GQL_PROBE_CACHE, FND_CPT_THROTTLE_WAITS,
FND_CPT_OVERLAY_VERIFY_WAIT, FND_THEME_JSON_VERIFY_WAIT and SHOPIFY_ADMIN_GQL_QUIET.
Every other switch — the compression, spill, guard and read-back-verify gates, and any switch
added later until it is listed here — is global-only: it comes from the shell or
~/.config/domaine/env, and a copy sitting in a project file is ignored (domaine-env list
shows it as project (ignored: global-only switch), and set --project refuses to write one).
Edit the files by hand or through the CLI:
node plugins/fnd/scripts/domaine-env.cjs list # switches, values, which source won
node plugins/fnd/scripts/domaine-env.cjs set FND_MCP_SLIM_DEBUG=1 # global
node plugins/fnd/scripts/domaine-env.cjs set FND_MCP_SLIM_DEBUG=1 --project # this repo only
node plugins/fnd/scripts/domaine-env.cjs unset FND_MCP_SLIM_DEBUG # and: path [--project]
The CLI is never installed on its own — it ships inside the plugin (bare Node, no
dependencies) and lives wherever the plugin does: the clone, or the host's plugin cache.
The intended route is to ask the agent in a session: say "enable mcp-slim debug for
this project" or "turn off the prompt-JSON guard globally", and the session runs the
matching set/unset against the script under its own plugin root — no path to remember.
Running it by hand from the plugin directory works exactly the same.
Two caveats. The shell fast-gates in the hook wirings (the [ "$FND_MCP_SLIM" = "0" ] && exit
short-circuits) see only the real process env — a 0 set in the global file still disables the
feature (the Node side re-checks after loading the files), it just no longer skips the node
spawn. And
FND_LEAN's and FND_STE's session gates are pure shell (hooks/session-start.sh, the composer
that cats the statics for the two shell wirings), so
those two switches are process-env-only where they are hook-gated — on Cursor our sessionStart hook
also hands the file values back to the host, which then feeds them to every later hook of the
session, shell gates included.
Hooks never read a project's .env file on any host.
Ten switches do not mean the same thing on every host — FND_LEAN, FND_STE, FND_PROFILE,
FND_CTX_MONITOR, FND_MCP_SLIM, FND_MCP_SLIM_DEBUG, FND_MCP_SLIM_STUB, FND_PROMPT_JSON,
FND_SCRATCH_GUARD and FND_SPILL_ACCESS. Each of those
rows carries a Host divergence: note; every other switch behaves identically everywhere,
because the script that reads it is the same single copy on all four hosts.
| Variable | Default | Effect |
|---|---|---|
| FND_LEAN | 1 | 0 disables the lean-code session convention. Hook-gated, so it applies where the convention arrives through a hook (Claude Code and Codex at session start; every host's subagent conventions on Claude Code, Cursor and Codex). Host divergence: on Cursor the SESSION copy ships as an always-applied rule instead — turn rules/fnd-lean-code.mdc off there — and on OpenCode the statics live in your own instructions config, which this switch cannot reach; either way, "normal mode" in the session still works |
| FND_STE | 1 | 0 disables the how-to-explain session convention (hooks/writing-style.md). Hook-gated, so it applies where the convention arrives through a hook (Claude Code and Codex at session start; subagents never get it). Host divergence: on Cursor it ships as an always-applied rule instead — turn rules/fnd-writing-style.mdc off there — and on OpenCode the statics live in your own instructions config, which this switch cannot reach; either way, "normal writing" in the session still works |
| FND_PROFILE | auto | overrides the project profile — foundation / theme / none, detected by scripts/project-profile.sh from the checkout itself (snippets/@*.liquid, sections/core-*.liquid, blocks/core-*.liquid or src/entry/core/ ⇒ foundation; else layout/theme.liquid ⇒ theme; else none), walking up from the session's directory and stopping at the repo boundary — the level that holds .git — so a hook running in a subdirectory answers about its own checkout and never about a parent repo. Every host prints the answer as fnd project profile: <value> right under the plugin-root line, and foundation adds one block to the session: hooks/comment-discipline-foundation.md (LiquidDoc on every snippet param; src/entry/core/* is protected — extend or compose it; the Liquid core may be edited but has to be hand-synced from the foundation repo) — plus the same block for code-writing subagents. Bundled scripts read the same answer where a Foundation-only command would otherwise be handed to a plain theme: scripts/worktree-setup.sh prints npm run dev -- --theme … in the worktree hand-off only on foundation, and shopify theme dev --theme … everywhere else. The three values are matched exactly, lowercase; an exported-but-EMPTY value still counts as set, so it shadows both env files and lands on detection; anything else falls back to detection, silently in a session (run scripts/project-profile.sh by hand to see the warning). Host divergence: on Cursor the comment-discipline convention is an always-applied rule and of that material only this addendum is detection-gated, injected by hooks/cursor-shim.cjs; on OpenCode both the profile line and the addendum ride the adapter's once-per-session chat.message injection, since the statics live in your own instructions config |
| FND_CTX_MONITOR | 1 | 0 disables the context-usage monitor; node still spawns for the prompt-JSON guard, the session title and the background reader relay unless FND_PROMPT_JSON=0, FND_SESSION_TITLE=0 and FND_READER_COMPRESSION=0 too (the four halves share one UserPromptSubmit process). Host divergence: the monitor reads the session transcript, which only Claude Code and Codex hand a hook — on Cursor (beforeSubmitPrompt) and OpenCode (chat.message) there is no transcript path, so the monitor is inert there whatever this is set to, and the switch only governs the prompt-JSON half. Mod (Claude Code): while the hooks module is live the monitor is silent whatever this is set to (the module's session marker; the band shows ctx and model), and its warn-level additionalContext goes with it, so the model gets no context nudge there and the band's colours and Compact button are the developer's cue. On Cursor, Codex and OpenCode this stays the switch |
| FND_CTX_WARN | 40 | context warn threshold, % of the window |
| FND_CTX_WINDOW | auto | override the assumed context window size (tokens). Auto = the Claude model family on Claude Code (200000 when the model is unrecognized) and the window the rollout states on Codex — where, if it states none, the monitor stays silent rather than guess |
| FND_MCP_SLIM | 1 | 0 disables the MCP result compression (PostToolUse mcp-slim hook) — node never spawns. Where it does spawn anyway — a 0 set in the global ~/.config/domaine/env, which the wiring's shell gate cannot see (this switch is global-only: a project .claude/domaine.env cannot set it at all) — the hook emits nothing but still runs its exit-time TTL sweep: hygiene is not compression, and nobody who silenced the compressor asked for a spill dir and a checkout that fill up. FND_MCP_SLIM_TTL is the switch that governs the sweep. Host divergence: Claude Code and OpenCode rewrite the result in place, so compression AND spill-and-stub both land. Cursor is observe-only: afterMCPExecution exposes no rewrite field, so nothing is compressed, stubbed or spilled there whatever this is set to — with the default the shim logs skip, and with 0 the wiring gate exits before node, so not even that line is written (see Known gaps and docs/README.cursor.md). On Codex both halves land through the one channel that host's PostToolUse can replace a result with: hooks/codex-mcp-shim.cjs returns the compressed body — or the stub — as a block reason, behind a header saying the call SUCCEEDED and must not be retried, since Codex frames a block to the model as Script failed / Script error:. That reason is capped (BLOCK_REASON_BYTES in the shim, 10000 — Codex truncates a longer hook output, measured at 2,500 host tokens of 4 bytes each), so a compressed body over the cap is spilled-and-stubbed instead (block-cap), and a result carrying a non-text block falls back to the old path: the stub rides as additionalContext, a compressed body is dropped (docs/README.codex.md). Mod (Claude Code): 0 also turns off the hooks module's slimming: no over-limit expansion and no savings toast. The module reads only the session's environment (shell, settings.json → env); a 0 that lives only in ~/.config/domaine/env is seen by the mcp-slim.cjs the module spawns, which then emits nothing, so the host's notice stands and the effect is the same one spawn later. |
| FND_MCP_SLIM_DIR | os.tmpdir() | directory where json-slim and the mcp-slim hook spill offloaded rows / the original result (the full=<path> handle) |
| FND_MCP_SLIM_TTL | 24 | hours a file survives before the exit-time sweep prunes it (by mtime, so full= handles outlive same-day resume). Two directories ride this clock: the spill dir, and the bundled playwright server's project output dir .claude/fnd-tmp/playwright — pruned recursively, files only, with directories left standing since a live browser session may own one. The sweep runs even with FND_MCP_SLIM=0 (that switch turns off compression, not hygiene). 0 disables both prunes — but not the /.claude/fnd-tmp/ line in .git/info/exclude, which the scratch-path guard stamps itself on the first bundled screenshot it allows. Any invalid value falls back to 24 |
| FND_MCP_SLIM_DEBUG | off | opt-in: append one JSONL trace line per mcp-slim / json-slim invocation to <FND_MCP_SLIM_DIR>/fnd-mcp-slim-debug.log (project, lvl, decision, reason, format on non-json, narrowed on a --jq CLI run, guide (full/reminder) on a JSONL profile, budget_partial on a mid-array wall-clock expiry, delivery/delivered + host on a host whose adapter decides what can be delivered (see below), bytes, %, stages, spills — never any payload); rotates one generation at ~5 MB. 1 (or true/yes/on) = key events: everything except the sub-gate size-gate lines for results under the 4 KB gate, which were ~76 % of a real week's log. 2 (any integer ≥ 2) = everything, sub-gate lines included. Unset / 0 / false / an unrecognized value ⇒ no file written. The level rides on each line as lvl, so --report can say which of its numbers cover a partial event set — a 1 window's totals % is not comparable with a 2 window's. Host divergence: where the host adapter decides delivery (Codex), the shrunk lines also carry what was DELIVERED — delivery (replace = the body or stub went back as the block reason, discard = the compressed body was dropped, additional = a stub rode back beside the raw result) plus delivered, the bytes of that stub — and --report credits a replace as the saving it is while counting the other two as 0 saved (and, for additional, as context ADDED), with a delivery: line naming the split whenever any event took a fallback |
| FND_MCP_SLIM_STUB | 1 | 0 disables the spill-and-stub guard: a result the compressor cannot bring under the threshold is then handed to the model raw again, instead of as a ~1 KB stub + spill. Host divergence: on Codex the stub is also what an over-cap compressed body degrades to (the block reason cannot carry a longer one), so 0 costs more there: the whale lands raw AND that compression is dropped rather than stubbed, with nothing said about the spill holding either. On Cursor there is no stub at all: afterMCPExecution is observe-only, so neither half of mcp-slim lands and this switch changes nothing there |
| FND_MCP_SLIM_STUB_BYTES | 32768 | bytes above which the mcp-slim hook spills-and-stubs instead of passing a whale through (also applies to a compressed body that is still this large); any invalid value falls back to 32768, never to 0, and any value below the stub's own ~1.2 KB is raised to it (a smaller gate could emit a stub bigger than the text it replaces). The hooks module reads it from the session's environment to tell the hook's own output from payload before it toasts a figure; a value set only in ~/.config/domaine/env leaves the module at 32768, which can cost a toast, never a result. |
| FND_MCP_SLIM_BUDGET_MS | 5000 | wall-clock ceiling for one mcp-slim compression run (shared across every block of a result). An expiry hands the ORIGINAL back — per block, so a partly-slimmed content array is logged compressed + budget_partial, and a whole-result expiry budget-exceeded (stubbed above the stub threshold); 0 disables the ceiling; a negative value is a ceiling already expired when the run starts (every block hands its original back — the deterministic form of a tiny budget, for diagnostics); any invalid value falls back to 5000, never to 0. The default is ~22× a 1 MB-class payload on this pipeline and well inside Claude Code's PostToolUse timeout — headroom, not a guarantee: a genuinely huge result (tens of MB, or several fat blocks sharing the one deadline) can still reach it, and is then handed back uncompressed rather than half-compressed |
| FND_WHALE_GUIDE | 1 | 0 (or false/no/off) disables the one-shot rule for json-slim's whale-recovery guidance: the full block (readline filter template + sed/grep single-row hints) is then printed after every profile instead of only the FIRST profile of a given file per session. With the default, later profiles of the same file carry a one-line reminder that stays self-sufficient — the file, the row count, any fence offset, the sed/grep single-row forms and this switch — dropping only the readline template (the reader of a repeat may be a subagent, or a context that was compacted since). The suppression state is a dotfile per session × resolved file path under FND_MCP_SLIM_DIR, expires after 2 h, and is pruned by the same sweep as the spills (so FND_MCP_SLIM_TTL=0, which disables that sweep, leaves the small per-file state files in place); a new file path, a missing, unreadable or future-dated state file always yields the full block. The profile itself is never affected, and each profile's debug line records which variant it printed (guide: full / reminder) |
| FND_NOGAIN_MEMO | 1 | 0 (or false/no/off) disables json-slim's per-file no-gain memo. With the default, a file run that printed its body back unchanged (a deliberate decline, now also stated on stderr) is remembered — per session × resolved file path, for 2 h, invalidated as soon as the file's size or mtime change — and a repeat run on that file answers with a one-line refusal naming the recovery instead of re-printing the body; the field pattern it removes is "decline → immediate re-run", where a 0 % result reads as a failed attempt. Only declines of at least 4 KB are remembered (below that a refusal saves nothing). The state is dotfiles under FND_MCP_SLIM_DIR, swept with the spills (so FND_MCP_SLIM_TTL=0 leaves them in place); a missing, unreadable, corrupt or future-dated state file always yields the normal run. The same switch also governs the second refusal, which needs no memo: fnd-slim-out-* files — json-slim's own Gate-A output spills, already slimmed — are answered the same way (only below the 8 MB stream gate; past it the big-document guidance is the useful answer). Neither refusal ever applies to a run whose answer it cannot stand for: a narrowing --jq <jq-path> (the documented recovery after a decline) bypasses both. --stats does not — the reader recipes pass it on every run, so a bypass would disarm the memo for exactly those callers; a refusal answers it with the measurement instead, printing the body-free notice on stdout and json-slim: <bytes> → <bytes> bytes (0.0% reduction) [declined earlier this session] (or [already json-slim output]) on stderr. The stderr decline notice is NOT governed by this switch: it rides on every file run that printed the file's own bytes back unchanged. Both refusals log already-slim-out / no-gain-memo and are counted on --report's cli runs: line |
| FND_PROMPT_JSON | 1 | 0 disables the prompt-JSON guard (UserPromptSubmit prompt-json-guard half); node still spawns for the context monitor, the session title and the background reader relay unless FND_CTX_MONITOR=0, FND_SESSION_TITLE=0 and FND_READER_COMPRESSION=0 too — only with all four at 0 does no node process run at all. Those clauses are in the Claude Code and Codex wirings alike, because the two carry the same command verbatim; on Codex the title and reader-relay halves are inert (both gated on FND_HOST=claude), so a Codex user who wants the old two-switch economy sets FND_SESSION_TITLE=0 and FND_READER_COMPRESSION=0 with the other two. Cursor's beforeSubmitPrompt runs its own shim, which has no title half, so there the two-switch short-circuit is unchanged. Host divergence: on OpenCode nothing can erase a message, so a blocking verdict rewrites the prompt instead — every blob the guard spilled is replaced in place by its full= handle, which offloads the paste exactly as elsewhere. Mod (Claude Code): 0 also turns off the hooks module's rewrite. The module reads only the session's environment (shell, settings.json → env); a 0 that lives only in ~/.config/domaine/env is seen by the prompt-json-guard.cjs --from-mod the module spawns, which then prints nothing, so the effect is the same one spawn later. Confirmed live on the desktop app's Code tab too. |
| FND_BAND_COST | off | 1 (or true/yes/on) adds the session-cost segment to the status band (cost $12.40, 💰 $12.40 on the desktop): the /cost total at API prices, a measure of work on a subscription. Read once at session start through the hooks module's $.env, so set it where Claude Code's own environment is built — the env block of ~/.claude/settings.json reaches the terminal and the desktop app alike. Host divergence: Claude Code only (the band is a mod). |
| FND_EVENT_LOG | 1 | 0 keeps the hooks module's event log (the Log pane, /fnd-log) empty: nothing is recorded, and the pane reads no events yet. Toasts are untouched. Read through the hooks module's $.env, so set it in the session's environment (~/.claude/settings.json → env). Host divergence: Claude Code only. |
| FND_SLIM_TOAST | 1 | 0 silences the hooks module's savings toasts: the MCP-slimming figure (fnd-mcp-slim: compressed …) and the pasted-JSON figure (fnd-prompt-slim: …). Slimming and the rewrite themselves are untouched, and so are the Compact-press result toast and the 90 % rate-window alarm (answers to the developer's own action and a warning). Read through the hooks module's $.env, so set it in the session's environment (~/.claude/settings.json → env). Host divergence: Claude Code only (toasts are a mod). |
| FND_SLIM_TOAST_MS | 5000 | How long the hooks module's savings toasts stay, in milliseconds (a whole number, floored at 1,000; anything else → the default). Set it where the module reads its environment (~/.claude/settings.json → env). Host divergence: Claude Code only. |
| FND_SCRATCH_GUARD | 1 | 0 disables the screenshot scratch-path guard (PreToolUse scratch-path-guard) — node never spawns. With the default, a take_screenshot / browser_take_screenshot / take_snapshot / get_network_request / browser_run_code_unsafe whose path resolves outside the project (scratchpad, another checkout — the servers refuse those anyway; a chrome-devtools write under its OS temp dir passes, since that server accepts it), or a written file that resolves inside the project working tree and outside a leading .claude/ segment, is denied with a reason naming the ABSOLUTE <project>/.claude/tasks/<work-id>/tmp/<name> (or <project>/.claude/tmp/<name> with no ticket) instead — absolute because a relative filename is resolved by the playwright server against its own output dir, not the project, so a relative remediation would land nested inside the litter dir it replaces. Which server it is decides the verdict: the bundled playwright, whose manifest pins --output-dir .claude/fnd-tmp/playwright (swept, git-excluded — and the guard stamps that exclude itself on the branches that allow, so the allow does not rest on a compressor switch), passes both a bare filename and no filename at all; every other spelling may be a default-configured server writing to <cwd>/.playwright-mcp inside the checkout, so its relative filename is denied and so is its no-filename call, which does not skip the write. That allow is bought with the sweep: a file in the bundled server's output dir expires on FND_MCP_SLIM_TTL (24 h), so anything meant to be kept — QA evidence, the screenshots a Steps to Test points at — still belongs in <project>/.claude/tasks/<work-id>/tmp/, which nothing prunes. The guard creates the two remediation destinations itself as it denies (nothing else does — chrome-devtools' write path makes no directory — so a compliant retry would fail with ENOENT). The project root is the session's project dir (CLAUDE_PROJECT_DIR, else the checkout the cwd's .claude/ sits in); in a git worktree a path resolving into the symlinked .claude/tasks is denied, and the remediation is <worktree>/.claude/tmp/<work-id>/<name>. Anything under a leading .claude/, an inline chrome-devtools screenshot / snapshot / network body (no path, no file written) and browser_run_code_unsafe with inline code or an in-tree script all pass, and any internal error fails open; an in-project tmp/ is denied like the rest of the tree, since a theme checkout neither ships nor gitignores one. Host divergence: wired on Claude Code, Codex and Cursor — the matcher is prefix-agnostic, since Codex names the same tools without the plugin_fnd_ prefix and either host may have the servers installed per-user, and on Cursor the deny travels through beforeMCPExecution (a permission: "deny" response, tool_input decoded from a JSON string). That prefix-agnosticism has a cost on those two hosts: an unprefixed browser_take_screenshot is indistinguishable from a per-user server, so even the bundled one is judged conservatively there — relative filenames denied, absolute workspace paths expected. OpenCode's tool hook has no verified MCP payload shape, so there screenshots are unguarded whatever this is set to. Mod (Claude Code): 0 also turns off the hooks module's guard: no deny on the tool call and no path note in the five tools' descriptions. The module reads the session's environment (and, for the description note, settings.json → env); a 0 that lives only in ~/.config/domaine/env is seen by the spawned scratch-path-guard.cjs alone, which then allows, so nothing is denied, but the description note stays for that session. |
| FND_SESSION_TITLE | 1 | 0 disables both session-title paths. Both halves read it the same way — process env first, then the GLOBAL Domaine env file (~/.config/domaine/env); it is deliberately not one of the project-file keys, so a client repo's .claude/domaine.env cannot rename the sessions of everyone who opens it. With the default, hooks/session-start.sh names the session after the ticket its BRANCH carries (<KEY> — <summary> when .claude/tasks/<KEY>/ticket.md holds one, <KEY> alone otherwise) and hooks/session-title.cjs names it after the first prompt of the session carrying a key it can CORROBORATE — a Jira /browse/<KEY> URL, or a .claude/tasks/ workspace for the same project. Key shape alone is not evidence: UTF-8, SHA-256, ISO-8601 and AES-256 all match it, and titling a session after one of those would also spend the shot the real ticket needs. The title is one shot per session, held by a marker file in the temp dir beside the context monitor's band state, so a re-run or a later ticket never re-titles; the title itself is clamped to 100 bytes on a character boundary, which is what keeps it inside the SessionStart envelope's budget. A session the developer already named (--name, /rename) is left alone by BOTH halves: the SessionStart input carries that name, and seeing it spends the one shot, so the prompt half cannot overwrite it either. The prompt half is also one of the four halves the UserPromptSubmit wiring's short-circuit counts — node spawns unless this, FND_CTX_MONITOR, FND_PROMPT_JSON and FND_READER_COMPRESSION are ALL 0. Host divergence: Claude Code only. sessionTitle is a Claude Code hook field; Codex, Cursor and OpenCode name sessions themselves and ignore it, so the prompt half is gated on FND_HOST=claude and the SessionStart half rides in the envelope that host alone receives. Verified on the Claude Code CLI; the desktop app's Code tab and Cowork are expected to honour it, unverified |
| FND_SPILL_ACCESS | 1 | 0 disables the spill-access recorder (PreToolUse spill-access.sh) — the wiring pre-gates on it, so nothing is spawned, and the script re-checks it too. With the default it appends one entry:"access" line per DISTINCT spill path a Bash / Read / Grep call named that is a file on disk — PreToolUse fires after the spill was written, so a path that does not exist was only spelled — harvested from the first 16 KB of the unescaped tool_input slice (fnd-mcp-slim-<hash>.json, or any name under a tool-results/ directory — the platform's overflow files are opaquely named, so the recorder matches the same family mcp-slim.cjs's OVERFLOW_PATH writes; at most 8 paths per call) to <FND_MCP_SLIM_DIR>/fnd-mcp-slim-debug.log, recording which reader got there — that is what lets json-slim --report stop calling a whale missed when the agent recovered it with jq instead of a json-slim run. It writes only while FND_MCP_SLIM_DEBUG is on (the log is a debug artefact), never blocks, prints nothing, and reads a run naming json-slim.cjs as no access at all (the CLI logs its own line). Being a PreToolUse hook it records the read that was attempted, and a command that only names a spill (rm, ls, echo …) is recorded as via: named, which --report does not count as a recovery. Host divergence: Claude Code matches Bash|Read|Grep; Codex adds its shell / local_shell spellings; Cursor covers the SHELL only, since it documents no read-file event; OpenCode covers bash plus its read / grep tools |
| FND_READER_COMPRESSION | 1 | 0 disables the reader-compression relay, both halves: PostToolUse reader-compression.cjs (matcher ^(Agent|Task)$, a foreground spawn's tool result — the wiring pre-gates on it, so node never spawns) and the reader-notification half of UserPromptSubmit user-prompt.cjs (a background spawn's <task-notification> prompt, the agent proven by the host's agent-<id>.meta.json beside the session transcript; counted in that wiring's four-switch short-circuit). With the default, a return from one of the three readers (jira-reader, figma-reader, doc-reader, with or without the fnd: prefix — no other agent, whatever its return quotes) is scanned for the contract's compression: field and the figure is put in front of the developer as systemMessage — one surface on every host (the CLI prints it inline, the desktop app under its collapsible hook notice), and the model is never asked to print or repeat it. It is the ONLY route out of a reader's own context: the skills no longer relay the field, so an ad-hoc reader spawn — a pasted ticket key, no skill — surfaces the figure too. A subagent return is data, and the ticket text it echoes is data quoted inside data, so the value is relayed only when it parses end to end as the printed line of a compressor this plugin ships — fnd-mcp-slim: <decision> <n> B → <n> B (<pct>), json-slim's <in> → <out> bytes (<pct>% reduction) with its optional bracketed refusal tag, figma-node-slim: … nodes= hidden= folded= — ; -joined, single-line and capped; a prefix followed by free text is not a figure and is dropped whole, and the field is read past any earlier compression: line the ticket body put above it. none, an absent field, a non-reader agent, a relay firing inside a subagent (no developer to reach) and an unparseable event all print nothing and exit 0, and so does any internal error — a relay may never delay or block a tool. Host divergence: Claude Code only — no other host spawns subagents through a tool whose PostToolUse this plugin sees, so nothing is wired there and this switch changes nothing |
| FND_HOST_TRACE | off | 1 (or true/yes/on) turns on the host-proof log: every fnd hook appends one JSONL line per invocation to <FND_MCP_SLIM_DIR>/fnd-host-trace.log (os.tmpdir() when that is unset), so "did the hooks fire on this host?" is answered from disk instead of from a model reporting on itself. One line = ts, host (claude/cursor/codex/opencode, or unknown when the wiring set no FND_HOST), event, hook, decision (pass / deny / inject / stub / compress / skip / error), plus tool on a Pre/PostToolUse line, agent on a SubagentStart one, the project basename and ms. Metadata only — never a payload, command text, prompt text or the path of a spill — and the whole thing is best-effort: a failure to log can never change a hook's stdout, its stderr or its exit code. The file rotates once to fnd-host-trace.log.1 at 5 MB and is kept by the spill sweep under both names. Global-only (process env or ~/.config/domaine/env; a project .claude/domaine.env cannot arm or silence a repo's own proof), and unset / 0 / an unrecognized value ⇒ no file is created. The off path costs the hot-path guards one builtin-only scan of the global env file per invocation (no fork, no external command) — well under a millisecond, but not literally nothing. Read it back with node plugins/fnd/scripts/doctor.cjs --trace [--since 2h], which prints an event/hook × host matrix with a decision breakdown |
| FND_FIGMA_SOURCE | auto | which source figma-reader may read a design through: auto walks the ladder (connector Figma MCP → the local figma-dev-mode bridge → the REST API with FIGMA_TOKEN), mcp forbids the token path, rest skips both MCP rungs — for a developer who knows the desktop app is closed. Matched exactly, lowercase; anything else falls back to auto with note=invalid_figma_source value=<v> on stderr. Global-only: it is deliberately NOT one of the thirteen project-file keys above, so a copy in a <repo>/.claude/domaine.env is ignored — which rung a read may take is a decision about this machine's Figma access, not something a client repository sets for everyone who opens it. scripts/figma-rest.sh --policy prints the resolved value and whether a token is present, with no network call and without printing the token |
| SHOPIFY_ADMIN_GQL_QUIET | off | non-0 value shortens the gql runner's engine-fallback note to note=engine=token |
| FND_GQL_PROBE_CACHE | 21600 | seconds the gql runner reuses its two sticky machine facts — the shopify version probe (~1.5 s, also invalidated whenever the CLI binary is newer than the cache) and "store execute is unavailable for this store", which lets a later call skip the doomed probe entirely. 0 re-probes on every call — the escape hatch right after a shopify store auth; any invalid value falls back to 21600. Both caches live in a 0700 per-user dir under $TMPDIR; --engine store ignores the second one and always attempts |
| FND_CPT_THROTTLE_WAITS | 20 60 | pause(s), in seconds, between create-preview-theme.sh's push retries after Shopify answers Throttled — the store+token rate limit is shared with a running shopify theme dev, so a bulk push can 429 while everything else is healthy. One retry per listed value; empty disables retrying (tests pass 0 0) |
| FND_CPT_OVERLAY_VERIFY | 1 | 0 skips create-preview-theme.sh create's overlay read-back (the run prints overlay=skipped). With the default, after the settings overlay pushes cleanly the script pulls the settings patterns back off the target theme and requires every overlaid *.json to be present — Shopify validates theme JSON server-side and silently drops a file whose section/block types the branch's code lacks while theme push exits 0 with a clean stderr (observed live: a dev-theme templates/product.json carrying a feature-branch block type never landed and every PDP on the "successful" preview 404'd). Each missing file prints warn=overlay_file_dropped file=… [unknown_types=…] (the alien types, best-effort, matched against the pushed code's schemas) plus overlay=partial — the run still exits 0: everything else landed, and the per-file recovery via theme-json.sh set beats deleting a theme whose re-create would replay the same drop. A read-back pull that itself fails is overlay=unverified + warn=overlay_unverified, never a drop claim and never a failed run. A dev-theme pull that produced no settings file at all is overlay=empty + warn=overlay_empty (--reuse: nothing overlaid, the theme keeps its previous settings) or error=overlay_pull_failed (fresh create: the theme is deleted), independent of this switch. Presence-only by design (content legitimately differs — Shopify re-stamps its /*…*/ banner), so a --reuse target's pre-existing stale copy can still pass — a known ceiling. Costs two pulls, and they retry through the same loop as the pushes (FND_CPT_THROTTLE_WAITS), so a throttled store can add those pauses after every piece of real work already succeeded |
| FND_CPT_OVERLAY_VERIFY_WAIT | 2 | seconds before the overlay read-back's ONE re-pull when a file comes back missing — a read issued straight after a write can trail it, and calling consistency lag a drop would cry wolf. 0 re-checks immediately (what the suites pass); any non-numeric value falls back to 2 |
| FND_THEME_JSON_VERIFY | 1 | 0 skips theme-json.sh set's read-back verify, which then believes the transport alone (shopify theme push's exit code, an empty themeFilesUpsert.userErrors) and prints verified=skipped on the result line. With the default — both engines — every set pulls the file back and compares it against the payload (*.json normalized: Shopify's re-stamped /*…*/ banner stripped, jq -S key order; raw bytes, trailing newlines aside, for anything else), retrying once after FND_THEME_JSON_VERIFY_WAIT seconds so a read served the pre-write copy is not called a failure. Landed ⇒ verified=true; a read-back that does not carry the payload ⇒ error=not_applied on stdout, a hint naming the two known triggers on stderr, preceded by note=verify_diff listing the leaf key paths that differ (only_in_payload= / only_in_theme= / changed=, 8 each, keys only, never values) and diff_lines= (the count alone when the read-back is not JSON), exit 6 — same code when the read-back itself could not run (error=verify_read_failed, state unconfirmed; a throttled or garbled gql read counts, and is never re-tried on the other engine, since the mutation already committed). Exit 6 means the theme does not serve the payload, not nothing changed: no pre-image is read before the write, so get and restore your snapshot if it moved. Normalizing *.json needs perl — without it (or with a perl that will not run) the compare is raw, which cannot tell a re-stamped banner from a lost write, so there a difference is verified=unverified + exit 0 and note=verify_raw_compare on stderr, never not_applied. A read-back that will not parse as JSON on a host that can normalize is the opposite case and not a degradation: the payload is validated as JSON before the upload, so that body is not the payload — note=verify_body_not_json and the ordinary not_applied + exit 6, judged per attempt (a garbled first read followed by a clean matching retry still verifies). Shopify keeps the old content for some payloads it rejects server-side while reporting success, so 0 is an escape hatch for a mis-verified write, not a speed-up |
| FND_THEME_JSON_VERIFY_WAIT | 2 | seconds theme-json.sh set waits before its ONE read-back retry — a read issued straight after a write can still be served the pre-write copy, and calling that a failure would send the caller chasing a phantom. 0 retries immediately (what the suites pass, so a not_applied case does not pay the pause); any non-numeric value falls back to 2. Only the pause is configurable: the retry itself is not optional, and FND_THEME_JSON_VERIFY=0 is the switch that skips the read-back entirely |
| TOML_PATH | shopify.theme.toml | path the theme scripts read their config from — store = (all three), the dev-theme id theme = (create-preview-theme.sh only; must resolve to a NUMERIC id — a theme name is refused, since names are not unique on a store and a mis-parse would orphan a created theme; the pin subcommand is the exception, deliberately allowed to run on an absent or malformed theme = line precisely to repair it), the Theme Access token (create-preview-theme.sh, theme-json.sh --engine themecli). In a multi-environment toml every value comes out of ONE [environments.*] block — the same block the pin writes: --env <name> (create-preview-theme.sh only), else $SHOPIFY_FLAG_ENVIRONMENT, else dev, else development, else the top-level keys, else (reads only) file order where every store = in the file names the same store; blocks naming different stores with none of those names refuse with error=ambiguous_env before any request, unless --store/$SHOPIFY_STORE (theme-json.sh, create-preview-theme.sh) names a store one block carries — that block is picked. A key the chosen block does not carry falls back to the top-level keys and then to the file at large — except store = / password =, the two halves of one per-store credential, which leave the block only in that same-store case. Also the write target of create-preview-theme.sh's session-theme pin (pin / --pin-toml), which rewrites the theme = line of one environment block via an atomic same-directory replace — permissions and the symlink target are preserved, the inode is not — so point it at a copy when the real config must not be touched |
| SHOPIFY_CLI_THEME_TOKEN | unset | Theme Access token for the shopify CLI. create-preview-theme.sh reads it as a LAST resort (the repo's own password = wins, so a token exported for another project cannot authenticate this repo's pushes against that store) — and as the ONLY token when --store/$SHOPIFY_STORE names a store other than the toml's (a Theme Access token is minted per store); theme-json.sh --engine themecli prefers it over the toml. Both export it into the CLI subprocess and never print it |
| SHOPIFY_STORE | unset | store handle/domain used by theme-json.sh, shopify-admin-gql.sh and create-preview-theme.sh when --store is not passed, ahead of the toml's store =. A store other than the toml's takes create-preview-theme.sh off the toml's token, dev theme and pin target: create and the pin are refused there, refresh pushes with $SHOPIFY_CLI_THEME_TOKEN |
| SHOPIFY_FLAG_ENVIRONMENT | unset | read, not set by fnd: the Shopify CLI's own environment selector (what shopify theme dev -e <name> reads), honored as the default [environments.<name>] block of shopify.theme.toml by all three theme scripts — create-preview-theme.sh (store, dev theme id, Theme Access token AND the session-theme pin target; its --env flag overrides it), theme-json.sh and shopify-admin-gql.sh (store, and the Theme Access token on --engine themecli; in those two --env names the dotenv file, so this variable is the only way to name a block — though --store/$SHOPIFY_STORE settles an ambiguous file for theme-json.sh and create-preview-theme.sh by picking the block that names that store). A name no block in the file carries is error=env_not_found, not a silent fallback |
| SHOPIFY_ADMIN_TOKEN | unset | Admin API access token for the gql runner's token engine, ahead of the --env dotenv file — and the escape hatch for a credential that is not shp*_-shaped (only the file value is shape-gated) |
| SHOPIFY_ADMIN_API_VERSION | 2026-04 | Admin API version the gql runner requests when --api-version is not passed |
| JIRA_EMAIL | unset | Atlassian login jira-attachments.sh authenticates the attachment download as, ahead of the --env dotenv (default ./.env). Half of one credential with JIRA_API_TOKEN — both absent ⇒ error=no_jira_credentials + the setup hint=, exit 3, and the ticket read degrades to metadata instead of failing. Shape-gated (no whitespace, quotes, backslash or control characters), never placed on the argv and never echoed |
| JIRA_API_TOKEN | unset | the per-developer scoped read-only Atlassian API token the same script pairs with JIRA_EMAIL, ahead of the --env dotenv. Read only by design — it grants strictly less than the Atlassian MCP already has, and the plain (unscoped) token the token page offers first is both wrong and rejected by the api.atlassian.com gateway the script speaks to. Charset-gated ([A-Za-z0-9_.+/=~-]); it reaches curl through a 0600 config file deleted on exit, never the argv, and is never printed. Wizard and the degradation contract: plugins/fnd/references/jira-attachments.md |
| JIRA_SITE | meetdomaine.atlassian.net | Atlassian site host jira-attachments.sh resolves the cloudId from (https://<site>/_edge/tenant_info, the one unauthenticated request and the only one that touches the site host — every authenticated call goes to the api.atlassian.com gateway). --site overrides it, --cloud-id <uuid> (the cloud UUID itself, not a site host) skips the lookup entirely; read like the two credentials, process env ahead of the --env dotenv |
| FIGMA_TOKEN | unset | the per-developer Figma personal access token figma-rest.sh authenticates the REST rung with, ahead of the --env dotenv (default ./.env). Scopes: File content: read-only (the node tree and the PNG render), Variables: read-only (design tokens by name — honoured on Enterprise plans only, harmless elsewhere), Current user: read-only (for --check). Charset-gated ([A-Za-z0-9_.+/=~:-]); it reaches curl through a 0600 config file deleted on exit, never the argv, and is never printed — and it rides to api.figma.com only: the render is fetched from its pre-signed S3 URL by a SEPARATE, token-less call. Absent ⇒ error=no_figma_token + the setup hint=, exit 3, and figma-reader asks the developer instead of guessing. Setup, scopes and the degradation contract: plugins/fnd/references/figma-rest.md |
| FND_HOST | set by the fnd wiring | set and read by fnd, never by you: the host name (claude / cursor / codex / opencode) each host's own hook wiring exports so a FND_HOST_TRACE line can say which host ran the hook. It is not a switch — domaine-env will not write one, and a hand-set value only makes the log lie. An absent or unrecognized value is logged as unknown, which is what a manual run or a test is |
| CLAUDE_CODE_SESSION_ID | set by Claude Code | read, not set by fnd: scopes FND_WHALE_GUIDE's one-shot state and FND_NOGAIN_MEMO's no-gain memo to the conversation, so a new session sees the full guidance block — and the declined body — again. Absent (a bare shell) ⇒ both are keyed on the file path alone and the 2 h expiry bounds them |
| CLAUDE_PROJECT_DIR | set by Claude Code | read, not set by fnd: its basename becomes the project tag on a debug line only when the invocation's cwd has no .git ancestor — the .git walk wins because Claude Code exports this variable to hooks but not to the Bash tool; no ancestor and unset ⇒ that cwd's basename |
| CLAUDE_PLUGIN_ROOT / PLUGIN_ROOT | set by the host | read, not set by fnd: where a hook wiring file finds the bundled scripts and session-convention markdown. Claude Code and Cursor set CLAUDE_PLUGIN_ROOT; Codex sets PLUGIN_ROOT plus CLAUDE_PLUGIN_ROOT as a compatibility alias, and hooks/hooks-codex.json prefers the alias with a fallback to PLUGIN_ROOT. Hook scripts never trust either one for guard logic — they resolve their own bundled paths from __dirname / their own dirname, because Cursor leaks the variable between concurrent plugins' hooks and Claude Code has a source-vs-cache inconsistency |
Mods (Claude Code function hooks)
Claude Code only. Besides the classic command hooks above, fnd ships a hooks module:
plugins/fnd/hooks/hooks.json names hooks/mods/register.tsx, which Claude Code loads into the
session itself as function hooks. Function hooks can do what a command hook cannot. They draw UI
above the prompt and in a side pane. They replace a tool result after the host has already cut it
down to an overflow notice. They add a note to a tool's description before the model sees it. They
rewrite a prompt before the model reads it. The module adds six things. The status band is
one row above the prompt with the prompt-cache countdown, model, context use, every rate-limit
window, the session's cost and the task digest. The progress pane shows the task workspace's
checklist. The event log pane keeps the last 50 things the module did or noticed (savings
figures, guard refusals, compactions, model switches, rate alarms, workspace changes, session start
and resume), one timestamped line each. The scratch-path guard answers on the tool call itself. MCP slimming also
reaches results over the platform limit, which the classic mcp-slim hook never sees. Pasted
JSON over the classic guard's gate is replaced in place by its compressed body or a stub, so the
prompt goes through in one submission instead of being blocked. Guard, slimming and pasted JSON do
not duplicate the Node hooks. They spawn the same scratch-path-guard.cjs, mcp-slim.cjs and
prompt-json-guard.cjs, so the D, M and P cases in tests/hooks-sim.sh keep testing the code
that decides. At the top of every prompt the module rewrites an empty
<tmpdir>/fnd-mod-session-<session id>; while that marker is under a minute old the classic
UserPromptSubmit hook skips its context monitor (the band covers it) and still runs the
pasted-JSON block, the session title and the reader relay. The block stays because the rewrite
already passes it, so it only catches what the module skipped or failed to rewrite. A marker
older than a minute counts for nothing, so a resumed session whose module no longer loads gets
the classic monitor back. The module cannot delete files, so old 0-byte markers stay in the temp
dir until the OS clears it.
Screenshots of the band and the pane on terminal and desktop (docs/img/mods-band.png, docs/img/mods-pane.png) are added after the live check.
Status band
One row, most important first. 170 columns, context at 47 %, an account reporting three windows, a task workspace open:
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
cache 42m │ fable-5-1 │ ctx 47% │ 5h 61% · 7d 34% · 7d·fable 12% │ cost $12.40 │ ELC-1591 3/5 ▶ Preview themes for QA review │ [ Compact ] [ Clear ] [ Progress ] [ Log ]
120 columns, same session. The row is 172 cells, so the Log button goes first, then Clear, then the digest:
cache 42m │ fable-5-1 │ ctx 47% │ 5h 61% · 7d 34% · 7d·fable 12% │ cost $12.40 │ [ Compact ] [ Progress ]
60 columns. The rate windows go next, the least full one first, then the model:
cache 42m │ ctx 47% │ [ Compact ] [ Progress ]
The full drop order is: the Log button (/fnd-log still opens the pane), the Clear button (/clear still works), the digest, the cost, then the
rate windows beyond the fullest (least full first), then the last window, then the model, then the Progress button. Cache, ctx and Compact are never
dropped. The row's text truncates as a backstop, so the band never takes a second row.
| Segment | Shows | Rule |
|---|---|---|
| cache | cache 42m, <1m, cache cold, cache —, cache ● | Minutes left of the prompt cache: the last main-thread response plus the TTL. ● while a turn runs; — before the first response and after /clear or resume; cold once the TTL has passed, after a compaction, or after a /model switch that forfeits the cache (another model, or the host reports it cold). A resumed session takes its state from the time since the last response. Hidden while any rate window is at or past 100 %: in overage the TTL is unknown. It is an estimate. The host reports no cache state, only the events it is derived from. |
| model | fable-5-1 | The model id as the session reports it, without the claude- prefix every id carries. A /model switch updates it from the switch event itself; every measurement re-reads it, so a switch the event missed shows by the next response. |
| ctx | ctx 47%, ctx — | Context-window use. It is — on a fresh session until the first response. Right after a compaction it shows the engine's own count of what was kept over the window (ctx 3%), or — when the engine reported no count; that count holds until a response reports a measured fill, as a reading with no fill (only the window) keeps the last one. A compaction reaches the band two ways, the session.compact chain and the engine's own PostCompact report (the settings-hook event, which arrives even when the chain skips the mod, as it did for a Compact press on Claude Code 2.1.289); reports within 30 s of each other are one compaction. Mid-turn it refreshes on a 30 s tick. |
| rates | 5h 61% · 7d 34% · 7d·fable 12% | Every window the API reports, in its order, separated by a dim ·: five_hour → 5h, seven_day → 7d, spend_limit → $; an unknown kind keeps a shortened raw name (7d·fable); past 100 % reads >100%. Empty off a subscription and before the first reading. |
| cost | cost $12.40 | Opt-in: drawn only with FND_BAND_COST=1 in the session's environment. What the session has cost at API prices, as /cost totals it (usage().cost.usd). A subscription is not billed per request, so there it is a measure of work, not a bill. Hidden while it is zero and where the host keeps no ledger. |
| digest | ELC-1591 3/5 ▶ Preview themes | work id · checked/total rows of the workspace's progress.md · the current row (cut to 28 characters): the first unchecked row below the last checked one, else the first unchecked row; ELC-1591 ✓ 5/5 when all are done; the bare id (ELC-1588) while the workspace has no progress.md yet. Hidden while the progress pane is open, and when no workspace resolves. |
| Compact | [ Compact ], c: Compact | Always drawn first and always pressable, so the other buttons never shift: before the first reading, while a turn runs and at any context. From 80 % between turns it switches to the accent color. While the band holds the keyboard it reads c: Compact. A press runs /compact and toasts the result (compacted 412,000 → 38,000 tokens, or why it was skipped or refused). Where the engine refuses compaction from a plugin (a headless / SDK session such as the desktop app), the press runs the /compact slash command as if typed instead and toasts its output. Pressed while a turn runs it only toasts turn is running — press Compact again when it ends; nothing is queued. |
| Clear | [ Clear ], x: Clear | Runs /clear behind the engine's own Yes/No dialog (Clear the conversation?), always: the dialog takes the keyboard, so a stray click or hotkey never clears, and a dismissed dialog is a No. Toasts the command's output. Pressed while a turn runs it only toasts turn is running — press Clear again when it ends. Dim at rest, x: Clear while the band holds the keyboard. |
| Progress | [ Progress ], p: Progress | Opens or closes the progress pane; dim at rest, p: Progress while the band holds the keyboard |
| Log | [ Log ], l: Log | Opens or closes the event log pane; dim at rest, l: Log while the band holds the keyboard |
Look. On a terminal a dim rule (────) separates the band from the transcript above it; the desktop frames its panel itself, so no rule is drawn there. The desktop draws the buttons on a second row under the figures, left-aligned, with a row of air between the two and padding around the panel: its native buttons are tall, and in the figures' row they squashed it and sat far right. The terminal keeps one row, as its height is the scarce side there. Each figure is a dim label and a bold value (cache dim, 42m bold; the same for ctx and each rate window), the model id is plain, the digest's work id is bold and the separators are dim.
Colors. ctx is green (the theme's success) up to 30 %, as the classic notice's 🟢 was; the rate
windows are plain there. Both use the theme's warning color above 30 % and the alarm look from 80 %. The cache is plain at 10 min or more, warning below 10 min and
the alarm below 2 min (a 5 min TTL scales both: warning below 2 min, the alarm below 24 s). Only
theme keys are used, so the band follows light, dark and high-contrast themes. The alarm look is
warning + bold + inverse until a dedicated error key is proven to draw on every theme.
Desktop and hover. In the desktop app's Code tab every segment carries a glyph instead of a word
(⏱ 42m │ 🤖 fable-5-1 │ 🧠 47% │ ⏳ 5h 61% · 7d 34% │ 💰 $12.40 │ 📋 ELC-1591 3/5 ▶ …), and Compact / Clear / Progress / Log are native
buttons. The desktop draws proportional text, so the width model above does not apply there: nothing is
dropped, the row clips at the panel's edge. When the
pointer rests on the cache, ctx, a rate window or the cost, a one-line card appears. This is meant for desktop
and for terminals that pass the pointer through (kitty, Ghostty, iTerm2, WezTerm; tmux passes
none). The glyph labels, the native buttons and hover are still a live check (desktop and
terminal paint). The cards read:
prompt cache: ~42 min left (estimate: last response + 1 h TTL),
context: 47% of 200,000 tokens, 94,000 used, 5h window: 61% used, resets in 2h 05m,
session cost: $12.40 at API prices, as /cost counts it (a subscription is not billed per request).
Toasts. When a rate window first reaches 90 %, one toast shows that window's card for 8 s
(5h window: 91% used, resets in 1h 05m). It re-arms once every window is back under the line, and
after /clear. The slimming path below adds a savings toast (FND_SLIM_TOAST=0 silences the savings
toasts alone). Toasts sit at the transcript's
top-right in fullscreen, or on the notification line under the prompt otherwise. Several toasts
stack; a click takes one off and the pointer over it holds it (there is no close button). They never
touch the transcript or what the model reads. They keep showing while the progress pane is open (it is
not a dialog and does not hold toasts).
Hotkeys. c, x, p and l work only while the band holds the keyboard: after ctrl+x tab or a
click on the band. They never fire from the composer, so typing a c is just a c. The letters are
drawn only then too (c: Compact x: Clear p: Progress l: Log): at rest the buttons read [ Compact ] [ Clear ] [ Progress ] [ Log ],
so the band never suggests a key the composer would swallow. The letters go away again on a press,
when a turn starts and on /clear (there is no focus-out event, so Esc alone leaves them until the
next of those). On a terminal
the buttons are mainly clicked (pointer-capable terminals) or reached as ctrl+x tab then the
letter, and Esc gives the keyboard back to the prompt. To focus the band with a single chord,
rebind abovePrompt:focus (default ctrl+x tab) in ~/.claude/keybindings.json, then press the
letter. On
desktop they are ordinary buttons with no hotkey at all (a desktop draws a hotkey as a badge on its native button, and a click is the way there). ctrl+x ctrl+a collapses the band; while it is collapsed the engine
shows one dim line above the prompt, plugin panel hidden · ctrl+x ctrl+a or click to show, and that
chord or a click on the line brings it back. The collapsed state is the engine's and persists across
reloads; the plugin cannot and does not reopen the band by itself.
Debug. /fnd-band prints the raw figures behind the band: the session's usage() answer, the cache state, the usage atom, the resolved workspace and session root, and the last render's surface and measured columns. Paste its output when a segment looks wrong on some surface.
Progress pane
p: Progress, or /fnd-progress, opens a pane with the resolved task workspace: docked beside the
transcript in a wide fullscreen terminal, inline otherwise (the surface decides). Esc or a second
press closes it.
ELC-1591 · feature/ELC-1591-preview-themes · 3/5
✓ Read the ticket
✓ Plan approved
✓ Header markup
▶ Preview themes for QA review
☐ Steps to Test
- preview theme 141234567890 created for QA
- decision: keep the old toggle behind a setting
- open: confirm the empty-state copy with design
The header is the work id · branch · checked/total. Below it come every progress.md row (✓ done
dimmed, ▶ the digest's current row in bold, ◌ unchecked rows above ▶ dimmed — they
wait on someone, not the queue — ☐ the rest) and the last three - lines of
notes.md, dimmed. A workspace without progress.md shows its id, the branch, one dim line
no progress.md yet — /fnd:save-task-context and the notes tail. With no workspace the pane is
one line: no task workspace — /fnd:save-task-context.
Which workspace. The first candidate whose .claude/tasks/<id>/ directory exists wins:
- an id pinned with
/fnd-progress <id>(/fnd-progress -clears the pin); - the ticket key in the branch name;
- the last ticket key you typed in a prompt that has a workspace (prompts only, not the model's replies; notifications and scheduled prompts do not count);
- the branch slug;
- the newest
progress.mdchanged in the last 12 h, which covers slug workspaces onmain.
The band and the pane redraw on a Write or Edit under .claude/tasks/. A 30 s tick also notices
edits made from Bash (sed, a script). A git checkout / switch / worktree and a directory
change re-resolve the id; /clear forgets the conversation key (a pin stays).
Event log pane
Toasts flash and go. l: Log, or /fnd-log, opens a pane that keeps them: the last 50 things the
module did or noticed, one line each, oldest first and newest last. Esc, a second press or the
engine's close mark closes it. The progress pane and the log pane can be open at once; the engine
shows one and keeps the other as a tab. Pressing the button or running the command of the pane behind the tab brings that pane forward instead of closing it.
14:02 session start
14:05 workspace ELC-1588
14:21 slim getJiraIssue: compressed 118,203 B → 29,412 B (−75.1%)
14:33 guard take_screenshot: path outside the project
14:40 compact manual 412k → 38k
The time is local HH:MM; the kind is dim. A line too long for the pane is cut at its end. When the
pane is shorter than the log, its first line reads … 12 earlier and the newest lines fill the rest.
| Kind | Written when | Text |
|---|---|---|
| session | The module starts (launch and reload), on a resume or fork, and on /clear | start, resume, fork, clear (a resume can log both start and resume) |
| model | A /model switch to another model | The full model id, claude-opus-5-5 |
| compact | A compaction of the main thread | The trigger (manual, auto, plugin for the Compact button) and the tokens before → after when the engine reports them. One line per compaction, whichever of its two reports (the session.compact chain, the PostCompact event) arrives first |
| rate | A rate window first reaches 90 % | The alarm toast's text, 5h window: 92% used, resets in 1h 05m |
| workspace | The resolved task workspace differs from the last one logged (re-resolving the same one after /clear logs nothing) | The work id, or none |
| slim | The module toasts an MCP savings figure (main thread only) | The tool (the part after the last __) and the toast's figure without its fnd-mcp-slim: prefix |
| prompt | A pasted JSON prompt is rewritten and accepted | The toast's figure without its fnd-prompt-slim: prefix |
| guard | The scratch-path guard refuses a tool call | The tool (the part after the last __) and the first line of the reason without the guard's own prefix |
At 50 lines the oldest slim or prompt line makes room first, so a session busy with MCP calls keeps its rarer lines.
The log lives in the session's state only: nothing is written to disk, and a new launch starts empty.
/clear keeps the lines and adds session clear where the conversation restarted.
Toasts are unchanged by it, and FND_SLIM_TOAST=0 silences a savings toast without dropping its line.
FND_EVENT_LOG=0 records nothing.
Scratch-path guard on the tool call
The five tools the classic guard covers (take_screenshot, browser_take_screenshot,
take_snapshot, get_network_request, browser_run_code_unsafe) get two things from the module:
- A description note. The model reads where paths must go (
.claude/tasks/<work-id>/tmp/,.claude/tmp/<work-id>/in a worktree, or.claude/tmp/, absolute) before it picks one. The note is a constant, so it never spends the prompt cache. Whether the note also reaches a deferred tool loaded through ToolSearch is still a live check. - A deny on the call. The tool call is handed to
scratch-path-guard.cjswith the same input the classic wiring builds. Its deny becomes the call's answer, with the same remediation text. This adds no new rule. The deny now lands above other plugins' PreToolUse hooks, and the classic guard stays wired beneath as the backstop. The project root is latched at session start, as the MCP servers' own roots are, so a later/cddoes not move it.
MCP slimming on the tool call
- Results over the platform limit. Claude Code cuts a result over
MAX_MCP_OUTPUT_TOKENSdown to a notice naming atool-results/file, and the classic PostToolUse hook only sees that notice. The module hands the notice tomcp-slim.cjs --overflow=expand, which slims the file it names. Payload text can forge a notice, so only this session's own host spill is accepted:<CLAUDE_CONFIG_DIR or ~/.claude>/projects/<dir>/<session id>/tool-results/mcp-<name>-<n>.txtafter realpath. The model gets the slimmed body with a<<full=…>>handle on that file. A refusal, a missing file or an error leaves the host's notice as it was. - Whale ceiling. A whale that slims under
FND_MCP_SLIM_STUB_BYTES(32 KB) arrives as content. A bigger one still comes back as a stub naming the host file, with anode json-slim/--jqrecipe, or, when no stub can carry it, the host's notice stands. The 3.2 MB, 100-issue*allJQL slims to about 540 KB, so it stays a handback. It is a slimmed one, though, and the model queries it instead of reading 3 MB in chunks. Raising the stub bytes for this path is an open owner decision. - Savings toast. Every slimmed main-thread result toasts the same figure the classic notice
prints, e.g.
fnd-mcp-slim: compressed 118,203 B → 29,412 B (−75.1%), for 5 s (FND_SLIM_TOAST_MS). Results under the limit are still slimmed in place by the classic hook, and the module only toasts the figure it finds in the result. A reader subagent's result is slimmed but never toasted, as with the classic notice.FND_SLIM_TOAST=0turns the toast off; the slimming stays. Until a session marker lets the classic hook drop its own notice, a slimmed result can show the figure twice: the notice line and the toast. - Never twice.
mcp-slim.cjspasses its own output through untouched on every host (debug reasonalready-slim), whatever order the module and the classic hook run in. - Counted as recovered.
json-slim --reportcounts a whale the module expanded into a compressed body as recovered (whale recoveries via: … mod), not missed. A stub handed back by the module is recovered only when its recipe is run, as before. - Subagent whales. A whale inside a reader subagent is expanded only if the host spills it
under this session's own
tool-results/, which is still a live check. When it is refused, the host's notice stands and the whale convention routes it through json-slim as before.
Pasted JSON on the prompt
A prompt the classic prompt-json-guard.cjs would block (over ~10 KB, carrying a parseable JSON
blob over ~8 KB) is handed to prompt-json-guard.cjs --from-mod instead. The script saves every
blob to a file first and replaces each one in place by mcp-slim.cjs's compressed body with a
<<full=…>> handle on that file, or by its stub when compression does not pay off (no gain, a
body still 8 KB or more, or the prompt's 32 KB replacement budget spent). A pasted blob's stub
leads with a runnable json-slim.cjs <file> --jq over a sub-path derived from the blob's shape (for a
Jira search: --jq '.issues[].key', then '.issues[].fields.status.name'), so a one-field question
costs a few hundred bytes; the whole-file profile is named after it, with its size, for what a
sub-path cannot answer. The model gets the rewritten prompt plus one context line naming the files and the
json-slim.cjs --jq recipe to narrow them, and a toast shows the figure, e.g.
fnd-prompt-slim: 48,210 B → 2,104 B (−95.6%): the paste against what the model reads after fnd. For a
stubbed blob that is the profile json-slim.cjs hands it on demand, not the stub's own size, and the
toast says so: fnd-prompt-slim: 25,072 B → 12,522 B (−50.1%), 1/1 stubbed. It stays for 5 s (FND_SLIM_TOAST_MS sets it, FND_SLIM_TOAST=0 silences it). One submission, nothing is blocked.
- The row shows the rewrite. The transcript row holds the rewritten text, not the paste. The original lives only in the saved files.
- The saved files are durable. They go to the task workspace
tmp/when there is exactly one work-id, else to the project's.claude/fnd-tmp/prompt-json/(git-excluded through.git/info/exclude), never to system temp. Nothing sweeps them, so a handle survives a resume days later. Delete them by hand. - What it leaves alone. Prompts with no JSON blob over the gate (prose, HTML, logs) pass as
typed. Slash commands,
!commands and prompts that do not come from a person (peer messages, task notifications, plugin prompts) are not rewritten, so a big JSON paste in them meets the classic block as before. - On failure the classic block applies. If the rewrite cannot be built (a failed save, a timeout, a too-large prompt), the prompt goes down unchanged and the classic guard blocks it as before. The classic guard always runs beneath the rewrite and passes it (a live check).
- Desktop. Confirmed live in the desktop app's Code tab: the rewrite applies there too, and the
spill lands under the project's
.claude/fnd-tmp/prompt-json/.
How mods load
- Users: install
fnd@domaineas in Install. The module loads in the terminal CLI and in the desktop app's Code tab. There is nothing to configure and no env entry to add./plugin disable fnd@domaineturns it off with the rest of the plugin, andstatusBand(below) hides only the band. - Development only:
claude --plugin-dir plugins/fndloads the checkout for one terminal session, which watches it and reloads on save. A desktop session cannot take that flag. There,CLAUDE_CODE_PLUGIN_DIRS(an absolute path) plusCLAUDE_CODE_PLUGIN_DIR_WATCH=1, both in theenvblock of~/.claude/settings.json, load and watch a folder. That setting is user-global, so it also loads into every terminal session, client projects included. Point it at a mod-only sandbox copy, never atplugins/fndbeside the installed plugin (that loads every skill, MCP server and classic hook twice), and remove both entries after the sitting. Delete any sandbox copy before installing a release that carries the module, or you get two bands and double hooks. claude plugin validate --strict plugins/fndandclaude plugin test plugins/fnd(wrapped bytests/mods-sim.sh) check the module without a session.
Mods settings (userConfig)
Two fields in plugin.json → userConfig. The engine documents them as rows in /config; where
they appear is still a live check. They are stored in
~/.claude/settings.json → pluginConfigs, and a change reloads the module.
| Field | Default | Effect |
|---|---|---|
| statusBand | true | false draws no band. The panes, /fnd-progress, /fnd-log, the guard, slimming and the pasted-JSON rewrite keep working. |
| cacheTtl | auto | The prompt-cache TTL behind the countdown. auto learns it from the session: a /model switch reports it, and a subagent result with 1 h cache writes proves 1 h. A learned 1 h is remembered across sessions; a reported 5 min is not (the host reports 5 min before it has seen a 1 h cache write, so remembered it would outlive the session that made it). Until then the estimate is 1 h on a claude.ai account and 5 min when the session bills an API key or a cloud provider (ANTHROPIC_API_KEY, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX or CLAUDE_CODE_USE_FOUNDRY present in the session's environment; only their presence is read). Rate-limit windows arriving also mean a subscription, so they set 1 h too, over the default and over a remembered value, and a 5 min report on a subscription is ignored. A subscription in overage drops to 5 min, which the band cannot see; the cache segment is hidden while a window is at 100 %. 5m or 1h forces it. |
Fallbacks and other hosts
- A module hook that fails is skipped, and the session goes on as without it. A failure in one fnd
hook skips the module's other hooks on the same event (the guard re-latches its root on the
first tool call;
/fnd-progressand/fnd-logare re-registered on the next prompt). The classic hooks stay wired underneath, so the guard and slimming lose nothing; a pasted-JSON prompt meets the classic block instead of the rewrite. - Where commands cannot be spawned (the module's process API is documented as CLI-only, though the
desktop app's Code tab spawns fine), the guard and slimming fall back to the classic hooks: the deny comes
from the PreToolUse guard, an over-limit result keeps the host's notice for the whale convention
to route, and a big JSON paste is blocked by
prompt-json-guard.cjsas before. The progress resolver then has no branch name and falls back to the conversation key and the newest workspace. - Kill switches:
FND_SCRATCH_GUARD=0,FND_MCP_SLIM=0andFND_PROMPT_JSON=0turn off the module's guard, slimming and pasted-JSON rewrite too. The module reads only the session's environment (the shell andsettings.json→env). A value set only in~/.config/domaine/envreaches just the spawned.cjs, which then allows or emits nothing, so the effect is the same one spawn later. See the three rows in Environment switches. - The context monitor (
FND_CTX_MONITOR) still runs, so its warning and the band's ctx figure both show.FND_CTX_MONITOR=0silences the monitor if the band is enough. - Cursor, Codex CLI and OpenCode do not load the module. Their manifests name their own hook
files (
hooks/hooks-cursor.json,hooks/hooks-codex.json), and OpenCode loads onlyopencode/fnd-plugin.js. Three shared changes reach them:mcp-slim.cjspasses its own output through untouched and is also loadable as a module without reading stdin, tracing or sweeping (tests/hooks-codex-sim.sh,tests/hooks-cursor-sim.shandtests/opencode-plugin-sim.mjspin the passthrough and that their spawn is unchanged,tests/hooks-sim.shM-req the module load); the whale convention split in two — Claude Code reads the shorthooks/mcp-whale-claude.md(over-limit results arrive already slimmed or stubbed), every other host keeps the fullhooks/mcp-whale.md(Cursor throughrules/fnd-mcp-whale.mdc); and the untrusted-content convention names the prompt spills (task workspacetmp/,.claude/fnd-tmp/prompt-json/) as real handles, which OpenCode's own<<fnd-prompt-json full=…>>rewrite (opencode/fnd-plugin.js) also produces.
Lean-code convention
A session-start hook injects hooks/lean-code.md — a "lazy senior developer" discipline
(idea adapted from ponytail): before writing
code, walk a reuse ladder (exists already? stdlib/Liquid built-in? Shopify-native? installed
dependency? one line?) and only then write the minimum that works. Two fnd-specific
guardrails: the ticket/AC defines scope (the ladder only governs how it's built), and
explicit skill output contracts outrank it. A SubagentStart hook
(hooks/subagent-conventions.sh) injects the same convention plus comment-discipline into
code-writing subagents (general-purpose, workflow) — plus the Foundation addendum where the
project profile says foundation (see FND_PROFILE); read-only readers skip all of those and
get only the outside-content rail. Disable
durably with FND_LEAN=0 (project or global settings.json → env), or say
"normal mode" to suspend it for the current session.
How-to-explain convention
A session-start hook also injects hooks/writing-style.md: when the agent explains code, a plan,
an error or a change, it writes about 80% to the ASD-STE100 rules — one idea per sentence, active
voice, one word per thing, answer first, steps as a numbered list, a symbol diagram past three
steps or parts. "Explain in HTML" asks for one self-contained interactive page. The main session
gets it; subagents do not. Skill output formats (Steps to Test, TA, PR body, QA report) still decide
structure and mandatory wording. Disable durably with FND_STE=0 (project or global
settings.json → env), or say "normal writing" to suspend it for the current session.
Permission design notes
Two deliberate decisions, recorded so they don't read as omissions:
- The big workflow skills ship without
allowed-tools.write-technical-approach,develop-feature-or-fix,qa-feature-or-fix,qa-preflight,write-steps-to-test,pre-commit-review,create-pull-request,ship, andsave-task-contextorchestrate open-ended work (editing, store runners, browser MCPs, subagents), so they run under the session's normal permission flow instead of a frozen allowlist — a frozen list that misses one instructed tool blocks the skill's own workflow. The other ten are narrow utilities (translations, breaking-changes ×2, preview themes, worktrees, commit, a11y fixes, preflight checks, smoke test, issue reports) and do declare tight allowlists. - The reader/writer agents are fenced with
disallowedTools:, nottools:.jira-reader,jira-writer,figma-reader, anddoc-readermust work whether the Atlassian / Figma / Notion MCP comes from this plugin or from the user's own config, and the MCP tool names differ per install scope — an allowlist of hardcoded names would break them silently on the other scope. A denylist can't: it names every write tool of those servers in both spellings (mcp__plugin_fnd_atlassian__editJiraIssueandmcp__atlassian__editJiraIssue, …), denies the servers each agent has no business touching whole, and dropsEdit, subagent spawning and — for the agents that don't need them —WebFetch/WebSearch. Reads keep working under any scope, a name that doesn't exist on this install is inert, and the prompt contract still holds: the three readers stay read-only toward their sources (each writes only its own workspace file),jira-writerkeeps exactly one approved write (editJiraIssue/addCommentToJiraIssue) plus its read-back. TheirBashexists for the bundled scripts alone —adf-to-md.cjs,json-slim.cjsand, forjira-reader,jira-attachments.sh(which is how a ticket's images reach disk without the agent runningcurlorffmpegitself) plus onedate -u +%FT%TZfor the workspace file'sfetched_atstamp. The code-reading agents (bug-hunter,change-reviewer,theme-explorer) name no MCP, so they are pinned toRead, Grep, Glob, Bashinstead.
Reporting plugin issues
When an fnd component misbehaves — a bundled script crashes or prints a misleading error=, a
converter mangles content, a skill contradicts what the tooling actually does — run
/fnd:report-plugin-issue. It collects sanitized debug info (versions, exact command, output —
never tokens or secrets), checks for duplicates, shows you the draft, and files a GitHub
issue on this repo after you approve. A session-start hook also nudges Claude to propose it
whenever it notices a plugin defect mid-task.
License
MIT — see LICENSE.
