Skip to content

PRD: HooksProvider Abstraction — Type-Safe, Replaceable Hooks Layer (StorageProvider Pattern) #153

@diberry

Description

@diberry

PRD: HooksProvider Abstraction — Type-Safe, Replaceable Hooks Layer for Squad

Executive Summary

Squad's hook system is split across two incompatible APIs (HookPipeline in hooks/index.ts and SquadSessionHooks in adapter/types.ts), only 2 of 8 Copilot hooks are wired at runtime, and hook behavior can't be swapped without modifying Squad source. This PRD proposes a HooksProvider interface — following the exact pattern of Squad's StorageProvider — that unifies all 8 hooks behind a single type-safe contract, ships a DefaultHooksProvider that preserves current behavior, and lets users swap in custom implementations via squad.config.ts.

This is a fork-friendly core change — it adds an interface and wiring, doesn't remove anything, and existing HookPipeline code becomes the default implementation behind the new interface.


Problem Statement

Three Problems

1. Two incompatible hook APIs

Squad has two hook surfaces that don't talk to each other:

API Location What it does What it doesn't do
HookPipeline hooks/index.ts Pre/post tool hooks + 5 policies. Fully functional. No lifecycle hooks (session start/end, errors, prompts). Not replaceable — it's a concrete class.
SquadSessionHooks adapter/types.ts Types for all 6 SDK hooks. Clean interface. Never called at runtime. Types are inert. Has no way to register the 2 VS Code-only hooks.

A user who wants to add an onSessionStart hook has no working path. A user who wants to replace the PII scrubber can't swap it without subclassing HookPipeline.

2. Only 2 of 8 hooks fire at runtime

The Copilot platform supports 8 hooks. Squad's runtime fires exactly 2 (onPreToolUse, onPostToolUse). The other 6 are dead code — typed but never invoked.

3. No provider pattern — hooks aren't swappable

Squad has a proven pattern for swappable I/O: StorageProvider (interface) → FSStorageProvider (default) → users pass custom implementations via config. Hooks don't follow this pattern. HookPipeline is a concrete class constructed directly. You can't replace it without modifying Squad source or forking.


Proposed Solution: HooksProvider Interface

The Pattern (Already Proven in Squad)

StorageProvider (interface)           HooksProvider (interface)
  ├── FSStorageProvider (default)       ├── DefaultHooksProvider (default)
  ├── InMemoryStorageProvider           ├── GovernanceHooksProvider
  ├── SQLiteStorageProvider             ├── TelemetryHooksProvider
  └── (users swap via config)           └── (users swap via config)

Interface Definition

// packages/squad-sdk/src/hooks/hooks-provider.ts

/**
 * HooksProvider — abstract lifecycle contract for Squad hooks.
 *
 * All hook invocations flow through this interface so that
 * implementations (default governance, telemetry, enterprise compliance)
 * can be swapped without touching business logic.
 *
 * Follows the same pattern as StorageProvider.
 * All methods are optional — return null/undefined to proceed with defaults.
 */
export interface HooksProvider {
  // ── Tool Lifecycle (Copilot SDK hooks 1-2, already working) ──────────
  
  /**
   * Called before a tool executes.
   * Return a decision to allow, deny, or modify the tool call.
   * Return null to allow by default.
   */
  onPreToolUse?(
    input: PreToolUseInput,
    context: HookContext
  ): Promise<PreToolUseOutput | null>;

  /**
   * Called after a tool executes.
   * Can transform results, redact data, or trigger side effects.
   * Return null to pass through unchanged.
   */
  onPostToolUse?(
    input: PostToolUseInput,
    context: HookContext
  ): Promise<PostToolUseOutput | null>;

  // ── Session Lifecycle (Copilot SDK hooks 3-6, not yet wired) ─────────

