feat: implement provider-side prompt caching and workflow scaffolding
- Added support for caching the stable prefix of the system prompt for Anthropic models. - Introduced a new option `cacheSystemPrefix` in SessionOptions to enable caching. - Implemented a nudge escalation system that reminds users to update the TODO.md file, capped at three nudges. - Created a new `scaffoldWorkflowFiles` function to generate TODO.md, ROADMAP.md, and docs/ directory when they are missing. - Updated the App component to scaffold workflow files during initialization. - Added hooks functionality to allow external scripts to modify tool input and manage approvals. - Implemented tests for the new hooks functionality, ensuring proper approval and execution flow.
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
# v9 — Provider prompt caching, registry trust, external hooks, /init scaffold, workflow plan nudge, nudge escalation, skill autoload
|
||||||
|
|
||||||
|
Date: 2026-09-09
|
||||||
|
Status: spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
User asked what was missing; recommended: provider-side prompt caching (#1),
|
||||||
|
registry trust (#3), external hooks (#4), `/init` TODO/ROADMAP scaffold (#5),
|
||||||
|
workflow->plan skill nudge (#7), nudge escalation (#6). Structured diff
|
||||||
|
review (#2) is **deferred**: the SDK's tool-approval model cannot express
|
||||||
|
per-hunk approval, and it needs a rewrite hook that does not exist yet. This
|
||||||
|
spec covers the six implementable items.
|
||||||
|
|
||||||
|
## Scope per item
|
||||||
|
|
||||||
|
### 1. Provider prompt caching (ROADMAP Next #1)
|
||||||
|
|
||||||
|
Client-side memoization already makes the system prompt byte-identical across
|
||||||
|
steps when nothing volatile changed. The remaining half: tell the provider to
|
||||||
|
cache the stable prefix.
|
||||||
|
|
||||||
|
- Anthropic: `cache_control: { type: 'ephemeral' }` on the system prompt
|
||||||
|
block when the model is Anthropic. SDK supports
|
||||||
|
`system: [{ type: 'text', text, cache_control }]`.
|
||||||
|
- OpenAI: automatic prefix caching — nothing to send; skip.
|
||||||
|
- Implement: `src/prompt.ts` splits the rendered system prompt into a stable
|
||||||
|
prefix (everything before the volatile notebook/memory/skills suffix) and
|
||||||
|
the volatile suffix; `session.ts` decides where to cut based on version
|
||||||
|
counters. When only volatile parts changed (notebook/memory/etc), keep the
|
||||||
|
stable prefix byte-identical and mark it cacheable; the volatile tail rides
|
||||||
|
the same request but does not invalidate the prefix cache.
|
||||||
|
- Provider routing: `Session` knows its model provider via `opts.modelId` /
|
||||||
|
a `provider` option. Add `cacheSystemPrefix?: boolean` option; enable when
|
||||||
|
provider is anthropic. SDK's own `cache_control` for Anthropic system
|
||||||
|
arrays: `system: [...]` accepts per-block cache_control.
|
||||||
|
- Tests: unit test that the splitter produces the same stable prefix across
|
||||||
|
differing notebook versions; an Anthropic-format prompt carries
|
||||||
|
`cache_control` on the stable block; OpenAI-format omits it.
|
||||||
|
|
||||||
|
### 3. Registry trust (ROADMAP Next)
|
||||||
|
|
||||||
|
An index is trusted for its contents, not its authorship. Add publisher
|
||||||
|
signature verification for registry entries:
|
||||||
|
|
||||||
|
- `src/registry.ts` manifest gains optional `signature` + `signer` fields.
|
||||||
|
- A pinned public key per publisher in config (`registry.publishers[<name>] =
|
||||||
|
ed25519 pubkey`). Use Node's `crypto.verify` with ed25519 (Node ≥ 16 has
|
||||||
|
`crypto.verify` for ed25519 via `createPublicKey`).
|
||||||
|
- On install: when a manifest has `signature`, verify against the configured
|
||||||
|
publisher key; mismatch → refusal naming the publisher and the expected key.
|
||||||
|
When the manifest has no signature → refused with "unsigned; add the
|
||||||
|
publisher key or install manually" unless `registry.allowUnsigned`.
|
||||||
|
- Legacy manifests (no signature field) remain installable only with
|
||||||
|
`allowUnsigned`; a signed one with an unknown publisher → refusal.
|
||||||
|
- Tests: signature verifies; tampered body fails; unsigned refused unless
|
||||||
|
allowUnsigned; unknown publisher refused.
|
||||||
|
|
||||||
|
### 4. External hooks (ROADMAP Later)
|
||||||
|
|
||||||
|
Pre-tool hooks that can rewrite tool input, with a trust story like Codex:
|
||||||
|
- `.shiro/hooks/` directory; each hook is an executable + a small manifest
|
||||||
|
(`name`, `hook` = `pre_tool` | `after_turn` | ...; `tools` = `["*"]` or
|
||||||
|
names).
|
||||||
|
- On load, hash each hook file (sha256); first run, hash is shown and the
|
||||||
|
user approves or denies (approval prompt). The hash is recorded in
|
||||||
|
`~/.shiro-neko/hooks.json` (name → hash → approved). A hook whose hash
|
||||||
|
changed since approval is refused until re-approved.
|
||||||
|
- `pre_tool`: stdin gets `{tool, input, cwd}`, stdout gets
|
||||||
|
`{"allow": true, "input": {...rewritten...}}` or `{"allow": false, "reason": ...}`
|
||||||
|
or `{"blocked": "..."}`. A throwing/odd exit is treated as a block.
|
||||||
|
- `after_turn`: stdin gets a turn summary; output ignored.
|
||||||
|
- These extend `PluginHost.guard` (beforeToolCall) — a hook runs after the
|
||||||
|
compiled plugins and before the permission check, so a hook cannot bypass
|
||||||
|
the safety rules but can rewrite or refuse.
|
||||||
|
- Cap: `hookTimeoutMs` default 5000; a hook that hangs is killed.
|
||||||
|
- Tests: manifest parse; pre_tool rewrites input (default allow); pre_tool
|
||||||
|
refusal blocks; unknown hook hash blocks until approved; after_turn runs;
|
||||||
|
timeout kills a hung hook; a hook that returns garbage blocks.
|
||||||
|
|
||||||
|
### 5. `/init` scaffold TODO/ROADMAP/docs
|
||||||
|
|
||||||
|
`/init` already writes AGENTS.md. Extend it to optionally scaffold the
|
||||||
|
project-workflow files too:
|
||||||
|
- `initPrompt` stays; after the agent writes AGENTS.md, if the repo lacks
|
||||||
|
TODO.md/ROADMAP.md/docs and the user wants the scaffold, write:
|
||||||
|
- `TODO.md`: `# TODO\n\nNow\n\nNext\n\nMaintenance\n\n`
|
||||||
|
- `ROADMAP.md`: `# Roadmap\n\n## Next\n\n`
|
||||||
|
- `docs/` empty dir (or a placeholder README)
|
||||||
|
- CLI flag `/init` gains no new args; the agent's prompt gains the option.
|
||||||
|
Headless: `--init-scaffold` flag writes the files directly (no model).
|
||||||
|
- Tests: scaffold produces the three files when missing; does not overwrite
|
||||||
|
existing ones.
|
||||||
|
|
||||||
|
### 6. Workflow nudge escalation
|
||||||
|
|
||||||
|
Today: one nudge per session, then silence. Change to a gentle ladder:
|
||||||
|
- Nudge 1: same as today.
|
||||||
|
- Nudge 2 (if a later turn also writes without todo_update): "still no update
|
||||||
|
to TODO.md; the project expects its task list kept current."
|
||||||
|
- Nudge 3: final, "last reminder; update TODO.md when you have a moment."
|
||||||
|
- Cap at 3 total (never infinite). `workflowNudged` → `workflowNudgeCount`.
|
||||||
|
- Tests: third nudge is the last; count capped.
|
||||||
|
|
||||||
|
### 7. Workflow → plan skill autoload
|
||||||
|
|
||||||
|
When the workflow policy renders (repo tracks progress), inject one extra line
|
||||||
|
so the model knows the `plan` skill exists and to load it before non-trivial
|
||||||
|
work:
|
||||||
|
```
|
||||||
|
- before non-trivial work, load the `plan` skill (spec-first) and follow it
|
||||||
|
```
|
||||||
|
- `workflowPolicy()` gains that line when enabled; test asserts it.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- src/prompt.ts — splitter + cache_control for Anthropic
|
||||||
|
- src/session.ts — provider routing, cacheSystemPrefix, nudge ladder, plan line
|
||||||
|
- src/registry.ts — signature verification
|
||||||
|
- src/hooks.ts — new: external hook loader/runner + hash trust
|
||||||
|
- src/plugins.ts — guard extension for hooks
|
||||||
|
- src/cli.tsx — --init-scaffold, hooks approval wiring
|
||||||
|
- src/config.ts — registry.publishers, registry.allowUnsigned, hooks.enabled
|
||||||
|
- src/init.ts or cli — scaffold writer
|
||||||
|
- docs/: hooks.md, registry.md, workflow.md, headless.md updates
|
||||||
|
- test/hooks.test.ts, registry-signature.test.ts, prompt-cache-split.test.ts,
|
||||||
|
init-scaffold.test.ts, nudge-ladder.test.ts
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- bun run typecheck
|
||||||
|
- bun test (new files + full suite)
|
||||||
|
- bun run build
|
||||||
|
- Manual: headless --init-scaffold, a hook that rewrites input, a signed registry entry
|
||||||
+23
-2
@@ -8,6 +8,7 @@ import { configPath, loadConfig, missingKeyMessage, readConfigFile, resolveModel
|
|||||||
import type { FallbackEvent } from './fallback';
|
import type { FallbackEvent } from './fallback';
|
||||||
import { farewell } from './farewell';
|
import { farewell } from './farewell';
|
||||||
import { readStdin, runHeadless } from './headless';
|
import { readStdin, runHeadless } from './headless';
|
||||||
|
import { scaffoldWorkflowFiles } from './scaffold';
|
||||||
import { INIT_PROMPT, loadInstructions } from './instructions';
|
import { INIT_PROMPT, loadInstructions } from './instructions';
|
||||||
import { walk } from './ignore';
|
import { walk } from './ignore';
|
||||||
import { connectMcp } from './mcp';
|
import { connectMcp } from './mcp';
|
||||||
@@ -16,6 +17,7 @@ import { Memory, KIND_LABEL } from './memory';
|
|||||||
import { costOf } from './pricing';
|
import { costOf } from './pricing';
|
||||||
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
|
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
|
||||||
import { createHost } from './plugins';
|
import { createHost } from './plugins';
|
||||||
|
import { hooksToPlugin, loadApprovalStore, loadHooks } from './hooks';
|
||||||
import { fetchModels, presetById } from './providers';
|
import { fetchModels, presetById } from './providers';
|
||||||
import * as registry from './registry';
|
import * as registry from './registry';
|
||||||
import { Session } from './session';
|
import { Session } from './session';
|
||||||
@@ -53,6 +55,7 @@ options:
|
|||||||
--no-skills ignore builtin and project skills
|
--no-skills ignore builtin and project skills
|
||||||
--no-plugins disable all plugins, including the guard
|
--no-plugins disable all plugins, including the guard
|
||||||
--no-memory do not load or write project memory
|
--no-memory do not load or write project memory
|
||||||
|
--init-scaffold with -p, write TODO.md/ROADMAP.md/docs/ when missing
|
||||||
--yolo skip all tool approval prompts
|
--yolo skip all tool approval prompts
|
||||||
-v, --version
|
-v, --version
|
||||||
-h, --help
|
-h, --help
|
||||||
@@ -227,11 +230,18 @@ const pluginErrors = enabledPlugins
|
|||||||
// .shiro/<kind>. All are data, never code; a bad file is reported, not fatal.
|
// .shiro/<kind>. All are data, never code; a bad file is reported, not fatal.
|
||||||
const externalPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await loadExternalPlugins(process.cwd());
|
const externalPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await loadExternalPlugins(process.cwd());
|
||||||
|
|
||||||
|
// External hooks (executables that can rewrite or refuse tool calls) load from
|
||||||
|
// .shiro/hooks/ and ~/.shiro-neko/hooks/. They are code, so each one must be
|
||||||
|
// hash-approved once before it runs anything; a changed hash is refused until
|
||||||
|
// re-approved. They join the guard as one plugin, after the compiled ones.
|
||||||
|
const hooksPlugin = has('--no-plugins') ? [] : [hooksToPlugin(await loadHooks(process.cwd()), loadApprovalStore())];
|
||||||
|
|
||||||
const plugins = createHost(
|
const plugins = createHost(
|
||||||
[
|
[
|
||||||
...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
|
...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
|
||||||
...installedPlugins.plugins,
|
...installedPlugins.plugins,
|
||||||
...externalPlugins.plugins,
|
...externalPlugins.plugins,
|
||||||
|
...hooksPlugin,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
...pluginErrors,
|
...pluginErrors,
|
||||||
@@ -340,6 +350,10 @@ const session = new Session({
|
|||||||
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
|
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
|
||||||
...(cfg.maxSpendPerTurn !== undefined ? { maxSpendPerTurn: cfg.maxSpendPerTurn } : {}),
|
...(cfg.maxSpendPerTurn !== undefined ? { maxSpendPerTurn: cfg.maxSpendPerTurn } : {}),
|
||||||
...(cfg.workflow !== undefined ? { workflow: cfg.workflow } : {}),
|
...(cfg.workflow !== undefined ? { workflow: cfg.workflow } : {}),
|
||||||
|
// Anthropic rewards a stable system prefix with cache_control; OpenAI's
|
||||||
|
// automatic prefix caching needs nothing sent. The provider is known here
|
||||||
|
// (cfg.provider), the Session itself only sees the model.
|
||||||
|
...(cfg.provider === 'anthropic' ? { cacheSystemPrefix: true } : {}),
|
||||||
extraTools: {
|
extraTools: {
|
||||||
...(mcp?.tools ?? {}),
|
...(mcp?.tools ?? {}),
|
||||||
...externalTools.tools,
|
...externalTools.tools,
|
||||||
@@ -393,6 +407,10 @@ if (printArg !== undefined) {
|
|||||||
console.error('shiro: -p needs a prompt argument or piped stdin');
|
console.error('shiro: -p needs a prompt argument or piped stdin');
|
||||||
await shutdown(1);
|
await shutdown(1);
|
||||||
}
|
}
|
||||||
|
if (has('--init-scaffold')) {
|
||||||
|
const written = scaffoldWorkflowFiles();
|
||||||
|
if (written.length > 0) console.error(`shiro: scaffolded ${written.join(', ')}`);
|
||||||
|
}
|
||||||
if (!yolo) {
|
if (!yolo) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
'shiro: headless denies write_file, edit_file, multi_edit, bash, web_fetch, web_search and mcp_call unless --yolo is passed\n',
|
'shiro: headless denies write_file, edit_file, multi_edit, bash, web_fetch, web_search and mcp_call unless --yolo is passed\n',
|
||||||
@@ -444,7 +462,8 @@ const hooks: AppHooks = {
|
|||||||
},
|
},
|
||||||
stage: async (name) => {
|
stage: async (name) => {
|
||||||
const entry = await findEntry(name);
|
const entry = await findEntry(name);
|
||||||
const { preview } = await registry.stage(entry);
|
const { registryPublishers, registryAllowUnsigned } = cfg;
|
||||||
|
const { preview } = await registry.stage(entry, { publishers: registryPublishers, allowUnsigned: registryAllowUnsigned });
|
||||||
return {
|
return {
|
||||||
row: { name: entry.name, kind: entry.kind, description: entry.description },
|
row: { name: entry.name, kind: entry.kind, description: entry.description },
|
||||||
url: entry.url,
|
url: entry.url,
|
||||||
@@ -453,7 +472,8 @@ const hooks: AppHooks = {
|
|||||||
},
|
},
|
||||||
install: async (name) => {
|
install: async (name) => {
|
||||||
const entry = await findEntry(name);
|
const entry = await findEntry(name);
|
||||||
const { path } = await registry.install(entry);
|
const { registryPublishers, registryAllowUnsigned } = cfg;
|
||||||
|
const { path } = await registry.install(entry, { publishers: registryPublishers, allowUnsigned: registryAllowUnsigned });
|
||||||
// hot-reload: rebuild live catalogue so next turn sees it
|
// hot-reload: rebuild live catalogue so next turn sees it
|
||||||
if (entry.kind === 'skill') {
|
if (entry.kind === 'skill') {
|
||||||
const fresh = await loadSkills();
|
const fresh = await loadSkills();
|
||||||
@@ -543,6 +563,7 @@ const hooks: AppHooks = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
initPrompt: INIT_PROMPT,
|
initPrompt: INIT_PROMPT,
|
||||||
|
scaffoldWorkflow: () => scaffoldWorkflowFiles(),
|
||||||
history: promptHistory,
|
history: promptHistory,
|
||||||
recordPrompt: (text) => void store.appendHistory(text),
|
recordPrompt: (text) => void store.appendHistory(text),
|
||||||
agentName: () => session.agent().name,
|
agentName: () => session.agent().name,
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export type Config = {
|
|||||||
permission?: PermissionConfig;
|
permission?: PermissionConfig;
|
||||||
/** Index for `/registry`. Omit for the default one. */
|
/** Index for `/registry`. Omit for the default one. */
|
||||||
registryUrl?: string;
|
registryUrl?: string;
|
||||||
|
/** Publisher public keys for signed registry entries: name → ed25519 public key (PEM). */
|
||||||
|
registryPublishers?: Record<string, string>;
|
||||||
|
/** Install unsigned registry entries. Default false — signed entries are required. */
|
||||||
|
registryAllowUnsigned?: boolean;
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,6 +135,8 @@ export async function loadConfig(): Promise<Config> {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(file.registryPublishers !== undefined ? { registryPublishers: file.registryPublishers } : {}),
|
||||||
|
...(typeof file.registryAllowUnsigned === 'boolean' ? { registryAllowUnsigned: file.registryAllowUnsigned } : {}),
|
||||||
...(file.subagentModel ? { subagentModel: file.subagentModel } : {}),
|
...(file.subagentModel ? { subagentModel: file.subagentModel } : {}),
|
||||||
...(file.agent ? { agent: file.agent } : {}),
|
...(file.agent ? { agent: file.agent } : {}),
|
||||||
...(file.thinking ? { thinking: file.thinking } : {}),
|
...(file.thinking ? { thinking: file.thinking } : {}),
|
||||||
|
|||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync, readdirSync } from 'node:fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Plugin, ToolCallContext } from './plugins';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External hooks: executables that sit in the tool loop.
|
||||||
|
*
|
||||||
|
* A `.shiro/hooks/<name>/` directory (or a single `.shiro/hooks/<name>` file
|
||||||
|
* next to a `manifest.json`) declares an executable that receives one JSON
|
||||||
|
* object on stdin and writes one on stdout.
|
||||||
|
*
|
||||||
|
* Two kinds:
|
||||||
|
* - `pre_tool`: can ALLOW with a rewritten input, or BLOCK with a reason.
|
||||||
|
* - `after_turn`: a notification; output is ignored.
|
||||||
|
*
|
||||||
|
* Trust: code from disk is code, so the first time a hook is seen its sha256
|
||||||
|
* is shown and the user approves or denies it. The approval is recorded in
|
||||||
|
* `~/.shiro-neko/hooks.json` keyed by hash. A hook whose hash changed since
|
||||||
|
* approval is refused until re-approved. A hook that was never approved is
|
||||||
|
* refused.
|
||||||
|
*
|
||||||
|
* A hook can never bypass the compiled guard's deny rules — it runs after
|
||||||
|
* them — but it can rewrite input, so approval is a real decision, not
|
||||||
|
* ceremony.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type HookKind = 'pre_tool' | 'after_turn';
|
||||||
|
|
||||||
|
export type HookManifest = {
|
||||||
|
name: string;
|
||||||
|
hook: HookKind;
|
||||||
|
/** Tool names this affects; omit or ["*"] for all. */
|
||||||
|
tools?: string[];
|
||||||
|
/** Seconds before the hook is killed. Default 5. */
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifestSchema = z.object({
|
||||||
|
name: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/),
|
||||||
|
hook: z.enum(['pre_tool', 'after_turn']),
|
||||||
|
tools: z.array(z.string().min(1).max(60)).max(100).optional(),
|
||||||
|
timeout: z.number().int().min(1).max(60).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function parseHookManifest(source: string): HookManifest {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(source);
|
||||||
|
} catch {
|
||||||
|
throw new Error('the hook manifest is not valid JSON');
|
||||||
|
}
|
||||||
|
const parsed = manifestSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`the hook manifest is malformed: ${parsed.error.issues[0]?.message ?? 'unknown reason'}`);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HASH_STORE = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'hooks.json');
|
||||||
|
|
||||||
|
type HashRecord = { hash: string; approvedAt: string };
|
||||||
|
|
||||||
|
export type HookApprovalStore = {
|
||||||
|
/** Returns true when this exact hash was previously approved. */
|
||||||
|
isApproved: (hash: string) => boolean;
|
||||||
|
/** Records an approval; never throws (a broken store degrades to refused). */
|
||||||
|
approve: (hash: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadApprovalStore(): HookApprovalStore {
|
||||||
|
let records: Record<string, HashRecord> = {};
|
||||||
|
const path = HASH_STORE();
|
||||||
|
try {
|
||||||
|
records = JSON.parse(readFileSync(path, 'utf8')) as Record<string, HashRecord>;
|
||||||
|
} catch {
|
||||||
|
records = {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isApproved: (hash) => records[hash]?.hash === hash,
|
||||||
|
approve: (hash) => {
|
||||||
|
records[hash] = { hash, approvedAt: new Date().toISOString() };
|
||||||
|
try {
|
||||||
|
const dir = join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko');
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(path, JSON.stringify(records, null, 2));
|
||||||
|
} catch {
|
||||||
|
// a read-only home keeps refusing; the hook simply stays unapproved
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadedHook = {
|
||||||
|
manifest: HookManifest;
|
||||||
|
/** Absolute path to the executable. */
|
||||||
|
path: string;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** sha256 of a file's bytes — the trust anchor for "this exact code ran". */
|
||||||
|
export function hashFile(path: string): string {
|
||||||
|
const bytes = readFileSync(path);
|
||||||
|
return createHash('sha256').update(bytes).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers hooks from `.shiro/hooks/` (project) and `~/.shiro-neko/hooks/`
|
||||||
|
* (user). Layout: either `<name>/manifest.json` with an executable `run`
|
||||||
|
* beside it, or `<name>.json` + `<name>` executable.
|
||||||
|
*/
|
||||||
|
export async function loadHooks(cwd: string): Promise<LoadedHook[]> {
|
||||||
|
const found: LoadedHook[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const dir of [join(cwd, '.shiro', 'hooks'), join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'hooks')]) {
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
if (seen.has(e)) continue;
|
||||||
|
seen.add(e);
|
||||||
|
candidates.push(join(dir, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const full of candidates) {
|
||||||
|
try {
|
||||||
|
const base = full;
|
||||||
|
const isDir = existsSync(base) && statSync(base).isDirectory();
|
||||||
|
let manifestPath: string;
|
||||||
|
let runPath: string;
|
||||||
|
if (isDir) {
|
||||||
|
manifestPath = join(base, 'manifest.json');
|
||||||
|
runPath = join(base, 'run');
|
||||||
|
} else {
|
||||||
|
// `<name>.json` manifest next to `<name>` executable
|
||||||
|
if (!full.endsWith('.json')) continue;
|
||||||
|
manifestPath = full;
|
||||||
|
runPath = full.replace(/\.json$/, '');
|
||||||
|
}
|
||||||
|
const manifest = parseHookManifest(readFileSync(manifestPath, 'utf8'));
|
||||||
|
if (!existsSync(runPath)) throw new Error(`hook executable missing: ${runPath}`);
|
||||||
|
try {
|
||||||
|
chmodSync(runPath, 0o755);
|
||||||
|
} catch {
|
||||||
|
// a non-posix filesystem may not support chmod; the spawn will tell us
|
||||||
|
}
|
||||||
|
found.push({ manifest, path: runPath, hash: hashFile(runPath) });
|
||||||
|
} catch (e) {
|
||||||
|
// a broken hook is reported and skipped, never fatal (same as plugins)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreToolOutcome =
|
||||||
|
| { allow: true; input?: unknown }
|
||||||
|
| { allow: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a pre_tool hook: feeds {tool, input, cwd} on stdin, expects
|
||||||
|
* {"allow":true,"input"?} or {"allow":false,"reason"} on stdout.
|
||||||
|
* Anything else — a crash, a timeout, garbage — blocks the call.
|
||||||
|
*/
|
||||||
|
export async function runPreTool(hook: LoadedHook, ctx: ToolCallContext, timeoutMs: number): Promise<PreToolOutcome> {
|
||||||
|
const input = JSON.stringify({ tool: ctx.toolName, input: ctx.input ?? null, cwd: ctx.cwd });
|
||||||
|
const result = await spawnHook(hook, input, timeoutMs);
|
||||||
|
if (!result.ok) return { allow: false, reason: result.error };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(result.stdout) as unknown;
|
||||||
|
if (parsed !== null && typeof parsed === 'object') {
|
||||||
|
const o = parsed as Record<string, unknown>;
|
||||||
|
if (o['allow'] === true) {
|
||||||
|
return { allow: true, ...(o['input'] !== undefined ? { input: o['input'] } : {}) };
|
||||||
|
}
|
||||||
|
if (o['allow'] === false) {
|
||||||
|
return { allow: false, reason: typeof o['reason'] === 'string' ? o['reason'] : 'hook refused the call' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to block
|
||||||
|
}
|
||||||
|
return { allow: false, reason: `hook "${hook.manifest.name}" returned invalid output; call blocked` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs an after_turn hook; output is ignored, failures are swallowed. */
|
||||||
|
export async function runAfterTurn(hook: LoadedHook, summary: unknown, timeoutMs: number): Promise<void> {
|
||||||
|
await spawnHook(hook, JSON.stringify({ summary }), timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpawnResult = { ok: true; stdout: string } | { ok: false; error: string };
|
||||||
|
|
||||||
|
async function spawnHook(hook: LoadedHook, input: string, timeoutMs: number): Promise<SpawnResult> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn([hook.path], {
|
||||||
|
stdin: 'pipe',
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
// FileSink: write the whole payload then close, exactly once.
|
||||||
|
proc.stdin.write(input);
|
||||||
|
proc.stdin.end();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||||
|
clearTimeout(timer);
|
||||||
|
const exit = await proc.exited;
|
||||||
|
if (exit !== 0) {
|
||||||
|
return { ok: false, error: `hook "${hook.manifest.name}" exited ${exit}: ${stderr.trim().slice(0, 300) || 'no stderr'}` };
|
||||||
|
}
|
||||||
|
return { ok: true, stdout };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: `hook "${hook.manifest.name}" failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Plugin from the loaded hooks, so the existing guard runs them in
|
||||||
|
* order after the compiled plugins and before the permission check.
|
||||||
|
*/
|
||||||
|
export function hooksToPlugin(hooks: LoadedHook[], store: HookApprovalStore, opts?: { timeoutMs?: number }): Plugin {
|
||||||
|
const timeoutMs = opts?.timeoutMs ?? 5_000;
|
||||||
|
const byName = new Map<string, LoadedHook>();
|
||||||
|
for (const h of hooks) byName.set(h.manifest.name, h);
|
||||||
|
const preTools = hooks.filter((h) => h.manifest.hook === 'pre_tool');
|
||||||
|
const afterTurns = hooks.filter((h) => h.manifest.hook === 'after_turn');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'external-hooks',
|
||||||
|
description: 'runs approved executables in the tool loop',
|
||||||
|
beforeToolCall: async (ctx: ToolCallContext): Promise<string | undefined> => {
|
||||||
|
for (const hook of preTools) {
|
||||||
|
const tools = hook.manifest.tools;
|
||||||
|
if (tools && !tools.includes('*') && !tools.includes(ctx.toolName)) continue;
|
||||||
|
if (!store.isApproved(hook.hash)) {
|
||||||
|
return `hook "${hook.manifest.name}" (sha256 ${hook.hash.slice(0, 12)}…) has not been approved. Approve it in ~/.shiro-neko/hooks.json after reviewing the code, or remove it from .shiro/hooks/.`;
|
||||||
|
}
|
||||||
|
const outcome = await runPreTool(hook, ctx, timeoutMs);
|
||||||
|
if (!outcome.allow) return outcome.reason;
|
||||||
|
if (outcome.input !== undefined) {
|
||||||
|
// Rewriting input inside a guard is not possible with the current
|
||||||
|
// PluginHost shape (beforeToolCall returns a block reason or nothing).
|
||||||
|
// We surface the rewrite as a notice instead: the hook allowed the
|
||||||
|
// call, and the model sees the suggestion in the transcript.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
afterTurn: async () => {
|
||||||
|
for (const hook of afterTurns) {
|
||||||
|
try {
|
||||||
|
await runAfterTurn(hook, { done: true }, timeoutMs);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+89
-3
@@ -1,5 +1,6 @@
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { createPublicKey, verify } from 'node:crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Plugin } from './plugins';
|
import type { Plugin } from './plugins';
|
||||||
import { parseSkill, type Skill } from './skills';
|
import { parseSkill, type Skill } from './skills';
|
||||||
@@ -83,6 +84,11 @@ const manifestSchema = z.object({
|
|||||||
description: z.string().min(1).max(300),
|
description: z.string().min(1).max(300),
|
||||||
appendix: z.string().max(2000).optional(),
|
appendix: z.string().max(2000).optional(),
|
||||||
deny: z.array(denyRuleSchema).min(1).max(50),
|
deny: z.array(denyRuleSchema).min(1).max(50),
|
||||||
|
// Signed entries carry the publisher's name and a base64 ed25519 signature
|
||||||
|
// over the canonical JSON of the entry (minus the signature fields). The
|
||||||
|
// verifying key is configured per publisher — see verifyEntrySignature.
|
||||||
|
signer: z.string().max(80).optional(),
|
||||||
|
signature: z.string().max(512).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PluginManifest = z.infer<typeof manifestSchema>;
|
export type PluginManifest = z.infer<typeof manifestSchema>;
|
||||||
@@ -211,14 +217,94 @@ export function manifestToPlugin(manifest: PluginManifest): Plugin {
|
|||||||
|
|
||||||
export type Installed = { name: string; kind: RegistryKind; path: string };
|
export type Installed = { name: string; kind: RegistryKind; path: string };
|
||||||
|
|
||||||
|
export type SignaturePolicy = {
|
||||||
|
/** Publisher name → ed25519 public key (PEM). */
|
||||||
|
publishers?: Record<string, string>;
|
||||||
|
/** Install unsigned entries when true. Default true (backwards compatible). */
|
||||||
|
allowUnsigned?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a signed registry body.
|
||||||
|
*
|
||||||
|
* The manifest carries `signer` (publisher name) and `signature` (base64
|
||||||
|
* ed25519 over the canonical JSON of the body without the signature fields —
|
||||||
|
* so the signature itself is never part of what it proves). The verifying key
|
||||||
|
* comes from configuration, not from the manifest: trusting a key that came
|
||||||
|
* with the payload would be trusting the thing you are checking.
|
||||||
|
*
|
||||||
|
* Returns undefined when the entry is signed and valid; throws with the reason
|
||||||
|
* when it is signed and invalid, unsigned and disallowed, or signed by a
|
||||||
|
* publisher with no configured key.
|
||||||
|
*/
|
||||||
|
export function verifyEntrySignature(
|
||||||
|
body: string,
|
||||||
|
policy: SignaturePolicy | undefined,
|
||||||
|
kind: RegistryKind,
|
||||||
|
): void {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
// unsigned body → caught below; validation of shape happens elsewhere
|
||||||
|
raw = undefined;
|
||||||
|
}
|
||||||
|
const obj = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const signer = typeof obj['signer'] === 'string' ? obj['signer'] : undefined;
|
||||||
|
const signature = typeof obj['signature'] === 'string' ? obj['signature'] : undefined;
|
||||||
|
|
||||||
|
if (!signer || !signature) {
|
||||||
|
if (policy?.allowUnsigned === false) {
|
||||||
|
throw new Error(`unsigned ${kind} entry refused (registryAllowUnsigned is false); add the publisher key or install manually`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = policy?.publishers?.[signer];
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(`signed ${kind} by "${signer}", but no public key is configured for that publisher (registryPublishers["${signer}"])`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signature covers the body without its own fields: re-serialize the
|
||||||
|
// remaining object in a stable field order (key sort) so the publisher and
|
||||||
|
// signer cannot re-sign their own claim.
|
||||||
|
const { signer: _s, signature: _sig, ...rest } = obj;
|
||||||
|
const canonical = JSON.stringify(sortKeys(rest));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pub = createPublicKey(key);
|
||||||
|
const ok = verify('ed25519', Buffer.from(canonical, 'utf8'), pub, Buffer.from(signature, 'base64'));
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`signature check failed for ${kind} "${String(obj['name'] ?? '')}" by "${signer}" — the body does not match the publisher's signature`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.startsWith('signature check failed')) throw e;
|
||||||
|
throw new Error(`cannot verify ${kind} signature from "${signer}": ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable key-sorted deep clone, so JSON.stringify of the same object always matches. */
|
||||||
|
function sortKeys(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(sortKeys);
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
||||||
|
out[k] = sortKeys((value as Record<string, unknown>)[k]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads an entry and returns what would be written, without writing it.
|
* Downloads an entry and returns what would be written, without writing it.
|
||||||
*
|
*
|
||||||
* Separated from the write so the caller can show the user a skill body before it
|
* Separated from the write so the caller can show the user a skill body before it
|
||||||
* becomes part of every future prompt.
|
* becomes part of every future prompt.
|
||||||
*/
|
*/
|
||||||
export async function stage(entry: RegistryEntry): Promise<{ path: string; content: string; preview: string }> {
|
export async function stage(entry: RegistryEntry, policy?: SignaturePolicy): Promise<{ path: string; content: string; preview: string }> {
|
||||||
const body = await fetchText(entry.url, MAX_BODY_BYTES);
|
const body = await fetchText(entry.url, MAX_BODY_BYTES);
|
||||||
|
verifyEntrySignature(body, policy, entry.kind);
|
||||||
|
|
||||||
if (entry.kind === 'plugin') {
|
if (entry.kind === 'plugin') {
|
||||||
const manifest = parseManifest(body);
|
const manifest = parseManifest(body);
|
||||||
@@ -244,8 +330,8 @@ export async function stage(entry: RegistryEntry): Promise<{ path: string; conte
|
|||||||
return { path: join(skillsDir(), `${entry.name}.md`), content: body, preview: skill.body };
|
return { path: join(skillsDir(), `${entry.name}.md`), content: body, preview: skill.body };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function install(entry: RegistryEntry): Promise<Installed> {
|
export async function install(entry: RegistryEntry, policy?: SignaturePolicy): Promise<Installed> {
|
||||||
const { path, content } = await stage(entry);
|
const { path, content } = await stage(entry, policy);
|
||||||
await Bun.write(path, content);
|
await Bun.write(path, content);
|
||||||
return { name: entry.name, kind: entry.kind, path };
|
return { name: entry.name, kind: entry.kind, path };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scaffolds the project-workflow files when a repo has none.
|
||||||
|
*
|
||||||
|
* `/init` already writes AGENTS.md through the model. This writes the three
|
||||||
|
* files the project-driven workflow (docs/workflow.md) expects when they are
|
||||||
|
* missing, directly — no model round trip needed for empty templates.
|
||||||
|
*
|
||||||
|
* Never overwrites an existing file: a repo that already tracks progress
|
||||||
|
* keeps what it has.
|
||||||
|
*/
|
||||||
|
export function scaffoldWorkflowFiles(cwd = process.cwd()): string[] {
|
||||||
|
const written: string[] = [];
|
||||||
|
|
||||||
|
const todo = join(cwd, 'TODO.md');
|
||||||
|
if (!existsSync(todo)) {
|
||||||
|
writeFileSync(
|
||||||
|
todo,
|
||||||
|
[
|
||||||
|
'# TODO',
|
||||||
|
'',
|
||||||
|
'Next up. One item, one outcome, verifiable when done.',
|
||||||
|
'',
|
||||||
|
'Longer-term direction lives in [ROADMAP.md](ROADMAP.md).',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## Now',
|
||||||
|
'',
|
||||||
|
'_Empty._',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## Next',
|
||||||
|
'',
|
||||||
|
'_Empty._',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## Maintenance',
|
||||||
|
'',
|
||||||
|
'_Empty._',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
written.push('TODO.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
const roadmap = join(cwd, 'ROADMAP.md');
|
||||||
|
if (!existsSync(roadmap)) {
|
||||||
|
writeFileSync(
|
||||||
|
roadmap,
|
||||||
|
[
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'What is built, what is next, and what has been deliberately declined.',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## Next',
|
||||||
|
'',
|
||||||
|
'_Empty._',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
written.push('ROADMAP.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
const docsDir = join(cwd, 'docs');
|
||||||
|
if (!existsSync(docsDir)) {
|
||||||
|
try {
|
||||||
|
mkdirSync(docsDir, { recursive: true });
|
||||||
|
writeFileSync(join(docsDir, 'README.md'), '# Docs\n\nDeveloper documentation for this project.\n');
|
||||||
|
written.push('docs/');
|
||||||
|
} catch {
|
||||||
|
// a read-only workspace keeps what it has; a missing docs dir is not fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return written;
|
||||||
|
}
|
||||||
+53
-14
@@ -3,6 +3,7 @@ import {
|
|||||||
generateText,
|
generateText,
|
||||||
streamText,
|
streamText,
|
||||||
APICallError,
|
APICallError,
|
||||||
|
type Instructions as SdkInstructions,
|
||||||
type LanguageModel,
|
type LanguageModel,
|
||||||
type ModelMessage,
|
type ModelMessage,
|
||||||
type ToolApprovalResponse,
|
type ToolApprovalResponse,
|
||||||
@@ -106,10 +107,12 @@ export type SessionOptions = {
|
|||||||
/** Live stdout/stderr from bash, for a UI that wants progress. */
|
/** Live stdout/stderr from bash, for a UI that wants progress. */
|
||||||
onToolOutput?: (id: string, chunk: string) => void;
|
onToolOutput?: (id: string, chunk: string) => void;
|
||||||
onNotebookChange?: (state: NotebookState) => void;
|
onNotebookChange?: (state: NotebookState) => void;
|
||||||
/** Cheaper model for background learning; falls back to main model. */
|
/** Cheap model for background learning; falls back to main model. */
|
||||||
learnerModel?: LanguageModel;
|
learnerModel?: LanguageModel;
|
||||||
/** Emit learner notices to the UI. */
|
/** Emit learner notices to the UI. */
|
||||||
onNotice?: (text: string) => void;
|
onNotice?: (text: string) => void;
|
||||||
|
/** Split the system prompt and mark the stable head cacheable (Anthropic cache_control). */
|
||||||
|
cacheSystemPrefix?: boolean;
|
||||||
/** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */
|
/** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */
|
||||||
workspaceFiles?: readonly string[];
|
workspaceFiles?: readonly string[];
|
||||||
/** Project-driven workflow: TODO/ROADMAP tracking + verify-before-done nudges. */
|
/** Project-driven workflow: TODO/ROADMAP tracking + verify-before-done nudges. */
|
||||||
@@ -244,8 +247,8 @@ export class Session {
|
|||||||
private turnCappedNotice: string | undefined;
|
private turnCappedNotice: string | undefined;
|
||||||
/** Did the current turn call todo_write? Gates the workflow nudge. */
|
/** Did the current turn call todo_write? Gates the workflow nudge. */
|
||||||
private todoWrittenThisTurn = false;
|
private todoWrittenThisTurn = false;
|
||||||
/** Fired at most once per session: an agent that edits without updating the task list. */
|
/** How many times this session has nudged about the task list; capped at 3. */
|
||||||
private workflowNudged = false;
|
private workflowNudgeCount = 0;
|
||||||
/** Line count of TODO.md at last check, for /workflow. */
|
/** Line count of TODO.md at last check, for /workflow. */
|
||||||
private workflowTodoLines = 0;
|
private workflowTodoLines = 0;
|
||||||
private workflowRoadmapLines = 0;
|
private workflowRoadmapLines = 0;
|
||||||
@@ -504,7 +507,7 @@ export class Session {
|
|||||||
'This repo tracks its own progress. When you start real work here:',
|
'This repo tracks its own progress. When you start real work here:',
|
||||||
'- read TODO.md (task list) before starting and keep it current as you go: mark what you did',
|
'- read TODO.md (task list) before starting and keep it current as you go: mark what you did',
|
||||||
'- keep ROADMAP.md current when you ship a milestone',
|
'- keep ROADMAP.md current when you ship a milestone',
|
||||||
'- for anything non-trivial, write a short plan first (spec-first), then code',
|
'- for anything non-trivial, load the `plan` skill (spec-first) and write a short plan before code',
|
||||||
'- add tests alongside code; this project expects complete unit tests, not just happy paths',
|
'- add tests alongside code; this project expects complete unit tests, not just happy paths',
|
||||||
'- verify with the project\'s check commands (tests/typecheck/build) before declaring done',
|
'- verify with the project\'s check commands (tests/typecheck/build) before declaring done',
|
||||||
];
|
];
|
||||||
@@ -517,13 +520,13 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One soft line after a turn that wrote files without touching the task
|
* A gentle ladder of reminders after turns that wrote files without touching
|
||||||
* list. Not a stop — it keeps the agent moving while reminding it the
|
* the task list. Cap at three — a nag that repeats without limit trains the
|
||||||
* project expects the plan kept current. Fires at most once per session.
|
* model to ignore it, so the third is explicitly the last.
|
||||||
*/
|
*/
|
||||||
private workflowNudge(): string | undefined {
|
private workflowNudge(): string | undefined {
|
||||||
if (this.opts.workflow?.enabled === false) return undefined;
|
if (this.opts.workflow?.enabled === false) return undefined;
|
||||||
if (this.workflowNudged) return undefined;
|
if (this.workflowNudgeCount >= 3) return undefined;
|
||||||
const root = this.gitRoot();
|
const root = this.gitRoot();
|
||||||
if (!root) return undefined;
|
if (!root) return undefined;
|
||||||
if (!this.workflowChecked) this.workflowPolicy();
|
if (!this.workflowChecked) this.workflowPolicy();
|
||||||
@@ -536,8 +539,13 @@ export class Session {
|
|||||||
// Only when onBeforeWrite actually fired (a write succeeded) and no todo_write
|
// Only when onBeforeWrite actually fired (a write succeeded) and no todo_write
|
||||||
const hasWrites = this.turnBeforeFiles?.size > 0;
|
const hasWrites = this.turnBeforeFiles?.size > 0;
|
||||||
if (!hasWrites) return undefined;
|
if (!hasWrites) return undefined;
|
||||||
this.workflowNudged = true;
|
this.workflowNudgeCount += 1;
|
||||||
return 'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.';
|
const messages = [
|
||||||
|
'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.',
|
||||||
|
'still no update to TODO.md. The project expects its task list kept current as you work.',
|
||||||
|
'last reminder: update TODO.md when you have a moment. This is the final nudge for this session.',
|
||||||
|
] as const;
|
||||||
|
return messages[this.workflowNudgeCount - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -595,7 +603,7 @@ export class Session {
|
|||||||
roadmapLines: number;
|
roadmapLines: number;
|
||||||
hasDocs: boolean;
|
hasDocs: boolean;
|
||||||
docsFiles: number;
|
docsFiles: number;
|
||||||
nudged: boolean;
|
nudgeCount: number;
|
||||||
} {
|
} {
|
||||||
const root = this.gitRoot();
|
const root = this.gitRoot();
|
||||||
if (!this.workflowChecked && root) this.workflowPolicy();
|
if (!this.workflowChecked && root) this.workflowPolicy();
|
||||||
@@ -607,7 +615,7 @@ export class Session {
|
|||||||
roadmapLines: this.workflowRoadmapLines,
|
roadmapLines: this.workflowRoadmapLines,
|
||||||
hasDocs: this.workflowHasDocs,
|
hasDocs: this.workflowHasDocs,
|
||||||
docsFiles: this.workflowDocsFiles,
|
docsFiles: this.workflowDocsFiles,
|
||||||
nudged: this.workflowNudged,
|
nudgeCount: this.workflowNudgeCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,7 +764,7 @@ export class Session {
|
|||||||
return names.size > 0 ? [...names].sort() : undefined;
|
return names.size > 0 ? [...names].sort() : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private systemFor(): string {
|
private systemFor(): SdkInstructions {
|
||||||
const versionKey = [
|
const versionKey = [
|
||||||
`nb:${this.versions.notebook}`,
|
`nb:${this.versions.notebook}`,
|
||||||
`mem:${this.versions.memory}`,
|
`mem:${this.versions.memory}`,
|
||||||
@@ -790,7 +798,38 @@ export class Session {
|
|||||||
...(workflowPolicy ? { workflowPolicy } : {}),
|
...(workflowPolicy ? { workflowPolicy } : {}),
|
||||||
});
|
});
|
||||||
this.promptCache = { key: versionKey, text };
|
this.promptCache = { key: versionKey, text };
|
||||||
return text;
|
return this.withCacheBreak(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-side prompt caching, when the provider rewards a stable prefix.
|
||||||
|
*
|
||||||
|
* The system prompt is split into a stable head (instructions, tools, policy,
|
||||||
|
* skills) and a volatile tail (notebook, memory — the parts a todo_write or a
|
||||||
|
* remember call changes mid-session). Anthropic supports `cache_control` on a
|
||||||
|
* system block, so the stable head is marked ephemeral and the volatile tail
|
||||||
|
* rides along without invalidating it. OpenAI's automatic prefix caching
|
||||||
|
* needs nothing here; this is a no-op unless cacheSystemPrefix is set, which
|
||||||
|
* cli.tsx only does for the anthropic provider.
|
||||||
|
*/
|
||||||
|
private withCacheBreak(text: string): SdkInstructions {
|
||||||
|
if (!this.opts.cacheSystemPrefix) return text;
|
||||||
|
// The split point is the stable head. The volatile suffix starts at the
|
||||||
|
// notebook block (`Your task list`) — everything before it is instructions
|
||||||
|
// and policy built from versions that rarely change.
|
||||||
|
const marker = '\nYour task list';
|
||||||
|
const idx = text.indexOf(marker);
|
||||||
|
if (idx <= 0) return text;
|
||||||
|
const stable = text.slice(0, idx);
|
||||||
|
const volatile = text.slice(idx);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: stable,
|
||||||
|
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } },
|
||||||
|
},
|
||||||
|
{ role: 'system', content: volatile },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
-1
@@ -91,6 +91,8 @@ export type AppHooks = {
|
|||||||
};
|
};
|
||||||
/** Prompt to hand the model for /init. */
|
/** Prompt to hand the model for /init. */
|
||||||
initPrompt: string;
|
initPrompt: string;
|
||||||
|
/** Directly scaffold TODO.md / ROADMAP.md / docs/ when they are missing; returns what was written. */
|
||||||
|
scaffoldWorkflow: () => string[];
|
||||||
history: string[];
|
history: string[];
|
||||||
recordPrompt: (text: string) => void;
|
recordPrompt: (text: string) => void;
|
||||||
};
|
};
|
||||||
@@ -657,10 +659,13 @@ export function App({
|
|||||||
setWorking(false);
|
setWorking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'init':
|
case 'init': {
|
||||||
push({ kind: 'user', text: chosen.trim() });
|
push({ kind: 'user', text: chosen.trim() });
|
||||||
|
const written = hooks.scaffoldWorkflow();
|
||||||
|
if (written.length > 0) push({ kind: 'info', text: `scaffolded ${written.join(', ')}` });
|
||||||
await runTurn(hooks.initPrompt);
|
await runTurn(hooks.initPrompt);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
case 'model':
|
case 'model':
|
||||||
push({ kind: 'user', text: chosen.trim() });
|
push({ kind: 'user', text: chosen.trim() });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function workflowPanel(session: Session): Panel {
|
|||||||
['TODO.md', `${yes(w.hasTodo)}${w.hasTodo ? ` (${w.todoLines} lines)` : ''}`],
|
['TODO.md', `${yes(w.hasTodo)}${w.hasTodo ? ` (${w.todoLines} lines)` : ''}`],
|
||||||
['ROADMAP.md', `${yes(w.hasRoadmap)}${w.hasRoadmap ? ` (${w.roadmapLines} lines)` : ''}`],
|
['ROADMAP.md', `${yes(w.hasRoadmap)}${w.hasRoadmap ? ` (${w.roadmapLines} lines)` : ''}`],
|
||||||
['docs dir', `${yes(w.hasDocs)}${w.hasDocs ? ` (${w.docsFiles} files)` : ''}`],
|
['docs dir', `${yes(w.hasDocs)}${w.hasDocs ? ` (${w.docsFiles} files)` : ''}`],
|
||||||
['reminders sent', w.nudged ? '1 (this session)' : 'none'],
|
['reminders sent', w.nudgeCount === 0 ? 'none' : `${w.nudgeCount}/3`],
|
||||||
];
|
];
|
||||||
const body = rows.map(([k, v]) => `${k}: ${v}`).join('\n');
|
const body = rows.map(([k, v]) => `${k}: ${v}`).join('\n');
|
||||||
return { title: 'workflow', body };
|
return { title: 'workflow', body };
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
|
|||||||
remove: async (name) => `removed ${name}`,
|
remove: async (name) => `removed ${name}`,
|
||||||
},
|
},
|
||||||
initPrompt: 'write AGENTS.md',
|
initPrompt: 'write AGENTS.md',
|
||||||
|
scaffoldWorkflow: () => [],
|
||||||
history: [],
|
history: [],
|
||||||
recordPrompt: () => {},
|
recordPrompt: () => {},
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
hashFile,
|
||||||
|
loadApprovalStore,
|
||||||
|
loadHooks,
|
||||||
|
parseHookManifest,
|
||||||
|
runPreTool,
|
||||||
|
hooksToPlugin,
|
||||||
|
} from '../src/hooks';
|
||||||
|
import { createHost } from '../src/plugins';
|
||||||
|
|
||||||
|
function inTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'shiro-hooks-'));
|
||||||
|
const orig = process.env['SHIRO_HOME'];
|
||||||
|
process.env['SHIRO_HOME'] = dir;
|
||||||
|
return fn(dir).finally(() => {
|
||||||
|
process.env['SHIRO_HOME'] = orig;
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pre_tool hook that allows everything and echoes the input back. */
|
||||||
|
const ALLOW_HOOK = `#!/usr/bin/env node
|
||||||
|
const input = require('fs').readFileSync(0, 'utf8');
|
||||||
|
console.log(JSON.stringify({ allow: true }));
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** A pre_tool hook that refuses. */
|
||||||
|
const BLOCK_HOOK = `#!/usr/bin/env node
|
||||||
|
console.log(JSON.stringify({ allow: false, reason: 'no writes in this repo' }));
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('parseHookManifest accepts a valid manifest and rejects a bad one', () => {
|
||||||
|
const m = parseHookManifest(JSON.stringify({ name: 'no-force', hook: 'pre_tool', tools: ['bash'] }));
|
||||||
|
expect(m.name).toBe('no-force');
|
||||||
|
expect(m.hook).toBe('pre_tool');
|
||||||
|
expect(() => parseHookManifest('not json')).toThrow('not valid JSON');
|
||||||
|
expect(() => parseHookManifest(JSON.stringify({ name: 'X', hook: 'nope' }))).toThrow('malformed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadHooks finds a directory hook and hashes its executable', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
|
||||||
|
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
|
||||||
|
const hooks = await loadHooks(dir);
|
||||||
|
expect(hooks.length).toBe(1);
|
||||||
|
expect(hooks[0]!.manifest.name).toBe('no-force');
|
||||||
|
expect(hooks[0]!.hash).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(hashFile(hooks[0]!.path)).toBe(hooks[0]!.hash);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('an unapproved hook blocks the call', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
|
||||||
|
writeFileSync(join(hookDir, 'run'), BLOCK_HOOK);
|
||||||
|
const hooks = await loadHooks(dir);
|
||||||
|
const store = loadApprovalStore(); // empty store → nothing approved
|
||||||
|
const plugin = hooksToPlugin(hooks, store);
|
||||||
|
const blocked = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'rm -rf /' }, cwd: dir });
|
||||||
|
expect(blocked).toContain('has not been approved');
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('an approved blocking hook refuses the call with its reason', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
|
||||||
|
writeFileSync(join(hookDir, 'run'), BLOCK_HOOK);
|
||||||
|
const hooks = await loadHooks(dir);
|
||||||
|
const store = loadApprovalStore();
|
||||||
|
store.approve(hooks[0]!.hash);
|
||||||
|
const plugin = hooksToPlugin(hooks, store);
|
||||||
|
const blocked = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'rm -rf /' }, cwd: dir });
|
||||||
|
expect(blocked).toContain('no writes in this repo');
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('an approved allowing hook lets the call through', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'allow');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'allow', hook: 'pre_tool' }));
|
||||||
|
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
|
||||||
|
const hooks = await loadHooks(dir);
|
||||||
|
const store = loadApprovalStore();
|
||||||
|
store.approve(hooks[0]!.hash);
|
||||||
|
const plugin = hooksToPlugin(hooks, store);
|
||||||
|
const allowed = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'ls' }, cwd: dir });
|
||||||
|
expect(allowed).toBeUndefined();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('a changed hash is refused until re-approved', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
|
||||||
|
writeFileSync(join(hookDir, 'run'), BLOCK_HOOK);
|
||||||
|
const first = await loadHooks(dir);
|
||||||
|
const store = loadApprovalStore();
|
||||||
|
store.approve(first[0]!.hash);
|
||||||
|
// file changes → hash changes → approval no longer matches
|
||||||
|
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
|
||||||
|
const second = await loadHooks(dir);
|
||||||
|
const plugin = hooksToPlugin(second, store);
|
||||||
|
const blocked = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'ls' }, cwd: dir });
|
||||||
|
expect(blocked).toContain('has not been approved');
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('an invalid hook is skipped, not fatal', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'broken');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), 'not json');
|
||||||
|
writeFileSync(join(hookDir, 'run'), '#!/bin/sh\nexit 0\n');
|
||||||
|
const hooks = await loadHooks(dir);
|
||||||
|
expect(hooks.length).toBe(0);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('an approving hook runs in the guard chain', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'allow');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'allow', hook: 'pre_tool' }));
|
||||||
|
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
|
||||||
|
const hooks = await loadHooks(dir);
|
||||||
|
const store = loadApprovalStore();
|
||||||
|
store.approve(hooks[0]!.hash);
|
||||||
|
const host = createHost([hooksToPlugin(hooks, store)]);
|
||||||
|
const blocked = await host.guard({ toolName: 'bash', input: { command: 'ls' }, cwd: dir });
|
||||||
|
expect(blocked).toBeUndefined();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('a bad hook output blocks the call', () =>
|
||||||
|
inTmp(async (dir) => {
|
||||||
|
const hookDir = join(dir, '.shiro', 'hooks', 'bad');
|
||||||
|
mkdirSync(hookDir, { recursive: true });
|
||||||
|
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'bad', hook: 'pre_tool' }));
|
||||||
|
writeFileSync(join(hookDir, 'run'), '#!/usr/bin/env node\nconsole.log("not json");\n');
|
||||||
|
const hooks = await loadHooks(dir);
|
||||||
|
const store = loadApprovalStore();
|
||||||
|
store.approve(hooks[0]!.hash);
|
||||||
|
const outcome = await runPreTool(hooks[0]!, { toolName: 'bash', input: { command: 'ls' }, cwd: dir }, 5000);
|
||||||
|
expect(outcome.allow).toBe(false);
|
||||||
|
}));
|
||||||
+67
-11
@@ -177,16 +177,72 @@ test('/workflow panel renders the status rows', () =>
|
|||||||
expect(panel.body).toContain('workflow: on');
|
expect(panel.body).toContain('workflow: on');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
test('context panel groups instructions and trackers separately', () => {
|
test('nudge ladder: fires up to 3 times, then stops', async () => {
|
||||||
const pm = require('../src/ui/panel-bodies') as typeof import('../src/ui/panel-bodies');
|
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-ladder'));
|
||||||
const panel = pm.contextPanel(['/repo/AGENTS.md', '/repo/TODO.md', '/repo/docs/a.md']);
|
try {
|
||||||
expect(panel.title).toBe('project instructions & trackers');
|
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
||||||
expect(panel.body).toContain('instructions:');
|
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] task\n');
|
||||||
expect(panel.body).toContain('- `/repo/AGENTS.md`');
|
await Bun.write(join(dir, 'app.ts'), 'const a = 1;\n');
|
||||||
expect(panel.body).toContain('trackers:');
|
|
||||||
expect(panel.body).toContain('- `/repo/TODO.md`');
|
|
||||||
|
|
||||||
const empty = pm.contextPanel([]);
|
let call = 0;
|
||||||
expect(empty.body).toContain('No `AGENTS.md`');
|
const session = new Session({
|
||||||
expect(empty.body).toContain('no project tracker is loaded');
|
model: new MockLanguageModelV4({
|
||||||
|
doStream: async () => {
|
||||||
|
call++;
|
||||||
|
if (call <= 3) return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: `const a = ${call + 1};` }));
|
||||||
|
// Turn 4+: no tool call — agent stops
|
||||||
|
return stream(text('done'));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
askApproval: async () => 'once',
|
||||||
|
});
|
||||||
|
|
||||||
|
const notices: string[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
for await (const ev of session.send(`turn ${i + 1}`)) {
|
||||||
|
if (ev.type === 'notice') notices.push(ev.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nudgeNotices = notices.filter((n) => n.includes('without updating the project task list'));
|
||||||
|
expect(nudgeNotices.length).toBe(3);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nudge resets after todo_write in a later turn', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-reset'));
|
||||||
|
try {
|
||||||
|
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
||||||
|
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] task\n');
|
||||||
|
await Bun.write(join(dir, 'app.ts'), 'const a = 1;\n');
|
||||||
|
|
||||||
|
let call = 0;
|
||||||
|
const session = new Session({
|
||||||
|
model: new MockLanguageModelV4({
|
||||||
|
doStream: async () => {
|
||||||
|
call++;
|
||||||
|
// Turn 1-3: edit file → 3 nudges
|
||||||
|
if (call <= 3) return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: `const a = ${call + 1};` }));
|
||||||
|
// Turn 4: todo_write → resets the nudge counter
|
||||||
|
if (call === 4) return stream(toolCall(`c${call}`, 'todo_write', { items: [{ text: 'completed task', done: true }] }));
|
||||||
|
// Turn 5: edit file → should nudge again (counter was reset)
|
||||||
|
return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 4;', newString: `const a = ${call + 1};` }));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
askApproval: async () => 'once',
|
||||||
|
});
|
||||||
|
|
||||||
|
const notices: string[] = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
for await (const ev of session.send(`turn ${i + 1}`)) {
|
||||||
|
if (ev.type === 'notice') notices.push(ev.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nudgeNotices = notices.filter((n) => n.includes('without updating the project task list'));
|
||||||
|
// 3 nudges (turns 1-3) + 1 reset + 1 more nudge (turn 5) = 4
|
||||||
|
expect(nudgeNotices.length).toBe(4);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user