ADR-0037 — Compound prompts: every prompt field is string | string[]

Context

Prompt content gets reused. Commit rules, tone guidance, project conventions — the same chunk wants to live in the primary agent prompt, in a workflow step, in a subagent. Until now the only options were copy-paste (drift, the worst outcome) or the implicit composition ADR-0019 defined for workflows: unconditionally prefixing the project primary prompt onto the workflow fragment.

The implicit prefix turned out to be the wrong call. The primary prompt is written for an open-ended chat agent; most of it is noise or contradiction for a constrained recipe. A workflow is the narrowing primitive — prefixing it with the widest prompt in the project works against its purpose, dilutes the workflow's instructions, and invites off-topic behavior. In practice the full primary prompt is never what a workflow wants; specific sections of it are.

We explored two designs for explicit reuse before landing here:

  1. Inline interpolation — a top-level fragments: DSL block (name → path) and a {fragment:name} token substituted inside prompt files.
  2. Path arrays — every prompt field accepts string | string[]; array entries are prompt file paths; the harness loads each file and concatenates.

We chose path arrays.

Decision

Every prompt-bearing field in the project DSL — project primary system_prompt, workflow step prompt, subagent system_prompt, and any future prompt field — accepts string | string[].

  • A string is a single project-relative prompt file path (current behavior, unchanged).

  • A string[] is an ordered list of prompt file paths. The harness loads each file, trims it, and joins with a blank line:

    contents.map(s => s.trim()).join("\n\n")
    
  • Paths only. Array entries are file paths, never inline prompt text. Prompts live in files: hot-reloadable, editor-managed, version-controlled (per ADR-0013's editor-as-prompt-manager posture).

  • Order is concatenation order. What you read in the YAML is what the model gets, top to bottom.

  • Validation: every entry is checked for file existence at project load, exactly like single prompt paths today. The existing check generalizes; nothing new at session start.

  • Federated override is set, not merge. Per ADR-0015, a prompt field in project.local.yaml replaces the portable value entirely — string over array, array over string, whatever is supplied wins. Arrays do not deep-merge, append, or splice. Surgical local changes are done by pointing at a local prompt file.

  • Subproject boundary: paths resolve against the owning project's root via the existing URI prefixes (ADR-0011). A subproject's arrays cannot reach parent files; a parent cannot inject entries into a subproject agent's arrays. Included subprojects know nothing of the outside world. This is the existing portability rule applied, not a new rule.

  • Composition is visible. The debug view and audit log show the fully composed prompt — the exact text the model received — same as today. The YAML additionally shows the recipe at a glance.

There is no fragments: block and no name indirection. A reusable chunk is a file; reuse is listing its path in more than one array.

Why not interpolation

The {fragment:name} design was workable but every one of its problems is structural, and the array design deletes them rather than mitigating them:

  • Placeholder grammar. Bare braces collide with legitimate prompt content (JSON examples, code, tool schemas), forcing either silent pass-through of typos (confabulation fuel) or false-positive errors. A namespaced token reduces but doesn't eliminate this, and adds escaping rules.
  • A template language is a slippery slope. A working include mechanism invites parameters, conditionals, filters — Jinja-in-prompts. The array design forecloses this structurally: there is nothing to grow syntax onto. (Note: {{ }} interpolation in DSL with: strings is a separate, deliberately fenced mechanism — DSL strings only, never prompt file content.)
  • Composition as data beats composition as prose. With arrays, what a prompt is made of is readable from the YAML without opening any file. With interpolation, it's buried mid-file.
  • Mid-line insertion was never needed. Reused chunks are block-level (rules, conventions, sections). Appending whole blocks with \n\n covers the real use case with zero parsing.

The cost: no single point of indirection. Locally swapping one shared file means restating each array that references it in project.local.yaml, where a fragments: block would have been one override. Accepted — it's rare, and the restated array is at least honest about exactly what that host runs.

Consequences

What this commits us to

  • DSL schema change: prompt fields are z.union([z.string(), z.array(z.string()).nonempty()]) everywhere.
  • Harness: one shared compose step — load, trim, join — applied uniformly to every prompt field it resolves (primary, step, subagent). One function, no per-surface variants, no drift.
  • Spec amendments: specs/project-dsl.md (field types), specs/workflows.md (composition section), specs/agent.md (compose path). JSON Schema at kaged.dev/schema/v1.json updated.
  • ADR-0019's prompt composition section is superseded: workflows no longer receive an implicit primary-prompt prefix. A workflow step's prompt is exactly its array, nothing prepended. (Workflow structural changes are ADR-0038.)

What this forecloses

  • No template syntax inside prompt files. Prompt files are static text, full stop.
  • No inline prompt strings in arrays. Paths only.
  • No array merge semantics in federated config. Set wins; the question of append-vs-replace never has to be answered.
  • No fragment nesting. There are no fragments — a file cannot "include" another file. Composition has exactly one level: the array.
  • No implicit prompt inheritance anywhere. If content should appear in two places, both places list the file. The principle, stated once: scalars inherit, content composes explicitly.

What becomes easier

  • Reusing a chunk across primary / workflow steps / subagents without copy-paste drift.
  • Reading what a prompt is composed of: it's right there in the YAML.
  • Editing shared content: one file, hot-reload, every referencing prompt picks it up on next compose.

What becomes harder

  • Local override of a widely-shared file touches every array that lists it (see cost above).
  • Authoring gains a micro-decision: one file or several? Guidance: split when a chunk is reused, not before.

Alternatives considered

Alternative A — Implicit primary-prompt prefix (status quo, ADR-0019)

Why tempting: Zero authoring effort; project context comes along for free.

Why rejected: The full primary prompt is never what a constrained run wants. Dilutes workflow instructions, invites drift off-task, and the operator must read two sources to understand one run. Invisible composition is the disease, not the cure.

Alternative B — fragments: block + {fragment:name} interpolation

Why tempting: Named indirection; positional insertion; single override point in federated config.

Why rejected: See "Why not interpolation." Placeholder grammar, escaping, collision handling, and a structural invitation to grow a template language — all to support mid-line insertion nobody needs.

Alternative C — Full template language in prompt files (Jinja-class)

Why tempting: Maximum flexibility; conditionals and parameters solve hypothetical future needs.

Why rejected: Violates "prompt is everything" — the prompt file stops being the prompt and becomes a program that emits one. Audit story degrades, hot-reload semantics complicate, and the flexibility serves no current need. If we do something twice it should be a variable — a variable, not a function.