  /**
   * Called when a session starts.
   * Inject context, load preferences, configure the session.
   */
  onSessionStart?(
    input: SessionStartInput,
    context: HookContext
  ): Promise<SessionStartOutput | null>;

  /**
   * Called when a user submits a prompt (before LLM processes it).
   * Rewrite prompts, capture directives, inject context.
   */
  onUserPromptSubmitted?(
    input: UserPromptInput,
    context: HookContext
  ): Promise<UserPromptOutput | null>;

  /**
   * Called when a session ends.
   * Cleanup, capture metrics, archive state.
   */
  onSessionEnd?(
    input: SessionEndInput,
    context: HookContext
  ): Promise<void>;

  /**
   * Called when an error occurs.
   * Implement retry logic, model fallback, alerting.
   */
  onErrorOccurred?(
    input: ErrorInput,
    context: HookContext
  ): Promise<ErrorRecoveryOutput | null>;

  // ── Agent Lifecycle (VS Code hooks 7-8, Squad-specific) ──────────────

  /**
   * Called before context compaction (pruning).
   * Preserve critical state before data is truncated.
   */
  onPreCompact?(
    input: PreCompactInput,
    context: HookContext
  ): Promise<PreCompactOutput | null>;

  /**
   * Called when a subagent (child agent) is spawned.
   * Validate, track, or modify spawn configuration.
   */
  onSubagentStart?(
    input: SubagentStartInput,
    context: HookContext
  ): Promise<SubagentStartOutput | null>;

  /**
   * Called when a subagent finishes.
   * Collect results, trigger follow-up work, log completion.
   */
  onSubagentStop?(
    input: SubagentStopInput,
    context: HookContext
  ): Promise<void>;
}

Hook Input/Output Types

// Shared context for all hooks
export interface HookContext {
  sessionId: string;
  agentName: string;
  teamRoot: string;
  timestamp: number;
}

// Pre-tool
export interface PreToolUseInput {
  toolName: string;
  toolArgs: Record<string, unknown>;
}
export interface PreToolUseOutput {
  decision: 'allow' | 'deny' | 'modify';
  reason?: string;
  modifiedArgs?: Record<string, unknown>;
  additionalContext?: string;
}

// Post-tool
export interface PostToolUseInput {
  toolName: string;
  toolArgs: Record<string, unknown>;
  toolResult: unknown;
  durationMs: number;
}
export interface PostToolUseOutput {
  modifiedResult?: unknown;
  additionalContext?: string;
  suppressOutput?: boolean;
}

// Session start
export interface SessionStartInput {
  model: string;
  charterContent?: string;
}
export interface SessionStartOutput {
  additionalContext?: string;
  modifiedConfig?: Partial<{ model: string }>;
}

// User prompt
export interface UserPromptInput {
  prompt: string;
}
export interface UserPromptOutput {
  modifiedPrompt?: string;
  additionalContext?: string;
  capturedDirectives?: string[];
}

// Session end
export interface SessionEndInput {
  tokensUsed: number;
  toolCallCount: number;
  filesModified: string[];
  durationMs: number;
}

// Error
export interface ErrorInput {
  error: Error;
  errorType: 'model' | 'tool' | 'network' | 'permission' | 'context_overflow' | 'unknown';
  currentModel?: string;
}
export interface ErrorRecoveryOutput {
  retry: boolean;
  fallbackModel?: string;
  modifiedPrompt?: string;
  backoffMs?: number;
}

// Pre-compact
export interface PreCompactInput {
  currentTokenCount: number;
  maxTokens: number;
  compactionStrategy: string;
}
export interface PreCompactOutput {
  preserveContext?: string[];
  exportState?: Record<string, unknown>;
}

// Subagent
export interface SubagentStartInput {
  agentName: string;
  model: string;
  taskType: string;
  charterPath: string;
}
export interface SubagentStartOutput {
  decision: 'allow' | 'deny';
  reason?: string;
  modifiedModel?: string;
  additionalContext?: string;
}

