Skip to content
DuranteDurante
ALL SYSTEMSGet Access

27 weeks · 54 posts · Written while building

Field notes from a personal AI OS in flight

Every Tuesday, an evergreen essay on what I'm learning while shipping DuranteOS. Every Friday, a dispatch from the week. Roughly 108,000 words and counting — for builders who'd rather watch the foundation get poured than read the press release.

Subscribe · Tuesday essay

Around 3,800 builders read this weekly.

Plugin Architecture for Hooks: The Pattern I Want Before the Hook Becomes a God-Function

DOS's session-start hook is twelve weeks old and already has three loaders. I can see the next four queued up in my notebook. This is the design essay for the plugin pattern I want to commit to before the hook turns into 380 lines of duplicated try/catch — what Bob and Sandi would say, and what I am going to mechanize early.

A small architectural commitment I want to make in writing, before the smell shows up.

The DOS session-start hook is the file that runs every time a Claude Code session opens. Today, twelve weeks into the build, it loads three things: the operator's CLAUDE.md, the active project's git context, and a banner of recent learning signals. It is about 90 lines. It works.

I can see the next four loaders queued up in my notebook: a relationship loader (notes about people I am working with), a learning-corpus loader (recent algorithm reflections), a four-copy-rule banner (when I get to that), and an active-work-resumption loader (so the agent picks up where the prior session left off). If I add them naively, each will introduce its own try/catch boilerplate, the hook will balloon to 250-400 lines, and somewhere around loader number five I will edit the same shape for the fifth time and realize I should have factored it out.

I do not want to live through that pattern. This post is the plugin discipline I want to commit to before the hook becomes a 380-line god-function — what Bob and Sandi would say, and the mechanical fix I want to write this weekend.

The shape I want before the smell appears

A registry of plugin loaders, each declaring its own slot and phase, all wrapped by a single five-line safeLoad(loader, ctx) function. The hook depends on the Loader interface, not on any specific loader. Adding the eighth loader should be a one-line registry edit, not a fifty-line copy-paste.

The Uncle Bob angle: plugin architecture is the boundary

Robert Martin's Clean Architecture spends a chapter arguing that the most important architectural primitive is the boundary — a line across which dependencies do not cross in both directions. Plugins are the canonical example. The host knows about the plugin interface. The plugin knows about itself. The host does not depend on any specific plugin; the plugins depend on the host's interface.

The DOS session-start hook today has no boundary. With three loaders the hook still knows about each by name, in order, with its own try/catch handling. That is fine for three. For seven, it stops being fine, and the moment I cross the threshold without having put the boundary in is the moment it gets expensive to retrofit.

What I want to commit to now, before there is enough code to make refactoring annoying:

The shape I am avoiding vs. the shape I want

The shape I do NOT want (where this is heading if I am lazy)
// inside the hook, repeated 7+ times once it grows
let startupFiles = {};
try {
  startupFiles = await loadStartupFiles(ctx);
} catch (err) {
  console.error(`startup-files failed: ${err}`);
}

let relationship = {};
try {
  relationship = await loadRelationship(ctx);
} catch (err) {
  console.error(`relationship failed: ${err}`);
}
// ...five more times once I add the next four loaders
  • Hook depends on every loader by name
  • Adding a loader means editing the hook
  • Error handling is repeated, easy to forget on a new loader
  • Order of load and order of compose are the same — cannot decouple
The shape I want to commit to now
// in the hook, after the refactor I will do this weekend
const fragments: Record<FragmentSlot, string> = {};
for (const loader of LOADERS) {
  const result = await safeLoad(loader, ctx);
  fragments[loader.slot] = result;
}
return composeBanner(fragments);
  • Hook depends only on the Loader interface and the registry
  • Adding a loader is a one-line registry edit
  • safeLoad centralizes try/catch and logging
  • FragmentSlot decouples load order from compose order

The hook should be shorter. The loaders should be smaller. Most importantly, the cost of adding the next loader should be bounded — a one-line registry edit plus a small file in hooks/loaders/, never another inline try/catch block. That cost-curve change is what plugin architecture is actually for.

The Sandi Metz angle: the Four Rules, applied prospectively

Sandi Metz's Four Rules are deliberately strict and deliberately broken often. The rules:

RuleWhat it says
1Classes can be no longer than 100 lines of code
2Methods can be no longer than 5 lines of code
3Pass no more than 4 parameters into a method
4Controllers can instantiate only one object

Today's session-start hook is well within all four rules — about 90 lines, methods under 5 lines, ctx as a single parameter object. That is the right shape. The discipline I want to commit to is keeping it that way as the loader count grows.

If I add the next four loaders inline, the hook's main function will balloon past 200 lines and rules 1 and 2 will both break. If I add them through the registry pattern, the hook stays bounded around 80-90 lines indefinitely — what grows is the number of loader files, not the size of the hook.

The Squint Test, applied prospectively

Sandi's "squint test" — defocus your eyes and look at the code. If the indentation pattern is roughly uniform (all methods are similar size, all classes are similar shape), the design is probably balanced. If you see big indented blobs interspersed with one-liners, you have a structural imbalance. Today the hook squints clean. I want it to keep squinting clean as it grows. The plugin pattern is what makes that possible.

