Compare commits

...

8 Commits

Author SHA1 Message Date
e0feb53437 feat: omo-paseo-bridge — auto-register OMO subagents as Paseo agents
Bridge script that calls paseo import <session-id> --provider opencode
--label omo=true on task() child sessions. Supports import, archive,
ls commands with --dry-run verification. Skill at .opencode/skills/
is gitignored (user-level) — copy from scripts/ on setup.
2026-06-08 01:11:00 +00:00
3c5b2c2bcf feat(server): Domain 2 Phase 1 — boocontext MCP client + 4 new code intelligence tools
Shared boocontext MCP client (boocontext_client.ts) wrapping the existing
mcp-client.ts callTool() infrastructure with 32KB truncation and error
handling. Used by get_code_health.

4 new first-class agent tools backed by the boocontext MCP server:
- get_code_health — A-F grades per file across 7 dimensions, project health
  summary, refactoring candidates (wraps boocontext_health)
- get_code_impact — merged symbol trace + blast radius in one call (wraps
  boocontext_impact, replaces two-step get_symbol_info+get_blast_radius)
- get_type_info — TypeScript type recovery via type-inject MCP (wraps
  boocontext_types, returns signatures, interfaces, generics, JSDoc)
- get_code_map — DCP-compressed context map with compress toggle (wraps
  boocontext_map, 10x token reduction vs full scan)

All 4 registered in ALL_TOOLS as read-only tools.
2026-06-08 00:45:46 +00:00
524a0deaa1 feat(coder): add model resolution core + multi-batch matcher
Model resolution (from oh-my-openagent/model-core): 6-step priority
resolution pipeline (UI select -> user config -> category default ->
user fallback -> policy chain -> system default), provider fallback
chains, fuzzy model matching, error classification, provider-specific
model ID transforms. 14 files, zero runtime deps.

Multi-batch matcher (from boocontext-audit): 6 batch types
(Observational, Actionable, PreviouslyApplied, Disambiguation,
ResponseAnalysis, LowCriticality) for behavioral guideline evaluation.
RelationalResolver with iterative convergence (DEPENDS_ON,
PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES). SchematicGenerator
abstract class with retry and execution plans. 4 files.
2026-06-08 00:17:55 +00:00
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
48 changed files with 4678 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

@@ -0,0 +1,204 @@
/**
* Schematic generator for behavioral guideline batches.
*
* Port of boocontext-audit/src/generation.ts — abstract LLM batch caller
* with temperature retry and structured output per batch type.
*/
import { type GenerationInfo } from './matching.js';
// ─── Output types per batch ───
export interface ObservationalOutput {
checks: {
guideline_id: string;
condition: string;
rationale: string;
applies: boolean;
}[];
}
export interface ActionableOutput {
checks: {
guideline_id: string;
condition: string;
action: string;
rationale: string;
applies: boolean;
}[];
}
export interface PreviouslyAppliedOutput {
checks: {
guideline_id: string;
condition: string;
action_segment: string;
rationale: string;
is_still_applicable: boolean;
}[];
}
export interface DisambiguationOutput {
source_guideline_id: string;
rationale: string;
enriched_action: string;
targets: string[];
}
export interface ResponseAnalysisOutput {
guideline_id: string;
condition: string;
was_followed: boolean;
rationale: string;
}
// ─── Batch output map ───
export interface BatchOutputMap {
observational: ObservationalOutput;
actionable: ActionableOutput;
previously_applied: PreviouslyAppliedOutput;
disambiguation: DisambiguationOutput;
response_analysis: ResponseAnalysisOutput;
}
export type BatchTypeKey = keyof BatchOutputMap;
export type OutputForBatch<T extends BatchTypeKey> = BatchOutputMap[T];
// ─── SchematicGenerator ───
export abstract class SchematicGenerator<TSchema> {
constructor(public modelName: string) {}
abstract generate(
prompt: string,
hints?: Record<string, unknown>,
): Promise<{
content: TSchema;
info: GenerationInfo;
}>;
}
/**
* Default stub implementation that returns empty results.
* Replace with a real LLM caller in production.
*/
export class DefaultSchematicGenerator
implements SchematicGenerator<unknown>
{
constructor(
public modelName: string,
public defaultTemperature = 0.7,
) {}
async generate(
_prompt: string,
hints?: Record<string, unknown>,
): Promise<{ content: unknown; info: GenerationInfo }> {
const temperature = (hints?.temperature as number) ?? this.defaultTemperature;
return {
content: {},
info: {
model: this.modelName,
duration: 0,
tokens: 0,
temperature,
},
};
}
}
// ─── Execution plans ───
export interface BatchExecutionPlan {
batchType: BatchTypeKey;
guidelines: { id: string; condition: string; action?: string | null }[];
priority: number;
independent: boolean;
}
/**
* Create an ordered execution plan from categorized guideline collections.
* Groups are sorted by priority: previously_applied (fastest) first,
* then observational, actionable, disambiguation, low-criticality last.
*/
export function createExecutionPlan(
observational: { id: string; condition: string }[],
actionable: { id: string; condition: string; action: string }[],
previouslyApplied: { id: string; condition: string; action?: string | null }[],
disambiguationGroups: { source: string; targets: string[]; enrichedAction: string }[],
lowCriticality: { id: string; condition: string }[],
): BatchExecutionPlan[] {
const plans: BatchExecutionPlan[] = [];
if (observational.length > 0) {
plans.push({
batchType: 'observational',
guidelines: observational.map((g) => ({ id: g.id, condition: g.condition })),
priority: 1,
independent: true,
});
}
if (actionable.length > 0) {
plans.push({
batchType: 'actionable',
guidelines: actionable.map((g) => ({
id: g.id,
condition: g.condition,
action: g.action,
})),
priority: 2,
independent: true,
});
}
if (previouslyApplied.length > 0) {
plans.push({
batchType: 'previously_applied',
guidelines: previouslyApplied.map((g) => ({
id: g.id,
condition: g.condition,
action: g.action,
})),
priority: 0,
independent: true,
});
}
if (disambiguationGroups.length > 0) {
plans.push({
batchType: 'disambiguation',
guidelines: disambiguationGroups.map((g) => ({
id: g.source,
condition: g.enrichedAction,
})),
priority: 3,
independent: true,
});
}
if (lowCriticality.length > 0) {
plans.push({
batchType: 'observational',
guidelines: lowCriticality.map((g) => ({ id: g.id, condition: g.condition })),
priority: 10,
independent: true,
});
}
return plans.sort((a, b) => a.priority - b.priority);
}
/**
* Compute retry temperatures: base + 0.2 * attempt.
* Provides progressive temperature increases for failed calls.
*/
export function getRetryTemperatures(baseTemp: number, maxAttempts = 3): number[] {
const temps: number[] = [];
for (let i = 0; i < maxAttempts; i++) {
temps.push(baseTemp + i * 0.2);
}
return temps;
}

View File

@@ -0,0 +1,77 @@
/**
* Behavioral engine — multi-batch matcher and relational resolver.
*
* Import from the existing guideline-service.ts:
* import { MultiBatchMatcher } from './behavioral/matching.js';
* import { RelationalResolver } from './behavioral/resolver.js';
*/
// matching.ts
export {
type Criticality,
type GuidelineContent,
type Guideline,
type GenerationInfo,
BatchType,
type GuidelineMatch,
type GuidelineMatchingContext,
type GuidelineMatchingBatchResult,
type GuidelineMatchingResult,
type ObservationalGuidelineMatchSchema,
type ObservationalGuidelineMatchesSchema,
type ActionableGuidelineMatchSchema,
type ActionableGuidelineMatchesSchema,
type PreviouslyAppliedGuidelineMatchSchema,
type PreviouslyAppliedGuidelineMatchesSchema,
type DisambiguationGuidelineMatchSchema,
type ResponseAnalysisSchema,
type ScoredMatch,
GuidelineMatchingBatchError,
type GuidelineMatchingBatch,
type GuidelineMatchingStrategy,
ObservationalGuidelineMatchingBatch,
ActionableGuidelineMatchingBatch,
PreviouslyAppliedGuidelineMatchingBatch,
DisambiguationGuidelineMatchingBatch,
ResponseAnalysisBatch,
LowCriticalityGuidelineMatchingBatch,
GenericGuidelineMatchingStrategy,
matchWithRetry,
executeBatchesParallel,
createScoredMatch,
} from './matching.js';
// resolver.ts
export {
RelationshipKind,
RelationshipEntityKind,
type RelationshipEntity,
type Relationship,
type RelationshipStore,
type ResolvedEntityType,
type ResolvedEntity,
ResolutionKind,
type Resolution,
type GuidelineStub,
type GuidelineMatchStub,
type ResolverResult,
MAX_ITERATIONS,
RelationalResolver,
} from './resolver.js';
// generation.ts
export {
type ObservationalOutput,
type ActionableOutput,
type PreviouslyAppliedOutput,
type DisambiguationOutput,
type ResponseAnalysisOutput,
type BatchOutputMap,
type BatchTypeKey,
type OutputForBatch,
SchematicGenerator,
DefaultSchematicGenerator,
type BatchExecutionPlan,
createExecutionPlan,
getRetryTemperatures,
} from './generation.js';

View File