export interface SubagentStopInput {
  agentName: string;
  model: string;
  durationMs: number;
  tokensUsed: number;
  toolCallCount: number;
  status: 'completed' | 'error' | 'timeout';
  filesModified: string[];
}

DefaultHooksProvider

Wraps the existing HookPipeline — zero behavior change for current users.

// packages/squad-sdk/src/hooks/default-hooks-provider.ts

export class DefaultHooksProvider implements HooksProvider {
  private pipeline: HookPipeline;

  constructor(config?: PolicyConfig) {
    this.pipeline = new HookPipeline(config ?? {});
  }

  async onPreToolUse(input: PreToolUseInput, ctx: HookContext): Promise<PreToolUseOutput | null> {
    const result = await this.pipeline.runPreToolHooks({
      toolName: input.toolName,
      arguments: input.toolArgs,
      agentName: ctx.agentName,
      sessionId: ctx.sessionId,
    });
    return {
      decision: result.action === 'block' ? 'deny' : result.action,
      reason: result.reason,
      modifiedArgs: result.modifiedArguments,
    };
  }

  async onPostToolUse(input: PostToolUseInput, ctx: HookContext): Promise<PostToolUseOutput | null> {
    const result = await this.pipeline.runPostToolHooks({
      toolName: input.toolName,
      arguments: input.toolArgs,
      result: input.toolResult,
      agentName: ctx.agentName,
      sessionId: ctx.sessionId,
    });
    return { modifiedResult: result.result };
  }

  // Lifecycle hooks — no-op in default provider (preserves current behavior)
  async onSessionStart() { return null; }
  async onUserPromptSubmitted() { return null; }
  async onSessionEnd() {}
  async onErrorOccurred() { return null; }
  async onPreCompact() { return null; }
  async onSubagentStart() { return null; }
  async onSubagentStop() {}

  /** Access the underlying HookPipeline for direct hook registration */
  getPipeline(): HookPipeline { return this.pipeline; }
  getReviewerLockout(): ReviewerLockoutHook { return this.pipeline.getReviewerLockout(); }
}

CompositeHooksProvider

Chains multiple providers — first non-null result wins for decisions, all providers fire for void hooks.

export class CompositeHooksProvider implements HooksProvider {
  constructor(private providers: HooksProvider[]) {}

  async onPreToolUse(input: PreToolUseInput, ctx: HookContext): Promise<PreToolUseOutput | null> {
    for (const p of this.providers) {
      const result = await p.onPreToolUse?.(input, ctx);
      if (result?.decision === 'deny') return result; // First deny wins
      if (result) return result; // First non-null wins
    }
    return null;
  }

  async onSessionEnd(input: SessionEndInput, ctx: HookContext): Promise<void> {
    // Void hooks — fire all providers (logging, metrics, cleanup all run)
    await Promise.all(this.providers.map(p => p.onSessionEnd?.(input, ctx)));
  }

  // ... same pattern for all hooks
}

Configuration (squad.config.ts)

// User's project — zero changes needed if they don't use hooks
import { defineSquad } from '@bradygaster/squad-sdk';

export default defineSquad({
  // Default — same as today
});

// User opts into custom hooks
import { defineSquad } from '@bradygaster/squad-sdk';
import { DefaultHooksProvider, CompositeHooksProvider } from '@bradygaster/squad-sdk/hooks';
import { TelemetryHooksProvider } from './my-telemetry-hooks';

export default defineSquad({
  hooks: new CompositeHooksProvider([
    new DefaultHooksProvider({
      allowedWritePaths: ['src/**'],
      scrubPii: true,
    }),
    new TelemetryHooksProvider({
      endpoint: 'https://telemetry.internal/ingest',
    }),
  ]),
});

Wiring Plan: Where Each Hook Fires

