|
| 1 | +# FR-004 Plan #1 — `prompt.*` metatype + loader + conformance fixtures (TS reference) |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or subagent-driven-development) to implement task-by-task. Steps use checkbox (`- [ ]`) syntax. TDD throughout. Execute in an **isolated worktree** (superpowers:using-git-worktrees), not the main checkout — FR-003 is in flight in a separate worktree. |
| 4 | +
|
| 5 | +**Goal:** Add the `prompt` base metatype (subtypes `template`, `fragment`) to the TypeScript reference loader, plus shared conformance fixtures, without breaking the green C# conformance suite. |
| 6 | + |
| 7 | +**Architecture:** `prompt` is a new base type modelled exactly on the recently-added `origin` (subtype→class dispatch, attr-only children). It gets its own concern folder `src/prompt/`. The loader is data-driven from constants + a registration loop in `core-types.ts`; the canonical serializer needs no changes. Shared `prompt-*` fixtures are added to `fixtures/conformance/`; because C#/Java don't support `prompt.*` yet, the fixtures are registered in C#'s `conformance-expected-failures.json` allowlist so its suite stays green. (Java's conformance harness is not yet in `main` — it's the in-flight H3b worktree work — so no Java gating is needed here; coordinate when H3b lands.) |
| 8 | + |
| 9 | +**Scope boundary (deferred to later plans):** payload-as-projection (Layer 1, needs FR-003 §5), the render engine + section-format grammar + filesystem/RDB providers + golden harness (Plan #2), and porting `prompt.*` to the C#/Java loaders. This plan is the metatype vocabulary + loader recognition + validation + fixtures only. |
| 10 | + |
| 11 | +**Tech Stack:** TypeScript (ESM), Bun test runner. Run tests from `server/typescript/packages/metadata/`. |
| 12 | + |
| 13 | +**Reference files (read before starting):** |
| 14 | +- Base types: `server/typescript/packages/metadata/src/shared/base-types.ts` (`SUBTYPE_BASE`, `BASE_TYPES`) |
| 15 | +- Mirror pattern (origin): `src/persistence/origin/{origin-constants.ts,meta-origin.ts,origin-schema.ts}` |
| 16 | +- Registration: `src/core-types.ts` (`def()` @105, `wildcard()` @97, origin block @262-271) |
| 17 | +- Attr schema type: `src/registry.ts` (`AttrSchema` @32); attr value-type constants: `src/core/attr/attr-constants.ts` |
| 18 | +- Node base: `src/shared/meta-data.ts` (`ownAttr(name)` @231) |
| 19 | +- Exports: `src/index.ts` |
| 20 | +- Conformance: `fixtures/conformance/` (mirror `origin-passthrough-simple/`, `error-origin-bad-aggregate-fn/`), runner `server/typescript/packages/metadata/test/conformance.test.ts`, spec `spec/conformance-tests.md` |
| 21 | +- C# gate: `server/csharp/MetaObjects.Conformance.Tests/conformance-expected-failures.json` |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## Task 1: `prompt.*` recognized, validated, and accessor-backed in the TS loader |
| 26 | + |
| 27 | +**Files:** |
| 28 | +- Create: `server/typescript/packages/metadata/src/prompt/prompt-constants.ts` |
| 29 | +- Create: `server/typescript/packages/metadata/src/prompt/meta-prompt.ts` |
| 30 | +- Create: `server/typescript/packages/metadata/src/prompt/prompt-schema.ts` |
| 31 | +- Modify: `server/typescript/packages/metadata/src/shared/base-types.ts` |
| 32 | +- Modify: `server/typescript/packages/metadata/src/core-types.ts` |
| 33 | +- Modify: `server/typescript/packages/metadata/src/index.ts` |
| 34 | +- Test: `server/typescript/packages/metadata/test/prompt.test.ts` |
| 35 | + |
| 36 | +- [ ] **Step 1: Write the failing test.** Create `test/prompt.test.ts`. (Mirror an existing loader test for `loadFromString`/`loadFromDirectory` usage — check `test/` for the exact loader entrypoint and adapt; the assertions below are the contract.) |
| 37 | + |
| 38 | +```ts |
| 39 | +import { describe, it, expect } from "bun:test"; |
| 40 | +import { Loader } from "../src/index.js"; // adjust to the test helper other tests use |
| 41 | +import { TYPE_PROMPT } from "../src/shared/base-types.js"; |
| 42 | +import { PROMPT_SUBTYPE_TEMPLATE, PROMPT_SUBTYPE_FRAGMENT } from "../src/prompt/prompt-constants.js"; |
| 43 | + |
| 44 | +const good = { |
| 45 | + "metadata.root": { |
| 46 | + package: "acme::ai", |
| 47 | + children: [ |
| 48 | + { "object.entity": { name: "Npc", children: [ |
| 49 | + { "field.string": { name: "name" } }, |
| 50 | + { "identity.primary": { "@fields": "name" } }, |
| 51 | + ] } }, |
| 52 | + { "prompt.fragment": { name: "combatRules", "@textRef": "rules/combat#core" } }, |
| 53 | + { "prompt.template": { name: "npcTurn", |
| 54 | + "@payloadRef": "NpcPromptPayload", "@textRef": "npc/turn#main", |
| 55 | + "@outputFormat": "xml" } }, |
| 56 | + ], |
| 57 | + }, |
| 58 | +}; |
| 59 | + |
| 60 | +describe("prompt metatype", () => { |
| 61 | + it("registers prompt as a base type", () => { |
| 62 | + expect(TYPE_PROMPT).toBe("prompt"); |
| 63 | + }); |
| 64 | + |
| 65 | + it("loads prompt.template and prompt.fragment with no errors", () => { |
| 66 | + const res = Loader().loadFromString(JSON.stringify(good)); // adapt to actual API |
| 67 | + expect(res.errors).toEqual([]); |
| 68 | + const tmpl = res.root.find(PROMPT_SUBTYPE_TEMPLATE, "npcTurn"); // adapt finder to actual API |
| 69 | + expect(tmpl?.payloadRef).toBe("NpcPromptPayload"); |
| 70 | + expect(tmpl?.textRef).toBe("npc/turn#main"); |
| 71 | + expect(tmpl?.outputFormat).toBe("xml"); |
| 72 | + const frag = res.root.find(PROMPT_SUBTYPE_FRAGMENT, "combatRules"); |
| 73 | + expect(frag?.textRef).toBe("rules/combat#core"); |
| 74 | + }); |
| 75 | + |
| 76 | + it("errors when prompt.template omits required @payloadRef", () => { |
| 77 | + const bad = structuredClone(good); |
| 78 | + delete (bad["metadata.root"].children[2] as any)["prompt.template"]["@payloadRef"]; |
| 79 | + const res = Loader().loadFromString(JSON.stringify(bad)); |
| 80 | + expect(res.errors.length).toBeGreaterThan(0); |
| 81 | + }); |
| 82 | +}); |
| 83 | +``` |
| 84 | + |
| 85 | +- [ ] **Step 2: Run it; confirm it fails.** Run: `cd server/typescript/packages/metadata && bun test test/prompt.test.ts`. Expected: FAIL — `TYPE_PROMPT` is not exported / loader reports unknown type `prompt.template`. |
| 86 | + |
| 87 | +- [ ] **Step 3: Add the base type.** In `src/shared/base-types.ts`, add `export const TYPE_PROMPT = "prompt";` next to `TYPE_ORIGIN`, and append `TYPE_PROMPT` to the `BASE_TYPES` array. |
| 88 | + |
| 89 | +- [ ] **Step 4: Create `src/prompt/prompt-constants.ts`.** |
| 90 | + |
| 91 | +```ts |
| 92 | +import { SUBTYPE_BASE } from "../shared/base-types.js"; |
| 93 | + |
| 94 | +export const PROMPT_SUBTYPE_TEMPLATE = "template"; |
| 95 | +export const PROMPT_SUBTYPE_FRAGMENT = "fragment"; |
| 96 | + |
| 97 | +export const PROMPT_SUBTYPES = [ |
| 98 | + SUBTYPE_BASE, |
| 99 | + PROMPT_SUBTYPE_TEMPLATE, |
| 100 | + PROMPT_SUBTYPE_FRAGMENT, |
| 101 | +] as const; |
| 102 | +export type PromptSubType = (typeof PROMPT_SUBTYPES)[number]; |
| 103 | + |
| 104 | +// Reserved @-attr names (no '@' in the constant; prefix is applied at wire time). |
| 105 | +export const PROMPT_ATTR_PAYLOAD_REF = "payloadRef"; |
| 106 | +export const PROMPT_ATTR_TEXT_REF = "textRef"; |
| 107 | +export const PROMPT_ATTR_OUTPUT_FORMAT = "outputFormat"; |
| 108 | +export const PROMPT_ATTR_REQUIRED_SLOTS = "requiredSlots"; |
| 109 | +export const PROMPT_ATTR_MAX_CHARS = "maxChars"; |
| 110 | +export const PROMPT_ATTR_MAX_TOKENS = "maxTokens"; |
| 111 | +export const PROMPT_ATTR_OWNER = "owner"; |
| 112 | +export const PROMPT_ATTR_SINCE = "since"; |
| 113 | +``` |
| 114 | + |
| 115 | +- [ ] **Step 5: Create `src/prompt/meta-prompt.ts`** (mirror `meta-origin.ts` — base + subtype classes, accessors via `ownAttr`). |
| 116 | + |
| 117 | +```ts |
| 118 | +import { MetaData } from "../shared/meta-data.js"; |
| 119 | +import { |
| 120 | + PROMPT_ATTR_PAYLOAD_REF, PROMPT_ATTR_TEXT_REF, PROMPT_ATTR_OUTPUT_FORMAT, |
| 121 | + PROMPT_ATTR_REQUIRED_SLOTS, PROMPT_ATTR_MAX_CHARS, PROMPT_ATTR_MAX_TOKENS, |
| 122 | +} from "./prompt-constants.js"; |
| 123 | + |
| 124 | +const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); |
| 125 | +const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined); |
| 126 | + |
| 127 | +export class MetaPrompt extends MetaData { |
| 128 | + get textRef(): string | undefined { return str(this.ownAttr(PROMPT_ATTR_TEXT_REF)); } |
| 129 | +} |
| 130 | + |
| 131 | +export class MetaPromptTemplate extends MetaPrompt { |
| 132 | + get payloadRef(): string | undefined { return str(this.ownAttr(PROMPT_ATTR_PAYLOAD_REF)); } |
| 133 | + get outputFormat(): string | undefined { return str(this.ownAttr(PROMPT_ATTR_OUTPUT_FORMAT)); } |
| 134 | + get requiredSlots(): unknown { return this.ownAttr(PROMPT_ATTR_REQUIRED_SLOTS); } |
| 135 | + get maxChars(): number | undefined { return num(this.ownAttr(PROMPT_ATTR_MAX_CHARS)); } |
| 136 | + get maxTokens(): number | undefined { return num(this.ownAttr(PROMPT_ATTR_MAX_TOKENS)); } |
| 137 | +} |
| 138 | + |
| 139 | +export class MetaPromptFragment extends MetaPrompt {} |
| 140 | +``` |
| 141 | + |
| 142 | +- [ ] **Step 6: Create `src/prompt/prompt-schema.ts`** (mirror `origin-schema.ts`; `required: true` is what drives the missing-attr error). Confirm value-type constant names in `src/core/attr/attr-constants.ts` (use the string one and the int one; if a string-array subtype exists, use it for `requiredSlots`, else `ATTR_SUBTYPE_STRING`). |
| 143 | + |
| 144 | +```ts |
| 145 | +import type { AttrSchema } from "../registry.js"; |
| 146 | +import { ATTR_SUBTYPE_STRING, ATTR_SUBTYPE_INT } from "../core/attr/attr-constants.js"; |
| 147 | +import { SUBTYPE_BASE } from "../shared/base-types.js"; |
| 148 | +import { |
| 149 | + PROMPT_SUBTYPE_TEMPLATE, PROMPT_SUBTYPE_FRAGMENT, |
| 150 | + PROMPT_ATTR_PAYLOAD_REF, PROMPT_ATTR_TEXT_REF, PROMPT_ATTR_OUTPUT_FORMAT, |
| 151 | + PROMPT_ATTR_REQUIRED_SLOTS, PROMPT_ATTR_MAX_CHARS, PROMPT_ATTR_MAX_TOKENS, |
| 152 | + PROMPT_ATTR_OWNER, PROMPT_ATTR_SINCE, |
| 153 | +} from "./prompt-constants.js"; |
| 154 | + |
| 155 | +const templateAttrs: AttrSchema[] = [ |
| 156 | + { name: PROMPT_ATTR_PAYLOAD_REF, valueType: ATTR_SUBTYPE_STRING, required: true, |
| 157 | + description: "The object.value projection this template renders against." }, |
| 158 | + { name: PROMPT_ATTR_TEXT_REF, valueType: ATTR_SUBTYPE_STRING, required: true, |
| 159 | + description: "Logical reference to the template body text." }, |
| 160 | + { name: PROMPT_ATTR_OUTPUT_FORMAT, valueType: ATTR_SUBTYPE_STRING, required: false, |
| 161 | + description: "Expected output format, e.g. xml | json | text." }, |
| 162 | + { name: PROMPT_ATTR_REQUIRED_SLOTS, valueType: ATTR_SUBTYPE_STRING, required: false, |
| 163 | + description: "Slots that must resolve at render time (drives verify)." }, |
| 164 | + { name: PROMPT_ATTR_MAX_CHARS, valueType: ATTR_SUBTYPE_INT, required: false, |
| 165 | + description: "Size budget in characters." }, |
| 166 | + { name: PROMPT_ATTR_MAX_TOKENS, valueType: ATTR_SUBTYPE_INT, required: false, |
| 167 | + description: "Size budget in tokens." }, |
| 168 | + { name: PROMPT_ATTR_OWNER, valueType: ATTR_SUBTYPE_STRING, required: false, description: "Governance: owner." }, |
| 169 | + { name: PROMPT_ATTR_SINCE, valueType: ATTR_SUBTYPE_STRING, required: false, description: "Governance: since version." }, |
| 170 | +]; |
| 171 | + |
| 172 | +const fragmentAttrs: AttrSchema[] = [ |
| 173 | + { name: PROMPT_ATTR_TEXT_REF, valueType: ATTR_SUBTYPE_STRING, required: true, |
| 174 | + description: "Logical reference to the fragment body text." }, |
| 175 | + { name: PROMPT_ATTR_OWNER, valueType: ATTR_SUBTYPE_STRING, required: false, description: "Governance: owner." }, |
| 176 | + { name: PROMPT_ATTR_SINCE, valueType: ATTR_SUBTYPE_STRING, required: false, description: "Governance: since version." }, |
| 177 | +]; |
| 178 | + |
| 179 | +export const PROMPT_ATTRS_MAP = new Map<string, AttrSchema[]>([ |
| 180 | + [SUBTYPE_BASE, []], |
| 181 | + [PROMPT_SUBTYPE_TEMPLATE, [...templateAttrs]], |
| 182 | + [PROMPT_SUBTYPE_FRAGMENT, [...fragmentAttrs]], |
| 183 | +]); |
| 184 | +``` |
| 185 | + |
| 186 | +- [ ] **Step 7: Wire registration in `src/core-types.ts`** (mirror the origin block @262-271). |
| 187 | + - Add to base-type imports: `TYPE_PROMPT`. |
| 188 | + - Add imports: `import { MetaPrompt, MetaPromptTemplate, MetaPromptFragment } from "./prompt/meta-prompt.js";`, `import { PROMPT_SUBTYPES, PROMPT_SUBTYPE_TEMPLATE, PROMPT_SUBTYPE_FRAGMENT } from "./prompt/prompt-constants.js";`, `import { PROMPT_ATTRS_MAP } from "./prompt/prompt-schema.js";` |
| 189 | + - Near the `ORIGIN_CLASS_MAP` definition add: |
| 190 | + ```ts |
| 191 | + const PROMPT_CLASS_MAP = new Map([ |
| 192 | + [PROMPT_SUBTYPE_TEMPLATE, MetaPromptTemplate], |
| 193 | + [PROMPT_SUBTYPE_FRAGMENT, MetaPromptFragment], |
| 194 | + ]); |
| 195 | + ``` |
| 196 | + - After the origin registration loop add: |
| 197 | + ```ts |
| 198 | + // prompt — LLM prompt construction (template, fragment). Only attr children. |
| 199 | + // Subtype→class dispatch: template → MetaPromptTemplate, fragment → MetaPromptFragment, |
| 200 | + // base (and any unmapped subtype) → MetaPrompt. |
| 201 | + for (const subType of PROMPT_SUBTYPES) { |
| 202 | + const NodeClass = PROMPT_CLASS_MAP.get(subType) ?? MetaPrompt; |
| 203 | + const promptAttrs = PROMPT_ATTRS_MAP.get(subType) ?? []; |
| 204 | + registry.register( |
| 205 | + def(TYPE_PROMPT, subType, `Prompt (${subType})`, [wildcard(TYPE_ATTR)], NodeClass, promptAttrs), |
| 206 | + ); |
| 207 | + } |
| 208 | + ``` |
| 209 | + |
| 210 | +- [ ] **Step 8: Export from `src/index.ts`** (mirror origin lines): `export * from "./prompt/prompt-constants.js";` and `export { MetaPrompt, MetaPromptTemplate, MetaPromptFragment } from "./prompt/meta-prompt.js";` (plus the `import type` line if the file maintains a type-only import block like origin's). |
| 211 | + |
| 212 | +- [ ] **Step 9: Run the test; confirm it passes.** Run: `cd server/typescript/packages/metadata && bun test test/prompt.test.ts`. Expected: PASS (all three cases). If the finder/loader API names in Step 1 don't match the real API, fix the test to use the real entrypoints (do NOT weaken the assertions). |
| 213 | + |
| 214 | +- [ ] **Step 10: Run the whole metadata package + typecheck.** Run: `cd server/typescript/packages/metadata && bun test && bunx tsc --noEmit` (or the package's typecheck script). Expected: all green — no regressions from adding the base type. |
| 215 | + |
| 216 | +- [ ] **Step 11: Commit.** |
| 217 | + |
| 218 | +```bash |
| 219 | +git add server/typescript/packages/metadata/src/prompt server/typescript/packages/metadata/src/shared/base-types.ts server/typescript/packages/metadata/src/core-types.ts server/typescript/packages/metadata/src/index.ts server/typescript/packages/metadata/test/prompt.test.ts |
| 220 | +git commit -m "feat(metadata): add prompt.* base metatype (template, fragment) [FR-004]" |
| 221 | +``` |
| 222 | + |
| 223 | +--- |
| 224 | + |
| 225 | +## Task 2: Shared conformance fixtures + C# expected-failures gate |
| 226 | + |
| 227 | +**Files:** |
| 228 | +- Create: `fixtures/conformance/prompt-template-simple/{input/meta.ai.json,expected.json}` |
| 229 | +- Create: `fixtures/conformance/prompt-fragment-and-template/{input/meta.ai.json,expected.json}` |
| 230 | +- Create: `fixtures/conformance/error-prompt-template-missing-payload-ref/{input/meta.ai.json,expected-errors.json}` |
| 231 | +- Modify: `server/csharp/MetaObjects.Conformance.Tests/conformance-expected-failures.json` |
| 232 | + |
| 233 | +- [ ] **Step 1: Author the happy-path fixture inputs.** Create `prompt-template-simple/input/meta.ai.json` with a `metadata.root` (package `acme::ai`) containing an `object.entity` "Npc" (one `field.string` "name" + `identity.primary` `@fields: "name"`) and a `prompt.template` "npcTurn" with `@payloadRef`, `@textRef`, `@outputFormat: "xml"`. Create `prompt-fragment-and-template/input/meta.ai.json` adding a `prompt.fragment` "combatRules" (`@textRef`) alongside the template. (Match the JSON shape of `origin-passthrough-simple/input/`.) |
| 234 | + |
| 235 | +- [ ] **Step 2: Generate `expected.json` from actual canonical output.** Per `spec/conformance-tests.md` §"Adding a new fixture": run the TS conformance test for each new fixture, which fails with an actual-vs-expected diff; set `expected.json` to the actual canonical output (fused-key form; `@fields` normalized to array; `@`-attrs alphabetical; 2-space indent; trailing newline). |
| 236 | + Run: `cd server/typescript/packages/metadata && bun test test/conformance.test.ts -t "prompt-template-simple"` then `... -t "prompt-fragment-and-template"`. Paste the actual canonical output into each `expected.json`. Re-run: both PASS. |
| 237 | + |
| 238 | +- [ ] **Step 3: Author the error fixture.** Create `error-prompt-template-missing-payload-ref/input/meta.ai.json` (a `prompt.template` missing `@payloadRef`). For `expected-errors.json`, use the array-of-`{code}` format (confirmed from `error-origin-bad-aggregate-fn/expected-errors.json`). Determine the exact code emitted for a missing required attr by reading `src/attr-schema-validate.ts` (the required-attr check) — set `expected-errors.json` to `[{ "code": "<that code>" }]`. |
| 239 | + Run: `cd server/typescript/packages/metadata && bun test test/conformance.test.ts -t "error-prompt-template-missing-payload-ref"`. Expected: PASS. If the emitted code differs, set the fixture to the actual emitted code. |
| 240 | + |
| 241 | +- [ ] **Step 4: Run the full TS conformance suite.** Run: `cd server/typescript/packages/metadata && bun test test/conformance.test.ts`. Expected: all fixtures green, including the three new `prompt-*`. |
| 242 | + |
| 243 | +- [ ] **Step 5: Gate C# so its suite stays green.** Add the three fixture directory names to the `fixtures` array in `server/csharp/MetaObjects.Conformance.Tests/conformance-expected-failures.json`: |
| 244 | + |
| 245 | +```json |
| 246 | +{ |
| 247 | + "language": "csharp", |
| 248 | + "fixtures": [ |
| 249 | + "prompt-template-simple", |
| 250 | + "prompt-fragment-and-template", |
| 251 | + "error-prompt-template-missing-payload-ref" |
| 252 | + ] |
| 253 | +} |
| 254 | +``` |
| 255 | + |
| 256 | +- [ ] **Step 6: Verify C# stays green.** Run: `cd server/csharp && dotnet test 2>&1 | tail -20`. Expected: pass. The C# loader will not recognize `prompt.*`; the expected-failures allowlist absorbs those three. **If a `prompt.*` fixture throws inside C# rather than being caught as a known failure** (e.g. unknown nested type throws instead of collecting an error), stop and report — the allowlist may key on collected-error fixtures only, and the C# parser's unknown-nested-type behavior needs confirming before this gating approach is final. |
| 257 | + |
| 258 | +- [ ] **Step 7: Commit.** |
| 259 | + |
| 260 | +```bash |
| 261 | +git add fixtures/conformance/prompt-template-simple fixtures/conformance/prompt-fragment-and-template fixtures/conformance/error-prompt-template-missing-payload-ref server/csharp/MetaObjects.Conformance.Tests/conformance-expected-failures.json |
| 262 | +git commit -m "test(conformance): add prompt.* fixtures + gate C# expected-failures [FR-004]" |
| 263 | +``` |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +## Self-review notes |
| 268 | + |
| 269 | +- **Spec coverage:** FR-004 §2 (prompt.template/fragment + reserved attrs) → Task 1. FR-004 §9 (loader/serializer conformance for prompt vocabulary) → Task 2. Deferred: §1 payload-as-projection, §3–§6 (addressing/render/variants/verify beyond required-attr), §7 codegen — Plan #2 and FR-003-dependent follow-ups, explicitly out of scope here. |
| 270 | +- **No-break guarantee:** Task 2 Step 5–6 keep C# green; Java has no `main` harness yet (coordinate at H3b). The serializer is data-driven (no change). The canonical serializer §key-order is unaffected — `prompt.*` nodes only carry `name` + `@`-attrs + (no children beyond attrs). |
| 271 | +- **Open risk flagged inline:** C# unknown-nested-type behavior (Task 2 Step 6) — verified at runtime, with a stop-and-report fallback rather than a guess. |
| 272 | +- **`@requiredSlots` typing:** left as `ATTR_SUBTYPE_STRING` pending the §1/§6 design (array vs csv); accessor returns raw `unknown`. Revisit in Plan #2. |
0 commit comments