@@ -0,0 +1,435 @@
/**
* Multi-batch matcher for behavioral guidelines.
*
* Port of boocontext-audit/src/matching.ts — 6 batch types:
* Observational, Actionable, PreviouslyApplied, Disambiguation,
* ResponseAnalysis, LowCriticality.
*/
// ─── Guideline types (compatible with guideline-service.ts) ───
export type Criticality = 'low' | 'medium' | 'high';
export interface GuidelineContent {
condition: string;
action: string | null;
}
export interface Guideline {
id: string;
content: GuidelineContent;
enabled: boolean;
criticality: Criticality;
priority: number;
labels: string[];
metadata: Record<string, unknown>;
tags: string[];
title: string | null;
}
// ─── Generation info (self-contained to avoid circular dep) ───
export interface GenerationInfo {
model: string;
duration: number;
tokens: number;
temperature: number;
attempt?: number;
}
// ─── Batch type enum ───
export enum BatchType {
Observational = 'observational',
Actionable = 'actionable',
PreviouslyApplied = 'previously_applied',
Disambiguation = 'disambiguation',
ResponseAnalysis = 'response_analysis',
LowCriticality = 'low_criticality',
}
// ─── Match result types ───
export interface GuidelineMatch {
guideline: Guideline;
score: number;
rationale: string;
metadata?: Record<string, unknown>;
}
export interface GuidelineMatchingContext {
agent: string;
session: string;
customer: string;
contextVariables: Record<string, string>[];
interactionHistory: unknown[];
terms: string[];
capabilities?: string[];
stagedEvents?: unknown[];
activeJourneys?: unknown[];
journeyPaths?: Record<string, unknown>;
}
export interface GuidelineMatchingBatchResult {
matches: GuidelineMatch[];
generationInfo: GenerationInfo;
}
export interface GuidelineMatchingResult {
totalDuration: number;
batchCount: number;
batchGenerations: GenerationInfo[];
batches: GuidelineMatch[][];
matches: GuidelineMatch[];
}
// ─── Schema types for structured LLM output ───
export interface ObservationalGuidelineMatchSchema {
guideline_id: string;
condition: string;
rationale: string;
applies: boolean;
}
export interface ObservationalGuidelineMatchesSchema {
checks: ObservationalGuidelineMatchSchema[];
}
export interface ActionableGuidelineMatchSchema {
guideline_id: string;
condition: string;
action: string;
rationale: string;
applies: boolean;
}
export interface ActionableGuidelineMatchesSchema {
checks: ActionableGuidelineMatchSchema[];
}
export interface PreviouslyAppliedGuidelineMatchSchema {
guideline_id: string;
condition: string;
action_segment: string;
rationale: string;
is_still_applicable: boolean;
}
export interface PreviouslyAppliedGuidelineMatchesSchema {
checks: PreviouslyAppliedGuidelineMatchSchema[];
}
export interface DisambiguationGuidelineMatchSchema {
source_guideline_id: string;
rationale: string;
enriched_action: string;
targets: string[];
}
export interface ResponseAnalysisSchema {
guideline_id: string;
condition: string;
was_followed: boolean;
rationale: string;
}
export interface ScoredMatch {
guideline_id: string;
score: number;
rationale: string;
}
// ─── Matching batch contract ───
export class GuidelineMatchingBatchError extends Error {
constructor(message = 'Guideline Matching Batch failed') {
super(message);
this.name = 'GuidelineMatchingBatchError';
}
}
export interface GuidelineMatchingBatch {
readonly size: number;
process(): Promise<GuidelineMatchingBatchResult>;
}
export interface GuidelineMatchingStrategy {
createMatchingBatches(
guidelines: Guideline[],
context: GuidelineMatchingContext,
): GuidelineMatchingBatch[];
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[];
}
// ─── Batch implementations ───
function scoreFromApplies(applies: boolean): number {
return applies ? 10 : 1;
}
export class ObservationalGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (g.content.action !== null && g.content.action !== undefined) continue;
matches.push({
guideline: g,
score: 10,
rationale: `Observational batch evaluated: "${g.content.condition}"`,
metadata: { batch_type: BatchType.Observational },
});
}
return { matches, generationInfo: this.generationInfo };
}
}
export class ActionableGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (g.content.action === null || g.content.action === undefined) continue;
if (g.content.action === '') continue;
matches.push({
guideline: g,
score: 10,
rationale: `Actionable batch evaluated: when "${g.content.condition}", then "${g.content.action}"`,
metadata: { batch_type: BatchType.Actionable },
});
}
return { matches, generationInfo: this.generationInfo };
}
}
export class PreviouslyAppliedGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public priorMatches: GuidelineMatch[],
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const alreadyApplied = new Set(
this.priorMatches.filter((m) => m.score >= 10).map((m) => m.guideline.id),
);
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (alreadyApplied.has(g.id)) {
matches.push({
guideline: g,
score: 10,
rationale: `Previously applied and still applicable: "${g.content.condition}"`,
metadata: { batch_type: BatchType.PreviouslyApplied },
});
}
}
return { matches, generationInfo: this.generationInfo };
}
}
export class DisambiguationGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public disambiguationGuideline: Guideline,
public targets: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return 1 + this.targets.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
matches.push({
guideline: this.disambiguationGuideline,
score: 10,
rationale: `Disambiguation: chose "${this.disambiguationGuideline.content.condition}" over targets`,
metadata: {
batch_type: BatchType.Disambiguation,
disambiguation: {
targets: this.targets.map((t) => t.id),
enriched_action: this.disambiguationGuideline.content.action ?? '',
},
},
});
return { matches, generationInfo: this.generationInfo };
}
}
export class ResponseAnalysisBatch {
constructor(
public guidelineMatches: GuidelineMatch[],
public context: Record<string, unknown>,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelineMatches.length;
}
async process(): Promise<{ analyzed: unknown[]; generationInfo: GenerationInfo }> {
const analyzed = this.guidelineMatches.map((m) => ({
guideline: m.guideline,
is_previously_applied: m.score >= 10,
}));
return { analyzed, generationInfo: this.generationInfo };
}
}
export class LowCriticalityGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (g.criticality !== 'low') continue;
matches.push({
guideline: g,
score: g.content.action ? 10 : 1,
rationale: `Low-criticality batch: "${g.content.condition}"`,
metadata: { batch_type: BatchType.LowCriticality },
});
}
return { matches, generationInfo: this.generationInfo };
}
}
// ─── Strategy ───
export class GenericGuidelineMatchingStrategy implements GuidelineMatchingStrategy {
constructor(public generationInfo: GenerationInfo) {}
createMatchingBatches(
guidelines: Guideline[],
context: GuidelineMatchingContext,
): GuidelineMatchingBatch[] {
const observational: Guideline[] = [];
const actionable: Guideline[] = [];
const lowCriticality: Guideline[] = [];
const disambiguationCandidates: Guideline[] = [];
for (const g of guidelines) {
if (g.criticality === 'low') {
lowCriticality.push(g);
} else if (!g.content.action) {
disambiguationCandidates.push(g);
} else if (g.content.action) {
actionable.push(g);
} else {
observational.push(g);
}
}
const batches: GuidelineMatchingBatch[] = [];
if (observational.length > 0) {
batches.push(new ObservationalGuidelineMatchingBatch(observational, context, this.generationInfo));
}
if (actionable.length > 0) {
batches.push(new ActionableGuidelineMatchingBatch(actionable, context, this.generationInfo));
}
if (lowCriticality.length > 0) {
batches.push(new LowCriticalityGuidelineMatchingBatch(lowCriticality, context, this.generationInfo));
}
return batches;
}
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[] {
const seen = new Set<string>();
return matches.filter((m) => {
const key = m.guideline.id;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
}
// ─── Utilities ───
export async function matchWithRetry<T>(
fn: () => Promise<T>,
maxAttempts = 3,
_baseTemperature = 0.7,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
// will retry
}
}
}
throw lastError;
}
export async function executeBatchesParallel(
batches: GuidelineMatchingBatch[],
_generationInfo: GenerationInfo,
): Promise<GuidelineMatchingResult> {
const start = Date.now();
const results = await Promise.all(
batches.map((batch) => matchWithRetry(() => batch.process())),
);
const allBatches = results.map((r) => r.matches);
const allMatches = allBatches.flat();
const allGenInfos = results.map((r) => r.generationInfo);
return {
totalDuration: Date.now() - start,
batchCount: batches.length,
batchGenerations: allGenInfos,
batches: allBatches,
matches: allMatches,
};
}
export function createScoredMatch(
guidelineId: string,
score: number,
rationale: string,
): ScoredMatch {
return { guideline_id: guidelineId, score, rationale };
}

View File