Hook Wire Into File Change Type
onPreToolUse Existing HookPipeline call site hooks/index.ts Delegate to provider instead of direct pipeline call
onPostToolUse Existing HookPipeline call site hooks/index.ts Delegate to provider instead of direct pipeline call
onSessionStart After createSession() returns agents/lifecycle.ts line ~160 New call: await hooksProvider.onSessionStart(...)
onUserPromptSubmitted Before session.sendMessage() adapter/client.ts sendMessage method New call: await hooksProvider.onUserPromptSubmitted(...)
onPreCompact Before context summarization adapter/client.ts or infinite session handler New call if compaction event is detectable from SDK
onSubagentStart Inside spawnAgent(), after session creation agents/lifecycle.ts line ~200 New call: await hooksProvider.onSubagentStart(...)
onSubagentStop In agent destroy/completion handler agents/lifecycle.ts destroy method New call: await hooksProvider.onSubagentStop(...)
onSessionEnd In session close handler adapter/client.ts close/destroy New call: await hooksProvider.onSessionEnd(...)
onErrorOccurred In catch blocks across adapter + lifecycle Multiple files New call: await hooksProvider.onErrorOccurred(...)

Files Modified (9 files)

File Changes
hooks/hooks-provider.ts NEW — Interface + input/output types
hooks/default-hooks-provider.ts NEW — Wraps existing HookPipeline
hooks/composite-hooks-provider.ts NEW — Chains multiple providers
hooks/index.ts Export new types + provider classes (no breaking changes)
agents/lifecycle.ts Add hooksProvider to LifecycleManagerConfig, wire onSubagentStart/Stop
adapter/client.ts Wire onSessionStart, onSessionEnd, onUserPromptSubmitted, onErrorOccurred
adapter/types.ts No changes needed — SquadSessionHooks still used by SDK passthrough
runtime/config.ts Accept hooks: HooksProvider in squad.config.ts schema
index.ts (barrel) Export HooksProvider and default implementations

Lines of Code Estimate

Component LOC
Interface + types ~150
DefaultHooksProvider ~120
CompositeHooksProvider ~100
Wiring (lifecycle + adapter + config) ~130
Total new/modified ~500

How This Serves Real Business Needs

Need 1: Enterprise Compliance — "We must audit everything agents do"

Without HooksProvider: Enterprise teams fork Squad, modify HookPipeline directly, maintain a permanent fork. Every Squad upgrade requires rebasing their audit code.

With HooksProvider:

export default defineSquad({
  hooks: new CompositeHooksProvider([
    new DefaultHooksProvider({ scrubPii: true }),
    new ComplianceHooksProvider({
      auditEndpoint: 'https://audit.corp.com/ingest',
      retentionPolicy: '7-years',
      // Fires on every hook — full lifecycle audit
    }),
  ]),
});

Ship compliance as a plugin. Upgrade Squad freely — the interface is stable.

Business value: Unblocks enterprise adoption. Regulated industries (fintech, healthcare, government) require audit trails they control. HooksProvider makes this possible without forking.

Need 2: Cost Control — "Agents ran up a $200 bill overnight"

Without HooksProvider: No way to intercept agent spawns or track spend programmatically. The coordinator's prose instructions about "cost-first model selection" are advisory, not enforced.

With HooksProvider:

class BudgetHooksProvider implements HooksProvider {
  private spent = 0;
  
  async onSubagentStart(input, ctx) {
    if (this.spent > this.budget * 0.9) {
      return { decision: 'deny', reason: 'Budget 90% consumed. Pausing spawns.' };
    }
    // Force cheap model if budget is tight
    if (this.spent > this.budget * 0.7) {
      return { decision: 'allow', modifiedModel: 'claude-haiku-4.5' };
    }
    return { decision: 'allow' };
  }
  
  async onSubagentStop(input, ctx) {
    this.spent += estimateCost(input.model, input.tokensUsed);
  }
  
