Compare commits

..

5 Commits

Author SHA1 Message Date
a7a40c5b46 feat(coder): add hashline editing core + wire audit hooks into dispatch pipeline
Hashline editing: content-hash anchors for edit_file stale-patch detection.
Pure-JS xxHash32, line hash computation, validation with HashlineMismatchError,
256-entry hash dictionary. 6 files in apps/coder/src/services/hashline/.

Audit hooks: emitHook('tool.execute.after') wired in frame-emitter.ts for
completed/failed tool results. emitHook('turn.end') wired at terminal points
in dispatcher.ts (all 5 run functions: native, external, opencode, warm ACP,
claude SDK). Fire-and-forget, non-blocking.
2026-06-07 23:17:47 +00:00
e5183cc71b feat(agents): differentiate tool restrictions per agent role
Each of 9 agents now has a unique purpose-scoped tool whitelist:
- Security Auditor: 10 tools (tightest, static analysis only)
- Prompt Builder: 5 tools (core file exploration + overview)
- Code Reviewer/Debugger/Recon: 18 tools each (different codecontext subsets)
- Refactorer/Planner: 19 tools each (full codecontext, planner narrower fs)
- Architect: 22 tools (only one with web_search + web_fetch)
- Builder: 25 tools (unchanged, only write-capable)
2026-06-07 23:17:38 +00:00
9abc14ef82 feat(skills): add self-healing and verify-gate skills from pskoett-skills fork
Self-healing: heal loop with verify-before-persist discipline, Pattern-Key
dedup, HEAL entry format, 3 scripts, examples reference, eval.yaml.
Verify-gate: 4-step process (Discover -> Run -> Fix Loop -> Gate Signal)
with 3-attempt fix loop, scope-to-fix-only discipline, command discovery.
.learnings/HEALS.md with template entry.
2026-06-07 23:17:33 +00:00
7ef479639a feat(booterm): add PTY session registry + listing endpoint
In-memory SessionMeta registry tracks active terminal sessions with
paneId, sessionId, projectPath, title, createdAt, lastActivityAt.
GET /api/term/sessions returns all active sessions as JSON array.
Registry is updated on WS attach and cleaned up on disconnect.
2026-06-07 22:40:27 +00:00
89a6ffe8a0 feat(mcp): add type-inject MCP server for TypeScript type recovery
Registers @nick-vi/type-inject-mcp as a stdio MCP server via npx.
Provides lookup_type and list_types tools for TypeScript type
recovery — solves the 0% TS type recovery gap in codecontext.
2026-06-07 22:40:27 +00:00
22 changed files with 1458 additions and 10 deletions

37
.learnings/HEALS.md Normal file
View File

@@ -0,0 +1,37 @@
# Self-healing log
Verified fixes for runtime failures. Each entry documents a failure, its root cause, the applied fix, and the verification proof.
**Pattern-Key discipline:** before filing a new HEAL, search this file for an existing Pattern-Key. If found, increment `Recurrence-Count` and update `Last-Seen` — do not duplicate.
**Lifecycle:** verified heals at Recurrence-Count ≥ 3 across distinct tasks get a `Handoff` block for promotion to project memory (`CLAUDE.md`, `AGENTS.md`, or a skill).
---
## [HEAL-YYYYMMDD-XXX] short_kebab_name
**Logged**: ISO-8601 timestamp
**Status**: pending-verify
**Trigger**: tool-failure | missing-capability | env-issue | external-change | <free-form>
**Area**: free-form tag (e.g. `build`, `tests`, `ci`, `auth`, `data-pipeline`)
**Priority**: low | medium | high | critical
### Failure
Concrete error: command, error message, exit code, blocked action.
### Diagnosis
Root cause as understood after investigation. What was verified during diagnosis.
### Fix
Patch applied. Verbatim commands, code snippets, or pointers to `.learnings/heals/<HEAL-ID>/`.
### Verification
What was run after the fix and what it returned. Exit code, output snippet, test pass count. **Proof.**
### Metadata
- Related Files: path/to/file.ext
- See Also: HEAL-... | LRN-... | ERR-...
- Pattern-Key: lower.snake.case (e.g. `env.lockfile_mismatch`)
- Recurrence-Count: 1
- First-Seen: YYYY-MM-DD
- Last-Seen: YYYY-MM-DD

View File