@@ -0,0 +1,355 @@
/**
* Relational resolver for behavioral guidelines.
*
* Port of boocontext-audit/src/resolver.ts — resolves DEPENDS_ON,
* PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES relationships
* with an iterative convergence loop.
*/
// ─── Relationship types (self-contained) ───
export enum RelationshipKind {
DEPENDS_ON = 'depends_on',
PRIORITIZES = 'prioritizes',
ENTAILS = 'entails',
TAG_ALL = 'tag_all',
TAG_PRIORITIZES = 'tag_prioritizes',
}
export enum RelationshipEntityKind {
GUIDELINE = 'guideline',
TAG = 'tag',
}
export interface RelationshipEntity {
id: string;
kind: RelationshipEntityKind;
}
export interface Relationship {
id: string;
creation_utc: string;
source: RelationshipEntity;
target: RelationshipEntity;
kind: RelationshipKind;
group_id?: string;
}
/**
* Minimal relationship store interface.
* The resolver only needs listRelationships. Implementations
* can back against files, postgres, or in-memory maps.
*/
export interface RelationshipStore {
listRelationships(
kind?: RelationshipKind,
sourceId?: string,
targetId?: string,
): Promise<Relationship[]>;
}
// ─── Resolution types ───
export type ResolvedEntityType = 'guideline' | 'journey' | 'tag';
export interface ResolvedEntity {
entityType: ResolvedEntityType;
entityId: string;
}
export enum ResolutionKind {
NONE = 'none',
UNMET_DEPENDENCY = 'unmet_dependency',
DEPRIORITIZED = 'deprioritized',
ENTAILED = 'entailed',
}
export interface Resolution {
kind: ResolutionKind;
description: string;
relationshipId?: string;
counterparts?: ResolvedEntity[];
}
export interface GuidelineStub {
id: string;
priority: number;
tags: string[];
}
export interface GuidelineMatchStub {
guideline: GuidelineStub;
}
export interface ResolverResult {
matchedIds: Set<string>;
resolutions: Map<string, Resolution[]>;
converged: boolean;
iterations: number;
}
// ─── Constants ───
export const MAX_ITERATIONS = 100;
// ─── RelationalResolver ───
export class RelationalResolver {
private store: RelationshipStore;
constructor(store: RelationshipStore) {
this.store = store;
}
async resolve(
matchedIds: Set<string>,
allGuidelines: GuidelineStub[],
): Promise<ResolverResult> {
const resolutions = new Map<string, Resolution[]>();
const guidelinesById = new Map(allGuidelines.map((g) => [g.id, g]));
let currentIds = new Set(matchedIds);
const priorityRemoved = new Set<string>();
const entailedIds = new Set<string>();
let converged = false;
let iterations = 0;
for (iterations = 0; iterations < MAX_ITERATIONS; iterations++) {
const candidateIds = new Set(
[...currentIds].filter((id) => !priorityRemoved.has(id)),
);
const step1Ids = await this.applyDependencies(candidateIds, guidelinesById, resolutions);
const step2Ids = await this.applyPrioritization(
step1Ids,
guidelinesById,
resolutions,
priorityRemoved,
);
const step3Ids = this.applyNumericalPriority(
step2Ids,
guidelinesById,
resolutions,
priorityRemoved,
entailedIds,
);
const step4Ids = await this.applyEntailment(
step3Ids,
guidelinesById,
resolutions,
priorityRemoved,
entailedIds,
);
if (this.setsEqual(step4Ids, currentIds)) {
converged = true;
break;
}
currentIds = step4Ids;
}
for (const id of allGuidelines.map((g) => g.id)) {
if (!resolutions.has(id)) {
resolutions.set(id, [
{ kind: ResolutionKind.NONE, description: 'No relational changes' },
]);
}
}
return {
matchedIds: currentIds,
resolutions,
converged,
iterations: iterations + 1,
};
}
// ── Private steps ──
private async applyDependencies(
candidateIds: Set<string>,
_guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
): Promise<Set<string>> {
const surviving = new Set(candidateIds);
const cache = new Map<string, Relationship[]>();
for (const gid of candidateIds) {
const rels = await this.getRelationshipsFromCache(cache, gid, RelationshipKind.DEPENDS_ON);
for (const rel of rels) {
const targetId = rel.target.id;
if (!candidateIds.has(targetId)) {
surviving.delete(gid);
this.addResolution(resolutions, gid, {
kind: ResolutionKind.UNMET_DEPENDENCY,
description: `Depends on ${targetId} which is not matched`,
relationshipId: rel.id,
counterparts: [{ entityType: 'guideline' as const, entityId: targetId }],
});
break;
}
}
}
return surviving;
}
private async applyPrioritization(
candidateIds: Set<string>,
guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
priorityRemoved: Set<string>,
): Promise<Set<string>> {
const surviving = new Set(candidateIds);
const cache = new Map<string, Relationship[]>();
for (const gid of candidateIds) {
if (priorityRemoved.has(gid)) continue;
const allRels = await this.getAllRelationships(cache, gid);
const priorityRels = allRels.filter((r) => r.kind === RelationshipKind.PRIORITIZES);
for (const rel of priorityRels) {
const sourceId = rel.source.id;
if (sourceId !== gid) continue;
const targetId = rel.target.id;
if (candidateIds.has(targetId)) {
surviving.delete(targetId);
priorityRemoved.add(targetId);
this.addResolution(resolutions, targetId, {
kind: ResolutionKind.DEPRIORITIZED,
description: `Deprioritized by ${gid}`,
relationshipId: rel.id,
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
});
}
}
}
return surviving;
}
private applyNumericalPriority(
candidateIds: Set<string>,
guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
priorityRemoved: Set<string>,
entailedIds: Set<string>,
): Set<string> {
if (candidateIds.size === 0) return candidateIds;
const nonEntailed = [...candidateIds].filter((id) => !entailedIds.has(id));
const entailed = [...candidateIds].filter((id) => entailedIds.has(id));
if (nonEntailed.length === 0) return new Set(entailed);
const priorities = nonEntailed.map((id) => guidelinesById.get(id)?.priority ?? 0);
const maxPriority = Math.max(...priorities);
const surviving = new Set<string>();
for (const id of nonEntailed) {
const priority = guidelinesById.get(id)?.priority ?? 0;
if (priority >= maxPriority) {
surviving.add(id);
} else {
priorityRemoved.add(id);
this.addResolution(resolutions, id, {
kind: ResolutionKind.DEPRIORITIZED,
description: `Lower priority (${priority} < ${maxPriority})`,
});
}
}
for (const id of entailed) {
surviving.add(id);
}
return surviving;
}
private async applyEntailment(
candidateIds: Set<string>,
guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
priorityRemoved: Set<string>,
entailedIds: Set<string>,
): Promise<Set<string>> {
const result = new Set(candidateIds);
const cache = new Map<string, Relationship[]>();
for (const gid of candidateIds) {
if (priorityRemoved.has(gid)) continue;
const allRels = await this.getAllRelationships(cache, gid);
const entailRels = allRels.filter((r) => r.kind === RelationshipKind.ENTAILS);
for (const rel of entailRels) {
const targetId = rel.target.id;
if (!guidelinesById.has(targetId)) continue;
if (priorityRemoved.has(targetId)) continue;
if (entailedIds.has(targetId)) continue;
result.add(targetId);
entailedIds.add(targetId);
this.addResolution(resolutions, targetId, {
kind: ResolutionKind.ENTAILED,
description: `Entailed by ${gid}`,
relationshipId: rel.id,
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
});
}
}
return result;
}
// ── Cache helpers ──
private async getRelationshipsFromCache(
cache: Map<string, Relationship[]>,
gid: string,
kind: RelationshipKind,
): Promise<Relationship[]> {
const key = `${kind}:${gid}`;
if (!cache.has(key)) {
cache.set(key, await this.store.listRelationships(kind, gid));
}
return cache.get(key)!;
}
private async getAllRelationships(
cache: Map<string, Relationship[]>,
gid: string,
): Promise<Relationship[]> {
const result: Relationship[] = [];
const kinds = Object.values(RelationshipKind) as RelationshipKind[];
for (const kind of kinds) {
const rels = await this.getRelationshipsFromCache(cache, gid, kind);
const targetRels = await this.getRelationshipsFromCache(cache, `target:${gid}`, kind);
result.push(...rels, ...targetRels);
}
return result;
}
private addResolution(
resolutions: Map<string, Resolution[]>,
id: string,
resolution: Resolution,
): void {
if (!resolutions.has(id)) resolutions.set(id, []);
resolutions.get(id)!.push(resolution);
}
private setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const item of a) if (!b.has(item)) return false;
return true;
}
}

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

@@ -0,0 +1,34 @@
import type { ModelMetadata } from "./provider-cache.js"
export interface ProviderModelsCache {
readonly models: Record<string, readonly string[] | readonly ModelMetadata[]>
readonly connected: readonly string[]
readonly updatedAt: string
}
export interface ConnectedProvidersAdapter {
readConnectedProvidersCache(): string[] | null
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
readProviderModelsCache(): ProviderModelsCache | null
}
export function readConnectedProvidersCache(): string[] | null {
return null
}
export function findProviderModelMetadata(
_providerID: string,
_modelID: string,
): ModelMetadata | undefined {
return undefined
}
export function readProviderModelsCache(): ProviderModelsCache | null {
return null
}
export const connectedProvidersAdapter: ConnectedProvidersAdapter = {
readConnectedProvidersCache,
findProviderModelMetadata,
readProviderModelsCache,
}

View File