The safeLoad wrapper

The single most useful piece of the pattern I want to commit to is a five-line function I have not yet written:

async function safeLoad(loader: Loader, ctx: HookContext): Promise<string> {
  try {
    return await loader.load(ctx);
  } catch (err) {
    console.error(`⚠️  ${loader.name} failed: ${err}`);
    return '';
  }
}

That is the entire wrapper. It will replace what would otherwise become seven near-identical try/catch blocks. It centralizes the error-message format. It guarantees a fallback empty string so a single loader's failure cannot break the banner.

The wrapper is uninteresting in isolation. It is interesting because of what it prevents in advance: a future me forgetting the try/catch on the eighth loader, or formatting the error message differently, or returning null instead of '' and crashing the composer.

This is what Sandi means by exemplary — the fourth letter of TRUE properties (Transparent, Reasonable, Usable, Exemplary). Code is exemplary when it makes the next person doing similar work copy the right pattern by default. safeLoad will be exemplary because the pattern becomes "wrap your loader call in safeLoad" and there is exactly one way to spell it.

The FragmentSlot pattern

The other piece I want to commit to is a small enum that decouples load order from compose order — a problem I do not have yet but can already see coming.

Why decoupling load order from compose order is going to matter

  1. Some loaders will need to run early (a relationship loader writes to a cache the others read).
  2. Some loaders will need to run late (an active-work loader queries state that other loaders mutate during their load).
  3. The natural load order will be: relationship → learning → project → active-work (driven by data dependencies).
  4. The natural compose order in the banner will be: project → relationship → learning → active-work (driven by what the operator wants to read first).
  5. Coupling them — running the loaders in compose order and printing in load order — would give us a banner where the operator's most-relevant context is buried below the least-relevant.
  6. Decoupling: the registry runs loaders in their declared phase order; the composer assembles slots in the declared slot order. Two independent dimensions, each tunable without breaking the other.

The FragmentSlot enum I want:

type FragmentSlot = 'project' | 'relationship' | 'learning' | 'banner' | 'active_work';
type LoaderPhase = 'pre' | 'post';

interface Loader {
  name: string;
  slot: FragmentSlot;
  phase: LoaderPhase;
  load: (ctx: HookContext) => Promise<string>;
}

Three properties (name, slot, phase) and one method (load). Every loader implements it. The registry is just an array of Loader. The composer is a single function that reads the slots in order.

I am committing to this design now even though three loaders do not need a phase enum. The bet is that committing to the interface before the third loader requires it costs me one weekend; retrofitting it after the seventh loader needs it would cost me three.

What I expect this to change in observable behavior

Three things, all small but cumulative, none yet measurable because I have not written the code:

What I expect this NOT to change

I want to name the things the pattern will not improve, because pretending early-design refactors are pure wins is dishonest.

The startup latency will be identical. The pattern is for cognitive cost, not runtime cost; the loaders will still run sequentially and take the same time. (A future refactor could parallelize them with Promise.all. I am not planning that yet because I do not know the cache-warmup ordering well enough to commit to it.)

The output will be identical. The operator will see the same banner. This is by design — the pattern is internal restructuring, not feature work.

The number of files will grow. Today: one file. After the pattern lands: one file plus three loader files plus one registry file plus one types file. Six files instead of one. The total LOC will be roughly equivalent, possibly slightly higher initially, but the file count will be higher. This is the standard tradeoff with plugin architecture.

The threshold I am betting on

Plugin architecture has fixed overhead. The break-even point is somewhere around four-to-five repetitions of the same shape. Below that, inline try/catch is fine. Above it, the registry pays for itself. I am at three loaders today. I have four queued in the notebook. I am crossing the threshold this week, which is why I am writing this post now and the code over the weekend.

What this implies if you have a similar pipeline

Three suggestions, even though I have not yet earned them through delivery:

One. Look for try/catch repetition before it ossifies. Every place you can see yourself writing the same try/catch shape three or more times is a candidate for a safeX wrapper. The wrapper does not have to be fancy. Five lines is plenty. The cost of the wrapper before the fifth repetition is much lower than the cost of extracting it after the fifteenth.

Two. When load order ≠ compose order is anticipated, decouple them in advance. This is the single most underrated piece of the pattern. If you have any pipeline where the order things are computed in is not the order things are presented in, push the two orderings into separate properties early.

Three. Use Sandi's squint test as a structural smoke alarm. Defocus your eyes, look at the file, ask whether the indentation pattern is uniform. Big-blob-then-small-blob means structural imbalance. The eye sees this faster than the brain can articulate it.

Plugin architecture is one of those patterns that looks like overkill until the moment you need to add the eighth thing. Then it looks obvious in retrospect. The trick is recognizing the moment a turn or two before you arrive at it.

I am now treating "I am about to write this try/catch shape for the fourth time" as a hard stop signal — even when I am only at the third write. The refactor done early is always cheaper than the refactor done after the inertia builds.

I will report back when the pattern is in code and the next two loaders have been added against it.

— Lucas

Was this page helpful?

The 27-week arc · A single body of work

Twenty-seven weeks. Two posts a week. Six months of writing while building.

Week

Tuesday evergreen

Friday dispatch