  async onSessionEnd(input, ctx) {
    await writeBudgetReport(this.spent, input);
  }
}

Business value: Makes autonomous Squad (Ralph loops, CI pipelines, nightly batches) financially safe. Spend is capped programmatically, not by hope.

Need 3: Multi-Tenant SaaS — "Each customer gets different rules"

Without HooksProvider: Can't parameterize governance per tenant. Every customer gets the same policies or you fork.

With HooksProvider:

function createTenantHooks(tenant: Tenant): HooksProvider {
  return new CompositeHooksProvider([
    new DefaultHooksProvider({
      allowedWritePaths: tenant.allowedPaths,
      blockedCommands: tenant.blockedCommands,
    }),
    new TenantMetricsProvider(tenant.metricsEndpoint),
    ...(tenant.plan === 'enterprise' ? [new ComplianceHooksProvider()] : []),
  ]);
}

// Per-tenant session
const session = await client.createSession({
  hooks: createTenantHooks(currentTenant).asSessionHooks(),
});

Business value: Enables Squad SDK as an embedded component in SaaS products. Each tenant gets isolated, configurable governance. New market segment for Squad.

Need 4: Developer Productivity — "Agents keep forgetting what we decided"

Without HooksProvider: Context injection is prompt-template-driven. Agents sometimes skip reads.

With HooksProvider:

class ContextInjectionProvider implements HooksProvider {
  async onSessionStart(input, ctx) {
    const decisions = await readDecisions(ctx.teamRoot);
    const history = await readHistory(ctx.teamRoot, ctx.agentName, { maxEntries: 20 });
    const skills = await matchSkills(ctx.teamRoot, input.charterContent);
    return {
      additionalContext: [decisions, history, ...skills].join('\n\n'),
    };
  }

  async onUserPromptSubmitted(input, ctx) {
    const directives = detectDirectives(input.prompt);
    if (directives.length > 0) {
      await writeDirectives(ctx.teamRoot, directives);
      return { capturedDirectives: directives };
    }
    return null;
  }
}

Business value: Decisions stick permanently. Directives are captured automatically. Agents start every session with full context — no reliance on agents "reading the right files."

Need 5: Resilient Autonomous Pipelines — "CI bot died at 3am"

Without HooksProvider: Error recovery is prose instructions in squad.agent.md. Rate limits crash sessions.

With HooksProvider:

class ResilienceProvider implements HooksProvider {
  private errorCount = 0;
  
  async onErrorOccurred(input, ctx) {
    this.errorCount++;
    
    // Circuit breaker
    if (this.errorCount > 5) {
      await alertOncall('Squad circuit breaker tripped', input.error);
      return { retry: false };
    }
    
    // Model fallback
    if (input.errorType === 'model') {
      const next = this.fallbackChain.shift();
      if (next) return { retry: true, fallbackModel: next };
    }
    
    // Rate limit backoff
    if (input.errorType === 'network') {
      return { retry: true, backoffMs: Math.pow(2, this.errorCount) * 1000 };
    }
    
    return null;
  }
}

Business value: Autonomous Squad survives infrastructure failures. CI bots, Ralph loops, and nightly batches recover automatically. PagerDuty fires when they can't.

Need 6: Agent Fleet Observability — "What are 20 Squad instances doing?"

Without HooksProvider: Each Squad instance has its own .squad/ logs. No aggregation.

With HooksProvider:

class ObservabilityProvider implements HooksProvider {
  async onSubagentStart(input, ctx) {
    await this.metrics.emit('squad.agent.spawn', {
      repo: process.env.REPO, agent: input.agentName, model: input.model,
    });
    return { decision: 'allow' };
  }

  async onSubagentStop(input, ctx) {
    await this.metrics.emit('squad.agent.complete', {
      repo: process.env.REPO, agent: input.agentName,
      duration: input.durationMs, tokens: input.tokensUsed,
      status: input.status,
    });
  }