@@ -0,0 +1,128 @@
import type { FallbackEntry } from "./model-requirement-types.js"
import type { FallbackModelObject } from "./fallback-model-object.js"
import { normalizeFallbackModels } from "./model-resolver.js"
import { KNOWN_VARIANTS } from "./known-variants.js"
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
if (typeof rawModel !== "string") {
return { modelID: "" }
}
const trimmedModel = rawModel.trim()
if (!trimmedModel) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModel }
}
export function parseFallbackModelEntry(
model: string,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
if (typeof model !== "string") return undefined
const trimmed = model.trim()
if (!trimmed) return undefined
const parts = trimmed.split("/")
const providerID =
parts.length >= 2 ? (parts[0]?.trim() ?? "") : (contextProviderID?.trim() || defaultProviderID)
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
if (!providerID || !rawModelID) return undefined
const parsed = parseVariantFromModel(rawModelID)
if (!parsed.modelID) return undefined
return {
providers: [providerID],
model: parsed.modelID,
variant: parsed.variant,
}
}
export function parseFallbackModelObjectEntry(
obj: FallbackModelObject,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
if (!base) return undefined
return {
...base,
variant: obj.variant ?? base.variant,
reasoningEffort: obj.reasoningEffort,
temperature: obj.temperature,
top_p: obj.top_p,
maxTokens: obj.maxTokens,
thinking: obj.thinking,
}
}
/**
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
* the resolved `provider/modelID`. Longest match wins so that e.g.
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
* the shorter `openai/gpt-5.4`.
*/
export function findMostSpecificFallbackEntry(
providerID: string,
modelID: string,
chain: FallbackEntry[],
): FallbackEntry | undefined {
const resolved = `${providerID}/${modelID}`.toLowerCase()
// Collect entries whose provider/model is a prefix of the resolved model,
// together with the length of the matching prefix (longest match wins).
const matches: { entry: FallbackEntry; matchLen: number }[] = []
for (const entry of chain) {
for (const p of entry.providers) {
const candidate = `${p}/${entry.model}`.toLowerCase()
if (resolved.startsWith(candidate)) {
matches.push({ entry, matchLen: candidate.length })
break // one match per entry is enough
}
}
}
if (matches.length === 0) return undefined
matches.sort((a, b) => b.matchLen - a.matchLen)
return matches[0]!.entry
}
export function buildFallbackChainFromModels(
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry[] | undefined {
const normalized = normalizeFallbackModels(fallbackModels)
if (!normalized || normalized.length === 0) return undefined
const parsed = normalized
.map((entry) => {
if (typeof entry === "string") {
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
}
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
})
.filter((entry): entry is FallbackEntry => entry !== undefined)
if (parsed.length === 0) return undefined
return parsed
}

View File

@@ -0,0 +1,9 @@
export type FallbackModelObject = {
readonly model: string
readonly variant?: string
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
readonly temperature?: number
readonly top_p?: number
readonly maxTokens?: number
readonly thinking?: { readonly type: "enabled" | "disabled"; readonly budgetTokens?: number }
}

View File

@@ -0,0 +1,80 @@
export type {
FallbackEntry,
ModelRequirement,
} from "./model-requirement-types.js"
export type {
FallbackModelObject,
} from "./fallback-model-object.js"
export type {
DelegatedModelConfig,
ModelResolutionRequest,
ModelResolutionProvenance,
ModelResolutionResult,
} from "./model-resolution-types.js"
export type {
ModelResolutionInput,
ModelSource,
ExtendedModelResolutionInput,
} from "./model-resolver.js"
export {
resolveModel,
resolveModelWithFallback,
normalizeFallbackModels,
flattenToFallbackModelStrings,
} from "./model-resolver.js"
export {
normalizeModel,
normalizeModelID,
} from "./model-normalization.js"
export {
fuzzyMatchModel,
isModelAvailable,
} from "./model-availability.js"
export {
transformModelForProvider,
transformModelForProviderDisplay,
} from "./provider-model-id-transform.js"
export {
buildFallbackChainFromModels,
parseFallbackModelEntry,
parseFallbackModelObjectEntry,
findMostSpecificFallbackEntry,
} from "./fallback-chain-from-models.js"
export {
KNOWN_VARIANTS,
} from "./known-variants.js"
export {
_setModelResolutionLogImplementationForTesting,
resolveModelPipeline,
} from "./model-resolution-pipeline.js"
export type {
ModelResolutionRequest as PipelineModelResolutionRequest,
ModelResolutionProvenance as PipelineModelResolutionProvenance,
ModelResolutionResult as PipelineModelResolutionResult,
ModelResolutionDeps,
} from "./model-resolution-pipeline.js"
export {
isRetryableModelError,
shouldRetryError,
getNextFallback,
hasMoreFallbacks,
selectFallbackProvider,
selectFallbackProviderWithCache,
} from "./model-error-classifier.js"
export type {
ErrorInfo,
} from "./model-error-classifier.js"
export type {
ProviderCache,
ModelMetadata,
} from "./provider-cache.js"
export type {
ProviderModelsCache,
ConnectedProvidersAdapter,
} from "./connected-providers-cache.js"
export {
readConnectedProvidersCache,
findProviderModelMetadata,
readProviderModelsCache,
connectedProvidersAdapter,
} from "./connected-providers-cache.js"

View File

@@ -0,0 +1,16 @@
/**
* Canonical set of recognised variant / effort tokens.
* Used by parseFallbackModelEntry (space-suffix detection) and
* flattenToFallbackModelStrings (inline-variant stripping).
*/
export const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])

View File

@@ -0,0 +1,64 @@
function normalizeModelName(name: string): string {
return name
.toLowerCase()
.replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3")
}
export function fuzzyMatchModel(
target: string,
available: Set<string>,
providers?: string[],
): string | null {
if (available.size === 0) {
return null
}
const targetNormalized = normalizeModelName(target)
let candidates = Array.from(available)
if (providers && providers.length > 0) {
const providerSet = new Set(providers)
candidates = candidates.filter((model) => {
const [provider] = model.split("/")
return providerSet.has(provider!)
})
}
if (candidates.length === 0) {
return null
}
const matches = candidates.filter((model) =>
normalizeModelName(model).includes(targetNormalized),
)
if (matches.length === 0) {
return null
}
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
if (exactMatch) {
return exactMatch
}
const exactModelIdMatches = matches.filter((model) => {
const modelId = model.split("/").slice(1).join("/")
return normalizeModelName(modelId) === targetNormalized
})
if (exactModelIdMatches.length > 0) {
return exactModelIdMatches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
}
return matches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
}
export function isModelAvailable(
targetModel: string,
availableModels: Set<string>,
): boolean {
return fuzzyMatchModel(targetModel, availableModels) !== null
}

View File

@@ -0,0 +1,261 @@
import type { FallbackEntry } from "./model-requirement-types.js"
import type { ProviderCache } from "./provider-cache.js"
import * as connectedProvidersCache from "./connected-providers-cache.js"
/**
* Error names that indicate a retryable model error.
* These errors halt execution and should trigger fallback retry.
*/
const RETRYABLE_ERROR_NAMES = new Set([
"providermodelnotfounderror",
"ratelimiterror",
"modelunavailableerror",
"providerconnectionerror",
"authenticationerror",
])
const STOP_ERROR_NAMES = new Set([
"quotaexceedederror",
"insufficientcreditserror",
"freeusagelimiterror",
])
/**
* Error names that should NOT trigger retry.
* These errors are typically user-induced or fixable without switching models.
*/
const NON_RETRYABLE_ERROR_NAMES = new Set([
"messageabortederror",
"permissiondeniederror",
"contextlengtherror",
"timeouterror",
"validationerror",
"syntaxerror",
"usererror",
])
/**
* Message patterns that indicate a retryable error even without a known error name.
*/
const RETRYABLE_MESSAGE_PATTERNS = [
"rate_limit",
"rate limit",
"usage_limit_reached",
"usage limit has been reached",
"quota",
"all credentials for model",
"cooling down",
"exhausted your capacity",
"not found",
"unavailable",
"insufficient",
"too many requests",
"over limit",
"overloaded",
"bad gateway",
"bad request",
"unknown provider",
"provider not found",
"model_not_supported",
"model not supported",
"model is not supported",
"connection error",
"network error",
"timeout",
"service unavailable",
"internal_server_error",
"free usage",
"usage exceeded",
"credit",
"balance",
"temporarily unavailable",
"try again",
"请稍后重试",
"503",
"502",
"504",
"429",
"529",
"selected provider is forbidden",
"provider is forbidden",
// Chinese retryable patterns (Zhipu, etc.)
"频率限制", // "rate limit"
"请求过于频繁", // "too many requests"
"暂时不可用", // "temporarily unavailable"
"服务不可用", // "service unavailable"
"server_error",
"an error occurred while processing",
]
/**
* Message patterns that indicate a non-retryable STOP error (quota/billing exhaustion).
* These take precedence over RETRYABLE_MESSAGE_PATTERNS.
*/
const STOP_MESSAGE_PATTERNS = [
"quota will reset after",
"quota exceeded",
"free usage limit",
"billing limit",
"billing hard limit",
"monthly limit",
"plan limit",
"subscription quota",
"subscription limit",
"payment required",
"out of credits",
"credits exhausted",
"insufficient credits",
"insufficient balance",
"credit balance",
"usage limit for this month",
"exhausted your capacity",
// GLM/Z.ai business error codes that indicate permanent quota/billing exhaustion
"daily call limit",
"daily limit",
"usage limit reached for",
"in arrears",
"fair use policy",
"recharge and try",
"使用上限",
"额度不足",
"余额不足",
"已耗尽",
]
const AUTO_RETRY_GATE_PATTERNS = [
"rate limit",
"cooling down",
"credentials for model",
]
function hasProviderAutoRetrySignal(message: string): boolean {
if (!message.includes("retrying in")) {
return false
}
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
}
export interface ErrorInfo {
name?: string
message?: string
/** HTTP status code from the provider response (e.g., 429 for rate limit) */
statusCode?: number
}
/**
* Determines if an error is a retryable model error.
* Returns true if it's a known retryable type OR matches retryable message patterns.
*/
export function isRetryableModelError(error: ErrorInfo): boolean {
// If we have an error name, check against known lists
if (error.name) {
const errorNameLower = error.name.toLowerCase()
// Explicit non-retryable takes precedence
if (NON_RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
return false
}
if (STOP_ERROR_NAMES.has(errorNameLower)) {
return false
}
// Check if it's a known retryable error
if (RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
return true
}
}
// Check message patterns for unknown errors
const msg = error.message?.toLowerCase() ?? ""
// STOP patterns take precedence over retryable patterns
if (STOP_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))) {
return false
}
if (hasProviderAutoRetrySignal(msg)) {
return true
}
// HTTP status code check: catches rate-limit errors regardless of message format/language.
// Uses the same codes as runtime-fallback config (400 excluded as it is a permanent client error).
if (
error.statusCode != null &&
(error.statusCode === 429 || error.statusCode === 503 || error.statusCode === 529)
) {
return true
}
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
}
/**
* Determines if an error should trigger a fallback retry.
* Returns true for errors that halt execution.
*/
export function shouldRetryError(error: ErrorInfo): boolean {
return isRetryableModelError(error)
}
/**
* Gets the next fallback model from the chain based on attempt count.
* Returns undefined if all fallbacks have been exhausted.
*/
export function getNextFallback(
fallbackChain: FallbackEntry[],
attemptCount: number,
): FallbackEntry | undefined {
return fallbackChain[attemptCount]
}
/**
* Checks if there are more fallbacks available after the current attempt.
*/
export function hasMoreFallbacks(
fallbackChain: FallbackEntry[],
attemptCount: number,
): boolean {
return attemptCount < fallbackChain.length
}
/**
* Selects the best provider for a fallback entry.
* Priority:
* 1) First connected provider in the entry's provider preference order
* 2) Preferred provider when connected (and entry providers are unavailable)
* 3) First provider listed in the fallback entry
*/
export function selectFallbackProvider(
providers: string[],
preferredProviderID?: string,
): string {
return selectFallbackProviderWithCache(
providers,
connectedProvidersCache,
preferredProviderID,
)
}
export function selectFallbackProviderWithCache(
providers: string[],
providerCache: ProviderCache,
preferredProviderID?: string,
): string {
const connectedProviders = providerCache.readConnectedProvidersCache()
if (connectedProviders) {
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
for (const provider of providers) {
if (connectedSet.has(provider.toLowerCase())) {
return provider
}
}
if (
preferredProviderID &&
connectedSet.has(preferredProviderID.toLowerCase())
) {
return preferredProviderID
}
}
return providers[0] ?? preferredProviderID ?? "opencode"
}

