Skip to content

feat: add per-layer subcommand helpers (tool.l1/l2/l3Subcommand) #41

Description

@beogip

Problem & Motivation

Today a tool is built with tool.subcommand(...) passing output and handler as separate fields (e.g. output: l3Output({...}) plus a separate handler). The handler is already typed HandlerReturn<O> and Tool.validateOutput() rejects a non-conforming return at runtime, so 'returning an object that does not conform' is already covered. The real gap is twofold: (1) output and handler are separate fields, so an l3Output(...) can be paired with a handler written for another layer, and the type only ties them once both are inside the same spec; (2) you must pick and type output by hand every time, which opens the door to choosing the wrong layer and adds repeated boilerplate. Goal: make building (and maintaining) a subcommand fix the layer from the way it is constructed, with no way to pick the wrong layer or repeat the output.

Scope & Decomposition

Add a per-layer helper as a chainable instance method, mirroring tool.subcommand: tool.l1Subcommand / tool.l2Subcommand / tool.l3Subcommand. Each fixes the layer output internally and forces, by types, that the handler returns the layer fields (does not compile if missing). The signature is NOT uniform across layers because each lXOutput needs different runtime inputs (the Zod schema must exist to validate; it cannot be inferred from the handler type): L1 takes a required fields; L2 takes a required classification enum plus optional fields; L3 takes optional fields (instructions is fixed). Each call is equivalent to tool.subcommand({ ..., output: lXOutput(...), handler }). Additive: tool.subcommand and the lXOutput helpers keep working as today. Binding and naming are decided: instance method, camelCase, chainable.

Acceptance Criteria

  • The system exposes one method per layer (tool.l1Subcommand / tool.l2Subcommand / tool.l3Subcommand). Each receives name/description/input/handler plus the layer-specific inputs (L1: fields; L2: classification + optional fields; L3: optional fields) and registers a subcommand equivalent to tool.subcommand with output fixed to the corresponding lXOutput(...).
  • The helper handler signature forces, by types, returning the layer fields (e.g. instructions in L3); it does not compile if missing. Verified with a type-level test (a @ts-expect-error fixture: an L3 handler without instructions must break compilation).
  • The output schema registered by tool.lXSubcommand(args) is equivalent to the one produced by lXOutput(...) with the same inputs (runtime-verifiable by comparing registered schemas).
  • tool.subcommand and the lXOutput helpers keep working as today (non-breaking; the helper is additive).
  • Editing the handler of a subcommand created via the helper keeps enforcing the layer type (same type-level test as above).

Risks & Constraints

Low risk: additive, does not break the current API. Main risk: duplicated API surface (two ways to build a subcommand). Mitigation: document the helper as the recommended path and raw tool.subcommand as the low-level one.
[inferred] The L2/L3 lXOutput overloads must keep producing the same base shape whether or not optional fields are passed, otherwise the schema-equivalence check is ambiguous.

Edge Cases

l1Output requires fields (no no-arg overload) and l2Output requires the classification enum, so a uniform {name, description, input, handler} signature cannot build L1 or L2 outputs; the per-layer signature must carry those inputs.
[inferred] Argless subcommands at L1 still need an explicit fields shape (even {}); confirm l1Subcommand accepts an empty fields object.

Out of Scope

  • Error model / ok: false (already exists in src/envelopes.ts).
  • Adapter to external SDKs (eve/claude): separate issue.
  • Package rename: parked.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions