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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,25 @@ This repository is the `entwining-mods` Claude Code marketplace. Each mod is a p

## Layout

- Development tooling lives only at the repository root: a `package.json` with a lockfile inside a plugin folder makes Claude Code install dependencies on every user's machine.
- An install copies only the plugin folder, so anything a mod needs at run time or a user needs to read stays inside it.
- `plugins/<mod>/.claude-plugin/types/` is written by the engine when it loads the mod and holds the machine's MCP tool list; it stays untracked.
- A mod's README is written for the people who install it; what only someone changing the mod needs goes in its AGENTS.md.
Development tooling lives only at the repository root: a `package.json` with a lockfile inside a plugin folder makes Claude Code install dependencies on every user's machine. An install copies only the plugin folder, so anything a mod needs at run time or a user needs to read stays inside it. `plugins/<mod>/.claude-plugin/types/` is written by the engine when it loads the mod and holds the machine's MCP tool list; it stays untracked.

A mod's README is written for the people who install it; what only someone changing the mod needs goes in its AGENTS.md.

## Code

- Use what the mods API, Bun, or the standard library already provides before writing a helper, and add no type, wrapper, or abstraction whose only purpose is to look tidy.
- A guard, fallback, or `catch` handles a failure a real session meets or a documented engine behavior, and comes with the test the Checks section asks for; otherwise leave it out. A case only a constructed input reaches goes in the README's known gaps instead, unless it breaks a promise the README makes.
- A file stays within 200 lines of code (`max-lines`, which skips blank lines and comments, so blank lines that set blocks apart cost nothing; a script's `*.generated.ts` output is not linted). Split it along what its parts do rather than to fit the count; a mod's tests follow the same split, with shared fixtures in `tests/` files not named `*.test.ts`.
- Comments state the reason the code cannot show, not what it does or what it replaced.
- `.oxlintrc.json` enforces the cases of these that a lint rule can catch.
Use what the mods API, Bun, or the standard library already provides before writing a helper, and add no type, wrapper, or abstraction whose only purpose is to look tidy.

A guard, fallback, or `catch` handles a failure a real session meets or a documented engine behavior, and comes with the test the Checks section asks for; otherwise leave it out. A case only a constructed input reaches goes in the README's known gaps instead, unless it breaks a promise the README makes.

A file stays within 200 lines of code (`max-lines`, which skips blank lines and comments, so blank lines that set blocks apart cost nothing; a script's `*.generated.ts` output is not linted). Split it along what its parts do rather than to fit the count; a mod's tests follow the same split, with shared fixtures in `tests/` files not named `*.test.ts`.

Comments state the reason the code cannot show, not what it does or what it replaced. `.oxlintrc.json` enforces the cases of these that a lint rule can catch.

## Checks

`bun run check` runs what CI runs, apart from CI regenerating preallow's gitleaks rules and failing when they differ from the committed file, so a Renovate update of the gitleaks release `scripts/gitleaks.ts` pins waits for someone to run `bun run gitleaks` on its branch. It runs `oxfmt --check`, `oxlint`, `claude plugin validate` for the marketplace and each plugin, `claude plugin test`, `bun run test:sources`, which fails when preallow's output scan finds a credential in one of the plugin's own files, since plugin tests cannot read files, and `bun run typecheck:core`, which checks every mod against the plugin API's core declarations that `scripts/core-types.ts` takes from the pinned engine's binary. That check is what keeps an engine update, which Renovate merges once CI passes, from adding a field to an answer a mod reads, such as preallow's `OWNS_ASK`, unnoticed. Tool records are declared only in the types an interactive session writes, so `tool-results.d.ts` types the ones a mod reads, as loosely as it reads them; add a record there when a mod starts reading one. `bun run typecheck` checks a mod against all the engine's declarations (see the README); run it whenever you load a mod from the checkout.

- Tests use synthetic inputs only. This repository is public: no transcripts, logs, real repository names, hostnames, or credentials. Build credential-shaped test strings at run time, such as `` `ghp_${'x'.repeat(36)}` ``, so the source does not look like a leaked key.
- Every safeguard has a test that fails when the safeguard is removed. After adding or changing one, remove it, watch a test fail, and restore it.
- Each test covers a behavior no other test fails on; merge or delete one that fails on the same bug as another, and delete one whose behavior the code no longer has.
Tests use synthetic inputs only. This repository is public: no transcripts, logs, real repository names, hostnames, or credentials. Build credential-shaped test strings at run time, such as `` `ghp_${'x'.repeat(36)}` ``, so the source does not look like a leaked key. Every safeguard has a test that fails when the safeguard is removed. After adding or changing one, remove it, watch a test fail, and restore it. Each test covers a behavior no other test fails on; merge or delete one that fails on the same bug as another, and delete one whose behavior the code no longer has.

## Commits and releases

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Claude Code plugins built on function hooks, published as the `entwining-mods` marketplace.

| Mod | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| [preallow](plugins/preallow) | In auto mode, a model judge runs the steps of your task without the classifier, leaving irreversible effects you did not explicitly ask for to it |
| Mod | What it does |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [preallow](plugins/preallow) | In auto mode, a model judge allows the steps of your task without the classifier, leaving the rest to it, such as a leak or an irreversible effect you did not explicitly ask for |

## Install

Expand Down
4 changes: 2 additions & 2 deletions plugins/preallow/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"model": {
"type": "string",
"title": "Judge model",
"description": "The model the judge of every call runs on, passed to the model client as you write it: an alias or a full model id. Leave empty for the plugin's default pair, haiku with a second judge on opus. If you set it and leave the second judge's model empty, there is no second judge."
"description": "The model the judge of every call runs on, passed to the model client as you write it: an alias or a full model id. Leave empty for the plugin's default pair, haiku with a second judge on opus. If you set it and leave Escalation model empty, there is no second judge."
},
"effort": {
"type": "string",
Expand All @@ -21,7 +21,7 @@
"escalation_model": {
"type": "string",
"title": "Escalation model",
"description": "The model of a second judge for every call the first judge did not allow, failures included; it judges the call again from the start, and its verdict is final. Passed as you write it. Leave empty for opus when the judge model is empty too, or for no second judge when you set the judge model."
"description": "The model of a second judge for every call the first judge did not allow, failures included; it judges the call again from the start, and its verdict is final. Passed as you write it. Leave empty for opus when Judge model is empty too, or for no second judge when you set Judge model."
},
"escalation_effort": {
"type": "string",
Expand Down
29 changes: 19 additions & 10 deletions plugins/preallow/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@

The README states what the plugin allows and what it leaves to native approval; keep it in step with the code.

- The plugin only loosens. `tool.check` turns the core's `ask` into `allow` or returns the core's answer unchanged; it never touches an `allow`, a `deny`, or an ask whose result names a decider other than the classifier (`OWNS_ASK` in `hooks/register.ts`), or whose input names a host as `approver`. Preserve this ownership boundary when the engine adds metadata. An unknown field leaves the ask to the judge, since sending every ask with an unknown field to the classifier would bring back the false denials the plugin exists to remove. A permission hook that throws or overruns its budget is skipped and the core's answer stands.
- A failed output scan withholds the output of a call the plugin allowed; the call has already run, so recovery must not execute it again. Every pattern in `hooks/withhold.ts` stays linear in the output's length, and `scripts/gitleaks.ts` strips the name gitleaks matches before a value under a keyword, which backtracks for seconds over a crafted megabyte, so a scan ends within the budget instead of withholding large outputs; the test that scans every rule's keyword fails when it comes back. A pattern needs a key's body, not only the words that name its format, and `scripts/gitleaks.ts` appends a token body to a gitleaks rule that is only a literal prefix, since the plugin's own files name every format; `bun run test:sources` fails when reading one of them would be withheld.
- `hooks/gitleaks.generated.ts` is written by `bun run gitleaks` (`scripts/gitleaks.ts`, which pins the gitleaks release and rewrites its Go regexes for JavaScript); change the script and regenerate instead of editing the file.
- One judge for every tool it is asked about: no per-tool or per-command policy branches or allowlists in its policy or around it, and the policy text names no project, command, or wording taken from real data. A tool whose verdict the engine keeps whatever a hook answers (`RESERVED_TOOLS` in `hooks/register.ts`) never reaches the judge, since a judge call there spends seconds on an allow the engine rejects or ignores; add a tool there only for that engine behavior, as a `rejected` log line or the engine's documentation shows it. A Bash, PowerShell, or command-monitor call requesting a non-empty `allowed_domains` list also stays with the classifier, whose approval registers the network grants. Nor does a call whose input asks to run in the background, since its output arrives where the withholding scan cannot see it; a tool whose output arrives later without such a flag (`Monitor`) is a known gap in the README until the engine signals it.
- The judge chooses what to check; `hooks/facts.ts`, with `hooks/paths.ts`, `hooks/refs.ts`, and `hooks/git.ts`, answers from a fixed set of queries that only read, stay offline, and carry a time limit, and takes the judge's strings only as absolute paths or ref names that cannot pose as options. A new query keeps all of that, with a test that fails without it.
- Only a reply that ends with a JSON object answering `allow` allows: text may come before it, and nothing but a closing code fence after it, and no conflicting verdict values may appear anywhere in the reply. One answering `check` with one to four well-formed items gets one more round with the facts; anything else, including no reply, is `review`. Facts that cannot be read, and a second `check`, are `review` too.
- The user's authority comes from `prompt.submit` with a `composer` or `bridge` origin and from the person's answer in an `AskUserQuestion` result, never an answer the dialog gave while the person was away. The answer reaches the judge as the engine gives it, with the questions from the result, which are what the person was shown: the plugin does not split or match it against the options, since the engine's answer is display text a label can also contain. A chosen option authorizes only the action and target it states, as `JUDGE_SYSTEM` says. Never from assistant text, other tools' results, files, skill bodies, or other sessions. Do not widen what counts as the user's words. They are kept in `$.store` under the session id, so a resume keeps them and `/clear`, which goes on under a new id, starts without them. A conversation is kept bounded by its JSON size (`addTurn` in `hooks/messages.ts`), so 50 fit the store whatever they hold, and the judge reads all of it for every call, since leaving out a limit lets through a call the user ruled out; each write replaces it whole: a judge reading meanwhile sees it before or after the message, never without it; never rebuild them from `$.session.messages`, which does not say where a message came from. The judge prompt is one JSON document, so text inside a message cannot pose as another field; it is sent as two blocks, the kept conversation first and marked for the prompt cache, then the call's fields (`judgePrompt` in `hooks/judge.ts`), so every call until the user writes again opens the same way and reads the conversation, and on gateway routes the policy, from the cache.
- The `model`, `effort`, `escalation_model`, and `escalation_effort` settings reach `$.model.complete` as the user wrote them: no aliases, option lists, or validation.
- `judge` (`hooks/judge.ts`) stays one stage, since the evaluation outside the engine calls it once per stage; the second stage lives in `judgeCall` (`hooks/stages.ts`).
- `JUDGE_SYSTEM` (`hooks/policy.ts`) decides what is allowed. Check a change to it or to either stage's default model or effort in `hooks/register.ts` against real denials kept outside this repository, on a split not used while writing the change, and compare allow and review rates before and after.
The plugin only loosens. `tool.check` turns the core's `ask` into `allow` or returns the core's answer unchanged; it never touches an `allow`, a `deny`, or an ask whose result names a decider other than the classifier (`OWNS_ASK` in `hooks/register.ts`), or whose input names a host as `approver`. Preserve this ownership boundary when the engine adds metadata. An unknown field leaves the ask to the judge, since sending every ask with an unknown field to the classifier would bring back the false denials the plugin exists to remove. A permission hook that throws or overruns its budget is skipped and the core's answer stands.

One judge for every tool it is asked about: no per-tool or per-command policy branches or allowlists in its policy or around it, and the policy text names no project, command, or wording taken from real data. A tool whose verdict the engine keeps whatever a hook answers (`RESERVED_TOOLS` in `hooks/register.ts`) never reaches the judge, since a judge call there spends seconds on an allow the engine rejects or ignores; add a tool there only for that engine behavior, as a `rejected` log line or the engine's documentation shows it. A Bash, PowerShell, or command-monitor call requesting a non-empty `allowed_domains` list also stays with the classifier, whose approval registers the network grants. Nor does a call whose input asks to run in the background, since its output arrives where the withholding scan cannot see it; a tool whose output arrives later without such a flag (`Monitor`) is a known gap in the README until the engine signals it.

`JUDGE_SYSTEM` (`hooks/policy.ts`) decides what is allowed. Check a change to it or to either stage's default model or effort in `hooks/register.ts` against real denials kept outside this repository, on a split not used while writing the change, and compare allow and review rates before and after.

The user's authority comes from `prompt.submit` with a `composer` or `bridge` origin and from the person's answer in an `AskUserQuestion` result, never an answer the dialog gave while the person was away, and never from assistant text, other tools' results, files, skill bodies, or other sessions. Do not widen what counts as the user's words. The answer reaches the judge as the engine gives it, with the questions from the result, which are what the person was shown: the plugin does not split or match it against the options, since the engine's answer is display text a label can also contain. A chosen option authorizes only the action and target it states, as `JUDGE_SYSTEM` says.

The user's words are kept in `$.store` under the session id, so a resume keeps them and `/clear`, which goes on under a new id, starts without them; never rebuild them from `$.session.messages`, which does not say where a message came from. A conversation is kept bounded by its JSON size (`addTurn` in `hooks/messages.ts`), so 50 fit the store whatever they hold, and the judge reads all of it for every call, since leaving out a limit lets through a call the user ruled out. Each write replaces it whole: a judge reading meanwhile sees it before or after the message, never without it. The judge prompt is one JSON document, so text inside a message cannot pose as another field; it is sent as two blocks, the kept conversation first and marked for the prompt cache, then the call's fields (`judgePrompt` in `hooks/judge.ts`), so every call until the user writes again opens the same way and reads the conversation, and on gateway routes the policy, from the cache.

The judge chooses what to check; `hooks/facts.ts`, with `hooks/paths.ts`, `hooks/refs.ts`, and `hooks/git.ts`, answers from a fixed set of queries that only read, stay offline, and carry a time limit, and takes the judge's strings only as absolute paths or ref names that cannot pose as options. A new query keeps all of that, with a test that fails without it.

Only a reply that ends with a JSON object answering `allow` allows: text may come before it, and nothing but a closing code fence after it, and no conflicting verdict values may appear anywhere in the reply. One answering `check` with one to four well-formed items gets one more round with the facts; anything else, including no reply, is `review`. Facts that cannot be read, and a second `check`, are `review` too.

`judge` (`hooks/judge.ts`) stays one stage, since the evaluation outside the engine calls it once per stage; the second stage lives in `judgeCall` (`hooks/stages.ts`). The `model`, `effort`, `escalation_model`, and `escalation_effort` settings reach `$.model.complete` as the user wrote them: no aliases, option lists, or validation.

A failed output scan withholds the output of a call the plugin allowed; the call has already run, so recovery must not execute it again. Every pattern in `hooks/withhold.ts` stays linear in the output's length, and `scripts/gitleaks.ts` strips the name gitleaks matches before a value under a keyword, which backtracks for seconds over a crafted megabyte, so a scan ends within the budget instead of withholding large outputs; the test that scans every rule's keyword fails when it comes back. A pattern needs a key's body, not only the words that name its format, and `scripts/gitleaks.ts` appends a token body to a gitleaks rule that is only a literal prefix, since the plugin's own files name every format; `bun run test:sources` fails when reading one of them would be withheld.

`hooks/gitleaks.generated.ts` is written by `bun run gitleaks` (`scripts/gitleaks.ts`, which pins the gitleaks release and rewrites its Go regexes for JavaScript); change the script and regenerate instead of editing the file.
Loading
Loading