View File

@@ -0,0 +1,8 @@
export function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim()
return trimmed || undefined
}
export function normalizeModelID(modelID: string): string {
return modelID.replace(/\.(\d+)/g, "-$1")
}

View File

@@ -0,0 +1,18 @@
export type FallbackEntry = {
providers: string[];
model: string;
variant?: string; // Entry-specific variant (e.g., GPT->high, Opus->max)
reasoningEffort?: string;
temperature?: number;
top_p?: number;
maxTokens?: number;
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
};
export type ModelRequirement = {
fallbackChain: FallbackEntry[];
variant?: string; // Default variant (used when entry doesn't specify one)
requiresModel?: string; // If set, only activates when this model is available (fuzzy match)
requiresAnyModel?: boolean; // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable)
requiresProvider?: string[]; // If set, only activates when any of these providers is connected
};

View File

@@ -0,0 +1,256 @@
import { fuzzyMatchModel } from "./model-availability.js"
import type { FallbackEntry } from "./model-requirement-types.js"
import { transformModelForProvider } from "./provider-model-id-transform.js"
import { normalizeModel } from "./model-normalization.js"
import type { ProviderCache } from "./provider-cache.js"
type LogImplementation = (message: string, data?: unknown) => void
let logImplementationForTesting: LogImplementation | undefined
function log(message: string, data?: unknown): void {
const logImpl = logImplementationForTesting
if (!logImpl) {
return
}
if (arguments.length === 1) {
logImpl(message)
return
}
logImpl(message, data)
}
export function _setModelResolutionLogImplementationForTesting(
logImplementation: LogImplementation | undefined,
): void {
logImplementationForTesting = logImplementation
}
export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
}
constraints: {
availableModels: Set<string>
connectedProviders?: string[] | null
}
policy?: {
fallbackChain?: FallbackEntry[]
systemDefaultModel?: string
}
}
export type ModelResolutionProvenance =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
provenance: ModelResolutionProvenance
variant?: string
attempted?: string[]
reason?: string
}
export type ModelResolutionDeps = {
fuzzyMatchModel: (
target: string,
available: Set<string>,
providers?: string[],
) => string | null
transformModelForProvider: (provider: string, model: string) => string
}
const DEFAULT_MODEL_RESOLUTION_DEPS: ModelResolutionDeps = {
fuzzyMatchModel,
transformModelForProvider,
}
export function resolveModelPipeline(
request: ModelResolutionRequest,
providerCache: ProviderCache = {
readConnectedProvidersCache: () => null,
findProviderModelMetadata: () => undefined,
},
deps: ModelResolutionDeps = DEFAULT_MODEL_RESOLUTION_DEPS,
): ModelResolutionResult | undefined {
const attempted: string[] = []
const { intent, constraints, policy } = request
const availableModels = constraints.availableModels
const fallbackChain = policy?.fallbackChain
const systemDefaultModel = policy?.systemDefaultModel
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
if (normalizedUiModel) {
log("Model resolved via UI selection", { model: normalizedUiModel })
return { model: normalizedUiModel, provenance: "override" }
}
const normalizedUserModel = normalizeModel(intent?.userModel)
if (normalizedUserModel) {
log("Model resolved via config override", { model: normalizedUserModel })
return { model: normalizedUserModel, provenance: "override" }
}
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
if (normalizedCategoryDefault) {
attempted.push(normalizedCategoryDefault)
if (availableModels.size > 0) {
const parts = normalizedCategoryDefault.split("/")
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
const match = deps.fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
if (match) {
log("Model resolved via category default (fuzzy matched)", {
original: normalizedCategoryDefault,
matched: match,
})
return { model: match, provenance: "category-default", attempted }
}
} else {
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
if (connectedProviders === null) {
log("Model resolved via category default (no cache, first run)", {
model: normalizedCategoryDefault,
})
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
}
const parts = normalizedCategoryDefault.split("/")
if (parts.length >= 2) {
const provider = parts[0]!
if (connectedProviders.includes(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
log("Model resolved via category default (connected provider)", {
model: transformedModel,
original: normalizedCategoryDefault,
})
return { model: transformedModel, provenance: "category-default", attempted }
}
}
}
log("Category default model not available, falling through to fallback chain", {
model: normalizedCategoryDefault,
})
}
//#when - user configured fallback_models, try them before hardcoded fallback chain
const userFallbackModels = intent?.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet !== null) {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
if (parts.length >= 2) {
const provider = parts[0]!
if (connectedSet.has(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
return { model: transformedModel, provenance: "provider-fallback", attempted }
}
}
}
log("No connected provider found in user fallback_models, falling through to hardcoded chain")
}
} else {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
const match = deps.fuzzyMatchModel(model, availableModels, providerHint)
if (match) {
log("Model resolved via user fallback_models (availability confirmed)", { model, match })
return { model: match, provenance: "provider-fallback", attempted }
}
}
log("No available model found in user fallback_models, falling through to hardcoded chain")
}
}
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet === null) {
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const transformedModelId = deps.transformModelForProvider(provider, entry.model)
const model = `${provider}/${transformedModelId}`
log("Model resolved via fallback chain (connected provider)", {
provider,
model: transformedModelId,
variant: entry.variant,
})
return {
model,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
}
log("No connected provider found in fallback chain, falling through to system default")
}
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = deps.fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", {
provider,
model: entry.model,
match,
variant: entry.variant,
})
return {
model: match,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
const crossProviderMatch = deps.fuzzyMatchModel(entry.model, availableModels)
if (crossProviderMatch) {
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
model: entry.model,
match: crossProviderMatch,
variant: entry.variant,
})
return {
model: crossProviderMatch,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
log("No available model found in fallback chain, falling through to system default")
}
}
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
return undefined
}
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, provenance: "system-default", attempted }
}

View File

@@ -0,0 +1,41 @@
import type { FallbackEntry } from "./model-requirement-types.js"
export interface DelegatedModelConfig {
providerID: string
modelID: string
variant?: string
reasoningEffort?: string
temperature?: number
top_p?: number
maxTokens?: number
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
}
export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
categoryDefaultModel?: string
}
constraints: {
availableModels: Set<string>
}
policy?: {
fallbackChain?: FallbackEntry[]
systemDefaultModel?: string
}
}
export type ModelResolutionProvenance =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
provenance: ModelResolutionProvenance
variant?: string
attempted?: string[]
reason?: string
}

View File

@@ -0,0 +1,109 @@
import type { FallbackEntry } from "./model-requirement-types.js"
import type { FallbackModelObject } from "./fallback-model-object.js"
import { normalizeModel } from "./model-normalization.js"
import { resolveModelPipeline } from "./model-resolution-pipeline.js"
import { KNOWN_VARIANTS } from "./known-variants.js"
import type { ConnectedProvidersAdapter } from "./connected-providers-cache.js"
import * as connectedProvidersCache from "./connected-providers-cache.js"
export type ModelResolutionInput = {
userModel?: string
inheritedModel?: string
systemDefault?: string
}
export type ModelSource =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
source: ModelSource
variant?: string
}
export type ExtendedModelResolutionInput = {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel?: string
}
export function resolveModel(input: ModelResolutionInput): string | undefined {
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
input.systemDefault
)
}
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
connectedProvidersAdapter: ConnectedProvidersAdapter = connectedProvidersCache,
): ModelResolutionResult | undefined {
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
const resolved = resolveModelPipeline({
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
constraints: { availableModels },
policy: { fallbackChain, systemDefaultModel },
}, connectedProvidersAdapter)
if (!resolved) {
return undefined
}
return {
model: resolved.model,
source: resolved.provenance,
variant: resolved.variant,
}
}
/**
* Normalizes fallback_models config to a mixed array.
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
*/
export function normalizeFallbackModels(
models: string | (string | FallbackModelObject)[] | undefined,
): (string | FallbackModelObject)[] | undefined {
if (!models) return undefined
if (typeof models === "string") return [models]
return models
}
/**
* Extracts plain model strings from a mixed fallback models array.
* Object entries are flattened to "model" or "model(variant)" strings.
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
*/
export function flattenToFallbackModelStrings(
models: (string | FallbackModelObject)[] | undefined,
): string[] | undefined {
if (!models) return undefined
return models.map((entry) => {
if (typeof entry === "string") return entry
const variant = entry.variant
if (variant) {
// Strip any supported inline variant syntax before appending explicit override.
// Supports both parenthesized and space-suffix forms so we don't emit
// invalid strings like "provider/model high(low)".
const model = entry.model
.replace(/\([^()]+\)\s*$/, "")
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (_match: string, suffix: string) => {
const normalized = String(suffix).toLowerCase()
return KNOWN_VARIANTS.has(normalized)
? ""
: _match
})
.trim()
return `${model}(${variant})`
}
return entry.model
})
}

View File

@@ -0,0 +1,27 @@
export interface ModelMetadata {
readonly id: string
readonly provider?: string
readonly context?: number
readonly output?: number
readonly name?: string
readonly variants?: Record<string, unknown>
readonly limit?: {
readonly context?: number
readonly input?: number
readonly output?: number
}
readonly modalities?: {
readonly input?: string[]
readonly output?: string[]
}
readonly capabilities?: Record<string, unknown>
readonly reasoning?: boolean
readonly temperature?: boolean
readonly tool_call?: boolean
readonly [key: string]: unknown
}
export interface ProviderCache {
readConnectedProvidersCache(): string[] | null
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
}

View File

