Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
This commit is contained in:
commit
5b8503fcd9
93 files changed
+12775
No files matched your search
@@ -0,0 +1,85 @@
|
||||
# Agents and thinking
|
||||
|
||||
An agent variant sets three things: how much the model deliberates, which tools it is
|
||||
offered, and a behaviour appendix in the system prompt.
|
||||
|
||||
```bash
|
||||
shiro --agent deep # at launch
|
||||
shiro --agent plan --think low # variant with an overridden level
|
||||
```
|
||||
|
||||
```
|
||||
/agent picker
|
||||
/agent review direct
|
||||
/think picker
|
||||
/think max direct
|
||||
```
|
||||
|
||||
## The variants
|
||||
|
||||
| Variant | Thinking | Tools | Steps | For |
|
||||
|---|---|---|---|---|
|
||||
| `default` | medium | all | 50 | ordinary work |
|
||||
| `quick` | off | all | 12 | small, well-scoped edits |
|
||||
| `deep` | max | all | 80 | hard problems, unclear causes |
|
||||
| `plan` | high | read-only | 50 | investigate and propose |
|
||||
| `review` | high | read-only | 50 | critique a change |
|
||||
|
||||
**`quick`** tells the model not to deliberate, not to write a task list, and not to explore
|
||||
beyond what the change needs. Good for a rename or a one-line fix where thinking budget is
|
||||
pure latency.
|
||||
|
||||
**`deep`** asks for more than one hypothesis before acting, more reading before concluding,
|
||||
and findings recorded with `remember` so they survive compaction.
|
||||
|
||||
**`plan`** and **`review`** are genuinely read-only. `write_file`, `edit_file`, and `bash`
|
||||
are withheld from the model, not merely discouraged in prose — a model that cannot see a
|
||||
tool cannot call it. Their prompts also forbid describing edits as if they had been made.
|
||||
|
||||
## Thinking levels
|
||||
|
||||
`off`, `low`, `medium`, `high`, `max`. They map to whatever the provider actually supports:
|
||||
|
||||
| Level | OpenAI `reasoning_effort` | Anthropic `thinking` |
|
||||
|---|---|---|
|
||||
| `off` | `none` | `{ type: "disabled" }` |
|
||||
| `low` | `low` | `budget_tokens: 6400` |
|
||||
| `medium` | `medium` | proportional budget |
|
||||
| `high` | `high` | `budget_tokens: 38400` |
|
||||
| `max` | `xhigh` | maximum budget |
|
||||
|
||||
Verified against both wire formats rather than assumed.
|
||||
|
||||
Higher costs more and takes longer. `off` on a hard problem produces confident wrong
|
||||
answers; `max` on a rename wastes a few cents and several seconds. The variants pick
|
||||
sensible defaults, so reach for `/think` only when a specific turn needs something else.
|
||||
|
||||
## Overriding
|
||||
|
||||
`--agent deep --think low` gives you `deep`'s tools, steps, and appendix with a low thinking
|
||||
budget. The override clones the preset rather than mutating it, so a later `/agent deep` in
|
||||
the same session still gets `max`.
|
||||
|
||||
## Defaults in config
|
||||
|
||||
```json
|
||||
{ "agent": "deep", "thinking": "high" }
|
||||
```
|
||||
|
||||
A flag beats the config file. An unknown name fails at startup with the valid list rather
|
||||
than silently falling back:
|
||||
|
||||
```
|
||||
$ shiro --agent turbo
|
||||
shiro: Unknown agent "turbo". Available: default, quick, deep, plan, review
|
||||
```
|
||||
|
||||
## What the variant changes in the prompt
|
||||
|
||||
The system prompt describes only the tools actually offered, and the workflow rules adapt.
|
||||
Under `plan` the model is told it has no tools that change anything and that it cannot run
|
||||
commands, so it should say what to run rather than claim it passed. Under `default` it is
|
||||
told which tools need approval and to verify with the project's tests.
|
||||
|
||||
A prompt that describes a withheld tool teaches the model to attempt calls that cannot
|
||||
succeed, which is why the description is generated from the live tool set.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Architecture
|
||||
|
||||
## The loop
|
||||
|
||||
One turn is a `streamText` call whose stream is translated into UI events.
|
||||
|
||||
```
|
||||
user prompt
|
||||
→ messages.push({ role: 'user', ... })
|
||||
→ streamText({ model, system, messages, tools, activeTools, reasoning, toolApproval })
|
||||
→ for each stream part → yield an AgentEvent
|
||||
→ if any tool needs approval, the stream ends suspended
|
||||
→ collect decisions from the UI
|
||||
→ push a tool message with the approval responses
|
||||
→ loop
|
||||
→ otherwise done
|
||||
```
|
||||
|
||||
`src/session.ts` is an async generator. The UI consumes events; it never touches the SDK.
|
||||
That is what lets the same session drive the Ink app, the headless printer, and the tests.
|
||||
|
||||
## Why approval goes through the SDK
|
||||
|
||||
An obvious design is a promise inside each tool's `execute`, resolved when the user answers.
|
||||
That was rejected: it makes "denied" a convention the tool must remember to honour, and one
|
||||
tool forgetting it is a silent security hole.
|
||||
|
||||
Instead the SDK's `toolApproval` is used. A denied call **provably never executes** — the SDK
|
||||
never reaches `execute`. The tool cannot opt out because the tool is not consulted.
|
||||
|
||||
```ts
|
||||
toolApproval: async ({ toolCall }) => {
|
||||
const blocked = await plugins?.guard({ toolName: toolCall.toolName, input: toolCall.input, cwd });
|
||||
if (blocked) return { type: 'denied', reason: blocked }; // --yolo cannot reach this
|
||||
if (yolo) return undefined;
|
||||
if (!needsApproval(toolCall.toolName)) return undefined;
|
||||
return 'user-approval';
|
||||
}
|
||||
```
|
||||
|
||||
Guards are checked first, so `--yolo` skips prompts but not refusals.
|
||||
|
||||
One subtlety: when this function denies, the SDK emits `tool-approval-request` with
|
||||
`isAutomatic: true` and answers it itself. Queueing that would prompt the user for a call
|
||||
that is already settled, so automatic requests are skipped and denial is surfaced from
|
||||
`tool-approval-response` instead.
|
||||
|
||||
## Where state lives
|
||||
|
||||
The system prompt is rebuilt on **every step**, not once per turn:
|
||||
|
||||
```ts
|
||||
prepareStep: ({ messages }) => {
|
||||
const instructions = this.systemFor(); // task list, memory, skills, agent
|
||||
if (estimateTokens(messages) <= threshold) return { instructions };
|
||||
return { instructions, messages: prunePreservingItems({ messages, reasoning: 'all', ... }) };
|
||||
}
|
||||
```
|
||||
|
||||
That is not an optimisation. A `todo_write` on step one must be visible to step two, and
|
||||
`system:` on `streamText` is bound once for the whole run. Returning `instructions` from
|
||||
`prepareStep` is the only place per-step state can enter.
|
||||
|
||||
The prompt also describes only the tools actually offered this turn. A prompt that mentions a
|
||||
withheld tool teaches the model to attempt impossible calls.
|
||||
|
||||
## Rendering
|
||||
|
||||
Ink re-renders the whole tree on every `setState`. At 50 tokens a second that is 50 full
|
||||
renders and a visibly flickering terminal.
|
||||
|
||||
Two things fix it:
|
||||
|
||||
- Finished lines go into `<Static>`, rendered once and never redrawn.
|
||||
- Token deltas accumulate in a ref and flush on a 60 ms interval, not per token.
|
||||
|
||||
Markdown is parsed on every flush. An unclosed fence renders as a code block that grows,
|
||||
which is what a reader expects while text is still arriving.
|
||||
|
||||
## Input
|
||||
|
||||
`ink-text-input` was replaced. It discards up and down before its own handler, so history
|
||||
recall is impossible, and it only ever *shrinks* its internal cursor offset, so an externally
|
||||
set value leaves the cursor stranded mid-string.
|
||||
|
||||
`src/ui/PromptInput.tsx` owns the cursor. That also gives home, end, and ctrl-a/e/k/u/w for
|
||||
free. It hands up, down, tab, and escape to a parent callback first, so the command menu and
|
||||
open panels can claim them before the input treats them as editing keys.
|
||||
|
||||
## Subagents
|
||||
|
||||
`task` runs a nested `streamText` with only `read_file`, `glob`, and `grep`. It returns one
|
||||
message.
|
||||
|
||||
Two consequences follow from the tool set, not from policy:
|
||||
|
||||
- It can never need approval, because it has no gated tools.
|
||||
- The parent's context holds the findings, not the search transcript.
|
||||
|
||||
Progress is reported through a callback, wired to a bus the panel subscribes to. Without the
|
||||
bus the panel would need a reference to the tool, and the tool would need one to React.
|
||||
|
||||
## Provider differences
|
||||
|
||||
Two are handled explicitly.
|
||||
|
||||
**Thinking levels.** `off`/`low`/`medium`/`high`/`max` become `reasoning_effort` on OpenAI and
|
||||
a `thinking` token budget on Anthropic. The SDK does the mapping; `src/agents.ts` only picks
|
||||
the level.
|
||||
|
||||
**Endpoint fallback.** Newer OpenAI models reject function tools on `/v1/chat/completions`
|
||||
and require `/v1/responses`. `src/fallback.ts` presents both as one model and switches when
|
||||
the first rejects the request *shape* — 400, 404, 405, 415, 422, 501 with `isRetryable` false.
|
||||
Retryable failures are left to the SDK's backoff.
|
||||
|
||||
The switch is sticky. Once an endpoint rejects the shape it will reject every later step too,
|
||||
so re-probing it each turn would waste a round trip per step.
|
||||
|
||||
Only `api.openai.com` gets the chain. Third-party endpoints do not implement `/v1/responses`.
|
||||
|
||||
## Compaction and its repair
|
||||
|
||||
`pruneMessages({ reasoning: 'all' })` strips a reasoning item and keeps the message item from
|
||||
the same response. The responses API treats the message as that reasoning item's dependent
|
||||
and returns 400.
|
||||
|
||||
The two carry different ids, so they cannot be matched by id. What links them is the assistant
|
||||
message they arrived in: one message is one response, and its reasoning item covers every
|
||||
other item in it. `src/prune.ts` drops the dependent parts of any turn whose reasoning was
|
||||
removed — which costs nothing, since pruning was already discarding those turns.
|
||||
|
||||
## Module map
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `session.ts` | the loop, approvals, compaction, event stream |
|
||||
| `tools.ts` | file and shell tools, ripgrep bridge, bash streaming |
|
||||
| `ignore.ts` | gitignore-aware walker, path jail |
|
||||
| `prompt.ts` | system prompt assembly from live state |
|
||||
| `agents.ts` | variants, thinking levels |
|
||||
| `skills.ts` | discovery, catalogue, `skill` tool |
|
||||
| `memory.ts` | durable notes, search, model compaction |
|
||||
| `notebook.ts` | session task list |
|
||||
| `plugins.ts` | host, hooks, guard chain |
|
||||
| `subagent.ts` | `task` tool and progress events |
|
||||
| `ask.ts` | the `ask` tool |
|
||||
| `mcp.ts` | MCP clients and namespacing |
|
||||
| `fallback.ts` | endpoint chain |
|
||||
| `prune.ts` | provider-item repair |
|
||||
| `markdown.ts` | parser, no dependency |
|
||||
| `store.ts` | sessions, prompt history |
|
||||
| `config.ts` | resolution, model construction |
|
||||
| `providers.ts` | presets, `/models` fetch |
|
||||
| `pricing.ts` | USD rates |
|
||||
| `commands.ts` | slash registry, parsing, menu matching |
|
||||
| `headless.ts` | `-p` mode |
|
||||
| `cli.tsx` | argv, wiring, lifecycle |
|
||||
| `ui/*` | Ink components |
|
||||
|
||||
Every module is pure of the UI except `ui/`, and `ui/` never touches the SDK. The seam is the
|
||||
`AgentEvent` stream.
|
||||
|
||||
## Testing
|
||||
|
||||
404 tests, no mocking framework. `MockLanguageModelV4` from `ai/test` drives the loop;
|
||||
`ink-testing-library` drives the UI with real keystrokes; MCP is tested against a real stdio
|
||||
server subprocess; provider wire formats are tested against a local HTTP server.
|
||||
|
||||
The pattern throughout is to assert on what actually crossed a boundary — what went on the
|
||||
wire, what is on screen, what is on disk — rather than on internal calls.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Configuration
|
||||
|
||||
Settings come from three places. Later wins:
|
||||
|
||||
1. `~/.shiro-neko/config.json`
|
||||
2. environment variables
|
||||
3. command-line flags
|
||||
|
||||
## The config file
|
||||
|
||||
Written by `/provider`, editable by hand. Every field is optional.
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "gpt-5",
|
||||
"baseURL": "https://api.openai.com/v1",
|
||||
"apiKey": "sk-...",
|
||||
"presetId": "openai",
|
||||
"agent": "default",
|
||||
"thinking": "medium",
|
||||
"maxRetries": 3,
|
||||
"plugins": ["guard", "time"],
|
||||
"mcpServers": {
|
||||
"fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `provider` | wire protocol: `anthropic` or `openai`. Not the vendor — Groq, OpenRouter, and Ollama all speak `openai` |
|
||||
| `model` | model id as the endpoint names it |
|
||||
| `baseURL` | API root. Defaults to the official endpoint for the provider |
|
||||
| `apiKey` | sent as `Authorization: Bearer` for `openai`, `x-api-key` for `anthropic` |
|
||||
| `presetId` | which preset `/provider` chose, so it can show what is configured |
|
||||
| `agent` | default variant: `default`, `quick`, `deep`, `plan`, `review` |
|
||||
| `thinking` | default level: `off`, `low`, `medium`, `high`, `max` |
|
||||
| `maxRetries` | retries per model call for transient failures. Default 3 |
|
||||
| `plugins` | which plugins to enable. Omit for `["guard", "time"]` |
|
||||
| `mcpServers` | see [MCP](mcp.md) |
|
||||
|
||||
## Provider presets
|
||||
|
||||
`/provider` offers these. Each sets `baseURL` and the wire protocol for you.
|
||||
|
||||
| Preset | Protocol | Endpoint |
|
||||
|---|---|---|
|
||||
| Anthropic | `anthropic` | `api.anthropic.com/v1` |
|
||||
| OpenAI | `openai` | `api.openai.com/v1` |
|
||||
| OpenRouter | `openai` | `openrouter.ai/api/v1` |
|
||||
| Groq | `openai` | `api.groq.com/openai/v1` |
|
||||
| DeepSeek | `openai` | `api.deepseek.com/v1` |
|
||||
| xAI | `openai` | `api.x.ai/v1` |
|
||||
| Ollama | `openai` | `localhost:11434/v1` |
|
||||
| LM Studio | `openai` | `localhost:1234/v1` |
|
||||
| Custom OpenAI-compatible | `openai` | you supply it |
|
||||
| Custom Anthropic-compatible | `anthropic` | you supply it |
|
||||
|
||||
After the key is entered, `GET /v1/models` is called and the list becomes a picker. If the
|
||||
endpoint does not implement it, you type the model id instead — the setup still completes.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `SHIRO_PROVIDER` | overrides `provider` |
|
||||
| `SHIRO_MODEL` | overrides `model` |
|
||||
| `SHIRO_BASE_URL` | overrides `baseURL` |
|
||||
| `SHIRO_API_KEY` | overrides `apiKey` |
|
||||
| `ANTHROPIC_API_KEY` | used when `provider` is `anthropic` and no key is set |
|
||||
| `OPENAI_API_KEY` | used when `provider` is `openai` and no key is set |
|
||||
| `SHIRO_HOME` | relocates config, sessions, memory, history, and user skills |
|
||||
| `SHIRO_INSTALL_DIR` | where `install:local` and the installers put the binary |
|
||||
| `SHIRO_REPO` | which GitHub repo the installers download from |
|
||||
| `SHIRO_VERSION` | pins the version the installers fetch |
|
||||
|
||||
`SHIRO_HOME` is what the test suite uses to keep a run out of your real config.
|
||||
|
||||
## Flags
|
||||
|
||||
```
|
||||
shiro [options]
|
||||
shiro -p "prompt" headless, prints to stdout
|
||||
cat file | shiro -p prompt read from stdin
|
||||
```
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `-p`, `--print [prompt]` | headless mode. Needs `--yolo` for tool use |
|
||||
| `--json` | with `-p`, one JSON event per line |
|
||||
| `-c`, `--continue` | resume the newest session for this directory |
|
||||
| `-r`, `--resume <id>` | resume by session id or unique prefix |
|
||||
| `--agent <name>` | `default`, `quick`, `deep`, `plan`, `review` |
|
||||
| `--think <level>` | `off`, `low`, `medium`, `high`, `max` |
|
||||
| `--provider <name>` | `anthropic` or `openai` |
|
||||
| `--model <id>` | model id |
|
||||
| `--base-url <url>` | API root |
|
||||
| `--no-mcp` | skip MCP servers |
|
||||
| `--no-subagent` | omit the `task` tool |
|
||||
| `--no-instructions` | ignore `AGENTS.md` and friends |
|
||||
| `--no-skills` | ignore builtin and project skills |
|
||||
| `--no-plugins` | disable all plugins, including the guard |
|
||||
| `--no-memory` | do not load or write project memory |
|
||||
| `--yolo` | skip every approval prompt |
|
||||
| `-v`, `--version` | version, bun version, platform, source or compiled |
|
||||
| `-h`, `--help` | usage |
|
||||
|
||||
## Where things live
|
||||
|
||||
```
|
||||
~/.shiro-neko/
|
||||
config.json provider, model, key, defaults
|
||||
sessions/<uuid>.json transcripts, token counts, cost, task list
|
||||
memory/<hash>.json durable per-project notes
|
||||
history/<hash>.json prompt history for up-arrow recall
|
||||
skills/*.md your own skills
|
||||
```
|
||||
|
||||
Project files:
|
||||
|
||||
```
|
||||
<project>/
|
||||
AGENTS.md instructions injected into the system prompt
|
||||
.shiro/skills/*.md project skills, override user and builtin
|
||||
.shiroignore extra ignore rules on top of .gitignore
|
||||
```
|
||||
|
||||
Memory and history file names are SHA-256 prefixes of the absolute project path, because a
|
||||
path is not a safe filename.
|
||||
|
||||
## OpenAI reasoning models
|
||||
|
||||
Newer OpenAI models reject function tools on `/v1/chat/completions` and require
|
||||
`/v1/responses`. For `api.openai.com` both are chained: a 400, 404, 405, 415, 422, or 501
|
||||
on the first switches to the second, sticks for the rest of the session, and prints one
|
||||
notice. Retryable failures — 429 and 5xx — are left to the SDK's backoff instead.
|
||||
|
||||
Third-party endpoints get a plain chat-completions model with no fallback probe, since they
|
||||
do not implement `/v1/responses`.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Development
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zakirkun/shiro-neko
|
||||
cd shiro-neko
|
||||
bun install
|
||||
```
|
||||
|
||||
Bun 1.3.14 or newer. Nothing else is required, though `rg` on PATH makes `grep` about 15x
|
||||
faster and the fallback path is exercised without it.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run shiro # run from source
|
||||
bun run typecheck # tsc --noEmit
|
||||
bun test # 404 tests
|
||||
bun run build # single binary for this platform -> dist/shiro
|
||||
bun run release # all five platforms -> dist/release + SHA256SUMS
|
||||
bun run install:local # build, then copy onto PATH
|
||||
```
|
||||
|
||||
`bun run install:local` copies the compiled binary. Do not use `bun link`: it writes a shim
|
||||
that re-execs `bun`, which fails on any machine where bun was installed without `bun.exe` on
|
||||
PATH — an npm install of bun, for instance. The compiled binary embeds its own runtime.
|
||||
|
||||
`SHIRO_INSTALL_DIR` overrides the target directory.
|
||||
|
||||
## Testing
|
||||
|
||||
No mocking framework. Everything is driven through a real boundary.
|
||||
|
||||
```ts
|
||||
// The loop: a mock provider, asserting what crossed the wire.
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async (o) => { seen.push(o); return stream(text('ok')); },
|
||||
});
|
||||
const session = new Session({ model, askApproval: async () => 'deny', agent: variantByName('plan') });
|
||||
for await (const _ of session.send('investigate')) void _;
|
||||
|
||||
const offered = (seen[0]?.tools ?? []).map((t) => t.name);
|
||||
expect(offered).not.toContain('write_file');
|
||||
```
|
||||
|
||||
```ts
|
||||
// The UI: real keystrokes, asserting what is on screen.
|
||||
const app = render(<App session={session} bridge={bridge} hooks={testHooks()} header="hdr" />);
|
||||
app.stdin.write('/');
|
||||
await wait(120);
|
||||
expect(app.lastFrame()).toContain('/compact');
|
||||
```
|
||||
|
||||
Provider wire formats are tested against a local `Bun.serve`. MCP is tested against a real
|
||||
stdio subprocess. Tools are tested in a temp directory with `process.chdir`.
|
||||
|
||||
`SHIRO_HOME` points config, sessions, memory, and history at a temp directory, so a test run
|
||||
never touches your real state.
|
||||
|
||||
### What to assert
|
||||
|
||||
Assert on what crossed a boundary: the request body, the rendered frame, the file on disk.
|
||||
Not on internal calls.
|
||||
|
||||
That is not style. Several real bugs were caught this way and would have passed a
|
||||
mock-verification test:
|
||||
|
||||
- `pruneMessages` leaving a message item without its reasoning item — visible only in the
|
||||
request body
|
||||
- `--json` serialising `Error` as `{}` — visible only in the printed output
|
||||
- Automatic approval requests prompting the user — visible only in the event sequence
|
||||
|
||||
## Adding a tool
|
||||
|
||||
1. Define it in `src/tools.ts` with a `zod` schema. Descriptions are read by the model, so
|
||||
write them as guidance, not as documentation.
|
||||
2. Add it to the `tools` object.
|
||||
3. If it mutates anything, add it to `MUTATING_TOOLS` so it requires approval.
|
||||
4. Add a line to `TOOL_DOCS` in `src/prompt.ts` saying *when* to reach for it.
|
||||
5. Test the behaviour in a temp directory, including the failure path.
|
||||
|
||||
Every tool costs roughly 550 characters of schema on every request. Thirteen live tools is
|
||||
already where selection accuracy starts to matter, so a new tool needs to earn its place —
|
||||
see [ROADMAP.md](../ROADMAP.md) for what has been declined and why.
|
||||
|
||||
## Adding a slash command
|
||||
|
||||
`src/commands.ts` is the single source of truth. Add a `CommandSpec` to `COMMANDS`, a case to
|
||||
`parseCommand`, and a case in `App.tsx`. The menu, `/help`, and the parser all read from that
|
||||
one array, and a test asserts every entry parses and appears in help — they cannot drift.
|
||||
|
||||
## Code conventions
|
||||
|
||||
Sample a neighbouring file before inventing a pattern. Broadly:
|
||||
|
||||
- No comment that restates the code. Comments explain *why*, and usually only where something
|
||||
non-obvious was forced by an external constraint.
|
||||
- No `as any`, no `@ts-ignore`. `tsconfig.json` runs strict with
|
||||
`noUncheckedIndexedAccess`.
|
||||
- Validate at trust boundaries — model output, file contents, network responses. Not between
|
||||
internal functions.
|
||||
- Duplication over premature abstraction. No interface with one implementation.
|
||||
- Errors carry what the reader needs to act. `oldString appears 3 times in src/x.ts` beats
|
||||
`edit failed`.
|
||||
|
||||
## Releasing
|
||||
|
||||
The version lives in `src/version.ts`, compiled into the binary. `package.json` carries it too
|
||||
for tooling, and `bun run release` refuses to build if the two disagree, or if a git tag
|
||||
disagrees with either:
|
||||
|
||||
```
|
||||
$ GITHUB_REF_NAME=v9.9.9 bun run release
|
||||
tag v9.9.9 does not match src/version.ts (0.1.0-beta.1). Bump the version or retag.
|
||||
```
|
||||
|
||||
A binary reporting the wrong version is worse than a failed release.
|
||||
|
||||
To cut one:
|
||||
|
||||
```bash
|
||||
# bump src/version.ts and package.json to the same value
|
||||
git commit -am "release 0.1.0-beta.2"
|
||||
git tag v0.1.0-beta.2
|
||||
git push --follow-tags
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` then runs typecheck and tests, cross-compiles all five
|
||||
targets on one Ubuntu runner, asserts the built binary reports the expected version, and
|
||||
publishes a GitHub release with the binaries and `SHA256SUMS`. A tag containing `-` is
|
||||
published as a prerelease.
|
||||
|
||||
Bun cross-compiles from any host, which is why there is no build matrix. Verified: a working
|
||||
`darwin-arm64` binary builds on Windows.
|
||||
|
||||
Publishing is gated on a `v*` tag, so a manual `workflow_dispatch` run produces artifacts
|
||||
without releasing.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/ci.yml` runs typecheck, tests, and a build on Ubuntu, macOS, and Windows
|
||||
for every push and PR.
|
||||
|
||||
All three are necessary. The tools shell out to `rg`, `git`, and a platform shell, and path
|
||||
handling differs — a Windows-only break is invisible on Linux until someone hits it.
|
||||
|
||||
## Debugging the agent itself
|
||||
|
||||
`--no-plugins --no-skills --no-memory --no-instructions --no-subagent --no-mcp` strips it to
|
||||
the seven core tools, which isolates whether a problem is the loop or something layered on it.
|
||||
|
||||
`--json` in headless mode shows the exact event sequence.
|
||||
|
||||
For provider issues, a local `Bun.serve` that logs the request body and returns a canned SSE
|
||||
stream answers "what did we actually send" faster than any amount of reading. Several bugs in
|
||||
this codebase were found that way.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Headless mode
|
||||
|
||||
`-p` runs one prompt without the TUI. For scripts, CI, and piping.
|
||||
|
||||
```bash
|
||||
shiro -p "list every route and its handler"
|
||||
git diff | shiro -p "review this diff" --yolo
|
||||
shiro -p "fix the failing test" --yolo --agent deep
|
||||
```
|
||||
|
||||
The prompt comes from the argument, or from stdin when the argument is omitted.
|
||||
|
||||
## Tool use needs `--yolo`
|
||||
|
||||
There is no terminal to approve on, so every gated tool is denied unless `--yolo` is passed:
|
||||
|
||||
```
|
||||
$ shiro -p "add a test for paginate()"
|
||||
shiro: headless denies write_file, edit_file, bash and mcp tools unless --yolo is passed
|
||||
[tool] write_file {"path":"test/paginate.test.ts",...}
|
||||
[denied] write_file (run with --yolo to allow tool use in headless mode)
|
||||
```
|
||||
|
||||
Read-only tools work either way, so `-p` without `--yolo` is a safe way to ask questions
|
||||
about a codebase from a script.
|
||||
|
||||
**`--yolo` does not disable plugin guards.** `rm -rf` is still refused.
|
||||
|
||||
## Output
|
||||
|
||||
### Text mode (default)
|
||||
|
||||
Assistant text to stdout, everything else to stderr. Pipe-friendly:
|
||||
|
||||
```bash
|
||||
shiro -p "one-line summary of src/session.ts" > summary.txt
|
||||
```
|
||||
|
||||
```
|
||||
$ shiro -p "what does prune.ts do?" 2>/dev/null
|
||||
src/prune.ts repairs provider-item dependencies after pruneMessages strips reasoning items.
|
||||
```
|
||||
|
||||
### JSON mode
|
||||
|
||||
`--json` emits one event per line:
|
||||
|
||||
```bash
|
||||
$ shiro -p "count the tools" --json
|
||||
{"type":"tool-call","id":"c1","name":"grep","input":{"pattern":"tool\\("}}
|
||||
{"type":"tool-result","id":"c1","name":"grep","output":"src/tools.ts:26: ..."}
|
||||
{"type":"text","text":"There are 6 built-in file and shell tools."}
|
||||
{"type":"done","inputTokens":4210,"outputTokens":88}
|
||||
```
|
||||
|
||||
Event types: `text`, `reasoning`, `tool-call`, `tool-output`, `tool-result`, `tool-error`,
|
||||
`tool-denied`, `compacted`, `notice`, `error`, `done`.
|
||||
|
||||
Errors are flattened to message strings, because `JSON.stringify` turns an `Error` into `{}`
|
||||
and a JSON stream that reports failures as empty objects is useless for the one case it
|
||||
matters.
|
||||
|
||||
## Exit codes
|
||||
|
||||
`0` on success, `1` on a model or stream error. A denied tool is not a failure — the model was
|
||||
told and can respond to it.
|
||||
|
||||
```bash
|
||||
if shiro -p "does this build?" --yolo; then echo ok; else echo failed; fi
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
Headless runs save like interactive ones, so `-c` picks up where one left off:
|
||||
|
||||
```bash
|
||||
shiro -p "start the refactor" --yolo
|
||||
shiro -p "now update the tests" --yolo -c
|
||||
```
|
||||
|
||||
## What is withheld
|
||||
|
||||
The `ask` tool is not offered at all, rather than being offered and left to hang. The model
|
||||
is told to decide and state its assumption instead.
|
||||
|
||||
Subagent progress events are not emitted; the report still comes back.
|
||||
|
||||
## CI recipes
|
||||
|
||||
Review a pull request diff:
|
||||
|
||||
```yaml
|
||||
- run: |
|
||||
git diff origin/main...HEAD > /tmp/diff
|
||||
shiro -p "Review this diff. Report defects with file and line. Say so if it is clean." \
|
||||
--agent review < /tmp/diff
|
||||
```
|
||||
|
||||
`--agent review` is read-only, so no `--yolo` is needed and nothing can be modified.
|
||||
|
||||
Fail the build on a specific finding:
|
||||
|
||||
```yaml
|
||||
- run: |
|
||||
shiro -p "Does any handler skip input validation? Answer only YES or NO." --json \
|
||||
| jq -r 'select(.type=="text") | .text' | grep -qv YES
|
||||
```
|
||||
|
||||
Generate a changelog entry:
|
||||
|
||||
```yaml
|
||||
- run: |
|
||||
git log --oneline "$(git describe --tags --abbrev=0)"..HEAD \
|
||||
| shiro -p "Write a changelog entry from these commits. Group by user-facing change." \
|
||||
>> CHANGELOG.md
|
||||
```
|
||||
|
||||
Pass the key as a secret:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
```
|
||||
|
||||
## Cost control
|
||||
|
||||
Headless runs are unattended, so a runaway loop costs real money. `--agent quick` caps the
|
||||
step count at 12. There is no spend ceiling yet — see [ROADMAP.md](../ROADMAP.md).
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# MCP
|
||||
|
||||
[Model Context Protocol](https://modelcontextprotocol.io) servers contribute tools. Configure
|
||||
them in `~/.shiro-neko/config.json` and they appear alongside the builtins.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fs": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
||||
},
|
||||
"db": {
|
||||
"command": "python",
|
||||
"args": ["-m", "my_mcp_server"],
|
||||
"env": { "DATABASE_URL": "postgres://localhost/dev" },
|
||||
"cwd": "/home/you/tools"
|
||||
},
|
||||
"api": {
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"type": "http",
|
||||
"headers": { "Authorization": "Bearer local-dev-token" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**stdio** servers take `command`, and optionally `args`, `env`, `cwd`. The process is spawned
|
||||
at startup and closed on exit.
|
||||
|
||||
**Remote** servers take `url`, and optionally `type` (`http` or `sse`, default `http`) and
|
||||
`headers`.
|
||||
|
||||
## Naming
|
||||
|
||||
Tools arrive as `mcp__<server>__<tool>`. A server named `fs` exposing `read_file` becomes
|
||||
`mcp__fs__read_file`.
|
||||
|
||||
The namespace is not cosmetic. Two servers both exposing `search` would otherwise silently
|
||||
shadow each other, and the model would call one believing it was the other.
|
||||
|
||||
## Approval
|
||||
|
||||
**Every MCP tool requires approval on every call.** They are third-party code with unknown
|
||||
side effects, so they are treated like `bash` rather than like `read_file`. `a` whitelists
|
||||
one tool for the session.
|
||||
|
||||
`--yolo` skips these prompts, as it does for the builtins. Plugin guards still apply.
|
||||
|
||||
## Failure handling
|
||||
|
||||
A server that fails to start is reported and the session continues:
|
||||
|
||||
```
|
||||
shiro-neko 0.1.0-beta.1 openai/gpt-5 session 0193ab2c
|
||||
mcp: 4 tools
|
||||
mcp db failed: spawn python ENOENT
|
||||
```
|
||||
|
||||
Nothing else is lost — the other servers still load, the builtins still work. A missing
|
||||
Python interpreter should not stop you from editing a file.
|
||||
|
||||
`--no-mcp` skips them all.
|
||||
|
||||
## Inspecting
|
||||
|
||||
`/tools` lists everything offered this turn, MCP tools included. The system prompt describes
|
||||
them as a group:
|
||||
|
||||
```
|
||||
- mcp__api__query, mcp__fs__read_file: from MCP servers, named mcp__<server>__<tool>.
|
||||
Each needs approval; read its own description before calling.
|
||||
```
|
||||
|
||||
Their individual descriptions come from the server, so that is what the model reads before
|
||||
calling one.
|
||||
|
||||
## Cost
|
||||
|
||||
Each tool adds roughly 550 characters of schema to every request. A server exposing twenty
|
||||
tools costs about 2,750 tokens per turn, sent whether or not the model uses any of them.
|
||||
|
||||
Prefer servers with a focused tool set. If one exposes many tools you never use, it is worth
|
||||
finding a narrower server or writing one.
|
||||
|
||||
## Writing a server
|
||||
|
||||
Any MCP-compliant server works. A minimal stdio one needs three methods: `initialize`,
|
||||
`tools/list`, and `tools/call`. The test suite includes one at
|
||||
`test/fixtures/mcp-stub.ts` — about 50 lines, and useful as a starting point.
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# Memory and state
|
||||
|
||||
Four kinds of state, each with a different lifetime.
|
||||
|
||||
| State | Lives in | Survives |
|
||||
|---|---|---|
|
||||
| transcript | the message array | until compaction or `/clear` |
|
||||
| task list | the system prompt, rebuilt each step | pruning and `/compact` |
|
||||
| project memory | `~/.shiro-neko/memory/<hash>.json` | across sessions, forever |
|
||||
| session record | `~/.shiro-neko/sessions/<uuid>.json` | until you delete it |
|
||||
|
||||
The split exists because compaction is destructive. `pruneMessages` deletes tool results and
|
||||
`/compact` deletes the whole transcript, so anything recorded only in messages is lost
|
||||
exactly when a long task needs it most.
|
||||
|
||||
## Project memory
|
||||
|
||||
Durable notes about the codebase, injected at the start of every session.
|
||||
|
||||
### `remember`
|
||||
|
||||
```
|
||||
kind fact | decision | gotcha | command
|
||||
text one self-contained line
|
||||
```
|
||||
|
||||
- **fact** — how something is. "The API is versioned under `/v2`."
|
||||
- **decision** — what was chosen and why. "We use snake_case for DB columns; the ORM
|
||||
expects it."
|
||||
- **gotcha** — a trap. "The migration must run before the seed or the FK fails."
|
||||
- **command** — an invocation that works. "Tests run with `bun test`, not `npm test`."
|
||||
|
||||
Duplicates are refused. Text is capped at 400 characters, the store at 300 entries.
|
||||
|
||||
### `recall`
|
||||
|
||||
Every term must appear. A match increments that entry's hit count, which protects it from
|
||||
compaction later — an entry the agent actually uses is worth keeping verbatim.
|
||||
|
||||
### `forget`
|
||||
|
||||
Removes by substring, for a note that turned out wrong.
|
||||
|
||||
### What the agent sees
|
||||
|
||||
```
|
||||
What you learned about this project in earlier sessions. Trust it, but verify anything
|
||||
that contradicts what you can see in the code now:
|
||||
- (command) tests run with bun test, not npm test
|
||||
- (gotcha) the migration must run before the seed
|
||||
- (decision) snake_case for DB columns, the ORM expects it
|
||||
```
|
||||
|
||||
Top 20 by hit count, then recency. The "verify anything that contradicts" line matters:
|
||||
memory goes stale and a confidently wrong note is worse than none.
|
||||
|
||||
### Compacting
|
||||
|
||||
`/memory` has the model merge entries. Two rules make it safe:
|
||||
|
||||
- Entries with at least one recall are kept verbatim and never merged.
|
||||
- A model returning nothing parseable leaves the store untouched.
|
||||
|
||||
Without the second rule a bad response wipes everything the agent has learned.
|
||||
|
||||
`/notes` lists the store with hit counts. `--no-memory` disables loading and writing.
|
||||
|
||||
## Task list
|
||||
|
||||
`todo_write` replaces the whole list each call. Four states:
|
||||
|
||||
```
|
||||
tasks ##########.............. 1/4 1 blocked
|
||||
[x] read the pagination code
|
||||
[~] fix the boundary
|
||||
[ ] add a test
|
||||
[!] update the docs (no write access to the wiki)
|
||||
```
|
||||
|
||||
`blocked` requires a note saying what is blocking it. The tool warns when more than one task
|
||||
is `in_progress`, when nothing is `in_progress` while work remains, or when a `blocked` task
|
||||
has no note.
|
||||
|
||||
The list is re-rendered into the system prompt on **every step**, not once per turn — a
|
||||
`todo_write` on step one has to be visible to step two. It is saved with the session and
|
||||
restored by `-c` or `/resume`.
|
||||
|
||||
`/todos` shows it. The panel above the input shows it live.
|
||||
|
||||
## Sessions
|
||||
|
||||
Every turn autosaves, debounced 400 ms so a long tool loop does not hit the disk each step.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "0193ab2c-…",
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"cwd": "/home/you/project",
|
||||
"provider": "openai", "model": "gpt-5",
|
||||
"title": "why does the pagination test fail?",
|
||||
"inputTokens": 48210, "outputTokens": 3105,
|
||||
"costUsd": 0.0913,
|
||||
"notebook": { "todos": [ … ] },
|
||||
"messages": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
shiro -c # newest session for this directory
|
||||
shiro -r 0193ab2c # by id or unique prefix
|
||||
```
|
||||
|
||||
```
|
||||
/sessions list the last 15
|
||||
/resume <id>
|
||||
/save write now instead of waiting for the debounce
|
||||
```
|
||||
|
||||
A corrupt session file is skipped rather than crashing the list.
|
||||
|
||||
## Compaction
|
||||
|
||||
Two mechanisms.
|
||||
|
||||
**Automatic**, at roughly 120k estimated tokens: `pruneMessages` strips reasoning and older
|
||||
tool calls from what goes on the wire. Local history is untouched, so the transcript on your
|
||||
screen stays complete. The turn reports it:
|
||||
|
||||
```
|
||||
context compacted: 192 messages pruned to 15 on the wire
|
||||
```
|
||||
|
||||
**Manual**, `/compact`: the model writes a summary — goal, files touched, decisions, commands
|
||||
and outcomes, what remains — and it replaces the transcript entirely.
|
||||
|
||||
### The pruning repair
|
||||
|
||||
`pruneMessages({ reasoning: 'all' })` strips a reasoning item and keeps the message item from
|
||||
the same response. The OpenAI responses API treats the message as a dependent of that
|
||||
reasoning item and rejects the request:
|
||||
|
||||
```
|
||||
400 Item 'msg_…' of type 'message' was provided without its required 'reasoning' item: 'rs_…'
|
||||
```
|
||||
|
||||
The two carry different ids, so they cannot be matched by id. What links them is the
|
||||
assistant message they arrived in — one message is one response. `src/prune.ts` drops the
|
||||
dependent parts of any turn whose reasoning was removed. That costs nothing, because pruning
|
||||
was already discarding those turns.
|
||||
|
||||
## Prompt history
|
||||
|
||||
Per-directory, capped at 200, deduplicated against the previous entry. Up and down in the
|
||||
input walk it; down past the newest restores what you were typing.
|
||||
|
||||
Stored at `~/.shiro-neko/history/<hash>.json`, where the hash is a SHA-256 prefix of the
|
||||
project path.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# Plugins
|
||||
|
||||
A plugin extends the agent in four ways: it can add tools, mark tools auto-approved, block
|
||||
a tool call before it runs, and append to the system prompt. It can also run something after
|
||||
each turn.
|
||||
|
||||
Plugins are compiled into the binary. Loading them from disk is deliberately not supported
|
||||
yet — see [ROADMAP.md](../ROADMAP.md).
|
||||
|
||||
## Enabling
|
||||
|
||||
```json
|
||||
{ "plugins": ["guard", "time"] }
|
||||
```
|
||||
|
||||
That is also the default when the field is absent. `--no-plugins` disables all of them,
|
||||
including the guard. `/plugins` lists what is active and reports any name that did not
|
||||
resolve.
|
||||
|
||||
## The interface
|
||||
|
||||
```ts
|
||||
export type Plugin = {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: ToolSet;
|
||||
autoApprove?: readonly string[];
|
||||
beforeToolCall?: (ctx: { toolName: string; input: unknown; cwd: string }) => string | undefined | Promise<string | undefined>;
|
||||
afterTurn?: () => void | Promise<void>;
|
||||
appendix?: string;
|
||||
};
|
||||
```
|
||||
|
||||
`beforeToolCall` returning a string **blocks** the call, and the string is given to the model
|
||||
as the reason. Returning `undefined` allows it.
|
||||
|
||||
Two decisions worth knowing about:
|
||||
|
||||
**A throwing hook blocks.** A guard that crashes must fail closed. Treating an exception as
|
||||
"allow" would mean a bug in a security plugin silently disables it.
|
||||
|
||||
**Blocks are checked before approval.** `--yolo` skips prompts; it does not skip guards. A
|
||||
plugin block is a refusal, not a permission question.
|
||||
|
||||
## Builtins
|
||||
|
||||
### `guard` (default on)
|
||||
|
||||
Refuses irreversible shell commands outright. Approval alone is a weak defence here: a user
|
||||
holding `a` through a batch of edits will approve one of these without reading it.
|
||||
|
||||
| Pattern | Why |
|
||||
|---|---|
|
||||
| `rm -rf`, `rm -f` | recursive or forced delete |
|
||||
| `git reset --hard` | discards uncommitted work |
|
||||
| `git clean -f` | deletes untracked files |
|
||||
| `git push --force`, `-f` | rewrites remote history |
|
||||
| `git branch -D` | deletes a branch without a merge check |
|
||||
| `DROP TABLE`, `TRUNCATE` | destroys database data |
|
||||
| `mkfs`, `dd of=/dev/…` | writes to a raw device |
|
||||
| `chmod 777` | makes files world-writable |
|
||||
| `shutdown`, `reboot`, `halt` | affects the whole machine |
|
||||
| `:(){ :\|:& };:` | fork bomb |
|
||||
| `curl … \| sh`, `wget … \| sh` | pipes a download into a shell |
|
||||
|
||||
```
|
||||
Blocked by the guard plugin: refusing "rm -rf build" (recursive or forced delete).
|
||||
Ask the user to run it themselves if it is really needed.
|
||||
```
|
||||
|
||||
The model is told to relay the command rather than work around it. `rm build/one-file.js`,
|
||||
`git push origin feature`, and `git commit` all pass — the patterns target irreversibility,
|
||||
not the commands themselves.
|
||||
|
||||
### `time` (default on)
|
||||
|
||||
Adds `current_time`, returning ISO 8601 plus the local string. Auto-approved; it reads
|
||||
nothing. Useful because models are confidently wrong about the date.
|
||||
|
||||
### `bell` (opt in)
|
||||
|
||||
Writes `\u0007` to stderr when a turn ends. Off by default — a bell after every turn is
|
||||
intrusive, but it is genuinely useful when a turn takes minutes.
|
||||
|
||||
```json
|
||||
{ "plugins": ["guard", "time", "bell"] }
|
||||
```
|
||||
|
||||
## Writing one
|
||||
|
||||
Plugins live in `src/plugins-builtin.ts` and are registered in `BUILTIN_PLUGINS`.
|
||||
|
||||
```ts
|
||||
export const noSecretsPlugin: Plugin = {
|
||||
name: 'no-secrets',
|
||||
description: 'refuses to write files that look like credentials',
|
||||
appendix:
|
||||
'The no-secrets plugin refuses writes to .env and credential files. Ask the user to ' +
|
||||
'add secrets themselves rather than working around it.',
|
||||
beforeToolCall: ({ toolName, input }) => {
|
||||
if (toolName !== 'write_file' && toolName !== 'edit_file') return undefined;
|
||||
const path = String((input as { path?: unknown } | null)?.path ?? '');
|
||||
if (/(^|\/)\.env|credentials|\.pem$/.test(path)) {
|
||||
return `refusing to write ${path}; add secrets yourself`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Then add it to `BUILTIN_PLUGINS` and, if it should be on by default, `DEFAULT_ENABLED`.
|
||||
|
||||
Write the `appendix` whenever the plugin can block something. Without it the model hits a
|
||||
refusal it was never told about and tries to route around it.
|
||||
|
||||
## Ordering
|
||||
|
||||
Plugins run in the order they are enabled. The first `beforeToolCall` to block wins;
|
||||
later hooks are not consulted. `afterTurn` runs every hook, and one throwing does not stop
|
||||
the rest.
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# Skills
|
||||
|
||||
A skill is a markdown file with instructions for one kind of task. Only its name and
|
||||
description sit in the system prompt; the body is loaded on demand.
|
||||
|
||||
That split matters. Four bundled skills are 4,659 characters of body but 681 characters of
|
||||
catalogue. Putting every body in the prompt would cost that on every request, for
|
||||
instructions that are relevant to one turn in twenty.
|
||||
|
||||
## Format
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy
|
||||
description: Ship a release. Use when asked to deploy, cut a release, or publish a build.
|
||||
---
|
||||
|
||||
# Deploy
|
||||
|
||||
1. Confirm the tests pass. Do not deploy on a red suite.
|
||||
2. Tag with the version from `src/version.ts`, not by hand.
|
||||
3. Push the tag. CI builds and publishes.
|
||||
|
||||
Never deploy from a dirty working tree.
|
||||
```
|
||||
|
||||
`name` and `description` are both required; a file missing either is skipped. The
|
||||
description is what the model matches against, so write it as a trigger — "use when asked
|
||||
to X" — not as a summary.
|
||||
|
||||
## Where they load from
|
||||
|
||||
Three sources, later overriding earlier by name:
|
||||
|
||||
1. **builtin** — compiled into the binary
|
||||
2. **user** — `~/.shiro-neko/skills/*.md`
|
||||
3. **project** — `.shiro/skills/*.md`
|
||||
|
||||
A project skill named `debug` replaces the bundled one entirely. `/skills` shows what
|
||||
loaded and where each came from.
|
||||
|
||||
`--no-skills` skips all of them, builtin included.
|
||||
|
||||
## The bundled skills
|
||||
|
||||
**`debug`** — reproduce first, form three hypotheses, disprove them cheapest-first, fix the
|
||||
cause not the symptom, write a test that failed before. After two failed attempts: re-read
|
||||
the error literally and check whether the code you think is running is the code that is
|
||||
running.
|
||||
|
||||
**`review`** — severity order: incorrect behaviour, missing validation at trust boundaries,
|
||||
security, resource handling, then clarity. Say plainly when something is fine. Do not invent
|
||||
findings to look thorough.
|
||||
|
||||
**`refactor`** — establish a safety net first, move in small steps with tests green between
|
||||
each, do not fix bugs while refactoring, do not add abstraction for a single caller.
|
||||
|
||||
**`test`** — read two existing test files first and match them, assert on behaviour not
|
||||
implementation, never weaken an assertion to make a test pass, a flaky test is a shared-state
|
||||
problem and not something to retry around.
|
||||
|
||||
They are string constants in `src/skills-builtin.ts` rather than files, because
|
||||
`bun build --compile` only embeds modules reachable through imports. A directory of `.md`
|
||||
files would be missing from the shipped binary.
|
||||
|
||||
## How the agent uses one
|
||||
|
||||
The catalogue appears in the system prompt:
|
||||
|
||||
```
|
||||
Skills available through the skill tool. Load one when its description matches the task,
|
||||
before you start working, and follow it as if the user had written it:
|
||||
- debug: Track down a bug whose cause is not obvious. Use when a test fails for unclear...
|
||||
- refactor: Restructure code without changing behaviour. Use when asked to refactor...
|
||||
```
|
||||
|
||||
When the model calls `skill({ name: "debug" })` it gets the full body back and is told to
|
||||
follow it for this task. The call needs no approval — it reads nothing outside the binary.
|
||||
|
||||
## Writing a good one
|
||||
|
||||
Skills work when they encode what a newcomer to *your* project would get wrong. The bundled
|
||||
ones are generic on purpose; yours should not be.
|
||||
|
||||
Useful:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: migration
|
||||
description: Write or run a database migration. Use when the schema changes.
|
||||
---
|
||||
|
||||
Migrations live in `db/migrations/` and are timestamped, never renumbered.
|
||||
|
||||
Run `bun run db:migrate` locally first. Staging runs them automatically on deploy;
|
||||
production needs `bun run db:migrate --env=prod` by hand, after the deploy is green.
|
||||
|
||||
Never edit a migration that has run anywhere. Write a new one.
|
||||
```
|
||||
|
||||
Not useful:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: quality
|
||||
description: Write good code.
|
||||
---
|
||||
|
||||
Follow best practices. Write clean, maintainable code with good naming.
|
||||
```
|
||||
|
||||
The second costs tokens and changes nothing.
|
||||
|
||||
## Skill or AGENTS.md?
|
||||
|
||||
`AGENTS.md` is always in the prompt. A skill is loaded when its description matches.
|
||||
|
||||
Put standing facts in `AGENTS.md`: build commands, layout, conventions that apply to every
|
||||
change. Put task-specific procedure in a skill: how to deploy, how to add a migration, how
|
||||
this project debugs its worker queue.
|
||||
|
||||
If it applies to every turn, it belongs in `AGENTS.md`. If it applies to one kind of turn,
|
||||
make it a skill.
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Tools
|
||||
|
||||
## The approval model
|
||||
|
||||
Three categories.
|
||||
|
||||
**Free.** Read-only, no prompt: `read_file`, `glob`, `grep`, `task`.
|
||||
|
||||
**Session tools.** Also free, because they touch the agent's own state rather than your
|
||||
files: `todo_write`, `remember`, `recall`, `forget`, `skill`, `ask`, and anything a plugin
|
||||
marks auto-approved.
|
||||
|
||||
**Gated.** Every call stops for a decision: `write_file`, `edit_file`, `bash`, and every
|
||||
`mcp__*` tool.
|
||||
|
||||
```
|
||||
edit_file wants to run
|
||||
src/users.ts +2 -1
|
||||
export function paginate(offset: number, total: number) {
|
||||
- if (offset < total) return next();
|
||||
+ if (offset <= total) return next();
|
||||
}
|
||||
y allow once | a always allow edit_file | n deny
|
||||
```
|
||||
|
||||
`a` whitelists that tool for the rest of the session. `n` tells the model it was denied and
|
||||
to ask what to do instead. `--yolo` skips all prompts.
|
||||
|
||||
**The guard runs before all of this.** It is not an approval — it is a refusal, and `--yolo`
|
||||
does not reach it. See [plugins](plugins.md).
|
||||
|
||||
## File tools
|
||||
|
||||
### `read_file`
|
||||
|
||||
```
|
||||
path file path relative to the workspace root
|
||||
offset first line, 1-based
|
||||
limit max lines, default 2000
|
||||
```
|
||||
|
||||
Returns contents with 1-based line numbers. Refuses binaries: a NUL byte in the first 8 KB
|
||||
means the file is not text, and a model that reads a 90 MB executable has burned its whole
|
||||
context on nothing.
|
||||
|
||||
### `write_file`
|
||||
|
||||
```
|
||||
path file path
|
||||
content full contents
|
||||
```
|
||||
|
||||
New files and full rewrites only. Creates parent directories.
|
||||
|
||||
### `edit_file`
|
||||
|
||||
```
|
||||
path file path
|
||||
oldString exact text to find, whitespace and indentation included
|
||||
newString replacement
|
||||
replaceAll replace every occurrence instead of requiring exactly one
|
||||
```
|
||||
|
||||
`oldString` must match byte-for-byte and appear exactly once unless `replaceAll` is set.
|
||||
An ambiguous match is an error naming the count, which pushes the model to add surrounding
|
||||
context rather than guessing which occurrence it meant.
|
||||
|
||||
### `glob`
|
||||
|
||||
```
|
||||
pattern e.g. "src/**/*.ts"
|
||||
limit max paths, default 200
|
||||
includeIgnored also return files git ignores
|
||||
```
|
||||
|
||||
Walks the tree honouring `.gitignore` and `.shiroignore`, skipping `.git` and
|
||||
`node_modules` unconditionally. Nested ignore files apply only within their own directory,
|
||||
as git does. Returns posix paths relative to the workspace root.
|
||||
|
||||
### `grep`
|
||||
|
||||
```
|
||||
pattern regex source
|
||||
include glob limiting the search, default "**/*"
|
||||
ignoreCase case-insensitive
|
||||
includeIgnored also search files git ignores
|
||||
```
|
||||
|
||||
Shells out to ripgrep when it is on PATH — roughly 15x faster on a real repo — and falls
|
||||
back to a JavaScript walker otherwise. Output is `path:line: text` either way, so the model
|
||||
sees one format regardless. Skips binaries. Caps at 200 hits.
|
||||
|
||||
### `bash`
|
||||
|
||||
```
|
||||
command shell command
|
||||
timeout ms, default 120000, max 600000
|
||||
```
|
||||
|
||||
Runs in the workspace root through `bash -lc` or `cmd /c`. Output streams live to the panel
|
||||
above the input rather than appearing all at once when the command exits — a two-minute test
|
||||
run is otherwise indistinguishable from a hang. Both pipes are drained concurrently, since a
|
||||
command that fills one while you block on the other deadlocks.
|
||||
|
||||
Returns exit code, stdout, stderr, and a note if a signal killed it.
|
||||
|
||||
## Agent tools
|
||||
|
||||
### `task`
|
||||
|
||||
```
|
||||
description short label shown to you
|
||||
prompt self-contained instructions
|
||||
kind "explore" (default) or "review"
|
||||
```
|
||||
|
||||
Spawns a read-only subagent with `read_file`, `glob`, and `grep` only. It returns one
|
||||
report, so the parent pays for findings rather than the whole search transcript. It sees
|
||||
none of the parent conversation, so its prompt has to stand alone.
|
||||
|
||||
`explore` finds and reports. `review` critiques code in severity order. Progress streams to
|
||||
the subagent panel.
|
||||
|
||||
### `ask`
|
||||
|
||||
```
|
||||
question one specific question
|
||||
options choices, recommendation first, each with an optional detail
|
||||
multiple allow more than one
|
||||
```
|
||||
|
||||
Stops the turn and puts the question on screen. With options it is a picker; without, free
|
||||
text. `esc` skips, which tells the model to decide and state its assumption.
|
||||
|
||||
Withheld entirely in headless mode — a question with no one to answer it would hang.
|
||||
|
||||
### `todo_write`
|
||||
|
||||
```
|
||||
todos the complete list: content, status, optional note
|
||||
```
|
||||
|
||||
Statuses: `pending`, `in_progress`, `done`, `blocked`. Send the whole list each time; it
|
||||
replaces the previous one. Warns when more than one task is `in_progress`, when nothing is
|
||||
`in_progress` while work remains, or when a `blocked` task has no note.
|
||||
|
||||
### `remember`, `recall`, `forget`
|
||||
|
||||
Durable per-project notes. See [memory](memory.md).
|
||||
|
||||
### `skill`
|
||||
|
||||
```
|
||||
name skill name from the catalogue
|
||||
```
|
||||
|
||||
Loads the body of a skill. See [skills](skills.md).
|
||||
|
||||
## Path safety
|
||||
|
||||
Every path a tool receives goes through a jail: resolved against the workspace root, then
|
||||
checked that it did not escape. `../../etc/passwd` and absolute paths outside the root are
|
||||
both refused before any filesystem call.
|
||||
|
||||
The model's output is a trust boundary. It can emit any string, so the check happens on
|
||||
every call rather than being assumed.
|
||||
|
||||
## Output caps
|
||||
|
||||
Any single tool result is truncated at 30,000 characters with a note saying how much was
|
||||
cut. `grep` stops at 200 hits, `glob` at 200 paths, `read_file` at 2000 lines by default.
|
||||
Without caps one `grep` for `function` can end a session.
|
||||
Reference in new issue
Block a user