@@ -4,6 +4,7 @@ import { loadConfig } from './config.js';
import { getPool, closeDb } from './db.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerTerminalRoutes } from './routes/terminals.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerWsAttachRoute } from './ws/attach.js';
async function main(): Promise<void> {
@@ -33,6 +34,7 @@ async function main(): Promise<void> {
registerHealthRoutes(app);
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
registerSessionRoutes(app);
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
const shutdown = async (signal: string) => {

View File

@@ -0,0 +1,44 @@
export interface SessionMeta {
paneId: string;
sessionId: string;
projectPath: string;
title?: string;
createdAt: Date;
lastActivityAt: Date;
}
const sessions = new Map<string, SessionMeta>();
export function register(
sessionId: string,
paneId: string,
projectPath: string,
title?: string,
): void {
const now = new Date();
const existing = sessions.get(paneId);
if (existing) {
existing.lastActivityAt = now;
return;
}
sessions.set(paneId, {
paneId,
sessionId,
projectPath,
title,
createdAt: now,
lastActivityAt: now,
});
}
export function unregister(paneId: string): void {
sessions.delete(paneId);
}
export function list(): SessionMeta[] {
return Array.from(sessions.values());
}
export function get(paneId: string): SessionMeta | undefined {
return sessions.get(paneId);
}

View File

@@ -0,0 +1,18 @@
import type { FastifyInstance } from 'fastify';
import { list } from '../pty/registry.js';
export function registerSessionRoutes(app: FastifyInstance): void {
app.get('/api/term/sessions', async (_req, reply) => {
const active = list();
return reply.code(200).send({
sessions: active.map((s) => ({
paneId: s.paneId,
sessionId: s.sessionId,
projectPath: s.projectPath,
title: s.title ?? null,
createdAt: s.createdAt.toISOString(),
lastActivityAt: s.lastActivityAt.toISOString(),
})),
});
});
}

View File

@@ -9,6 +9,7 @@ import {
} from '../pty/manager.js';
import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js';
import { register, unregister } from '../pty/registry.js';
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{
@@ -57,6 +58,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return;
}
register(sid, pid, session.project_path);
let handle: IPty;
try {
handle = attachPty({
@@ -157,6 +160,7 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
// teardown happens via the /kill route called from the frontend when the
// user closes the pane.
socket.on('close', () => {
unregister(pid);
try {
handle.kill();
} catch {

View File

@@ -30,6 +30,7 @@ import {
type TerminalMessageStatus,
} from './finalize-message.js';
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
import { emitHook } from '../plugins/host.js';
interface InferenceRunner {
enqueue: (
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
}
// EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing
// is silently swallowed so it never blocks the dispatch flow.
function emitTurnEnd(
sessionId: string,
taskId: string,
state: string,
agent?: string | null,
model?: string | null,
outputSummary?: string,
): void {
void emitHook('turn.end', {
sessionId,
turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary },
});
}
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
// state and publish the matching message_complete frame. Best-effort + idempotent
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
@@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): {
// Declared before try so the catch block can write it back on the task row.
let chatId: string | null = null;
let sessionId: string | undefined;
try {
// Mark running
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
// whose persona is stamped on the session via agent_id) or create a fresh one.
const model = task.model ?? config.DEFAULT_MODEL;
let sessionId: string;
if (task.session_id) {
sessionId = task.session_id;
} else {
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId}
`;
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
return;
}
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId}
`;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
} else {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId}
`;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): {
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
WHERE id = ${taskId}
`.catch(() => {});
if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg);
}
}
@@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId);
return;
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
// #10: external-agent turn completed cleanly.
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
// preceded its assignment — guard so the status publish never masks the real
// error.
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
// Best-effort cleanup
await cleanupWorktree(projectPath, taskId);
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm
}
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed.
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
@@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm
}
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
@@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm
}
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}

View File

@@ -19,9 +19,10 @@
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { AgentEvent } from './agent-backend.js';
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js';
import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import type { DcpStreamStripper } from './dcp-strip.js';
import { emitHook } from '../plugins/host.js';
export interface FrameEmitterOpts {
broker?: Broker;
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
}
break;
case 'tool_call':
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
if (canStream()) {
broker!.publishFrame(sessionId!, {
type: 'tool_call',
message_id: assistantId!,
chat_id: chatId!,
tool_call: snapshotToWireToolCall(e.toolCall),
} as WsFrame);
}
break;
case 'tool_update':
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
{
const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput);
if (lifecycle === 'completed' || lifecycle === 'failed') {
void emitHook('tool.execute.after', {
toolName: e.toolCall.title,
args: e.toolCall.rawInput,
result: e.toolCall.rawOutput,
duration: undefined,
});
}
}
if (canStream()) {
broker!.publishFrame(sessionId!, {
type: 'tool_call',

View File

@@ -0,0 +1,10 @@
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
const high = i >>> 4
const low = i & 0x0f
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
})
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/

View File

@@ -0,0 +1,31 @@
import { HASHLINE_DICT } from "./constants.js"
import { hashXxh32 } from "./xxhash32.js"
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
const stripped = normalizedContent
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
const hash = hashXxh32(stripped, seed)
const index = hash % 256
return HASHLINE_DICT[index]!
}
export function computeLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
}
export function computeLegacyLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
}
export function formatHashLine(lineNumber: number, content: string): string {
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}|${content}`
}
export function formatHashLines(content: string): string {
if (!content) return ""
const lines = content.split("\n")
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
}

View File

@@ -0,0 +1,11 @@
/**
* Hashline editing core — content-hash anchors for edit_file stale-patch detection.
*
* Ported from oh-my-openagent/packages/hashline-core/.
* Bundles a runtime-aware xxHash32 (Bun fast-path, pure-JS fallback).
*/
export { computeLineHash, formatHashLines, formatHashLine, computeLegacyLineHash } from "./hash-computation.js"
export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation.js"
export type { LineRef } from "./validation.js"
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants.js"
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types.js"

View File

@@ -0,0 +1,20 @@
export interface ReplaceEdit {
op: "replace"
pos: string
end?: string
lines: string | string[]
}
export interface AppendEdit {
op: "append"
pos?: string
lines: string | string[]
}
export interface PrependEdit {
op: "prepend"
pos?: string
lines: string | string[]
}
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit

View File

@@ -0,0 +1,192 @@
import { computeLegacyLineHash, computeLineHash } from "./hash-computation.js"
import { HASHLINE_REF_PATTERN } from "./constants.js"
export interface LineRef {
line: number
hash: string
}
interface HashMismatch {
line: number
expected: string
}
const MISMATCH_CONTEXT = 2
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
}
export function normalizeLineRef(ref: string): string {
const originalTrimmed = ref.trim()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
trimmed = trimmed.replace(/\s*#\s*/, "#")
trimmed = trimmed.replace(/\|.*$/, "")
trimmed = trimmed.trim()
if (HASHLINE_REF_PATTERN.test(trimmed)) {
return trimmed
}
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
if (extracted) {
return extracted[1]!
}
return originalTrimmed
}
export function parseLineRef(ref: string): LineRef {
const normalized = normalizeLineRef(ref)
const match = normalized.match(HASHLINE_REF_PATTERN)
if (match) {
return {
line: Number.parseInt(match[1]!, 10),
hash: match[2]!,
}
}
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
const suffix = normalized.slice(hashIdx + 1)
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
throw new Error(
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
`Use the actual line number from the read output.`
)
}
}
throw new Error(
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
)
}
export function validateLineRef(lines: string[], ref: string): void {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
const content = lines[line - 1]
if (content === undefined) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
if (!isCompatibleLineHash(line, content, hash)) {
throw new HashlineMismatchError([{ line, expected: hash }], lines)
}
}
export class HashlineMismatchError extends Error {
readonly remaps: ReadonlyMap<string, string>
constructor(
private readonly mismatches: HashMismatch[],
private readonly fileLines: string[]
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
this.name = "HashlineMismatchError"
const remaps = new Map<string, string>()
for (const mismatch of mismatches) {
const content = fileLines[mismatch.line - 1]
const actualLine = content ?? ""
const actual = computeLineHash(mismatch.line, actualLine)
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
}
this.remaps = remaps
}
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchByLine = new Map<number, HashMismatch>()
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
const displayLines = new Set<number>()
for (const mismatch of mismatches) {
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
for (let line = low; line <= high; line++) displayLines.add(line)
}
const sortedLines = [...displayLines].sort((a, b) => a - b)
const output: string[] = []
output.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
)
output.push("")
let previousLine = -1
for (const line of sortedLines) {
if (previousLine !== -1 && line > previousLine + 1) {
output.push(" ...")
}
previousLine = line
const content = fileLines[line - 1] ?? ""
const hash = computeLineHash(line, content)
const prefix = `${line}#${hash}|${content}`
if (mismatchByLine.has(line)) {
output.push(`>>> ${prefix}`)
} else {
output.push(` ${prefix}`)
}
}
return output.join("\n")
}
}
function suggestLineForHash(ref: string, lines: string[]): string | null {
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
if (!hashMatch) return null
const hash = hashMatch[1]!
for (let i = 0; i < lines.length; i++) {
if (isCompatibleLineHash(i + 1, lines[i] ?? "", hash)) {
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i] ?? "")}"?`
}
}
return null
}
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
try {
return parseLineRef(ref)
} catch (parseError) {
const hint = suggestLineForHash(ref, lines)
if (hint && parseError instanceof Error) {
throw new Error(`${parseError.message} ${hint}`)
}
throw parseError
}
}
export function validateLineRefs(lines: string[], refs: string[]): void {
const mismatches: HashMismatch[] = []
for (const ref of refs) {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
}
const content = lines[line - 1]
if (content === undefined) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
}
if (!isCompatibleLineHash(line, content, hash)) {
mismatches.push({ line, expected: hash })
}
}
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, lines)
}
}

View File