@@ -0,0 +1,69 @@
function inferSubProvider(model: string): string | undefined {
if (model.startsWith("claude-")) return "anthropic"
if (model.startsWith("gpt-")) return "openai"
if (model.startsWith("gemini-")) return "google"
if (model.startsWith("grok-")) return "xai"
if (model.startsWith("minimax-")) return "minimax"
if (model.startsWith("kimi-")) return "moonshotai"
if (model.startsWith("glm-")) return "zai"
return undefined
}
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
function claudeVersionDot(model: string): string {
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
}
function applyGatewayTransforms(model: string): string {
return claudeVersionDot(model).replace(
GEMINI_31_PRO_PREVIEW,
"gemini-3.1-pro-preview",
)
}
function transformModelForProviderUsingAnthropicBehavior(
provider: string,
model: string,
): string {
if (provider === "vercel") {
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
const subProvider = model.substring(0, slashIndex)
const subModel = model.substring(slashIndex + 1)
return `${subProvider}/${applyGatewayTransforms(subModel)}`
}
const subProvider = inferSubProvider(model)
if (subProvider) {
return `${subProvider}/${applyGatewayTransforms(model)}`
}
return model
}
if (provider === "github-copilot") {
return claudeVersionDot(model)
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "google") {
return model
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "anthropic") {
return model
}
return model
}
export function transformModelForProvider(provider: string, model: string): string {
return transformModelForProviderUsingAnthropicBehavior(provider, model)
}
export function transformModelForProviderDisplay(
provider: string,
model: string,
): string {
return transformModelForProviderUsingAnthropicBehavior(provider, model)
}

View File

