From a5ac822de45473e2cb1fc691b40a282c626a10e6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 00:19:57 -0700 Subject: [PATCH] feat(core): trait registry, 14 built-in traits, composition validator (U2) --- .../core/src/__tests__/builtin-traits.test.ts | 102 +++++ .../core/src/__tests__/trait-registry.test.ts | 222 +++++++++++ packages/core/src/builtin-traits.ts | 257 ++++++++++++ packages/core/src/trait-registry.ts | 375 ++++++++++++++++++ packages/core/src/trait-types.ts | 125 ++++++ 5 files changed, 1081 insertions(+) create mode 100644 packages/core/src/__tests__/builtin-traits.test.ts create mode 100644 packages/core/src/__tests__/trait-registry.test.ts create mode 100644 packages/core/src/builtin-traits.ts create mode 100644 packages/core/src/trait-registry.ts create mode 100644 packages/core/src/trait-types.ts diff --git a/packages/core/src/__tests__/builtin-traits.test.ts b/packages/core/src/__tests__/builtin-traits.test.ts new file mode 100644 index 0000000000..2a819656d8 --- /dev/null +++ b/packages/core/src/__tests__/builtin-traits.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + BUILTIN_TRAIT_DEFINITIONS, + BUILTIN_TRAIT_IDS, + registerBuiltinTraits, +} from "../builtin-traits.js"; +import { TraitRegistry } from "../trait-registry.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import type { WorkflowIrV2 } from "../workflow-ir-types.js"; + +function freshRegistry(): TraitRegistry { + const r = new TraitRegistry(); + registerBuiltinTraits(r); + return r; +} + +describe("built-in traits", () => { + it("ships exactly the 14 vocabulary traits", () => { + expect(BUILTIN_TRAIT_IDS).toHaveLength(14); + expect(BUILTIN_TRAIT_DEFINITIONS.map((d) => d.id).sort()).toEqual([...BUILTIN_TRAIT_IDS].sort()); + }); + + it("all built-ins are flagged builtin: true and register cleanly", () => { + const r = freshRegistry(); + for (const id of BUILTIN_TRAIT_IDS) { + const def = r.getTrait(id); + expect(def, `missing built-in trait ${id}`).toBeDefined(); + expect(def?.builtin).toBe(true); + } + expect(r.listTraits()).toHaveLength(14); + }); + + it("only built-in traits carry restricted capabilities", () => { + const r = freshRegistry(); + expect(r.getTrait("complete")?.flags.complete).toBe(true); + expect(r.getTrait("archived")?.flags.archived).toBe(true); + // Sync guards live only on built-ins (merge-blocker, human-review). + expect(r.getTrait("merge-blocker")?.hooks?.guard).toBe(true); + expect(r.getTrait("human-review")?.hooks?.guard).toBe(true); + // The plugin-facing gate trait uses the async gate hook, not a sync guard. + expect(r.getTrait("gate")?.hooks?.guard).toBeUndefined(); + expect(r.getTrait("gate")?.hooks?.gate).toBe(true); + }); + + it("merge trait ships a config STUB shape (behavior is U7)", () => { + const r = freshRegistry(); + const keys = (r.getTrait("merge")?.configSchema?.fields ?? []).map((f) => f.key).sort(); + expect(keys).toEqual(["conflictStrategy", "fileScope", "squash", "strategy"]); + expect(r.getTrait("merge")?.flags.mergeOrchestration).toBe(true); + }); + + it("hold trait's release config matches WorkflowHoldRelease kinds", () => { + const r = freshRegistry(); + const release = r.getTrait("hold")?.configSchema?.fields.find((f) => f.key === "release"); + expect(release?.enumValues).toEqual([ + "manual", + "timer", + "capacity", + "dependency", + "external-event", + ]); + }); + + it("registering built-ins twice into the same registry is idempotent", () => { + const r = freshRegistry(); + expect(() => registerBuiltinTraits(r)).not.toThrow(); + expect(r.listTraits()).toHaveLength(14); + }); +}); + +describe("default workflow columns validate cleanly", () => { + it("BUILTIN_CODING_WORKFLOW_IR columns pass the composition validator", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const violations = r.validateColumnTraits(ir.columns, "save"); + expect(violations).toEqual([]); + }); + + it("the default workflow has exactly one intake column (triage)", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const intakeCols = ir.columns.filter((c) => r.resolveColumnFlags(c).intake); + expect(intakeCols.map((c) => c.id)).toEqual(["triage"]); + }); + + it("the default workflow's done column resolves the complete flag", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const done = ir.columns.find((c) => c.id === "done")!; + expect(r.resolveColumnFlags(done).complete).toBe(true); + }); + + it("the default workflow's in-progress column resolves wip+abort+timing flags", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const inProgress = ir.columns.find((c) => c.id === "in-progress")!; + const flags = r.resolveColumnFlags(inProgress); + expect(flags.countsTowardWip).toBe(true); + expect(flags.abortOnExit).toBe(true); + expect(flags.timing).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/trait-registry.test.ts b/packages/core/src/__tests__/trait-registry.test.ts new file mode 100644 index 0000000000..ce36ccd15e --- /dev/null +++ b/packages/core/src/__tests__/trait-registry.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + TraitRegistry, + TraitRegistrationError, +} from "../trait-registry.js"; +import type { TraitDefinition } from "../trait-types.js"; +import type { WorkflowIrColumn } from "../workflow-ir-types.js"; + +function col(id: string, traits: string[]): WorkflowIrColumn { + return { id, name: id, traits: traits.map((t) => ({ trait: t })) }; +} + +function builtin(id: string, def: Partial): TraitDefinition { + return { id, name: id, builtin: true, flags: {}, ...def }; +} + +function plugin(id: string, def: Partial): TraitDefinition { + return { id, name: id, flags: {}, ...def }; +} + +/** A registry seeded with a representative built-in set used across tests. */ +function seeded(): TraitRegistry { + const r = new TraitRegistry(); + r.register(builtin("intake", { flags: { intake: true } })); + r.register(builtin("complete", { flags: { complete: true } })); + r.register(builtin("archived", { flags: { archived: true, hiddenFromBoard: true } })); + r.register(builtin("wip", { flags: { countsTowardWip: true } })); + r.register(builtin("wip2", { flags: { countsTowardWip: true } })); + r.register(builtin("merge-blocker", { flags: { mergeBlocker: true }, hooks: { guard: true } })); + r.register(builtin("timing", { flags: { timing: true }, hooks: { onEnter: true, onExit: true } })); + return r; +} + +describe("TraitRegistry — registration", () => { + it("rejects a duplicate trait id", () => { + const r = new TraitRegistry(); + r.register(builtin("intake", { flags: { intake: true } })); + expect(() => r.register(builtin("intake", { flags: { intake: true } }))).toThrowError( + TraitRegistrationError, + ); + try { + r.register(builtin("intake", { flags: { intake: true } })); + } catch (err) { + expect((err as TraitRegistrationError).reason).toBe("duplicate-id"); + } + }); + + it("blocks a non-builtin from overriding a built-in namespace id", () => { + const r = new TraitRegistry(); + r.register(builtin("complete", { flags: { complete: true } })); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("complete", { flags: {} })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught).toBeInstanceOf(TraitRegistrationError); + expect(caught?.reason).toBe("builtin-namespace-protected"); + }); + + it("rejects a non-builtin declaring the restricted `complete` flag", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:done", { flags: { complete: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-flag"); + }); + + it("rejects a non-builtin declaring the restricted `archived` flag", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:arch", { flags: { archived: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-flag"); + }); + + it("rejects a non-builtin declaring a sync `guard` hook (built-in only)", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:guard", { flags: {}, hooks: { guard: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-guard-hook"); + }); + + it("allows a non-builtin declaring async-only hooks", () => { + const r = new TraitRegistry(); + expect(() => + r.register( + plugin("my-plugin:gate", { + flags: { gate: true }, + hooks: { gate: true, onEnter: true, onExit: true, releaseCondition: true }, + }), + ), + ).not.toThrow(); + }); +}); + +describe("TraitRegistry — flag resolution", () => { + it("merges effective flags across a column's traits (OR)", () => { + const r = seeded(); + const flags = r.resolveColumnFlags(col("in-progress", ["wip", "timing"])); + expect(flags.countsTowardWip).toBe(true); + expect(flags.timing).toBe(true); + expect(flags.complete).toBeUndefined(); + }); + + it("ignores unknown trait ids in flag resolution", () => { + const r = seeded(); + const flags = r.resolveColumnFlags(col("x", ["wip", "nope"])); + expect(flags.countsTowardWip).toBe(true); + }); +}); + +describe("TraitRegistry — composition validator", () => { + it("rejects complete + countsTowardWip with its reason code", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["complete", "wip"])]); + expect(v.find((x) => x.code === "complete-with-wip")?.severity).toBe("error"); + }); + + it("rejects two capacity (wip) traits on one column", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["wip", "wip2"])]); + const hit = v.find((x) => x.code === "two-capacity-traits"); + expect(hit?.severity).toBe("error"); + expect(hit?.traitIds.sort()).toEqual(["wip", "wip2"]); + }); + + it("rejects complete + intake", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["complete", "intake"])]); + expect(v.find((x) => x.code === "complete-with-intake")?.severity).toBe("error"); + }); + + it("rejects archived + countsTowardWip", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["archived", "wip"])]); + expect(v.find((x) => x.code === "archived-with-wip")?.severity).toBe("error"); + }); + + it("rejects more than one intake column per workflow", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("a", ["intake"]), col("b", ["intake"])]); + expect(v.find((x) => x.code === "multiple-intake-columns")?.severity).toBe("error"); + }); + + it("a valid single-intake / clean column set passes", () => { + const r = seeded(); + const v = r.validateColumnTraits([ + col("triage", ["intake"]), + col("in-progress", ["wip", "timing"]), + col("done", ["complete"]), + ]); + expect(v).toEqual([]); + }); + + it("conflicting boolean flags reject at save (validator), not at runtime", () => { + const r = seeded(); + // The conflict is surfaced by the validator (save-time), not on flag merge. + const flags = r.resolveColumnFlags(col("c", ["complete", "wip"])); + expect(flags.complete && flags.countsTowardWip).toBe(true); // merge does not throw + const v = r.validateColumnTraits([col("c", ["complete", "wip"])]); + expect(v.some((x) => x.code === "complete-with-wip" && x.severity === "error")).toBe(true); + }); + + it("unknown trait is a save-blocking error in save mode", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "save"); + const hit = v.find((x) => x.code === "unknown-trait"); + expect(hit?.severity).toBe("error"); + }); + + it("load-time re-validation degrades unknown trait to advisory, not error", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "load"); + const hit = v.find((x) => x.code === "unknown-trait"); + expect(hit?.severity).toBe("degraded"); + // The definition still "loads" — there is no error-severity violation. + expect(v.some((x) => x.severity === "error")).toBe(false); + }); +}); + +describe("TraitRegistry — hook implementation DI", () => { + it("resolves a declared hook with no registered impl to a no-op + audit warning", () => { + const r = seeded(); + const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard"); + expect(typeof impl).toBe("function"); + expect(impl?.()).toBeUndefined(); // no-op + expect(warning?.kind).toBe("missing-hook-impl"); + expect(warning?.traitId).toBe("merge-blocker"); + expect(warning?.hookKind).toBe("guard"); + }); + + it("resolves a registered impl without a warning", () => { + const r = seeded(); + let called = false; + r.registerTraitHookImpl("merge-blocker", "guard", () => { + called = true; + return "ok"; + }); + const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard"); + expect(warning).toBeUndefined(); + expect(impl?.()).toBe("ok"); + expect(called).toBe(true); + }); + + it("returns no impl and no warning when the trait does not declare the hook", () => { + const r = seeded(); + const { impl, warning } = r.resolveTraitHook("wip", "onEnter"); + expect(impl).toBeUndefined(); + expect(warning).toBeUndefined(); + }); +}); diff --git a/packages/core/src/builtin-traits.ts b/packages/core/src/builtin-traits.ts new file mode 100644 index 0000000000..769297575c --- /dev/null +++ b/packages/core/src/builtin-traits.ts @@ -0,0 +1,257 @@ +/** + * The 14 built-in traits (U2, R7) from the Trait Vocabulary table. Behavior for + * each trait lands in later units; here we ship the definitions (flags + config + * schema + hook descriptors) and register them into the shared trait registry. + * + * The `merge` trait's config schema is a STUB here (shape only — strategy / + * fileScope / squash / conflictStrategy); its behavior is U7. + * + * Registration is idempotent at module scope (registered once on import). Tests + * that need a clean slate use `__resetTraitRegistryForTests()` + + * `registerBuiltinTraits(registry)`. + */ + +import type { TraitDefinition } from "./trait-types.js"; +import { TraitRegistry, getTraitRegistry } from "./trait-registry.js"; + +/** The ids of the 14 built-in traits, in vocabulary-table order. */ +export const BUILTIN_TRAIT_IDS = [ + "intake", + "complete", + "archived", + "merge-blocker", + "wip", + "hold", + "human-review", + "gate", + "merge", + "abort-on-exit", + "reset-on-entry", + "timing", + "stall-detection", + "notify", +] as const; + +export type BuiltinTraitId = (typeof BUILTIN_TRAIT_IDS)[number]; + +/** The built-in trait definitions. */ +export const BUILTIN_TRAIT_DEFINITIONS: TraitDefinition[] = [ + { + id: "intake", + name: "Intake", + description: "Where new cards land; exactly one per workflow.", + builtin: true, + flags: { intake: true }, + configSchema: { + fields: [{ key: "autoTriage", type: "boolean", description: "Auto-triage new cards" }], + }, + }, + { + id: "complete", + name: "Complete", + description: "Terminal success; satisfies dependencies. Restricted flag.", + builtin: true, + flags: { complete: true }, + }, + { + id: "archived", + name: "Archived", + description: "Hidden from board; global semantics. Restricted flag.", + builtin: true, + flags: { archived: true, hiddenFromBoard: true }, + }, + { + id: "merge-blocker", + name: "Merge blocker", + description: + "Generalized FN-5147: entry to complete-bound columns blocked until the merge-class node completed.", + builtin: true, + flags: { mergeBlocker: true }, + hooks: { guard: true }, + }, + { + id: "wip", + name: "WIP / capacity", + description: "Substrate-enforced in-txn capacity limit; never bypassable (KTD-10).", + builtin: true, + flags: { countsTowardWip: true }, + configSchema: { + fields: [ + { key: "limit", type: "number", required: true, description: "Max concurrent cards" }, + { key: "countPending", type: "boolean", description: "Count mid-transition cards" }, + ], + }, + }, + { + id: "hold", + name: "Hold", + description: "Passive dwell; released by a configured condition.", + builtin: true, + flags: { hold: true }, + hooks: { releaseCondition: true }, + configSchema: { + fields: [ + { + key: "release", + type: "enum", + required: true, + enumValues: ["manual", "timer", "capacity", "dependency", "external-event"], + description: "Release condition kind (matches WorkflowHoldRelease)", + }, + ], + }, + }, + { + id: "human-review", + name: "Human review", + description: + "Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). Not on the default workflow.", + builtin: true, + flags: { humanReview: true }, + hooks: { guard: true }, + configSchema: { + fields: [ + { key: "approvers", type: "array", description: "Allowed approver ids" }, + { key: "checklist", type: "array", description: "Required checklist items" }, + ], + }, + }, + { + id: "gate", + name: "Gate", + description: + "Workflow-step gate semantics generalized to columns; the plugin-facing gate surface. Blocking gates fail closed.", + builtin: true, + flags: { gate: true }, + hooks: { gate: true }, + configSchema: { + fields: [ + { + key: "gateMode", + type: "enum", + required: true, + enumValues: ["blocking", "advisory"], + description: "Blocking gates fail closed; advisory gates record and allow", + }, + { key: "prompt", type: "string", description: "Gate prompt" }, + { key: "script", type: "string", description: "Gate script" }, + ], + }, + }, + { + id: "merge", + name: "Merge", + description: + "Enqueues onto the merge-request queue; configures merge policy. Behavior is U7 — this is a config STUB.", + builtin: true, + flags: { mergeOrchestration: true }, + hooks: { onEnter: true, onExit: true }, + configSchema: { + fields: [ + { + key: "strategy", + type: "enum", + enumValues: ["squash", "merge-commit", "rebase", "pr-only"], + description: "Merge strategy", + }, + { + key: "fileScope", + type: "enum", + enumValues: ["strict", "warn", "off", "custom"], + description: "File-scope enforcement mode", + }, + { key: "squash", type: "boolean", description: "Squash posture" }, + { + key: "conflictStrategy", + type: "string", + description: "Conflict resolution strategy", + }, + ], + }, + }, + { + id: "abort-on-exit", + name: "Abort on exit", + description: "Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9).", + builtin: true, + flags: { abortOnExit: true }, + hooks: { onExit: true }, + configSchema: { + fields: [ + { + key: "direction", + type: "enum", + enumValues: ["backward", "any"], + description: "Which exits trigger abort", + }, + { key: "confirm", type: "boolean", description: "Require user confirmation" }, + ], + }, + }, + { + id: "reset-on-entry", + name: "Reset on entry", + description: "Legacy reopen-to-todo field/step resets.", + builtin: true, + flags: { resetOnEntry: true }, + hooks: { onEnter: true }, + configSchema: { + fields: [ + { key: "preserveProgress", type: "boolean", description: "Keep progress fields" }, + ], + }, + }, + { + id: "timing", + name: "Timing", + description: "cumulativeActiveMs accounting generalized.", + builtin: true, + flags: { timing: true }, + hooks: { onEnter: true, onExit: true }, + }, + { + id: "stall-detection", + name: "Stall detection", + description: "In-review stall signals generalized to any column (sweep-evaluated).", + builtin: true, + flags: { stallDetection: true }, + configSchema: { + fields: [ + { key: "timeoutMs", type: "number", required: true, description: "Stall threshold" }, + { + key: "action", + type: "enum", + enumValues: ["annotate", "notify", "move"], + description: "Action on stall", + }, + ], + }, + }, + { + id: "notify", + name: "Notify", + description: "Basic notifications; richer notification traits are the canonical plugin example.", + builtin: true, + flags: { notify: true }, + hooks: { onEnter: true, onExit: true }, + configSchema: { + fields: [ + { key: "events", type: "array", description: "Events to notify on" }, + { key: "channel", type: "string", description: "Notification channel" }, + ], + }, + }, +]; + +/** Register all 14 built-in traits into the given registry (defaults to the + * shared registry). Idempotent guard for the shared instance lives in the + * module-scope registration below. */ +export function registerBuiltinTraits(registry: TraitRegistry = getTraitRegistry()): void { + for (const def of BUILTIN_TRAIT_DEFINITIONS) { + if (registry.has(def.id)) continue; + registry.register(def); + } +} + +// Register into the shared registry on import (idempotent via `has`). +registerBuiltinTraits(); diff --git a/packages/core/src/trait-registry.ts b/packages/core/src/trait-registry.ts new file mode 100644 index 0000000000..e24f6936e2 --- /dev/null +++ b/packages/core/src/trait-registry.ts @@ -0,0 +1,375 @@ +/** + * Trait registry (U2, R6/R8/R22). + * + * One registry resolving trait ids to definitions (flags + hook descriptors) + * for both built-ins and (later) plugins. Provides: + * - registration with `builtin:`-style namespace protection and + * restricted-capability enforcement (R22); + * - hook-implementation DI (engine registers impls; unregistered hooks + * resolve to a no-op + audit warning — degraded, not crashed); + * - effective-flag resolution for a column's trait set; + * - the save-time / load-time composition validator returning typed + * violations with named reason codes, distinguishing `error` + * (save-blocked) from `degraded` (load-time advisory). + * + * Core stays engine-free: no `@fusion/engine` import. Hook implementations are + * wired in via `registerTraitHookImpl` (mirrors `setCreateFnAgent`). + */ + +import type { + TraitDefinition, + TraitFlags, + TraitHookImpl, + TraitHookKind, +} from "./trait-types.js"; +import { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js"; +import type { WorkflowIrColumn, WorkflowIrColumnTrait } from "./workflow-ir-types.js"; + +// ── Registration error ────────────────────────────────────────────────────── + +/** Named reason codes for a rejected trait registration. */ +export type TraitRegistrationReason = + | "duplicate-id" + | "builtin-namespace-protected" + | "restricted-flag" + | "restricted-guard-hook" + | "invalid-definition"; + +export class TraitRegistrationError extends Error { + readonly reason: TraitRegistrationReason; + readonly traitId: string; + constructor(reason: TraitRegistrationReason, traitId: string, message: string) { + super(message); + this.name = "TraitRegistrationError"; + this.reason = reason; + this.traitId = traitId; + } +} + +// ── Composition violation contract ────────────────────────────────────────── + +/** Named reason codes for a composition violation. */ +export type TraitViolationCode = + | "complete-with-wip" + | "two-capacity-traits" + | "complete-with-intake" + | "archived-with-wip" + | "multiple-intake-columns" + | "unknown-trait"; + +/** Severity: `error` blocks the save; `degraded` is a load-time advisory — the + * definition still loads (per U2's load-time re-validation requirement). */ +export type TraitViolationSeverity = "error" | "degraded"; + +export interface TraitViolation { + code: TraitViolationCode; + severity: TraitViolationSeverity; + /** Column id the violation applies to, or null for workflow-wide violations. */ + columnId: string | null; + /** The trait ids implicated (for actionable messaging). */ + traitIds: string[]; + message: string; +} + +/** A simple audit-warning record returned by hook resolution / load-time + * re-validation. Modeled as a returned value (not a thrown error and not an + * engine logger) so core stays engine-free; callers may forward it to audit. */ +export interface TraitAuditWarning { + kind: "missing-hook-impl" | "degraded-composition"; + traitId?: string; + hookKind?: TraitHookKind; + message: string; +} + +// ── The registry ──────────────────────────────────────────────────────────── + +export class TraitRegistry { + private readonly traits = new Map(); + private readonly hookImpls = new Map(); + + /** Register a trait. Rejects duplicates, builtin-namespace overrides by + * non-builtins, and restricted-capability declarations by non-builtins (R22). */ + register(def: TraitDefinition): void { + if (!def.id || typeof def.id !== "string") { + throw new TraitRegistrationError( + "invalid-definition", + String(def.id), + "Trait definition must have a non-empty string id", + ); + } + + const existing = this.traits.get(def.id); + if (existing) { + // A built-in id (or any already-registered id) cannot be overridden. + if (!def.builtin && existing.builtin) { + throw new TraitRegistrationError( + "builtin-namespace-protected", + def.id, + `Trait id '${def.id}' is a built-in trait and cannot be overridden by a non-builtin registration`, + ); + } + throw new TraitRegistrationError( + "duplicate-id", + def.id, + `Trait id '${def.id}' is already registered`, + ); + } + + if (!def.builtin) { + // Non-builtin (plugin) traits cannot declare restricted flags (R22). + for (const flag of RESTRICTED_TRAIT_FLAGS) { + if (def.flags?.[flag]) { + throw new TraitRegistrationError( + "restricted-flag", + def.id, + `Non-builtin trait '${def.id}' may not declare the restricted flag '${flag}'`, + ); + } + } + // Non-builtin traits cannot declare the sync `guard` hook (KTD-2/R22). + if (def.hooks?.guard) { + throw new TraitRegistrationError( + "restricted-guard-hook", + def.id, + `Non-builtin trait '${def.id}' may not declare a sync 'guard' hook (built-in only)`, + ); + } + } + + this.traits.set(def.id, def); + } + + getTrait(id: string): TraitDefinition | undefined { + return this.traits.get(id); + } + + /** Catalog of all registered traits (for the dashboard endpoint, later). */ + listTraits(): TraitDefinition[] { + return [...this.traits.values()]; + } + + has(id: string): boolean { + return this.traits.has(id); + } + + // ── Hook implementation DI (engine wires impls in) ──────────────────────── + + /** Register a hook implementation for a (traitId, hookKind). Called by the + * engine (mirrors `setCreateFnAgent`); core never supplies impls. */ + registerTraitHookImpl(traitId: string, hookKind: TraitHookKind, impl: TraitHookImpl): void { + this.hookImpls.set(traitHookKey(traitId, hookKind), impl); + } + + /** Resolve a hook implementation. If the trait declares the hook but no impl + * is registered, returns a no-op plus an audit warning (degraded, not + * crashed). Returns `{ impl: undefined }` with no warning if the trait does + * not declare the hook at all. */ + resolveTraitHook( + traitId: string, + hookKind: TraitHookKind, + ): { impl: TraitHookImpl | undefined; warning?: TraitAuditWarning } { + const def = this.traits.get(traitId); + const declared = Boolean(def?.hooks?.[hookKind]); + const impl = this.hookImpls.get(traitHookKey(traitId, hookKind)); + if (impl) return { impl }; + if (declared) { + const noop: TraitHookImpl = () => undefined; + return { + impl: noop, + warning: { + kind: "missing-hook-impl", + traitId, + hookKind, + message: `Trait '${traitId}' declares a '${hookKind}' hook but no implementation is registered; resolving to a no-op`, + }, + }; + } + return { impl: undefined }; + } + + // ── Flag resolution ─────────────────────────────────────────────────────── + + /** Merged effective flags of a column's traits (OR across booleans). Unknown + * trait ids are ignored here (validation surfaces them via + * validateColumnTraits). */ + resolveColumnFlags(column: WorkflowIrColumn): TraitFlags { + const merged: TraitFlags = {}; + for (const ct of column.traits) { + const def = this.traits.get(ct.trait); + if (!def) continue; + for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) { + if (value) merged[key] = true; + } + } + return merged; + } + + // ── Composition validation ──────────────────────────────────────────────── + + /** + * Validate a workflow's columns' trait composition. Returns typed violations + * with named reason codes. `mode: "save"` produces `error` severities that + * block the save; `mode: "load"` degrades the *unknown-trait* violation to an + * advisory so definitions predating a newly added trait still load (per U2's + * load-time re-validation requirement). Hard structural conflicts remain + * errors in both modes (they reflect genuine nonsense, not vocabulary drift). + */ + validateColumnTraits( + columns: WorkflowIrColumn[], + mode: "save" | "load" = "save", + ): TraitViolation[] { + const violations: TraitViolation[] = []; + + let intakeColumnCount = 0; + + for (const column of columns) { + const knownDefs: TraitDefinition[] = []; + + // Unknown trait ids: degradable. In save mode it's an error; in load mode + // it degrades to an advisory (the rule/vocabulary may have changed under + // a persisted definition). + for (const ct of column.traits) { + const def = this.traits.get(ct.trait); + if (!def) { + violations.push({ + code: "unknown-trait", + severity: mode === "load" ? "degraded" : "error", + columnId: column.id, + traitIds: [ct.trait], + message: `Column '${column.id}' references unknown trait '${ct.trait}'`, + }); + continue; + } + knownDefs.push(def); + } + + const flags = this.mergeFlags(knownDefs); + + // Capacity traits on this column (traits whose flags set countsTowardWip). + const capacityTraitIds = knownDefs + .filter((d) => d.flags.countsTowardWip) + .map((d) => d.id); + + if (flags.complete && flags.countsTowardWip) { + violations.push({ + code: "complete-with-wip", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "countsTowardWip"]), + message: `Column '${column.id}' is both a completion column and counts toward WIP — a terminal column cannot hold a capacity slot`, + }); + } + + if (capacityTraitIds.length > 1) { + violations.push({ + code: "two-capacity-traits", + severity: "error", + columnId: column.id, + traitIds: capacityTraitIds, + message: `Column '${column.id}' has more than one capacity (WIP) trait: ${capacityTraitIds.join(", ")}`, + }); + } + + if (flags.complete && flags.intake) { + violations.push({ + code: "complete-with-intake", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "intake"]), + message: `Column '${column.id}' is both a completion column and an intake column`, + }); + } + + if (flags.archived && flags.countsTowardWip) { + violations.push({ + code: "archived-with-wip", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["archived", "countsTowardWip"]), + message: `Column '${column.id}' is archived but counts toward WIP — archived cards must not hold capacity`, + }); + } + + if (flags.intake) intakeColumnCount += 1; + } + + if (intakeColumnCount > 1) { + violations.push({ + code: "multiple-intake-columns", + severity: "error", + columnId: null, + traitIds: [], + message: `Workflow has ${intakeColumnCount} intake columns; exactly one is allowed`, + }); + } + + return violations; + } + + private mergeFlags(defs: TraitDefinition[]): TraitFlags { + const merged: TraitFlags = {}; + for (const def of defs) { + for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) { + if (value) merged[key] = true; + } + } + return merged; + } + + private traitIdsWithFlags(defs: TraitDefinition[], flagKeys: (keyof TraitFlags)[]): string[] { + return defs + .filter((d) => flagKeys.some((k) => d.flags[k])) + .map((d) => d.id); + } +} + +// ── Module-level default registry ──────────────────────────────────────────── +// +// A single shared registry instance the built-ins register into and the engine +// wires hook impls into. Tests can construct fresh `new TraitRegistry()` +// instances for isolation. + +let defaultRegistry: TraitRegistry | undefined; + +export function getTraitRegistry(): TraitRegistry { + if (!defaultRegistry) defaultRegistry = new TraitRegistry(); + return defaultRegistry; +} + +/** Test-only: reset the shared registry (so built-in registration can be + * re-exercised in isolation). */ +export function __resetTraitRegistryForTests(): void { + defaultRegistry = undefined; +} + +// ── Convenience pass-throughs to the default registry ──────────────────────── + +export function getTrait(id: string): TraitDefinition | undefined { + return getTraitRegistry().getTrait(id); +} + +export function listTraits(): TraitDefinition[] { + return getTraitRegistry().listTraits(); +} + +export function resolveColumnFlags(column: WorkflowIrColumn): TraitFlags { + return getTraitRegistry().resolveColumnFlags(column); +} + +export function validateColumnTraits( + columns: WorkflowIrColumn[], + mode: "save" | "load" = "save", +): TraitViolation[] { + return getTraitRegistry().validateColumnTraits(columns, mode); +} + +export function registerTraitHookImpl( + traitId: string, + hookKind: TraitHookKind, + impl: TraitHookImpl, +): void { + getTraitRegistry().registerTraitHookImpl(traitId, hookKind, impl); +} + +/** Re-export for callers that only need the column-trait shape. */ +export type { WorkflowIrColumnTrait }; diff --git a/packages/core/src/trait-types.ts b/packages/core/src/trait-types.ts new file mode 100644 index 0000000000..e17b71b15c --- /dev/null +++ b/packages/core/src/trait-types.ts @@ -0,0 +1,125 @@ +/** + * Trait model (U2). A trait is declarative flags + optional config schema + + * optional executable lifecycle hook *descriptors*. Per KTD-2 there are two + * guard classes: + * - `guard` — sync, in-lock, fast/pure (DB reads only). BUILT-IN ONLY. + * - `gate` — async, pre-evaluated outside the lock; the plugin-facing + * surface. The verdict is recorded and re-checked cheaply in-lock. + * + * Hooks here are *descriptors* (what the trait declares it participates in); + * the executable implementations are registered separately by the engine via + * the core→engine DI seam (mirrors `setCreateFnAgent`). This keeps core + * engine-free: core never imports `@fusion/engine`. + */ + +/** The set of hook points a trait can declare (KTD-2). */ +export type TraitHookKind = "guard" | "gate" | "onEnter" | "onExit" | "releaseCondition"; + +/** All declarative trait flags. Derived from the Trait Vocabulary table. + * Every flag is optional; an absent flag means `false`. Flags compose by OR + * across a column's traits (see resolveColumnFlags). */ +export interface TraitFlags { + /** Cards in this column count against a WIP/capacity limit (substrate-enforced). */ + countsTowardWip?: boolean; + /** Terminal-success column; satisfies dependencies. RESTRICTED (built-in only). */ + complete?: boolean; + /** Globally archived; hidden from the board. RESTRICTED (built-in only). */ + archived?: boolean; + /** Hidden from the board lane (e.g. archived columns). */ + hiddenFromBoard?: boolean; + /** Leaving this column hard-cancels in-flight work (abort-on-exit). */ + abortOnExit?: boolean; + /** Cards cannot leave until explicit human approval. */ + humanReview?: boolean; + /** Where new cards land; exactly one per workflow (validated). */ + intake?: boolean; + /** Passive dwell column with a release condition. */ + hold?: boolean; + /** Participates in merge/PR orchestration (enqueues onto the merge queue). */ + mergeOrchestration?: boolean; + /** Entry to this column is blocked until the merge-class node completed. */ + mergeBlocker?: boolean; + /** Card progress/fields are reset on entry (reopen semantics). */ + resetOnEntry?: boolean; + /** Cumulative active-time accounting runs on enter/exit. */ + timing?: boolean; + /** Stall detection is evaluated by the sweep for cards dwelling here. */ + stallDetection?: boolean; + /** Emits notifications on enter/exit. */ + notify?: boolean; + /** A gate (advisory or blocking) is evaluated before entry. */ + gate?: boolean; +} + +/** The flag keys that are restricted to built-in traits (R22, KTD-7). A + * non-builtin (plugin) trait declaring any of these is rejected at + * registration. The sync `guard` hook descriptor is restricted separately. */ +export const RESTRICTED_TRAIT_FLAGS = ["complete", "archived"] as const; +export type RestrictedTraitFlag = (typeof RESTRICTED_TRAIT_FLAGS)[number]; + +/** A trait's hook descriptors — *what* the trait declares it participates in. + * `true` means "this trait has a hook of this kind"; the implementation is + * registered separately via the engine DI seam. */ +export interface TraitHookDescriptors { + /** Sync, in-lock guard. BUILT-IN ONLY (KTD-2/R22). */ + guard?: boolean; + /** Async, pre-evaluated gate. Plugin-facing surface. */ + gate?: boolean; + /** Post-commit, async, idempotent enter effect. */ + onEnter?: boolean; + /** Post-commit, async, idempotent exit effect. */ + onExit?: boolean; + /** Release-condition evaluation for hold columns (sweep-driven). */ + releaseCondition?: boolean; +} + +/** A declarative description of a trait's config schema. Lightweight by design + * (U2 ships the shapes; richer validation lands with each behavior unit). */ +export interface TraitConfigField { + key: string; + type: "string" | "number" | "boolean" | "enum" | "object" | "array"; + required?: boolean; + /** For `enum` fields: the allowed values. */ + enumValues?: readonly string[]; + description?: string; +} + +export interface TraitConfigSchema { + fields: TraitConfigField[]; +} + +/** A trait definition: declarative flags + optional config schema + optional + * hook descriptors. Built-in traits set `builtin: true`. */ +export interface TraitDefinition { + id: string; + name: string; + description?: string; + flags: TraitFlags; + configSchema?: TraitConfigSchema; + hooks?: TraitHookDescriptors; + /** True for the 14 built-in traits; plugin/custom traits leave this falsy. + * Restricted capabilities (R22) are allowed only when `builtin` is true. */ + builtin?: boolean; +} + +// ── Hook implementation DI seam (core→engine) ─────────────────────────────── +// +// Implementations of trait hooks are NOT defined in core (core is engine-free). +// The engine registers them via `registerTraitHookImpl` the way it wires +// `setCreateFnAgent`. Core resolves an implementation through +// `getTraitHookImpl`; an unregistered hook resolves to a no-op (the registry's +// `resolveTraitHook` returns a no-op + an audit warning, see trait-registry). +// +// The impl signature is intentionally opaque here: core never invokes hooks +// directly (the store/sweep do, in engine-adjacent code), so core only needs to +// store/retrieve the registration. Using `unknown` keeps core free of engine +// types while remaining type-safe at the registration boundary. + +/** A registered hook implementation. Opaque to core; the engine supplies a + * concrete callable and casts at its own call sites. */ +export type TraitHookImpl = (...args: unknown[]) => unknown; + +/** Stable key for a (traitId, hookKind) implementation registration. */ +export function traitHookKey(traitId: string, hookKind: TraitHookKind): string { + return `${traitId}::${hookKind}`; +}