@@ -0,0 +1,90 @@
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
const encoder = new TextEncoder()
const PRIME32_1 = 0x9e3779b1
const PRIME32_2 = 0x85ebca77
const PRIME32_3 = 0xc2b2ae3d
const PRIME32_4 = 0x27d4eb2f
const PRIME32_5 = 0x165667b1
function rotateLeft32(value: number, bits: number): number {
return ((value << bits) | (value >>> (32 - bits))) >>> 0
}
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
return (
((input[offset] ?? 0) |
((input[offset + 1] ?? 0) << 8) |
((input[offset + 2] ?? 0) << 16) |
((input[offset + 3] ?? 0) << 24)) >>>
0
)
}
function round32(accumulator: number, value: number): number {
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
}
function xxHash32Js(input: Uint8Array, seed: number): number {
let offset = 0
const length = input.length
let hash: number
if (length >= 16) {
const limit = length - 16
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
let value2 = (seed + PRIME32_2) >>> 0
let value3 = seed >>> 0
let value4 = (seed - PRIME32_1) >>> 0
while (offset <= limit) {
value1 = round32(value1, readUint32LittleEndian(input, offset))
offset += 4
value2 = round32(value2, readUint32LittleEndian(input, offset))
offset += 4
value3 = round32(value3, readUint32LittleEndian(input, offset))
offset += 4
value4 = round32(value4, readUint32LittleEndian(input, offset))
offset += 4
}
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
hash = (hash + rotateLeft32(value3, 12)) >>> 0
hash = (hash + rotateLeft32(value4, 18)) >>> 0
} else {
hash = (seed + PRIME32_5) >>> 0
}
hash = (hash + length) >>> 0
while (offset + 4 <= length) {
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
offset += 4
}
while (offset < length) {
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
offset += 1
}
hash = (hash ^ (hash >>> 15)) >>> 0
hash = Math.imul(hash, PRIME32_2) >>> 0
hash = (hash ^ (hash >>> 13)) >>> 0
hash = Math.imul(hash, PRIME32_3) >>> 0
return (hash ^ (hash >>> 16)) >>> 0
}
export function hashXxh32(input: string, seed: number): number {
const bun = runtime.Bun
if (bun !== undefined) {
return bun.hash.xxHash32(input, seed)
}
return xxHash32Js(encoder.encode(input), seed >>> 0)
}

View File

@@ -17,7 +17,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
description: Reviews code for bugs, security issues, and maintainability. Read-only.
---
You review code. Find real problems, not style nits.
@@ -56,7 +56,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
description: Diagnoses bugs from error messages, logs, or described symptoms.
---
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
@@ -82,7 +82,7 @@ top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 5
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
---
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
@@ -125,7 +125,7 @@ top_k: 20
min_p: 0.0
presence_penalty: 1.5
steps: 20
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes, web_fetch, web_search]
description: Designs new features, modules, or architectural changes. Outputs a build plan.
---
You design. You produce build plans, not code.
@@ -167,7 +167,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
description: Audits code for security vulnerabilities. Read-only.
---
You audit for security issues. Concrete findings only, no generic warnings.
@@ -212,7 +212,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [view_file, list_dir, grep, find_files]
tools: [find_files, get_codebase_overview, grep, list_dir, view_file]
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
---
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
@@ -250,7 +250,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
---
You map codebases. Start broad, then drill into specifics.
@@ -278,7 +278,7 @@ top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 10
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, watch_changes]
description: Produces actionable step plans from requirements. Read-only — never modifies files.
---
You produce actionable step plans. You do not modify files.

View File

@@ -7,6 +7,12 @@
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
},
"enabled": false
},
"type-inject": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@nick-vi/type-inject-mcp"],
"enabled": true
}
}
}

View File