@@ -0,0 +1,110 @@
/**
* v2.7.18: shared MCP client wrapper for the boocontext sidecar.
*
* Calls into the existing multi-server MCP client infrastructure
* (services/mcp-client.ts) which connects to boocontext as a stdio
* MCP process defined in data/mcp.json (server name "boocontext",
* command: `node /opt/forks/boocontext/dist/standalone.js`).
*
* The boocontext MCP server is initialized once at app boot in
* index.ts via initMcp() and the actual MCP tool call routing is
* handled by mcp-client.ts:callTool() — this module is a thin
* convenience wrapper that prepends the "boocontext_" server prefix,
* normalises the response, and applies inline truncation matching
* the same pattern as codecontext_client.ts.
*
* Usage:
* import { callBoocontext } from './services/boocontext_client.js';
* const resp = await callBoocontext({
* toolName: 'codesight_get_summary',
* args: { directory: '/opt/boocode' },
* });
*/
import { callTool } from './mcp-client.js';
import { truncateIfNeeded } from './truncate.js';
// ---- Exported types ----
export interface BoocontextRequest {
/** Unprefixed tool name as defined on the boocontext MCP server
* (e.g. "codesight_scan", "boocontext_overview", "codesight_get_summary"). */
toolName: string;
/** Arguments to pass to the tool. */
args: Record<string, unknown>;
}
export interface BoocontextResponse {
/** The tool output text. */
result: string;
/** Whether the result was truncated to fit the inline limit. */
truncated: boolean;
/** Opaque id pointing at the full pre-slice content on tmpfs, set when
* truncated=true and storage succeeded. */
outputPath?: string;
}
// ---- Constants ----
/** Must match the server name in data/mcp.json. */
const BOOCONTEXT_SERVER_NAME = 'boocontext';
/** Inline truncation limit, matching codecontext_client.ts. */
const TRUNCATION_LIMIT = 32_000;
// ---- Public API ----
/**
* Call a boocontext MCP tool by its unprefixed name.
*
* Prepends the "boocontext_" server prefix, delegates to the
* multi-server MCP client's callTool(), and normalises the response
* into a BoocontextResponse with inline truncation.
*
* @param req The tool name and arguments.
* @param log Optional Fastify-compatible logger (for debug traces).
* @returns The tool result, possibly truncated.
* @throws If the boocontext server is not connected or the tool
* returns an MCP-level error.
*/
export async function callBoocontext(
req: BoocontextRequest,
log?: { debug?: (obj: object, msg: string) => void; warn?: (obj: object, msg: string) => void },
): Promise<BoocontextResponse> {
const prefixedName = `${BOOCONTEXT_SERVER_NAME}_${req.toolName}`;
log?.debug?.({ tool: prefixedName }, 'boocontext: calling tool');
const raw = await callTool(prefixedName, req.args);
// callTool returns { error: true, output: string } on failure (both
// for MCP-level isError and for network/protocol exceptions).
if (typeof raw === 'object' && raw !== null && (raw as Record<string, unknown>).error === true) {
const errOutput = (raw as Record<string, unknown>).output ?? 'Unknown MCP error';
throw new Error(`boocontext error: ${String(errOutput)}`);
}
const result = typeof raw === 'string' ? raw : JSON.stringify(raw);
// Inline truncation at 32 kB, matching codecontext_client.ts.
// The model gets a clear hint about how to narrow the next call
// rather than a silent cut.
if (result.length > TRUNCATION_LIMIT) {
const truncated = result.slice(0, TRUNCATION_LIMIT);
const omitted = result.length - TRUNCATION_LIMIT;
const slicedWithMarker =
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with additional filters]`;
const wrapped = await truncateIfNeeded({
fullContent: result,
slicedContent: slicedWithMarker,
wasTruncated: true,
});
return {
result: wrapped.content,
truncated: wrapped.truncated,
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
};
}
return { result, truncated: false };
}

View File

@@ -0,0 +1,62 @@
import { z } from 'zod';
import type { ToolDef } from '../types.js';
import { callBoocontext } from '../../boocontext_client.js';
export const GetCodeHealthInput = z.object({
directory: z.string().optional().describe('Directory to analyze (defaults to project root)'),
file: z.string().optional().describe('Optional: specific file to analyze'),
});
export type GetCodeHealthInputT = z.infer<typeof GetCodeHealthInput>;
const DESCRIPTION =
'Code health analysis. Returns AF grades per file across 7 dimensions ' +
'(cohesion, coupling, complexity, documentation, duplication, unit size, test coverage). ' +
'Includes project health summary and refactoring candidates.';
/**
* Standalone execute function — calls the boocontext MCP server's
* boocontext_health tool and returns the raw report text.
*
* Structured for direct test access: accepts input + projectPath,
* no side effects beyond the MCP call.
*/
export async function executeGetCodeHealth(
input: GetCodeHealthInputT,
projectPath: string,
): Promise<string> {
const args: Record<string, unknown> = {};
if (input.directory) args['directory'] = input.directory;
if (input.file) args['file'] = input.file;
const resp = await callBoocontext({ toolName: 'boocontext_health', args });
return resp.result;
}
export const getCodeHealth: ToolDef<GetCodeHealthInputT> = {
name: 'get_code_health',
description: DESCRIPTION,
inputSchema: GetCodeHealthInput,
jsonSchema: {
type: 'function',
function: {
name: 'get_code_health',
description: DESCRIPTION,
parameters: {
type: 'object',
properties: {
directory: {
type: 'string',
description: 'Directory to analyze (defaults to project root)',
},
file: {
type: 'string',
description: 'Optional: specific file to analyze',
},
},
additionalProperties: false,
},
},
},
async execute(input, projectRoot) {
return executeGetCodeHealth(input, projectRoot);
},
};

View File

@@ -0,0 +1,228 @@
import { spawn } from 'node:child_process';
import { resolve } from 'node:path';
import { z } from 'zod';
import type { ToolDef } from '../types.js';
import type { CodecontextResponse } from '../../codecontext_client.js';
// ======================= MCP Client =======================
const BOOCONTEXT_PATH = resolve('/opt/forks/boocontext/dist/standalone.js');
const TOOL_CALL_TIMEOUT_MS = 60_000;
interface JsonRpcMessage {
jsonrpc: '2.0';
id?: number | string;
result?: {
content?: Array<{ type: string; text: string }>;
};
error?: { code?: number; message: string };
}
/**
* Single-shot MCP JSON-RPC client for boocontext.
* Spawns the process, sends initialize + tools/call over NDJSON, returns the
* text result from the content array. The boocontext MCP server auto-detects
* newline-delimited JSON transport when the first input lacks Content-Length
* headers, which is exactly what we send.
*/
async function callBoocontext(
toolName: string,
args: Record<string, unknown>,
): Promise<string> {
return new Promise<string>((resolvePromise, reject) => {
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: TOOL_CALL_TIMEOUT_MS,
});
let stdout = '';
let stderr = '';
let resolved = false;
function finalize(err?: Error, result?: string): void {
if (resolved) return;
resolved = true;
if (err) reject(err);
else resolvePromise(result!);
child.kill();
}
child.stdout!.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr!.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (err: Error) => {
finalize(new Error(`boocontext spawn error: ${err.message}`));
});
child.on('close', (code: number | null) => {
if (resolved) return;
// Parse newline-delimited JSON responses from stdout
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
let toolText: string | undefined;
let toolError: string | undefined;
for (const line of lines) {
try {
const msg = JSON.parse(line) as JsonRpcMessage;
if (msg.id === 2) {
if (msg.error) {
toolError = msg.error.message ?? 'boocontext tool call failed';
} else if (msg.result?.content?.[0]?.text !== undefined) {
toolText = msg.result.content[0].text;
}
}
} catch {
// skip malformed JSON lines
}
}
if (toolError) {
finalize(new Error(toolError));
} else if (toolText !== undefined) {
finalize(undefined, toolText);
} else {
const errSuffix =
stderr.length > 0 ? ` stderr: ${stderr.slice(0, 500)}` : '';
finalize(
new Error(`boocontext MCP call failed (exit ${code})${errSuffix}`),
);
}
});
// Step 1: initialize — establishes MCP protocol version + capabilities
child.stdin!.write(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'boocode-server', version: '1.0.0' },
},
}) + '\n',
);
// Step 2: tools/call — invoke the named boocontext tool
child.stdin!.write(
JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: toolName, arguments: args },
}) + '\n',
);
child.stdin!.end();
// Safety timeout — prevent hung processes
setTimeout(() => {
finalize(
new Error(
`boocontext call timed out after ${TOOL_CALL_TIMEOUT_MS}ms`,
),
);
}, TOOL_CALL_TIMEOUT_MS);
});
}
// ======================= Tool Definition =======================
const TRUNCATION_LIMIT = 32_000;
export const GetCodeImpactInput = z.object({
symbol: z.string().min(1).describe('Symbol name for TSA trace_impact'),
file: z.string().optional().describe('File path for codesight blast_radius'),
directory: z
.string()
.optional()
.describe('Directory (defaults to project root)'),
depth: z
.number()
.int()
.min(1)
.max(5)
.optional()
.describe('Max blast-radius traversal depth (default 1)'),
});
export type GetCodeImpactInputT = z.infer<typeof GetCodeImpactInput>;
const DESCRIPTION =
'Impact analysis. Merges symbol-level call trace with file-level blast radius. ' +
'Use before making changes to understand change propagation. ' +
'Single call replaces separate get_symbol_info + get_blast_radius steps.';
/**
* Standalone execute function — calls the boocontext MCP `boocontext_impact`
* tool via a short-lived child process, then wraps the result in the standard
* CodecontextResponse shape with inline truncation at 32 KB.
*/
export async function executeGetCodeImpact(
input: GetCodeImpactInputT,
projectPath: string,
): Promise<CodecontextResponse> {
const args: Record<string, unknown> = {
symbol: input.symbol,
directory: input.directory ?? projectPath,
};
if (input.file) args['file'] = input.file;
const text = await callBoocontext('boocontext_impact', args);
// Inline truncation matching codecontext_client.ts patterns (32 KB ceiling).
if (text.length > TRUNCATION_LIMIT) {
const sliced = text.slice(0, TRUNCATION_LIMIT);
const omitted = text.length - TRUNCATION_LIMIT;
return {
result: `${sliced}\n\n[truncated, ${omitted} chars omitted; narrow with symbol or file parameters]`,
truncated: true,
};
}
return { result: text, truncated: false };
}
export const getCodeImpact: ToolDef<GetCodeImpactInputT> = {
name: 'get_code_impact',
description: DESCRIPTION,
inputSchema: GetCodeImpactInput,
jsonSchema: {
type: 'function',
function: {
name: 'get_code_impact',
description: DESCRIPTION,
parameters: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'Symbol name for TSA trace_impact',
},
file: {
type: 'string',
description: 'File path for codesight blast_radius',
},
directory: {
type: 'string',
description: 'Directory (defaults to project root)',
},
depth: {
type: 'number',
description: 'Max blast-radius traversal depth (default 1)',
},
},
required: ['symbol'],
additionalProperties: false,
},
},
},
execute(input, projectRoot) {
return executeGetCodeImpact(input, projectRoot);
},
};

View File

@@ -0,0 +1,192 @@
import { spawn } from 'node:child_process';
import { z } from 'zod';
import type { ToolDef } from '../types.js';
export const GetCodeMapInput = z.object({
directory: z.string().optional().describe('Directory to scan (defaults to project root)'),
compress: z.boolean().optional().describe('Apply DCP compression if payload exceeds threshold (default: true)'),
});
export type GetCodeMapInputT = z.infer<typeof GetCodeMapInput>;
const DESCRIPTION =
'DCP-compressed codebase context map. Returns filenames, sizes, import relationships in a compressed format. ' +
'Use compress=false for full detail, compress=true (default) for token-efficient overview.';
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
const TOOL_TIMEOUT_MS = 30_000;
const MAX_RESULT_BYTES = 32_768;
export interface CodeMapResponse {
result: string;
truncated: boolean;
}
/**
* Calls the boocontext MCP server over stdio JSON-RPC to invoke
* the boocontext_map tool. Spawns the standalone binary, sends
* initialize + tools/call, collects NDJSON responses, and kills
* the child process.
*/
function callBoocontextMap(args: Record<string, unknown>): Promise<CodeMapResponse> {
return new Promise((resolve, reject) => {
const child = spawn('node', [BOOCONTEXT_PATH], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdoutBuf = '';
const lines: string[] = [];
let timedOut = false;
let resolved = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
reject(new Error(`boocontext MCP call timed out after ${TOOL_TIMEOUT_MS}ms`));
}, TOOL_TIMEOUT_MS);
function tryParse(): void {
if (resolved || timedOut) return;
// Accumulate complete NDJSON lines
const parts = stdoutBuf.split('\n');
stdoutBuf = parts.pop()! ?? '';
for (const p of parts) {
const t = p.trim();
if (t) lines.push(t);
}
// Need at least 2 responses: initialize + tools/call
if (lines.length < 2) return;
resolved = true;
clearTimeout(timer);
child.kill();
try {
const callResponse = JSON.parse(lines[1]!);
if (callResponse.error) {
reject(new Error(`MCP error: ${callResponse.error.message}`));
return;
}
const content = callResponse.result?.content;
if (!content?.[0]?.text) {
reject(new Error('Unexpected MCP response shape — missing content[0].text'));
return;
}
// content[0].text is JSON-stringified VerdictEnvelope from boocontext
const envelope = JSON.parse(content[0].text as string);
const details = envelope.details;
let result: string;
if (details && typeof details === 'object' && 'data' in details) {
// DcpEnvelope shape: { compressed, originalLength, compressedLength, data }
if (details.compressed) {
// Return the full DcpEnvelope as JSON so the LLM can pass it
// transparently to a decompression step
result = JSON.stringify(details);
} else {
// Uncompressed — data is the raw output
result = details.data;
}
} else {
result = JSON.stringify(details ?? envelope);
}
const truncated = Buffer.byteLength(result, 'utf-8') > MAX_RESULT_BYTES;
if (truncated) {
result = result.substring(0, MAX_RESULT_BYTES);
}
resolve({ result, truncated });
} catch (e: any) {
reject(new Error(`Failed to parse boocontext response: ${e.message}`));
}
}
child.stdout!.on('data', (chunk: Buffer) => {
if (timedOut) return;
stdoutBuf += chunk.toString('utf-8');
tryParse();
});
child.stderr!.on('data', (_chunk: Buffer) => {
// Captured but not surfaced — logged only on parse failure
});
child.on('error', (err: Error) => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
reject(new Error(`boocontext spawn failed: ${err.message}`));
}
});
child.on('close', () => {
clearTimeout(timer);
if (!resolved && !timedOut) {
tryParse();
if (!resolved) {
resolved = true;
reject(new Error('boocontext process closed without producing a valid response'));
}
}
});
// Step 1: initialize
child.stdin!.write(
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n',
);
// Step 2: tools/call for boocontext_map
child.stdin!.write(
JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'boocontext_map', arguments: args },
}) + '\n',
);
});
}
export const getCodeMap: ToolDef<GetCodeMapInputT> = {
name: 'get_code_map',
description: DESCRIPTION,
inputSchema: GetCodeMapInput,
jsonSchema: {
type: 'function',
function: {
name: 'get_code_map',
description: DESCRIPTION,
parameters: {
type: 'object',
properties: {
directory: { type: 'string', description: 'Directory to scan (defaults to project root)' },
compress: {
type: 'boolean',
description: 'Apply DCP compression if payload exceeds threshold (default: true)',
},
},
additionalProperties: false,
},
},
},
async execute(input, projectRoot): Promise<CodeMapResponse> {
return callBoocontextMap({
directory: input.directory ?? projectRoot,
compress: input.compress ?? true,
});
},
};
export async function executeGetCodeMap(
input: GetCodeMapInputT,
projectRoot: string,
): Promise<CodeMapResponse> {
return callBoocontextMap({
directory: input.directory ?? projectRoot,
compress: input.compress ?? true,
});
}

View File

@@ -0,0 +1,262 @@
import { z } from 'zod';
import { spawn } from 'node:child_process';
import type { ToolDef } from '../types.js';
import type { CodecontextResponse } from '../../codecontext_client.js';
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
const TRUNCATION_LIMIT = 32_000;
export const GetTypeInfoInput = z.object({
file: z.string().min(1).describe('File path to resolve types in'),
symbol: z.string().optional().describe('Symbol name to resolve (supports regex)'),
directory: z.string().optional().describe('Project directory for type resolution context'),
});
export type GetTypeInfoInputT = z.infer<typeof GetTypeInfoInput>;
const DESCRIPTION =
'TypeScript type recovery. Returns type signatures, interface definitions, ' +
'generic constraints, and JSDoc for symbols in a file. Uses type-inject MCP server.';
// ---- JSON-RPC-over-stdio MCP caller for boocontext --------------------------
async function callBoocontext(
toolName: string,
args: Record<string, unknown>,
): Promise<CodecontextResponse> {
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 60_000,
});
let stderrBuf = '';
child.stderr!.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf-8');
});
let killed = false;
const killChild = () => {
if (killed) return;
killed = true;
child.kill();
};
try {
// Read one complete JSON-RPC response from stdout (handles both
// Content-Length framed and newline-delimited transport).
async function readResponse(timeoutMs = 30_000): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error('Timeout reading boocontext response'));
}, timeoutMs);
let buf = '';
const cleanup = () => {
clearTimeout(timer);
child.stdout!.removeListener('data', onData);
child.stdout!.removeListener('end', onEnd);
child.stdout!.removeListener('error', onError);
};
const onData = (chunk: Buffer) => {
buf += chunk.toString('utf-8');
const msg = tryExtractMessage(buf);
if (msg !== null) {
cleanup();
resolve(msg);
return;
}
if (buf.length > 1_024 * 1_024) {
cleanup();
reject(new Error('Boocontext response exceeded 1 MB'));
}
};
const onEnd = () => {
cleanup();
if (buf.trim()) {
try {
resolve(JSON.parse(buf.trim()));
} catch {
reject(new Error('Boocontext stream ended with incomplete data'));
}
} else {
reject(new Error('Boocontext stream ended unexpectedly'));
}
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
child.stdout!.on('data', onData);
child.stdout!.on('end', onEnd);
child.stdout!.on('error', onError);
});
}
// Wait for the process to be fully spawned.
await new Promise<void>((resolve, reject) => {
child.on('error', reject);
child.on('spawn', () => resolve());
});
// Step 1 — MCP initialize
let reqId = 0;
reqId++;
child.stdin!.write(
JSON.stringify({ jsonrpc: '2.0', id: reqId, method: 'initialize' }) + '\n',
);
const initResp = await readResponse() as { error?: { message: string } };
if (initResp.error) {
throw new Error(`Boocontext init failed: ${initResp.error.message}`);
}
// Step 2 — tools/call
reqId++;
child.stdin!.write(
JSON.stringify({
jsonrpc: '2.0',
id: reqId,
method: 'tools/call',
params: { name: toolName, arguments: args },
}) + '\n',
);
const callResp = await readResponse() as {
error?: { message: string };
result?: { content?: Array<{ type: string; text: string }> };
};
if (callResp.error) {
throw new Error(`Boocontext tool call failed: ${callResp.error.message}`);
}
// Extract text from the MCP tool result shape:
// { content: [{ type: "text", text: "…" }] }
const content = callResp.result?.content;
let text: string;
if (Array.isArray(content) && content.length > 0 && content[0]!.type === 'text') {
text = content[0]!.text;
} else {
text = JSON.stringify(callResp.result);
}
// Inline truncation at 32 KB.
if (text.length > TRUNCATION_LIMIT) {
const omitted = text.length - TRUNCATION_LIMIT;
return {
result:
text.slice(0, TRUNCATION_LIMIT) +
`\n\n[truncated, ${omitted} chars omitted; narrow with file or symbol filter]`,
truncated: true,
};
}
return { result: text, truncated: false };
} finally {
killChild();
// Give the process a moment to release resources.
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 2_000);
child.on('exit', () => {
clearTimeout(timer);
resolve();
});
});
}
}
/**
* Attempt to extract one complete JSON-RPC message from the head of a
* buffer. Handles both Content-Length framed and newline-delimited
* formats. Returns `null` when more data is needed.
*/
function tryExtractMessage(buf: string): unknown | null {
// --- Content-Length framed ---
const headerEnd = buf.indexOf('\r\n\r\n');
if (headerEnd !== -1) {
const header = buf.substring(0, headerEnd);
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
if (lengthMatch) {
const contentLength = parseInt(lengthMatch[1]!, 10);
const bodyStart = headerEnd + 4;
if (buf.length >= bodyStart + contentLength) {
const jsonStr = buf.substring(bodyStart, bodyStart + contentLength);
return JSON.parse(jsonStr);
}
return null; // need more data
}
// Has \r\n\r\n but no Content-Length — junk segment; skip and retry.
return tryExtractMessage(buf.substring(headerEnd + 4));
}
// --- Newline-delimited ---
const nlIndex = buf.indexOf('\n');
if (nlIndex !== -1) {
const line = buf.substring(0, nlIndex).trim();
if (line && line.startsWith('{')) {
return JSON.parse(line);
}
// Non-JSON line (e.g. stderr echo), skip and continue.
return tryExtractMessage(buf.substring(nlIndex + 1));
}
return null; // need more data
}
// ---- ToolDef ----------------------------------------------------------------
export const getTypeInfo: ToolDef<GetTypeInfoInputT> = {
name: 'get_type_info',
description: DESCRIPTION,
inputSchema: GetTypeInfoInput,
jsonSchema: {
type: 'function',
function: {
name: 'get_type_info',
description: DESCRIPTION,
parameters: {
type: 'object',
properties: {
file: { type: 'string', description: 'File path to resolve types in' },
symbol: {
type: 'string',
description: 'Symbol name to resolve (supports regex)',
},
directory: {
type: 'string',
description: 'Project directory for type resolution context',
},
},
required: ['file'],
additionalProperties: false,
},
},
},
async execute(input): Promise<CodecontextResponse> {
const args: Record<string, unknown> = { file: input.file };
if (input.symbol) args['symbol'] = input.symbol;
return callBoocontext('boocontext_types', args);
},
};
/**
* Standalone execute function matching the `execute` shape returned by
* `makeCodecontextTool` — useful for direct callers and tests.
*
* Note: unlike the HTTP-backed codecontext tools this does NOT accept a
* `fetcher` override because it communicates over stdio rather than HTTP.
*/
export async function executeGetTypeInfo(
input: GetTypeInfoInputT,
_projectPath?: string,
): Promise<CodecontextResponse> {
const args: Record<string, unknown> = { file: input.file };
if (input.symbol) args['symbol'] = input.symbol;
return callBoocontext('boocontext_types', args);
}

View File

@@ -13,3 +13,8 @@ export { getBlastRadius } from './get_blast_radius.js';
export { getHotFiles } from './get_hot_files.js';
export { getRoutes } from './get_routes.js';
export { getMiddleware } from './get_middleware.js';
// v2.8.14-domain2-phase1: boocontext-backed tools.
export { getCodeHealth } from './get_code_health.js';
export { getCodeImpact } from './get_code_impact.js';
export { getTypeInfo } from './get_type_info.js';
export { getCodeMap } from './get_code_map.js';

View File

@@ -19,6 +19,10 @@ import {
getHotFiles,
getRoutes,
getMiddleware,
getCodeHealth,
getCodeImpact,
getTypeInfo,
getCodeMap,
} from './codecontext/index.js';
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
@@ -75,6 +79,12 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
// v2.6.x: read a tab's transcript by its session-scoped tab number.
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
readTabByNumber as ToolDef<unknown>,
// v2.8.14-domain2-phase1: boocontext-backed tools. Backed by the boocontext
// MCP server. All read-only. Health, impact, types, map analysis.
getCodeHealth as ToolDef<unknown>,
getCodeImpact as ToolDef<unknown>,
getTypeInfo as ToolDef<unknown>,
getCodeMap as ToolDef<unknown>,
].sort((a, b) => a.name.localeCompare(b.name));
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

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.

160
scripts/omo-paseo-bridge.sh Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# omo-paseo-bridge.sh — Import OMO task() child sessions as Paseo agents
#
# Automates calling `paseo import` on child session IDs so OMO subagents
# appear in `paseo ls` alongside native Paseo agents.
#
# Usage:
# omo-paseo-bridge.sh import [--type <category>] <session-id>...
# Import session(s) as Paseo agents with omo=true labels
#
# omo-paseo-bridge.sh archive <agent-id>...
# Archive (soft-delete) agent(s) imported by this bridge
#
# omo-paseo-bridge.sh ls [--all]
# List agents tagged omo=true via paseo ls
#
# omo-paseo-bridge.sh --dry-run <command> ...
# Print what would be done without executing
#
# Examples:
# omo-paseo-bridge.sh import ses_abc123 ses_def456
# omo-paseo-bridge.sh import --type research ses_abc123
# omo-paseo-bridge.sh archive agt_789
# omo-paseo-bridge.sh ls
# omo-paseo-bridge.sh --dry-run import ses_abc123
# =============================================================================
SCRIPT_NAME="$(basename "$0")"
PASEO="$(which paseo 2>/dev/null || echo "paseo")"
DRY_RUN=false
# ── helpers ──────────────────────────────────────────────────────────────────
log() { printf "[%s] %s\n" "$SCRIPT_NAME" "$*"; }
warn() { printf "[%s] WARNING: %s\n" "$SCRIPT_NAME" "$*" >&2; }
err() { printf "[%s] ERROR: %s\n" "$SCRIPT_NAME" "$*" >&2; exit 1; }
paseo_cmd() {
if $DRY_RUN; then
log "[DRY-RUN] would run: $PASEO $*"
return 0
fi
"$PASEO" "$@" 2>&1 || warn "'paseo $*' exited with code $?"
}
paseo_import() {
local session_id="$1"
shift
local type_label="${1:-}"
local labels=("--label" "omo=true")
# Add parent session label if OMO_SESSION_ID is set (injected by agent)
if [[ -n "${OMO_SESSION_ID:-}" ]]; then
labels+=("--label" "parent=${OMO_SESSION_ID}")
fi
if [[ -n "$type_label" ]]; then
labels+=("--label" "type=${type_label}")
fi
log "Importing session ${session_id} as Paseo agent ..."
paseo_cmd import "$session_id" --provider opencode "${labels[@]}"
}
paseo_archive() {
local agent_id="$1"
log "Archiving agent ${agent_id} ..."
paseo_cmd archive "$agent_id" --force
}
paseo_list() {
local all_flag="${1:-}"
if [[ "$all_flag" == "--all" ]]; then
paseo_cmd ls --label "omo=true" --all
else
paseo_cmd ls --label "omo=true"
fi
}
# ── usage ────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [--dry-run] <command> [options] [args...]
Commands:
import [--type <category>] <session-id>...
Import OMO child session(s) as Paseo agents
archive <agent-id>...
Archive Paseo agent(s) (soft-delete)
ls [--all]
List agents tagged omo=true
Options:
--dry-run Print actions without executing them
-h, --help Show this help
Examples:
$SCRIPT_NAME import --type research ses_abc123
$SCRIPT_NAME archive agt_789
$SCRIPT_NAME ls --all
EOF
exit 0
}
# ── main ─────────────────────────────────────────────────────────────────────
# Peel off global flags
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage ;;
*) break ;;
esac
done
[[ $# -eq 0 ]] && usage
COMMAND="$1"
shift
case "$COMMAND" in
import)
TYPE_LABEL=""
SESSION_IDS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--type) TYPE_LABEL="$2"; shift 2 ;;
--type=*) TYPE_LABEL="${1#*=}"; shift ;;
-*) err "Unknown option for import: $1" ;;
*) SESSION_IDS+=("$1"); shift ;;
esac
done
[[ ${#SESSION_IDS[@]} -eq 0 ]] && err "import requires at least one session-id"
for sid in "${SESSION_IDS[@]}"; do
paseo_import "$sid" "$TYPE_LABEL"
done
;;
archive)
[[ $# -eq 0 ]] && err "archive requires at least one agent-id"
for aid in "$@"; do
paseo_archive "$aid"
done
;;
ls)
paseo_list "${1:-}"
;;
*)
err "Unknown command: $COMMAND\n$(usage)"
;;
esac