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:
+23
-2
@@ -8,6 +8,7 @@ import { configPath, loadConfig, missingKeyMessage, readConfigFile, resolveModel
|
||||
import type { FallbackEvent } from './fallback';
|
||||
import { farewell } from './farewell';
|
||||
import { readStdin, runHeadless } from './headless';
|
||||
import { scaffoldWorkflowFiles } from './scaffold';
|
||||
import { INIT_PROMPT, loadInstructions } from './instructions';
|
||||
import { walk } from './ignore';
|
||||
import { connectMcp } from './mcp';
|
||||
@@ -16,6 +17,7 @@ import { Memory, KIND_LABEL } from './memory';
|
||||
import { costOf } from './pricing';
|
||||
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
|
||||
import { createHost } from './plugins';
|
||||
import { hooksToPlugin, loadApprovalStore, loadHooks } from './hooks';
|
||||
import { fetchModels, presetById } from './providers';
|
||||
import * as registry from './registry';
|
||||
import { Session } from './session';
|
||||
@@ -53,6 +55,7 @@ options:
|
||||
--no-skills ignore builtin and project skills
|
||||
--no-plugins disable all plugins, including the guard
|
||||
--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
|
||||
-v, --version
|
||||
-h, --help
|
||||
@@ -227,11 +230,18 @@ const pluginErrors = enabledPlugins
|
||||
// .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());
|
||||
|
||||
// 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(
|
||||
[
|
||||
...BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
|
||||
...installedPlugins.plugins,
|
||||
...externalPlugins.plugins,
|
||||
...hooksPlugin,
|
||||
],
|
||||
[
|
||||
...pluginErrors,
|
||||
@@ -340,6 +350,10 @@ const session = new Session({
|
||||
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
|
||||
...(cfg.maxSpendPerTurn !== undefined ? { maxSpendPerTurn: cfg.maxSpendPerTurn } : {}),
|
||||
...(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: {
|
||||
...(mcp?.tools ?? {}),
|
||||
...externalTools.tools,
|
||||
@@ -393,6 +407,10 @@ if (printArg !== undefined) {
|
||||
console.error('shiro: -p needs a prompt argument or piped stdin');
|
||||
await shutdown(1);
|
||||
}
|
||||
if (has('--init-scaffold')) {
|
||||
const written = scaffoldWorkflowFiles();
|
||||
if (written.length > 0) console.error(`shiro: scaffolded ${written.join(', ')}`);
|
||||
}
|
||||
if (!yolo) {
|
||||
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',
|
||||
@@ -444,7 +462,8 @@ const hooks: AppHooks = {
|
||||
},
|
||||
stage: async (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 {
|
||||
row: { name: entry.name, kind: entry.kind, description: entry.description },
|
||||
url: entry.url,
|
||||
@@ -453,7 +472,8 @@ const hooks: AppHooks = {
|
||||
},
|
||||
install: async (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
|
||||
if (entry.kind === 'skill') {
|
||||
const fresh = await loadSkills();
|
||||
@@ -543,6 +563,7 @@ const hooks: AppHooks = {
|
||||
},
|
||||
},
|
||||
initPrompt: INIT_PROMPT,
|
||||
scaffoldWorkflow: () => scaffoldWorkflowFiles(),
|
||||
history: promptHistory,
|
||||
recordPrompt: (text) => void store.appendHistory(text),
|
||||
agentName: () => session.agent().name,
|
||||
|
||||
@@ -46,6 +46,10 @@ export type Config = {
|
||||
permission?: PermissionConfig;
|
||||
/** Index for `/registry`. Omit for the default one. */
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -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.agent ? { agent: file.agent } : {}),
|
||||
...(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 { join } from 'node:path';
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import type { Plugin } from './plugins';
|
||||
import { parseSkill, type Skill } from './skills';
|
||||
@@ -83,6 +84,11 @@ const manifestSchema = z.object({
|
||||
description: z.string().min(1).max(300),
|
||||
appendix: z.string().max(2000).optional(),
|
||||
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>;
|
||||
@@ -211,14 +217,94 @@ export function manifestToPlugin(manifest: PluginManifest): Plugin {
|
||||
|
||||
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.
|
||||
*
|
||||
* Separated from the write so the caller can show the user a skill body before it
|
||||
* 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);
|
||||
verifyEntrySignature(body, policy, entry.kind);
|
||||
|
||||
if (entry.kind === 'plugin') {
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function install(entry: RegistryEntry): Promise<Installed> {
|
||||
const { path, content } = await stage(entry);
|
||||
export async function install(entry: RegistryEntry, policy?: SignaturePolicy): Promise<Installed> {
|
||||
const { path, content } = await stage(entry, policy);
|
||||
await Bun.write(path, content);
|
||||
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,
|
||||
streamText,
|
||||
APICallError,
|
||||
type Instructions as SdkInstructions,
|
||||
type LanguageModel,
|
||||
type ModelMessage,
|
||||
type ToolApprovalResponse,
|
||||
@@ -106,10 +107,12 @@ export type SessionOptions = {
|
||||
/** Live stdout/stderr from bash, for a UI that wants progress. */
|
||||
onToolOutput?: (id: string, chunk: string) => 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;
|
||||
/** Emit learner notices to the UI. */
|
||||
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. */
|
||||
workspaceFiles?: readonly string[];
|
||||
/** Project-driven workflow: TODO/ROADMAP tracking + verify-before-done nudges. */
|
||||
@@ -244,8 +247,8 @@ export class Session {
|
||||
private turnCappedNotice: string | undefined;
|
||||
/** Did the current turn call todo_write? Gates the workflow nudge. */
|
||||
private todoWrittenThisTurn = false;
|
||||
/** Fired at most once per session: an agent that edits without updating the task list. */
|
||||
private workflowNudged = false;
|
||||
/** How many times this session has nudged about the task list; capped at 3. */
|
||||
private workflowNudgeCount = 0;
|
||||
/** Line count of TODO.md at last check, for /workflow. */
|
||||
private workflowTodoLines = 0;
|
||||
private workflowRoadmapLines = 0;
|
||||
@@ -504,7 +507,7 @@ export class Session {
|
||||
'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',
|
||||
'- 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',
|
||||
'- 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
|
||||
* list. Not a stop — it keeps the agent moving while reminding it the
|
||||
* project expects the plan kept current. Fires at most once per session.
|
||||
* A gentle ladder of reminders after turns that wrote files without touching
|
||||
* the task list. Cap at three — a nag that repeats without limit trains the
|
||||
* model to ignore it, so the third is explicitly the last.
|
||||
*/
|
||||
private workflowNudge(): string | undefined {
|
||||
if (this.opts.workflow?.enabled === false) return undefined;
|
||||
if (this.workflowNudged) return undefined;
|
||||
if (this.workflowNudgeCount >= 3) return undefined;
|
||||
const root = this.gitRoot();
|
||||
if (!root) return undefined;
|
||||
if (!this.workflowChecked) this.workflowPolicy();
|
||||
@@ -536,8 +539,13 @@ export class Session {
|
||||
// Only when onBeforeWrite actually fired (a write succeeded) and no todo_write
|
||||
const hasWrites = this.turnBeforeFiles?.size > 0;
|
||||
if (!hasWrites) return undefined;
|
||||
this.workflowNudged = true;
|
||||
return 'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.';
|
||||
this.workflowNudgeCount += 1;
|
||||
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;
|
||||
hasDocs: boolean;
|
||||
docsFiles: number;
|
||||
nudged: boolean;
|
||||
nudgeCount: number;
|
||||
} {
|
||||
const root = this.gitRoot();
|
||||
if (!this.workflowChecked && root) this.workflowPolicy();
|
||||
@@ -607,7 +615,7 @@ export class Session {
|
||||
roadmapLines: this.workflowRoadmapLines,
|
||||
hasDocs: this.workflowHasDocs,
|
||||
docsFiles: this.workflowDocsFiles,
|
||||
nudged: this.workflowNudged,
|
||||
nudgeCount: this.workflowNudgeCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -756,7 +764,7 @@ export class Session {
|
||||
return names.size > 0 ? [...names].sort() : undefined;
|
||||
}
|
||||
|
||||
private systemFor(): string {
|
||||
private systemFor(): SdkInstructions {
|
||||
const versionKey = [
|
||||
`nb:${this.versions.notebook}`,
|
||||
`mem:${this.versions.memory}`,
|
||||
@@ -790,7 +798,38 @@ export class Session {
|
||||
...(workflowPolicy ? { workflowPolicy } : {}),
|
||||
});
|
||||
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. */
|
||||
initPrompt: string;
|
||||
/** Directly scaffold TODO.md / ROADMAP.md / docs/ when they are missing; returns what was written. */
|
||||
scaffoldWorkflow: () => string[];
|
||||
history: string[];
|
||||
recordPrompt: (text: string) => void;
|
||||
};
|
||||
@@ -657,10 +659,13 @@ export function App({
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
case 'init':
|
||||
case 'init': {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
case 'model':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
try {
|
||||
|
||||
@@ -115,7 +115,7 @@ export function workflowPanel(session: Session): Panel {
|
||||
['TODO.md', `${yes(w.hasTodo)}${w.hasTodo ? ` (${w.todoLines} lines)` : ''}`],
|
||||
['ROADMAP.md', `${yes(w.hasRoadmap)}${w.hasRoadmap ? ` (${w.roadmapLines} lines)` : ''}`],
|
||||
['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');
|
||||
return { title: 'workflow', body };
|
||||
|
||||
Reference in New Issue
Block a user