@@ -0,0 +1,287 @@
---
name: self-healing
description: "Active runtime recovery for coding agents: when something breaks mid-task, diagnose the root cause, write a fix, VERIFY by re-running the broken thing, then file a `HEAL-` entry to `.learnings/HEALS.md` with proof. Use whenever a command, test, build, or lint fails or exits non-zero; on missing tooling, dependency/lockfile mismatch, wrong runtime version, venv or permission errors, port conflicts, dirty git state, or a missing `.env`; when the agent needs a helper or one-off script that doesn't exist yet; when an external API, tool, or MCP errors or rate-limits; or when a test flakes. Search `HEALS.md` by `Pattern-Key` first — most heals are recurrences, so increment `Recurrence-Count` instead of duplicating. Verify is mandatory: mark `pending-verify` honestly if sandboxed, `abandoned` if the fix can't be made to work. Pairs with `self-improvement` (which promotes recurring heals to durable memory) but owns the verify-before-persist discipline self-improvement doesn't."
---
# Self-Healing
Active runtime recovery for coding agents. When something breaks, run the loop: **diagnose → patch → verify → file**. Leave behind a reusable, verified artifact instead of a swept-under-the-rug failure.
The premise mirrors [browser-use/browser-harness](https://github.com/browser-use/browser-harness): *the harness improves itself every run*. An agent that hits a gap doesn't fail — it writes the fix during execution, verifies it works, and files the durable artifact for future runs. Coding tasks deserve the same loop.
## What this skill is for
When a coding agent hits a wall mid-task, the default failure modes are:
1. **Paper over it** — "let me try a different approach" — and lose the recovery
2. **Pretend the fix worked** — without re-running the broken thing
3. **Symptom-fix** — skip the test, swallow the error, retry until green
All three turn a one-time failure into a recurrence. The next agent on the same project hits the same wall.
This skill enforces one discipline: **verify before persist**. A patch isn't real until you've re-run the failing operation and watched it succeed. When it does, file the verified fix so the next run benefits.
## Relationship to self-improvement
These two skills are deliberately split. Run both — they feed each other but don't overlap.
| Aspect | `self-healing` (this skill) | `self-improvement` |
| ----------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |
| **When** | During execution, failure is live | After the fact, at natural breakpoints |
| **Verb** | Heal now — restore working state | Remember for later — accumulate knowledge |
| **Outcome** | Verified patch + (optional) reusable artifact | Logged learning, correction, request |
| **Verify** | **Mandatory** — no persist without proof | Not required |
| **Files** | `.learnings/HEALS.md` + `.learnings/heals/<HEAL-ID>/` (lazy) | `.learnings/ERRORS.md`, `LEARNINGS.md`, `FEATURE_REQUESTS.md` |
| **Trigger** | Failure observed mid-task | Correction, knowledge gap, feature request, recurrence |
**Boundary rule:** if you're capturing a fact, a correction, or a wish — that's `self-improvement`. If you're applying and verifying a fix to a live failure — that's `self-healing`.
## The Heal Loop
```
● failure observed
● 1. DIAGNOSE capture context — command, error, env, what was attempted
│ search HEALS.md for the same Pattern-Key first
│ (most heals are recurrences; don't reinvent)
● 2. PATCH write the fix — script, helper, env tweak, alt command
│ artifacts → .learnings/heals/<HEAL-ID>/ (only if needed)
● 3. VERIFY re-run the failing op — must succeed
│ ↻ if still failing: refine and retry, cap at 3 attempts
│ ✗ if uncrackable: file Status: abandoned with notes
● 4. FILE write HEAL-YYYYMMDD-XXX to .learnings/HEALS.md
│ with Pattern-Key, status, verification proof
✓ working state restored, heal persisted
(conditional) PROMOTE if Pattern-Key recurrence ≥ 3 across distinct tasks,
append a Handoff block → self-improvement promotes to memory
```
If you abandon a heal mid-loop, don't pretend it succeeded. File a `HEAL-` entry with `Status: abandoned` and notes on what didn't work. The next agent learns from the dead end too.
## When to trigger
Self-healing fires on **active failures during execution** — the agent has just observed something not working and needs to make it work to continue. Five shapes:
### 1. Tool failure (command / test / build / lint)
Any invocation exits non-zero or produces wrong output. Don't acknowledge and retry verbatim — diagnose, patch, verify.
*Examples:* `npm install` errors when a `pnpm-lock.yaml` is present (switch tool); `pytest` fails with `ModuleNotFoundError` (activate the venv); `tsc` flags a stale type (regenerate the client); `eslint` reports a config error (install the missing parser).
### 2. Missing capability / tool gap
The agent needs something that doesn't exist yet — a script, a helper, a wrapper, a glue function. Write it in the moment. This is the closest analog to browser-harness's `agent_helpers.py`.
*Examples:* dedupe a CSV by custom key (write a small Python helper); bootstrap 12 microservices the same way (write `scripts/bootstrap-all.sh`); bulk-rename branches matching a pattern (write a `gh`-based shell helper).
### 3. Environment issue
The local environment isn't what the project expects. Detect, patch, verify.
*Examples:* runtime version mismatch (`nvm use`, `pyenv local`, `rustup override`); stale dependency cache after a branch switch; dirty git state blocking a checkout; missing `.env` (copy from `.env.example` and surface gaps).
### 4. External service / API change
A service the agent depends on returns something unexpected. Find a workaround and capture it.
*Examples:* an MCP tool returns `InputValidationError` because the schema changed (patch the call shape); a public API hits a rate limit (back off, switch endpoint, batch); an upstream lib bumped a default and broke a script (pin the version).
### 5. About-to-retry-the-same-broken-approach
The agent catches itself about to redo the failing step. That self-recognition is a heal forming — capture the alternate approach as the patch.
### Detection signals to watch for
- Non-zero exit codes
- Stack traces in tool output
- The same operation failing twice with the same error
- "I'll try a different approach" — capture it as a heal
- `command not found` / `module not found` / `permission denied`
- Stale assertions, snapshot mismatches, type errors that weren't there before
- "Weird" output that suggests environmental rather than logical bugs
## HEAL Entry Format
Append to `.learnings/HEALS.md` (create if missing):
```markdown
## [HEAL-YYYYMMDD-XXX] short_kebab_name
**Logged**: ISO-8601 timestamp
**Status**: verified | pending-verify | abandoned
**Trigger**: tool-failure | missing-capability | env-issue | external-change | <free-form>
**Active-Context**: (optional) — current skill, task phase, or workflow stage; omit if not applicable
**Area**: free-form tag — what part of the system (`build`, `tests`, `ci`, `auth`, `data-pipeline`, `mobile`, ...)
**Priority**: low | medium | high | critical
### Failure
What broke — concrete: the command, the error message, the action that was blocked. Include exit codes and verbatim error lines.
### Diagnosis
The root cause as understood after investigation. Why the obvious approach didn't work. Not a guess — what was actually verified during the heal.
### Fix
The patch that was applied. Verbatim commands, code snippets, or pointers to files under `.learnings/heals/<HEAL-ID>/`. Keep it minimal — just enough to reproduce.
### Verification
What was run after the fix and what it returned. Exit code, output snippet, test pass count. **This is the proof.** Without it, the entry is `pending-verify` or `abandoned`.
### Artifacts
(omit this section if no files were generated; otherwise list relative paths under `.learnings/heals/<HEAL-ID>/`)
### Metadata
- Related Files: path/to/file.ext
- See Also: HEAL-... | LRN-... | ERR-... (related entries)
- Pattern-Key: lower.snake.case key for recurrence detection (e.g. `env.lockfile_mismatch`)
- Recurrence-Count: 1
- First-Seen / Last-Seen: YYYY-MM-DD
---
```
### Field guidance
- **Status** — `verified` = the verify step passed. `pending-verify` = patch applied but couldn't be fully proven (sandboxed/offline/CI-only) — surface to the user. `abandoned` = patch didn't work or diagnosis was wrong — document what was tried.
- **Trigger** — free-form is fine. The listed values are common shapes; what matters is that the failure shape is described enough for future agents to match against.
- **Active-Context** — optional. Use it if your environment has a meaningful "what was I doing" tag (an active skill, a current task phase, a build stage, an agent role). Skip if not applicable. The browser-harness analog is the per-domain scoping of `domain-skills/<site>/`.
- **Area** — free-form. Pick whatever helps future agents find this. `frontend`, `data-pipeline`, `ci`, `auth`, `terraform`, `mobile`, `embedded` — anything that fits your project shape.
- **Pattern-Key** — lower.snake.case, stable, reusable across projects. Two heals with the same key are recurrences. `env.lockfile_mismatch` is good; `fixed_thing_tuesday` isn't.
## ID generation
Format: `HEAL-YYYYMMDD-XXX`. `XXX` is sequential 3-digit or 3-char random alphanumeric. Examples: `HEAL-20260524-001`, `HEAL-20260524-A7B`.
## Artifacts directory (lazy)
Only create `.learnings/heals/<HEAL-ID>/` when the heal generated something worth preserving. One-line fixes don't need a folder; the HEAL entry text is enough. Abandoned heals with no applied patch also skip the folder.
```
.learnings/
├── HEALS.md
├── ERRORS.md / LEARNINGS.md / FEATURE_REQUESTS.md (self-improvement)
└── heals/
└── HEAL-20260524-001/
├── helper.sh
├── patch.diff
└── notes.md
```
**Put here:** generated scripts/helpers, patch files, supplementary notes, output captures that document the diagnosis.
**Don't put here:** project source changes (those go in the project tree, referenced via Related Files); secrets; output already captured in the HEAL text.
## Verification rules
Verify is the load-bearing wall. The whole point of self-healing over self-improvement is that the fix is *proven*, not theorized.
### What counts as proof
| Failure shape | Verification |
| ------------------------------------- | ------------------------------------------------------------------ |
| Tool / command / test / build / lint | Re-run the original invocation; expect exit 0 / pass |
| Missing capability | Invoke the helper end-to-end on a real input; expect the intent |
| Environment drift | Re-run the operation that triggered the diagnosis |
| External service workaround | Re-run the failed call with the patch; expect a usable response |
### Sandboxed / offline / CI-only failures
When you genuinely can't run the verify step (no network, no real remote, sandboxed shell, CI-only reproduction), file `Status: pending-verify` with:
- The exact command the user / CI should run
- The acceptance criteria — what counts as proof
- A simulated proof if you can construct one (e.g. a dry-run mode, a stub of the failing call, a sandbox script)
`pending-verify` is honest. Faking `verified` is the failure mode this skill exists to prevent.
### When to invest in a proof script
Most heals don't need a separate proof script — the verify step is just re-running the failing thing. Build a proper proof script when:
- The heal generates a reusable helper that needs to be exercised across cases
- The failure can't be reproduced live but can be reproduced in a sandbox (clean git repo, mock service, fake input)
- You expect the heal to be re-applied across projects — the proof script then doubles as a regression check
### If verification fails
1. **Once** — refine the patch and retry. First diagnosis is often wrong.
2. **Twice** — step back and reconsider the diagnosis. Maybe the root cause is elsewhere.
3. **Three times** — stop. File `Status: abandoned` with notes on what you tried. Surface to the user. Don't flail.
### What does NOT count as verification
- "It looks right" / "I think this should work"
- Re-running a *different* command than the one that originally failed
- Suppressing the failure (`|| true`, `--ignore-errors`) — that's hiding
- Skipping or deleting the failing test — that's regression
- Passing because the cache was warm from before the fix
### Reversibility
Prefer reversible patches. If your heal modifies project files, capture the diff in `patch.diff`. If the heal is destructive (deletes generated files, rewrites locks), note it explicitly — a future agent reading the HEAL needs to know what was destroyed.
## Recurrence and promotion
Most heals are recurrences. Before filing a new HEAL, search:
```bash
grep -n "Pattern-Key: <your-pattern-key>" .learnings/HEALS.md
```
If found:
- Increment `Recurrence-Count`
- Update `Last-Seen`
- Add the current occurrence as a See Also link
- **Do not** create a duplicate entry
### Promotion threshold
Add a `Handoff` block to an existing entry when **all** are true:
- `Recurrence-Count >= 3`
- Seen across at least 2 distinct tasks
- The fix is generalizable (not project-specific in a way that's already in a memory file)
```markdown
### Handoff
- **Promoted To**: self-improvement at YYYY-MM-DD
- **Promotion Target**: CLAUDE.md | AGENTS.md | .github/copilot-instructions.md | new-skill
- **Distilled Rule**: One-line prevention guidance derived from the heal
```
Then `self-improvement` (or a learning aggregator) takes over: distills the rule, writes it into the right context file, or extracts a reusable skill. The HEAL stays for traceability.
## Anti-patterns
1. **Logging without verifying.** A HEAL filed before the fix is proven turns this into noisier self-improvement. If verify hasn't passed, the entry is `pending-verify` or `abandoned`.
2. **Healing the symptom, not the cause.** A failing test isn't healed by skipping it (`pytest.skip`, `it.skip`, `xit`). A flaky CI isn't healed by `--retry`. Find the root cause; if you can't, abandon honestly.
3. **Generating a new fix without trying existing ones first.** Search `HEALS.md` by Pattern-Key. Most heals are recurrences.
4. **Inventing helpers when the project already has them.** Look in `scripts/`, `Makefile`, `justfile`, `package.json`, `pyproject.toml` first. Heal = write what's missing, not what's there.
5. **Scope creep.** A heal is scoped to one failure. Cleanup belongs in a quality pass; refactors are features. Scope creep makes heals unreviewable.
6. **Empty artifact folders.** Don't create `.learnings/heals/<HEAL-ID>/` if nothing goes in it.
## Best practices
1. **Heal eagerly, file always.** Even abandoned heals teach the next agent what doesn't work.
2. **Verify before persist.** The non-negotiable rule.
3. **Minimal and reversible patches.** A 3-line fix is a heal; a 300-line refactor is a feature.
4. **Stable Pattern-Keys.** `env.node_version_mismatch` is reusable; `fixed_the_thing_on_tuesday` isn't.
5. **Reference, don't duplicate.** Cross-link related HEAL/LRN/ERR via See Also.
6. **Hand off recurrences.** A heal seen 3 times deserves to be in the project's permanent memory.
7. **Don't gate the main tree on heal artifacts.** Files under `.learnings/heals/` are reference material; if a script becomes load-bearing, promote it to `scripts/`.
## Setup
```bash
mkdir -p .learnings # heals/ is lazy — created only when artifacts exist
touch .learnings/HEALS.md
```
Gitignore choices match `self-improvement`. Keep heals local (`.learnings/` in `.gitignore`) or share them as team knowledge (don't gitignore — they become reviewable durable context).
## Multi-agent use
The skill is agent-agnostic. The `.learnings/HEALS.md` format is plain markdown — any agent (Claude Code, BooCode agents, OpenCode, Copilot, Cursor, Aider, ...) can read and write it.
## See also
- [`references/examples.md`](references/examples.md) — canonical HEAL entry shapes (command failure, missing capability, env drift, external API workaround, abandoned heal)

View File

@@ -0,0 +1,35 @@
skill: self-healing
tasks:
- prompt: "I'm in a project root that has pnpm-lock.yaml present but no package-lock.json. I just tried to run `npm install` and it failed. Get me to a working state so I can keep working — I have other things to do, just unblock me. After fixing it, make sure future agents in this project know what happened."
grader:
- the response invokes the self-healing skill
- the response diagnoses pnpm vs npm mismatch as the root cause
- the response runs pnpm install successfully
- the response files a HEAL entry to .learnings/HEALS.md with Status: verified
- the HEAL entry has Trigger: tool-failure
- the HEAL entry has a Pattern-Key resembling env.lockfile_mismatch
- the HEAL entry includes the verification output
- prompt: "I need to bulk-rename 8 git branches in this repo from `feat-XXX-name` to `feat/XXX-name`. There's no existing script for this and `gh` doesn't have a bulk-rename. Write what's needed, prove it works on a dry run, and capture the work so it's not lost if I need it again."
grader:
- the response invokes the self-healing skill
- the response recognizes this as a missing-capability heal
- the response writes a helper script under .learnings/heals/HEAL-<date>-<seq>/
- the response runs a dry-run verification
- the response files a HEAL entry with Status: verified and Trigger: missing-capability
- the HEAL entry references the helper script in the Artifacts section
- prompt: "I just ran `pytest` and got `ModuleNotFoundError: No module named 'pydantic'`. There's already a `.learnings/HEALS.md` in this project with a prior heal for a similar venv-not-activated issue. Fix this, and do the right thing with the heal records."
grader:
- the response invokes the self-healing skill
- the response searches HEALS.md first (using find-similar-heals.sh or grep) before writing a new fix
- the response finds the existing HEAL entry and applies its fix (activate venv)
- the response increments Recurrence-Count on the existing entry
- the response updates Last-Seen on the existing entry
- the response does NOT create a duplicate HEAL entry
- prompt: "A test in this repo is failing intermittently — the snapshot for `Card.test.tsx` flakes. I've already tried fixing it once by stubbing the date; it passes twice then flakes again because there's a UUID that's also non-deterministic. I don't have time to refactor the Card component to inject dependencies. Just do the right thing — get me to a state that's honest about what's known and not known, and don't pretend the heal worked."
grader:
- the response invokes the self-healing skill
- the response diagnoses that the initial patch attempt was incomplete
- the response files a HEAL entry with Status: abandoned
- the HEAL entry documents what was tried and why it failed
- the response does NOT mark anything as verified
- the response surfaces the situation honestly to the user

View File

@@ -0,0 +1,248 @@
# Self-Healing Examples
Concrete HEAL entries showing the format applied to real failure shapes. Use these as templates when filing your own heals. All examples use the iteration-2 schema (free-form `Trigger` / `Area`, optional `Active-Context`, no `Source` field, lazy artifact folders).
---
## Example 1 — Tool failure (lockfile mismatch)
```markdown
## [HEAL-20260524-001] npm_install_pnpm_lockfile
**Logged**: 2026-05-24T14:22:01Z
**Status**: verified
**Trigger**: tool-failure
**Area**: build
**Priority**: medium
### Failure
`npm install` exited 1 with `npm ERR! code EUSAGE` and a notice that `pnpm-lock.yaml` is present but `package-lock.json` is missing. The project uses pnpm workspaces; npm refuses to install against a pnpm lockfile.
### Diagnosis
Project root contains `pnpm-lock.yaml`. The README and CI both invoke `pnpm`. `npm` was a habit from previous projects, not the actual project's package manager.
### Fix
Use pnpm instead:
```bash
pnpm install
```
### Verification
```
$ pnpm install
Lockfile is up to date, resolution step is skipped
Already up to date
✓ Done in 1.4s
```
Exit 0.
### Metadata
- Related Files: package.json, pnpm-lock.yaml
- See Also: (none yet)
- Pattern-Key: env.lockfile_mismatch
- Recurrence-Count: 1
- First-Seen: 2026-05-24
- Last-Seen: 2026-05-24
---
```
Pattern-Key `env.lockfile_mismatch` is reusable across projects (yarn.lock, bun.lockb, etc.). At Recurrence ≥ 3, this should be promoted to `CLAUDE.md` or `AGENTS.md` as a verification step.
No Artifacts section — the fix is a tool swap, no files generated. Lazy folder pattern: nothing to put in `.learnings/heals/HEAL-20260524-001/`, so the folder isn't created.
---
## Example 2 — Missing capability (helper written on the fly)
```markdown
## [HEAL-20260524-002] bulk_rename_branches_helper
**Logged**: 2026-05-24T15:10:44Z
**Status**: verified
**Trigger**: missing-capability
**Area**: ci
**Priority**: low
### Failure
Need to rename 12 feature branches from `feat-XXX-name` to `feat/XXX-name`. No existing project script handles this; `gh` doesn't have a bulk-rename primitive.
### Diagnosis
This is glue work, not a project bug. A small shell helper using `gh api` per branch is the right level — not worth a top-level script, but worth keeping the file for the next time someone asks.
### Fix
Wrote `.learnings/heals/HEAL-20260524-002/rename-branches.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
git fetch --all
for branch in $(git branch -r | grep 'origin/feat-' | sed 's|origin/||'); do
new="${branch/feat-/feat/}"
echo "$branch → $new"
gh api -X POST "repos/{owner}/{repo}/git/refs" \
-f "ref=refs/heads/$new" \
-f "sha=$(git rev-parse "origin/$branch")"
gh api -X DELETE "repos/{owner}/{repo}/git/refs/heads/$branch"
done
```
### Verification
Dry-run (commented out the API calls) printed the 12 expected mappings.
Live run renamed all 12; `git branch -r | grep 'feat-' | wc -l` returns 0.
### Artifacts
- `.learnings/heals/HEAL-20260524-002/rename-branches.sh`
### Metadata
- Related Files: (none — operates on git refs)
- See Also: (none)
- Pattern-Key: tool.gh.bulk_branch_rename
- Recurrence-Count: 1
- First-Seen: 2026-05-24
- Last-Seen: 2026-05-24
---
```
Helper script lives under `.learnings/heals/<HEAL-ID>/` — referenceable, but not assumed to be load-bearing. If it gets reused frequently, promote to `scripts/`.
---
## Example 3 — Environment issue (runtime version)
```markdown
## [HEAL-20260524-003] nvm_use_project_node
**Logged**: 2026-05-24T16:01:12Z
**Status**: verified
**Trigger**: env-issue
**Active-Context**: verify-gate
**Area**: tests
**Priority**: medium
### Failure
`pnpm test` exited 1 with `engine "node" is incompatible with this module. Expected version "^20.10.0". Got "18.19.0"`.
### Diagnosis
`.nvmrc` requests node 20.10.0; current shell has 18.19.0 from a previous project context. The shell's nvm wasn't switched after `cd`-ing into the repo.
### Fix
```bash
nvm use # reads .nvmrc
```
### Verification
```
$ node --version
v20.10.0
$ pnpm test
✓ 47 tests passed
```
### Metadata
- Related Files: .nvmrc, package.json
- See Also: (none)
- Pattern-Key: env.node_version_mismatch
- Recurrence-Count: 1
- First-Seen: 2026-05-24
- Last-Seen: 2026-05-24
---
```
`Active-Context: verify-gate` because that's the workflow phase the agent was in when the test step blew up. An upstream context loader could surface this entry next time `verify-gate` runs in a node project. If you don't have an analogous concept in your pipeline, omit the field.
---
## Example 4 — External service workaround
```markdown
## [HEAL-20260524-004] gh_api_rate_limit_backoff
**Logged**: 2026-05-24T17:33:08Z
**Status**: verified
**Trigger**: external-change
**Area**: ci
**Priority**: high
### Failure
Looping `gh api repos/.../issues` over 200 issues started returning `403 rate limit exceeded` after ~60 calls. Unauthenticated burst limit (abuse detection on rapid successive calls).
### Diagnosis
Script was using `gh api` REST without batching. `gh` is authenticated but the secondary rate limit fires on rapid successive calls — not the primary 5000/hour limit. Switching to a single paginated GraphQL query bypasses the secondary limit entirely.
### Fix
```bash
gh api graphql -f query='
query($owner:String!,$repo:String!,$cursor:String) {
repository(owner:$owner,name:$repo) {
issues(first:100,after:$cursor) { ... }
}
}' -F owner=... -F repo=...
```
Took ~3 calls total instead of 200.
### Verification
Full run completed in 4.8s, no 403s, all 200 issues retrieved. Compared output against a sample of the original per-issue calls — fields match.
### Artifacts
- `.learnings/heals/HEAL-20260524-004/fetch-issues.sh`
### Metadata
- Related Files: (none — ad-hoc query)
- See Also: (none)
- Pattern-Key: api.gh.rate_limit
- Recurrence-Count: 1
- First-Seen: 2026-05-24
- Last-Seen: 2026-05-24
---
```
---
## Example 5 — Abandoned heal (diagnosis was wrong)
```markdown
## [HEAL-20260524-005] vitest_flaky_snapshot
**Logged**: 2026-05-24T18:14:22Z
**Status**: abandoned
**Trigger**: tool-failure
**Active-Context**: verify-gate
**Area**: tests
**Priority**: medium
### Failure
`vitest` snapshot test `Card > renders default` flaked twice in three runs. Diff showed a timestamp string differing by ~3 seconds.
### Diagnosis (initial — wrong)
Assumed flake was timezone drift in the snapshot fixture. Patched the fixture to use a fixed `Date.now()` stub.
### Diagnosis (current — correct)
The snapshot depends on multiple non-deterministic values: timestamp AND a `crypto.randomUUID()`. The clock stub addressed only one of them. The UUID is still random per render, so the snapshot keeps drifting on subsequent runs.
### Fix (attempted)
Added `vi.useFakeTimers({ now: 1700000000000 })` to the test setup.
### Verification
Test passed twice, then flaked again on the third run — same `Card > renders default`, different diff (this time the UUID changed). Original diagnosis was incomplete.
### Abandonment notes
The right fix is to make the component deterministic via dependency injection (pass a `clock` and `idGen` prop), not to stub globally. That's a real change to the component contract — out of scope for a heal. Filed `FEAT-20260524-001` via self-improvement; surfaced to the user.
### Metadata
- Related Files: src/components/Card.tsx, src/components/Card.test.tsx
- See Also: FEAT-20260524-001
- Pattern-Key: tests.flaky_snapshot_multi_nondeterminism
- Recurrence-Count: 1
- First-Seen: 2026-05-24
- Last-Seen: 2026-05-24
---
```
Abandoned heals are first-class. They document a dead end so the next agent doesn't re-walk it. The handoff to a `FEAT-` entry via self-improvement is the right next step when the real fix is a feature, not a heal.
No Artifacts section — the attempted patch was reverted; nothing reusable was generated.

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# detect-failure.sh — PostToolUse hook for Bash invocations.
# Reads the tool result JSON on stdin (per Claude Code hook spec); if exit_code != 0,
# emits a system reminder pointing the agent at self-healing.
#
# Wire up in .claude/settings.json:
# "hooks": {
# "PostToolUse": [{ "matcher": "Bash",
# "hooks": [{ "type": "command",
# "command": "./data/skills/boocode/self-healing/scripts/detect-failure.sh" }] }]
# }
set -euo pipefail
# Hook payload arrives on stdin. We tolerate either jq-style JSON or raw text.
PAYLOAD="$(cat || true)"
# Try to parse exit_code; fall through silently on parse failure.
EXIT_CODE=$(printf '%s' "$PAYLOAD" | python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read() or "{}")
# Common shapes: {"tool_result": {"exit_code": N}}, {"exit_code": N}, {"output": "...", "exit_code": N}
for path in (("tool_result","exit_code"), ("exit_code",), ("result","exit_code")):
d = data
ok = True
for k in path:
if isinstance(d, dict) and k in d:
d = d[k]
else:
ok = False
break
if ok and isinstance(d, int):
print(d)
sys.exit(0)
except Exception:
pass
print(0)
' 2>/dev/null || echo 0)
if [[ "$EXIT_CODE" != "0" ]]; then
cat <<'EOF'
<self-healing-trigger>
A Bash command just exited non-zero. This is a heal opportunity.
Before retrying the same command verbatim:
1. DIAGNOSE — read the error; identify the root cause (env? missing dep? wrong tool?)
2. Search .learnings/HEALS.md for a matching Pattern-Key (don't re-solve a solved problem)
3. PATCH — write the fix (or apply a known one)
4. VERIFY — re-run the command; require exit 0
5. FILE — append a HEAL entry to .learnings/HEALS.md via data/skills/boocode/self-healing/scripts/new-heal.sh
</self-healing-trigger>
EOF
fi

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# find-similar-heals.sh — Search existing heals before generating a new fix.
# Usage: ./find-similar-heals.sh <pattern-key-or-keyword>
#
# Prints matching HEAL entries with their Pattern-Key, Status, and Recurrence-Count
# so the agent can decide whether to re-apply an existing fix or write a new one.
set -euo pipefail
QUERY="${1:-}"
HEALS_FILE="$(pwd)/.learnings/HEALS.md"
if [[ -z "$QUERY" ]]; then
echo "usage: $0 <pattern-key-or-keyword>" >&2
exit 2
fi
if [[ ! -f "$HEALS_FILE" ]]; then
echo "(no .learnings/HEALS.md yet — no prior heals to consult)"
exit 0
fi
# Find HEAL section headers that contain the query in their body (Pattern-Key, name, or text).
python3 - <<PY "$QUERY" "$HEALS_FILE"
import sys, re
query, path = sys.argv[1].lower(), sys.argv[2]
with open(path) as f:
text = f.read()
# Split into entries by ^## [HEAL-...]
entries = re.split(r"(?m)^## \[HEAL-", text)[1:]
hits = []
for body in entries:
if query in body.lower():
head = body.splitlines()[0]
pk = re.search(r"Pattern-Key:\s*(\S+)", body)
status = re.search(r"Status\*\*:\s*(\S+)", body) or re.search(r"Status:\s*(\S+)", body)
rc = re.search(r"Recurrence-Count:\s*(\d+)", body)
hits.append({
"id": "HEAL-" + head.split("]")[0],
"name": head.split("]", 1)[1].strip() if "]" in head else head,
"pattern_key": pk.group(1) if pk else "?",
"status": status.group(1) if status else "?",
"recurrence": rc.group(1) if rc else "1",
})
if not hits:
print(f"(no heals match '{query}')")
else:
print(f"Found {len(hits)} matching heal(s):\n")
for h in hits:
print(f" {h['id']} {h['name']}")
print(f" pattern={h['pattern_key']} status={h['status']} recurrence={h['recurrence']}")
PY

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# new-heal.sh — Initialize a new HEAL-<date>-<seq> entry skeleton.
# Usage: ./new-heal.sh <short_kebab_name> [trigger]
# trigger: tool-failure | missing-capability | env-issue | external-change | <free-form>
#
# Appends a templated HEAL entry to .learnings/HEALS.md and prints the HEAL-ID.
# Does NOT create .learnings/heals/<HEAL-ID>/ — that folder is lazy, created
# only when artifacts are written.
set -euo pipefail
NAME="${1:-}"
TRIGGER="${2:-tool-failure}"
if [[ -z "$NAME" ]]; then
echo "usage: $0 <short_kebab_name> [trigger]" >&2
exit 2
fi
LEARNINGS_DIR="$(pwd)/.learnings"
HEALS_FILE="$LEARNINGS_DIR/HEALS.md"
mkdir -p "$LEARNINGS_DIR"
DATE="$(date +%Y%m%d)"
SEQ=$(grep -c "^## \[HEAL-${DATE}-" "$HEALS_FILE" 2>/dev/null || echo 0)
NEXT=$(printf "%03d" $((SEQ + 1)))
HEAL_ID="HEAL-${DATE}-${NEXT}"
# Active-Context is optional. The agent / harness can set ACTIVE_CONTEXT in env.
ACTIVE_CONTEXT="${ACTIVE_CONTEXT:-}"
ACTIVE_LINE=""
if [[ -n "$ACTIVE_CONTEXT" ]]; then
ACTIVE_LINE="**Active-Context**: $ACTIVE_CONTEXT
"
fi
cat >> "$HEALS_FILE" <<EOF
## [$HEAL_ID] $NAME
**Logged**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
**Status**: pending-verify
**Trigger**: $TRIGGER
${ACTIVE_LINE}**Area**: TODO
**Priority**: medium
### Failure
TODO — concrete error, command, exit code
### Diagnosis
TODO — root cause after investigation
### Fix
TODO — patch applied (commands, snippets, or pointers to .learnings/heals/$HEAL_ID/ if files were generated)
### Verification
TODO — what was run after the fix, what it returned. **Update Status to "verified" only after this passes.**
### Metadata
- Related Files: TODO
- See Also: TODO
- Pattern-Key: TODO
- Recurrence-Count: 1
- First-Seen: $(date +%Y-%m-%d)
- Last-Seen: $(date +%Y-%m-%d)
---
EOF
# stdout = the HEAL-ID alone, so `ID=$(new-heal.sh ...)` captures it cleanly.
# Human guidance goes to stderr.
echo "$HEAL_ID"
echo "$HEALS_FILE" >&2
echo "(create .learnings/heals/$HEAL_ID/ only if you generate artifacts to put there)" >&2

View File

@@ -0,0 +1,178 @@
---
name: verify-gate
description: "Runs project compile, test, and lint commands between implementation and quality review. Gates simplify-and-harden behind machine verification. If checks fail, enters a fix loop with diagnostics. If checks pass, signals ready for quality pass. Use after any implementation work completes and before signaling done. Essential for the inner loop's verify step."
---
# Verify Gate
Machine verification gate between implementation and quality review. Runs the project's compile, test, and lint commands. If any fail, enters a fix loop. If all pass, unblocks the quality pass.
This is the inner loop's **verify** step. Without it, the agent hands off code with zero machine signal about whether it actually works.
## When to Use
- After any implementation work completes, before signaling "done"
- Before running simplify-and-harden or quality review
- After fixing audit findings from code review
- Any time you want a machine-verified green signal
## Pipeline Position
```
[implementation] → verify-gate → [quality review / simplify-and-harden]
↻ fix loop — on failure, diagnose and retry
```
## Step 1: Discover Project Commands
Read the project's configuration to find verification commands. Check these sources in order:
1. **Project instruction files** (`CLAUDE.md`, `data/AGENTS.md`) — look for a `## Verification` or `## Test Commands` section
2. **package.json**`scripts.test`, `scripts.lint`, `scripts.typecheck`, `scripts.build`. BooCode uses pnpm, so prefer `pnpm run <script>` when `pnpm-lock.yaml` is present.
3. **Makefile** / **Justfile**`test`, `lint`, `check`, `build` targets
4. **Cargo.toml**`cargo build`, `cargo test`, `cargo clippy`
5. **pyproject.toml** / **setup.cfg**`pytest`, `mypy`, `ruff`
6. **go.mod**`go build ./...`, `go test ./...`, `go vet ./...`
7. **deno.json** / **deno.jsonc**`deno task <name>` for any defined tasks
If no commands are discoverable, ask the user once and suggest they add a `## Verification` section to `CLAUDE.md` for future sessions:
```markdown
## Verification
- Build: `pnpm run build`
- Test: `pnpm test`
- Lint: `pnpm run lint`
- Type check: `npx tsc -p apps/server/tsconfig.json --noEmit`
```
## Step 2: Run Verification
Run discovered commands in this order. Stop at the first failure category.
### Phase 1: Compile / Type Check
Run the build or type-check command. These catch structural errors before wasting time on tests.
```
Exit 0 → proceed to Phase 2
Exit non-zero → enter fix loop with compiler output
```
### Phase 2: Tests
Run the test command. Scope to changed files if the test runner supports it.
```
Exit 0 → proceed to Phase 3
Exit non-zero → enter fix loop with test output
```
### Phase 3: Lint (optional, skippable with --skip-lint)
Run the lint command. Lint failures are lower severity but still worth catching.
```
Exit 0 → all phases green, gate passes
Exit non-zero → enter fix loop with lint output
```
## Step 3: Fix Loop
When a phase fails:
1. **Read the output.** Parse the error output for actionable diagnostics — file paths, line numbers, error messages.
2. **Scope the fix.** Only fix what the verification caught. Do not refactor, improve, or touch unrelated code.
3. **Apply the fix.** Make the minimal change to resolve the failure.
4. **Re-run the failed phase.** Not all phases — just the one that failed.
5. **If it passes**, continue to the next phase.
6. **If it fails again**, increment the attempt counter.
### Fix Loop Limits
- **Default max attempts:** 3 per phase (configurable via `--fix-limit N`)
- **Counter increments on every attempt**, even if the error changes. Fixing Error A and uncovering Error B counts as attempt 2, not attempt 1. The counter tracks fix attempts, not unique errors.
- **If limit reached:** Stop. Report what failed, what was tried, and the remaining error output. Do not guess further — signal to the user that manual intervention is needed.
- **Total budget:** The fix loop should not exceed 20% of the original implementation effort. If fixes are snowballing, stop and report.
## Step 4: Gate Signal
When all phases pass:
```markdown
## Verify Gate: PASSED
- Build: passed
- Tests: passed (N tests, M suites)
- Lint: passed (or skipped)
Ready for quality review.
```
When the fix loop is exhausted:
```markdown
## Verify Gate: BLOCKED
- Build: passed
- Tests: FAILED (attempt 3/3)
- [file:line] error description
- [file:line] error description
- Lint: not reached
Fix loop exhausted. Manual intervention needed before quality review.
```
## Integration with Other Skills
### boocode simplify-and-harden / quality review
verify-gate should gate any quality pass. Run verify-gate first; only proceed to review if the gate passes.
### self-healing (if available)
On any failure during the verify run, consider handing the diagnostics to a self-healing loop (diagnose → patch → verify → persist). Verify-gate then re-runs the checks. Up to 3 heal attempts per phase before abandoning.
### self-improvement
If a recurring error pattern emerges across verify runs, capture it in `CLAUDE.md` or as a new skill under `data/skills/boocode/` so future verify-gate runs don't rediscover the same fix.
## What This Skill Does NOT Do
- Does not review code quality (that's a separate review pass)
- Does not check security
- Does not verify spec compliance
- Does not modify test files or add new tests
- Does not run tests for code it didn't change (unless the test runner doesn't support scoping)
## Configuration
If the project has a `verify-gate` section in `CLAUDE.md` or `data/AGENTS.md`:
```yaml
## Verify Gate Config
build: pnpm run build
test: pnpm test
lint: pnpm run lint
type_check: npx tsc -p apps/server/tsconfig.json --noEmit
fix_limit: 3
skip_lint: false
test_scope: changed # changed | all
```
If no configuration exists, discover commands automatically (Step 1) and suggest persisting them.
### Custom Verification Steps
Projects with custom invariants can define additional verification phases. These run as extra phases after the standard compile/test/lint checks.
Example — a project that needs API schema validation:
```yaml
## Verify Gate Config
custom_checks:
- name: validate-schema
command: python scripts/validate_schema.py --strict
- name: check-no-legacy-imports
command: grep -r "from legacy" src/ --include="*.py" && exit 1 || exit 0
```
When custom checks are defined, verify-gate runs them as **Phase 4** after lint. Each check's exit code determines pass/fail. Failed checks enter the same fix loop as standard phases.
This moves project-specific invariants from "knowledge in your head" to "knowledge in the harness" — exactly where the agent can reach it.