  async onPostToolUse(input, ctx) {
    await this.metrics.emit('squad.tool.call', {
      tool: input.toolName, agent: ctx.agentName, duration: input.durationMs,
    });
    return null;
  }
}

Business value: Platform teams get a single Grafana/Datadog dashboard for all Squad instances. Error rates, token spend, agent performance — all visible in one place.


Migration Path

For existing users (zero changes required)

// Before — works exactly the same
export default defineSquad({
  hooks: defineHooks({
    allowedWritePaths: ['src/**'],
    scrubPii: true,
  }),
});

// After — defineHooks() internally creates DefaultHooksProvider
// User sees no difference

For users who want custom hooks

// Step 1: Import the provider
import { DefaultHooksProvider, CompositeHooksProvider } from '@bradygaster/squad-sdk/hooks';

// Step 2: Create custom provider implementing only the hooks you need
class MyHooks implements HooksProvider {
  async onSessionStart(input, ctx) { /* ... */ }
  // All other hooks: undefined = default behavior
}

// Step 3: Compose with defaults
export default defineSquad({
  hooks: new CompositeHooksProvider([
    new DefaultHooksProvider({ scrubPii: true }),
    new MyHooks(),
  ]),
});

For HookPipeline users (programmatic)

// Before
const pipeline = new HookPipeline({ scrubPii: true });
pipeline.addPreToolHook(myCustomHook);

// After — still works, HookPipeline is not removed
const pipeline = new HookPipeline({ scrubPii: true });
pipeline.addPreToolHook(myCustomHook);

// OR — use the provider pattern for lifecycle hooks
const provider = new DefaultHooksProvider({ scrubPii: true });
provider.getPipeline().addPreToolHook(myCustomHook);

Implementation Priority

Phase What Effort Breaks anything?
Phase 1 Interface + types + DefaultHooksProvider wrapping HookPipeline 2 days No — additive only
Phase 2 Wire onSubagentStart/onSubagentStop into lifecycle.ts 1 day No — no-op in default provider
Phase 3 Wire onSessionStart/onSessionEnd into adapter 1 day No — no-op in default
Phase 4 Wire onErrorOccurred with retry/fallback support 2 days No — no-op in default
Phase 5 Wire onUserPromptSubmitted into adapter 1 day No — no-op in default
Phase 6 Wire onPreCompact (depends on SDK exposing compaction event) 1 day No — may be deferred if SDK doesn't expose the event
Phase 7 CompositeHooksProvider + config integration 1 day No

Total: ~9 days of focused work. Zero breaking changes at any phase.

Success Metrics

  • Zero regressions — existing tests pass at every phase
  • HooksProvider adopted by 3+ community projects in first quarter
  • Custom providers shipped by at least 1 enterprise customer
  • Default provider performance — less than 1ms overhead per hook invocation
  • All 8 hooks firing — verifiable via telemetry provider

Relationship to PRDs #151 and #152

PRD Approach Relationship to this PRD
#151 (Core hooks) Embed hooks directly into Squad core This PRD supersedes #151 — same hooks, but behind a swappable interface
#152 (Plugin) Ship hooks as an installable plugin This PRD enables #152 — the plugin becomes a HooksProvider implementation that users install and configure
#153 (This PRD) Type-safe, replaceable interface Foundation layer — both #151 and #152 benefit from this abstraction

Ship order: This PRD (#153) first → Plugin PRD (#152) becomes a provider implementation → Core hooks (#151) are already wired by the interface.


This PRD follows the StorageProvider pattern proven in Squad since Wave 1. The interface is the same shape, the config integration is the same, and the migration path is the same: zero changes for existing users, full power for those who opt in.

Metadata

Metadata

Assignees

No one assigned

    Labels

    go:needs-researchNeeds investigationsquadSquad triage inbox — Lead will assign to a membersquad:fidoAssigned to FIDO (Quality Owner)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions