Skip to main content

P3.2 โ€” Skill Trigger Engine Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax.

Goal: Build a NestJS skill-trigger engine that reads published skill config-assets for the (org ร— specialist), evaluates each skill's trigger against the incoming message, merges/ranks multiple hits, renders the winning skills' instructions into the assembler's skill layer โ‘ฃ, and โ€” on no match โ€” injects an abstain posture string; all behind the existing default-OFF prompt_assembler_enabled flag (skill population only happens on the assembler path).

Architecture: Skills currently have zero runtime consumers (Phase 2A landed storage only) โ€” P3.2 is the first. The engine is a pluggable matcher strategy: the skeleton (published-skill reader โ†’ skill renderer โ†’ rank/merge โ†’ layer-โ‘ฃ injection โ†’ abstain โ†’ trace) does NOT depend on how triggers match. P3.2 ships the keyword/substring matcher (pure NestJS, deterministic, never-throws, mirrors the shipped task-classification.util.ts precedent) as the initial SkillTriggerMatcher implementation. A semantic (Haystack) matcher is a documented drop-in replacement (same interface, zero skeleton rework) โ€” see the DECISION POINT below.

โš ๏ธ DECISION POINT (awaiting user, made a safe default during their absence โ€” reversible): The matcher mechanism (keyword-substring vs Haystack-semantic) echoes the exact question the user decided in P2.4, where they REJECTED config-side keyword matching for golden-answer retrieval ("ใ€Ž่ƒฝ้€€ๅ—ใ€ๅ‘ฝไธญไธไบ†ใ€Ž้€€ๆฌพๆ”ฟ็ญ–ใ€; a silently-failing layer is worse than none") and chose Haystack semantic retrieval. Skill trigger is not fully isomorphic โ€” a skill's trigger is operator-authored keywords declaring "activate me on these terms" over a small set (a handful per specialist), not a semantic search over a whole KB corpus. But the semantic-miss risk is the same class. The chosen default de-risks this: by isolating matching behind the SkillTriggerMatcher interface, P3.2 lands the full skeleton with the keyword matcher (smallest change, deterministic, flag-OFF safety net, tested-precedent shape) WITHOUT betting the contract. If the user wants semantic matching, only the matcher implementation swaps (reuse the P2.4 A1+B1 mechanism: publish โ†’ BullMQ upsert โ†’ Haystack shadow row metadata.kind="skill_trigger" โ†’ semantic hit) โ€” the reader/renderer/rank/inject/abstain/trace skeleton is untouched. Confirm with the user before choosing the semantic matcher; the keyword matcher ships now as the reversible default.

Tech Stack: NestJS 11 (SkillTriggerService, pure skill-trigger.util.ts + skill-render.ts, ConfigAssetsService.getPublishedSkills), Jest.

Scope note: Long-lived branch feat/specialist-value-output-p2plus. P3.2 depends on P3.1 (assembler + flag). Commit per sub-task.

Boundary (P3.2 does NOT do):

  • Does NOT change the SkillContent Zod schema (no new priority/weight/semantic/regex fields) โ€” ranking derives from hit-count + updatedAt + slug, all data that exists today.
  • Does NOT add the Haystack semantic matcher (documented follow-up; the interface is prepared).
  • Does NOT wire skill.redLines into a red-line check โ€” that's P3.5. P3.2 renders only trigger-matched instructions (+ optionally responseTemplates references) into layer โ‘ฃ.
  • Does NOT populate skill.tools at runtime โ€” that's P4.7 (skills โˆฉ bindings).
  • Does NOT move the crisis prefix off the soul layer (P3.1 review observation #1) โ€” that is a crisis-layer task, tracked for whoever wires the real crisis: input; noted here so it isn't lost.

Pre-existing baseline: verify failures against branch base 490517fb. NestJS local jest ~21 known pre-existing failures; only NEW failures block. Prompt-shape changes on the assembler path are covered by the P3.1 echo-guard evals โ€” re-run agent/evals/test_prompt_echo_guard.py if any agent-visible text changes (P3.2 is NestJS-only, so no agent change expected, but the layer-โ‘ฃ content becomes part of assembled_context which the agent executes verbatim โ€” re-run to be safe).


File Structureโ€‹

Create:

  • api/src/config-assets/skill-render.ts โ€” pure renderSkillLayer(skills: RankedSkill[]): string. The single-source SkillContentโ†’layer-โ‘ฃ-string renderer (mirrors renderSoulMd's single-source rule). NO second renderer anywhere.
  • api/src/config-assets/skill-render.spec.ts โ€” pure unit test.
  • api/src/conversations/skill-trigger.util.ts โ€” pure evaluateSkillTriggers(input): SkillMatchResult. The keyword matcher + ranking. Deterministic, never-throws (mirrors task-classification.util.ts). Defines the SkillTriggerMatcher seam.
  • api/src/conversations/skill-trigger.util.spec.ts โ€” pure unit test.
  • api/src/conversations/skill-trigger.service.ts โ€” @Injectable() SkillTriggerService: fetches published skills (via ConfigAssetsService), runs the pure matcher, renders the layer, returns { skillLayerText, sourceRefs, abstain }. Exception-level fail-open (returns "no skills" on any error โ€” mirrors getPublishedSoulMd).
  • api/src/conversations/skill-trigger.service.spec.ts โ€” service test (mock ConfigAssetsService).

Modify:

  • api/src/config-assets/config-assets.service.ts โ€” add getPublishedSkills({orgId, specialistId}): Promise<PublishedSkill[]> (parallels getPublishedSoulMd; returns structured content + asset id + version + updatedAt, NOT a rendered string โ€” rendering is the trigger engine's job after ranking).
  • api/src/conversations/assembler.service.ts โ€” add skillSourceRefs?: string[] to AssembleInput and thread it onto the skill layer spec entry (so the manifest carries skill ids+versions for P3.4 trace โ€” today only kb carries sourceRefs).
  • api/src/conversations/conversations.service.ts โ€” at BOTH assembler call sites (main dispatch assemble() ~line 3226; regenerate ~line 6516), when assembling, call SkillTriggerService.evaluate(...) and pass skill: + skillSourceRefs: into assemble({...}).
  • api/src/conversations/conversations.module.ts โ€” register SkillTriggerService.
  • api/src/conversations/CLAUDE.md, api/src/config-assets/CLAUDE.md โ€” document the trigger engine + getPublishedSkills + abstain posture.

Dependency direction: ConversationsModule already imports ConfigAssetsModule (Task-11 soul path). SkillTriggerService lives in ConversationsModule, calls ConfigAssetsService.getPublishedSkills, uses the pure util + renderer. Additive to the existing assembler input โ€” no wire-contract change (skill just becomes part of assembled_context, which P3.1 already ships).


Task 1: getPublishedSkills reader in ConfigAssetsServiceโ€‹

Files:

  • Modify: api/src/config-assets/config-assets.service.ts
  • Test: api/src/config-assets/config-assets.service.spec.ts (or a focused new spec config-assets-published-skills.spec.ts if the main spec is heavy)

Precedent: getPublishedSoulMd (config-assets.service.ts:405-434) โ€” soul-only, hard-coded slug, returns a rendered string, exception-level fail-open. listAssets (:306-326) enforces the ADR-020 dual filter and returns metadata-only rows. getAsset's contentOf(versionId) (:356-364) loads a version's content.

  • Step 1: Read the precedents โ€” getPublishedSoulMd (fail-open pattern, dual-scope where-clause), listAssets (the 400-on-missing-half guard + scope: "org_instance" + archivedAt: IsNull() filter), contentOf (version content load), and skill.schema.ts (SkillContent/SkillContentT).

  • Step 2: Write the failing test

Add to a spec that constructs ConfigAssetsService with mocked assetRepo + versionRepo. Mirror the soul test's repo stubs.

describe("getPublishedSkills", () => {
it("returns parsed published skills scoped by org ร— specialist", async () => {
// asset with a publishedVersionId
assetRepo.find.mockResolvedValue([
{ id: "sk-1", publishedVersionId: "v-1", updatedAt: new Date("2026-01-02"), slug: "refunds" },
{ id: "sk-2", publishedVersionId: null, updatedAt: new Date("2026-01-03"), slug: "draft-only" }, // no published version โ†’ skipped
]);
versionRepo.findOneBy.mockImplementation(async ({ id }: { id: string }) =>
id === "v-1"
? { id: "v-1", versionNo: 3, content: {
trigger: { description: "refund questions", topics: ["refunds"], keywords: ["refund", "money back"] },
instructions: "Explain the 30-day refund policy.",
responseTemplates: [], escalationRules: [], redLines: [], tools: [],
} }
: null,
);

const skills = await service.getPublishedSkills({ orgId: "org-1", specialistId: "spec-1" });

expect(skills).toHaveLength(1);
expect(skills[0]).toMatchObject({
assetId: "sk-1", versionNo: 3, slug: "refunds",
content: expect.objectContaining({ instructions: "Explain the 30-day refund policy." }),
});
// dual-scope filter reached the repo
expect(assetRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
assetType: "skill", scope: "org_instance", orgId: "org-1", specialistId: "spec-1",
}),
}),
);
});

it("returns [] (never throws) when org or specialist id is missing", async () => {
expect(await service.getPublishedSkills({ orgId: undefined as any, specialistId: "spec-1" })).toEqual([]);
expect(await service.getPublishedSkills({ orgId: "org-1", specialistId: undefined as any })).toEqual([]);
});

it("skips a published version whose content fails SkillContent parse (half-valid โ†’ skip, never throw)", async () => {
assetRepo.find.mockResolvedValue([{ id: "sk-1", publishedVersionId: "v-1", updatedAt: new Date(), slug: "bad" }]);
versionRepo.findOneBy.mockResolvedValue({ id: "v-1", versionNo: 1, content: { trigger: { topics: [] } } }); // invalid: topics.min(1)
expect(await service.getPublishedSkills({ orgId: "org-1", specialistId: "spec-1" })).toEqual([]);
});

it("returns [] on any repo error (exception-level fail-open)", async () => {
assetRepo.find.mockRejectedValue(new Error("db down"));
expect(await service.getPublishedSkills({ orgId: "org-1", specialistId: "spec-1" })).toEqual([]);
});
});

Run โ†’ FAIL (method absent).

  • Step 3: Add the type + method

Near the other exported types in config-assets.service.ts (or a local interface above the class):

import { SkillContent, type SkillContentT } from "./schemas/skill.schema";

export interface PublishedSkill {
assetId: string;
versionId: string;
versionNo: number;
slug: string;
updatedAt: Date;
content: SkillContentT;
}

Method (mirror getPublishedSoulMd's fail-open shape; use the SAME dual-scope where-clause as listAssets):

/**
* Published `skill` assets for the (org ร— specialist), parsed to structured
* content. First runtime consumer of skills (Phase 3 / P3.2 trigger engine).
*
* Exception-level fail-open โ€” ANY error (tables not migrated, repo down,
* half-valid stored content) resolves to `[]`, never throws: this sits on the
* /chat assembler path where a throw would escape runAgentPipeline's fail-open
* boundary and black-hole the turn (same reasoning as getPublishedSoulMd).
* A stored version that no longer passes SkillContent is SKIPPED (not
* distributed half-valid), mirroring the soul path.
*/
async getPublishedSkills(scope: {
orgId: string | undefined;
specialistId: string | undefined;
}): Promise<PublishedSkill[]> {
const { orgId, specialistId } = scope;
if (!orgId || !specialistId) return []; // ADR-020: never a single-half query
try {
const assets = await this.assetRepo.find({
where: {
assetType: "skill",
scope: "org_instance",
orgId,
specialistId,
archivedAt: IsNull(),
},
});
const out: PublishedSkill[] = [];
for (const asset of assets) {
if (!asset.publishedVersionId) continue; // unpublished draft โ†’ not distributed
const version = await this.versionRepo.findOneBy({ id: asset.publishedVersionId });
if (!version) continue;
const parsed = SkillContent.safeParse(version.content);
if (!parsed.success) continue; // half-valid โ†’ skip (never distribute)
out.push({
assetId: asset.id,
versionId: version.id,
versionNo: version.versionNo,
slug: asset.slug,
updatedAt: asset.updatedAt,
content: parsed.data,
});
}
return out;
} catch (err) {
this.logger.warn(`getPublishedSkills failed for org=${orgId} specialist=${specialistId}: ${(err as Error).message}`);
return [];
}
}

Confirm the real field names when implementing: SpecialistConfigAsset.publishedVersionId, .slug, .updatedAt, .archivedAt; SpecialistConfigVersion.versionNo, .content. The Explore report and config-asset.entity.ts / config-asset-version.entity.ts are authoritative โ€” align to the actual columns, do not copy these names blind. IsNull is already imported for getPublishedSoulMd; the logger field name matches the existing one in this service.

  • Step 4: Run tests โ†’ PASS (4). npx tsc --noEmit clean.

  • Step 5: Commit

git add api/src/config-assets/config-assets.service.ts api/src/config-assets/*.spec.ts
git commit -m "feat(config-assets): getPublishedSkills reader โ€” first runtime skill consumer (P3.2)"

Task 2: pure keyword matcher + ranking (skill-trigger.util.ts)โ€‹

Files:

  • Create: api/src/conversations/skill-trigger.util.ts
  • Test: api/src/conversations/skill-trigger.util.spec.ts

Precedent: task-classification.util.ts โ€” sanitizePatterns (:99-106: trim/lowercase/length-gate 3-100/cap 50), matchPatterns (:108-111: case-insensitive substring), classifyTask (:128-179: deterministic, total, never-throws, emits matched_<x>:<hit> signals). Reuse this exact shape.

Design: the matcher is a strategy seam. P3.2 ships keywordMatcher. Ranking (when multiple skills hit): more hits first, then more-recently-updated first (updatedAt desc), then slug asc (stable, deterministic tie-break โ€” no schema priority field exists, design ยง6.6). No match โ†’ matched: [] (the service turns this into the abstain posture).

  • Step 1: Write the failing test
import { evaluateSkillTriggers, type SkillTriggerCandidate } from "./skill-trigger.util";

const cand = (over: Partial<SkillTriggerCandidate> = {}): SkillTriggerCandidate => ({
assetId: "sk-1", versionNo: 1, slug: "refunds",
updatedAt: new Date("2026-01-01"),
topics: ["refunds"], keywords: ["refund", "money back"],
instructions: "Explain the 30-day refund policy.",
...over,
});

describe("evaluateSkillTriggers (keyword matcher)", () => {
it("matches on a keyword substring (case-insensitive)", () => {
const r = evaluateSkillTriggers({ message: "Can I get a REFUND please?", candidates: [cand()] });
expect(r.matched.map((m) => m.assetId)).toEqual(["sk-1"]);
expect(r.matched[0].hits).toContain("refund");
expect(r.abstain).toBe(false);
});

it("matches on a topic when no keyword hits", () => {
const r = evaluateSkillTriggers({
message: "question about refunds policy",
candidates: [cand({ keywords: [] })],
});
expect(r.matched.map((m) => m.assetId)).toEqual(["sk-1"]);
});

it("no match โ†’ empty + abstain=true (the semantic-miss case: ใ€Œ่ƒฝ้€€ๅ—ใ€ vs keyword ใ€Œ้€€ๆฌพใ€)", () => {
const r = evaluateSkillTriggers({ message: "hello there", candidates: [cand()] });
expect(r.matched).toEqual([]);
expect(r.abstain).toBe(true);
});

it("ranks multiple hits: more hits first, then updatedAt desc, then slug asc", () => {
const r = evaluateSkillTriggers({
message: "refund and money back and shipping",
candidates: [
cand({ assetId: "a", slug: "refunds", keywords: ["refund", "money back"], updatedAt: new Date("2026-01-01") }), // 2 hits
cand({ assetId: "b", slug: "shipping", keywords: ["shipping"], updatedAt: new Date("2026-06-01") }), // 1 hit, newer
cand({ assetId: "c", slug: "aardvark", keywords: ["shipping"], updatedAt: new Date("2026-06-01") }), // 1 hit, newer, slug<
],
});
expect(r.matched.map((m) => m.assetId)).toEqual(["a", "c", "b"]); // 2-hit first; then tie broken by slug (aardvark<shipping)
});

it("is total โ€” never throws on empty/garbage input", () => {
expect(evaluateSkillTriggers({ message: "", candidates: [] }).matched).toEqual([]);
expect(evaluateSkillTriggers({ message: "x", candidates: [cand({ topics: [], keywords: [] })] }).abstain).toBe(true);
});
});

Run โ†’ FAIL (module absent).

  • Step 2: Implement skill-trigger.util.ts
/**
* Skill trigger evaluation (P3.2). Pure, deterministic, total โ€” never throws.
* Mirrors task-classification.util.ts: case-insensitive substring matching of
* a skill's operator-authored trigger topics+keywords against the raw inbound
* message.
*
* MATCHER STRATEGY SEAM: `evaluateSkillTriggers` IS the keyword matcher. A
* semantic (Haystack) matcher would be a separate implementation returning the
* same `SkillMatchResult` shape โ€” the service (skill-trigger.service.ts) and
* everything downstream (render/inject/abstain/trace) are matcher-agnostic.
* See the P3.2 plan DECISION POINT: keyword is the shipped default; semantic is
* a documented drop-in. Keyword matching CANNOT catch a semantic variant
* (message ใ€Œ่ƒฝ้€€ๅ—ใ€ vs trigger keyword ใ€Œ้€€ๆฌพใ€) โ€” that limitation is why the
* matcher is isolated behind this seam.
*/

export interface SkillTriggerCandidate {
assetId: string;
versionNo: number;
slug: string;
updatedAt: Date;
topics: string[];
keywords: string[];
instructions: string;
}

export interface MatchedSkill extends SkillTriggerCandidate {
hits: string[]; // which topic/keyword strings matched (audit/trace)
}

export interface SkillMatchResult {
matched: MatchedSkill[]; // ranked, best first
abstain: boolean; // true when nothing matched
}

const MAX_HITS_TRACKED = 8; // cap per-skill hit trace so a pathological message can't bloat trace

function normalizePatterns(patterns: string[]): string[] {
return patterns
.filter((p): p is string => typeof p === "string")
.map((p) => p.trim().toLowerCase())
.filter((p) => p.length > 0);
}

function matchOne(haystack: string, cand: SkillTriggerCandidate): string[] {
const patterns = normalizePatterns([...(cand.topics ?? []), ...(cand.keywords ?? [])]);
const hits: string[] = [];
for (const p of patterns) {
if (haystack.includes(p) && !hits.includes(p)) hits.push(p);
if (hits.length >= MAX_HITS_TRACKED) break;
}
return hits;
}

export function evaluateSkillTriggers(input: {
message: string;
candidates: SkillTriggerCandidate[];
}): SkillMatchResult {
const haystack = (typeof input.message === "string" ? input.message : "").toLowerCase();
const candidates = Array.isArray(input.candidates) ? input.candidates : [];

const matched: MatchedSkill[] = [];
for (const cand of candidates) {
const hits = matchOne(haystack, cand);
if (hits.length > 0) matched.push({ ...cand, hits });
}

// Rank: more hits first, then newer updatedAt, then slug asc (stable,
// deterministic โ€” no schema priority field exists, design ยง6.6).
matched.sort((a, b) => {
if (b.hits.length !== a.hits.length) return b.hits.length - a.hits.length;
const at = a.updatedAt?.getTime?.() ?? 0;
const bt = b.updatedAt?.getTime?.() ?? 0;
if (bt !== at) return bt - at;
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
});

return { matched, abstain: matched.length === 0 };
}
  • Step 3: Run tests โ†’ PASS (5). npx tsc --noEmit clean.

  • Step 4: Commit

git add api/src/conversations/skill-trigger.util.ts api/src/conversations/skill-trigger.util.spec.ts
git commit -m "feat(conversations): pure keyword skill-trigger matcher + deterministic ranking (P3.2)"

Task 3: single-source skill-layer renderer (skill-render.ts)โ€‹

Files:

  • Create: api/src/config-assets/skill-render.ts
  • Test: api/src/config-assets/skill-render.spec.ts

Precedent: soul-render.service.ts renderSoulMd โ€” the ONE SoulContentโ†’string renderer (single-source rule, config-assets/CLAUDE.md). Skill has none today; this creates it. Put it in config-assets/ (next to the schema + soul renderer) so the single-source rule is discoverable there.

Design: render the ranked matched skills' instructions into one layer-โ‘ฃ string, each labeled by slug so the agent (and trace) can tell which skills are active. Deterministic; input is already ranked by Task 2. NO truncation here โ€” the assembler owns budget (SKILL_BUDGET_CHARS is enforced per-skill at save; the assembler's layer-โ‘ฃ budget handles the aggregate, design ยง6.3).

  • Step 1: Write the failing test
import { renderSkillLayer, ABSTAIN_POSTURE } from "./skill-render";

describe("renderSkillLayer", () => {
it("renders each matched skill's instructions, slug-labeled, in input order", () => {
const out = renderSkillLayer([
{ slug: "refunds", instructions: "Explain the 30-day refund policy." },
{ slug: "shipping", instructions: "Quote 3-5 business days." },
]);
expect(out).toContain("ACTIVE SKILLS");
expect(out.indexOf("refunds")).toBeLessThan(out.indexOf("shipping")); // preserves ranked order
expect(out).toContain("Explain the 30-day refund policy.");
expect(out).toContain("Quote 3-5 business days.");
});

it("returns null for an empty list (no skill layer)", () => {
expect(renderSkillLayer([])).toBeNull();
});

it("ABSTAIN_POSTURE is a non-empty stable string", () => {
expect(typeof ABSTAIN_POSTURE).toBe("string");
expect(ABSTAIN_POSTURE.length).toBeGreaterThan(0);
});
});

Run โ†’ FAIL (module absent).

  • Step 2: Implement skill-render.ts
/**
* Single-source renderer for the assembler's SKILL layer โ‘ฃ (P3.2). Like
* renderSoulMd is the one SoulContentโ†’string source, this is the one
* (ranked matched skills)โ†’layer-โ‘ฃ-string source. Do NOT render skill
* instructions anywhere else (config-assets/CLAUDE.md single-source rule).
*
* Budget: no truncation here โ€” each skill is โ‰ค SKILL_BUDGET_CHARS (enforced at
* save) and the assembler's layer budget handles the aggregate (design ยง6.3).
*/

export interface RenderableSkill {
slug: string;
instructions: string;
}

/**
* The no-skill-matched abstain posture (P3.2, R5.2/R5.5). Injected into layer
* โ‘ฃ when trigger evaluation returns abstain. STACKS WITH โ€” does not replace โ€”
* the agent-side CLARIFICATION_POLICY (agent/prompts_loader.py): the agent
* still owns clarify-first + the JSON response contract. This posture just
* tells the model that no specialist skill claimed this turn, so it should not
* improvise domain specifics and should lean toward clarify/escalate.
*/
export const ABSTAIN_POSTURE =
"NO SPECIALIST SKILL MATCHED THIS REQUEST. Do not improvise domain-specific " +
"procedures or policies you were not given. If the request needs specialist " +
"knowledge you do not have, ask a clarifying question or escalate for human " +
"review rather than guessing.";

export function renderSkillLayer(skills: RenderableSkill[]): string | null {
if (!Array.isArray(skills) || skills.length === 0) return null;
const blocks = skills.map((s) => `### SKILL: ${s.slug}\n${s.instructions.trim()}`);
return `ACTIVE SKILLS โ€” apply the matched specialist guidance below:\n\n${blocks.join("\n\n")}`;
}
  • Step 3: Run tests โ†’ PASS (3). npx tsc --noEmit clean.

  • Step 4: Commit

git add api/src/config-assets/skill-render.ts api/src/config-assets/skill-render.spec.ts
git commit -m "feat(config-assets): single-source skill-layer renderer + abstain posture (P3.2)"

Task 4: SkillTriggerService โ€” orchestration (fetch โ†’ match โ†’ render)โ€‹

Files:

  • Create: api/src/conversations/skill-trigger.service.ts
  • Test: api/src/conversations/skill-trigger.service.spec.ts

Design: the injectable seam ConversationsService calls. Fetches published skills (ConfigAssetsService.getPublishedSkills), maps them to matcher candidates, runs evaluateSkillTriggers, renders the winners (or the abstain posture) โ†’ returns { skillLayerText, sourceRefs, abstain }. Exception-level fail-open (mirror getPublishedSoulMd): any failure โ†’ { skillLayerText: null, sourceRefs: [], abstain: false } so a skill-engine hiccup never degrades the assembler path (skill layer simply absent). @Optional() ConfigAssetsService so lean test modules without it degrade to no-skills.

  • Step 1: Write the failing test
import { SkillTriggerService } from "./skill-trigger.service";

const published = (over: any = {}) => ({
assetId: "sk-1", versionId: "v-1", versionNo: 2, slug: "refunds", updatedAt: new Date("2026-01-01"),
content: {
trigger: { description: "", topics: ["refunds"], keywords: ["refund"] },
instructions: "Explain the 30-day refund policy.",
responseTemplates: [], escalationRules: [], redLines: [], tools: [],
},
...over,
});

function makeService(getPublishedSkills: jest.Mock) {
return new SkillTriggerService({ getPublishedSkills } as any);
}

describe("SkillTriggerService.evaluate", () => {
it("matched skill โ†’ renders layer text + sourceRefs (assetId:versionNo), abstain=false", async () => {
const svc = makeService(jest.fn().mockResolvedValue([published()]));
const r = await svc.evaluate({ orgId: "o", specialistId: "s", message: "can I get a refund?" });
expect(r.skillLayerText).toContain("Explain the 30-day refund policy.");
expect(r.sourceRefs).toEqual(["sk-1:2"]);
expect(r.abstain).toBe(false);
});

it("no skills published โ†’ abstain posture text, abstain=true, no sourceRefs", async () => {
const svc = makeService(jest.fn().mockResolvedValue([]));
const r = await svc.evaluate({ orgId: "o", specialistId: "s", message: "hi" });
expect(r.abstain).toBe(true);
expect(r.skillLayerText).toContain("NO SPECIALIST SKILL MATCHED");
expect(r.sourceRefs).toEqual([]);
});

it("skills exist but none match โ†’ abstain posture (the keyword-miss case)", async () => {
const svc = makeService(jest.fn().mockResolvedValue([published()]));
const r = await svc.evaluate({ orgId: "o", specialistId: "s", message: "totally unrelated" });
expect(r.abstain).toBe(true);
expect(r.skillLayerText).toContain("NO SPECIALIST SKILL MATCHED");
});

it("fetch throws โ†’ fail-open: null layer, not-abstain (skill engine never degrades the turn)", async () => {
const svc = makeService(jest.fn().mockRejectedValue(new Error("boom")));
const r = await svc.evaluate({ orgId: "o", specialistId: "s", message: "refund" });
expect(r.skillLayerText).toBeNull();
expect(r.sourceRefs).toEqual([]);
expect(r.abstain).toBe(false);
});

it("no ConfigAssetsService (lean module) โ†’ no skills, no throw", async () => {
const svc = new SkillTriggerService(undefined as any);
const r = await svc.evaluate({ orgId: "o", specialistId: "s", message: "refund" });
expect(r.skillLayerText).toBeNull();
expect(r.abstain).toBe(false);
});
});

Run โ†’ FAIL (service absent).

  • Step 2: Implement skill-trigger.service.ts
import { Injectable, Optional, Logger } from "@nestjs/common";
import { ConfigAssetsService } from "../config-assets/config-assets.service";
import { renderSkillLayer, ABSTAIN_POSTURE } from "../config-assets/skill-render";
import { evaluateSkillTriggers, type SkillTriggerCandidate } from "./skill-trigger.util";

export interface SkillEvaluation {
/** Rendered layer-โ‘ฃ text (matched skills' instructions OR abstain posture), or null when the skill engine is absent/errored. */
skillLayerText: string | null;
/** `assetId:versionNo` refs for the matched skills (P3.4 trace); empty on abstain/error. */
sourceRefs: string[];
/** True when skills were consulted but none matched (drives abstain posture). */
abstain: boolean;
}

const NO_SKILLS: SkillEvaluation = { skillLayerText: null, sourceRefs: [], abstain: false };

@Injectable()
export class SkillTriggerService {
private readonly logger = new Logger(SkillTriggerService.name);

constructor(@Optional() private readonly configAssets?: ConfigAssetsService) {}

/**
* Evaluate published skills for the (org ร— specialist) against the message.
* Exception-level fail-open: ANY failure โ†’ NO_SKILLS (skill layer simply
* absent, turn unaffected) โ€” same reasoning as getPublishedSoulMd.
*/
async evaluate(input: {
orgId: string | undefined;
specialistId: string | undefined;
message: string;
}): Promise<SkillEvaluation> {
if (!this.configAssets) return NO_SKILLS; // lean test module
try {
const published = await this.configAssets.getPublishedSkills({
orgId: input.orgId,
specialistId: input.specialistId,
});
if (published.length === 0) {
// Nothing published โ†’ abstain posture (deterministic: no skill claims the turn).
return { skillLayerText: ABSTAIN_POSTURE, sourceRefs: [], abstain: true };
}
const candidates: SkillTriggerCandidate[] = published.map((p) => ({
assetId: p.assetId,
versionNo: p.versionNo,
slug: p.slug,
updatedAt: p.updatedAt,
topics: p.content.trigger.topics,
keywords: p.content.trigger.keywords,
instructions: p.content.instructions,
}));
const { matched, abstain } = evaluateSkillTriggers({ message: input.message, candidates });
if (abstain) {
return { skillLayerText: ABSTAIN_POSTURE, sourceRefs: [], abstain: true };
}
const skillLayerText = renderSkillLayer(
matched.map((m) => ({ slug: m.slug, instructions: m.instructions })),
);
const sourceRefs = matched.map((m) => `${m.assetId}:${m.versionNo}`);
return { skillLayerText, sourceRefs, abstain: false };
} catch (err) {
this.logger.warn(`skill trigger evaluate failed: ${(err as Error).message}`);
return NO_SKILLS;
}
}
}
  • Step 3: Run tests โ†’ PASS (5). npx tsc --noEmit clean.

  • Step 4: Commit

git add api/src/conversations/skill-trigger.service.ts api/src/conversations/skill-trigger.service.spec.ts
git commit -m "feat(conversations): SkillTriggerService orchestration โ€” fetch/match/render/abstain, fail-open (P3.2)"

Task 5: thread skillSourceRefs through the assemblerโ€‹

Files:

  • Modify: api/src/conversations/assembler.service.ts
  • Test: api/src/conversations/assembler.service.spec.ts

Why: today only the kb layer carries sourceRefs (assembler :89). The skill layer needs sourceRefs (skill assetId:versionNo) for P3.4 trace. Small additive change: add skillSourceRefs?: string[] to AssembleInput and attach it to the skill spec entry.

  • Step 1: Write the failing test (add to assembler.service.spec.ts)
test("carries sourceRefs for the skill layer (skill asset ids for P3.4 trace)", () => {
const { layer_manifest } = svc.assemble({
...baseInput,
skill: "ACTIVE SKILLS ...",
skillSourceRefs: ["sk-1:2", "sk-2:1"],
});
const skillLayer = layer_manifest.find((l) => l.id === "skill")!;
expect(skillLayer).toBeDefined();
expect(skillLayer.sourceRefs).toEqual(["sk-1:2", "sk-2:1"]);
});

baseInput in the existing spec has no skill field; add skill + skillSourceRefs only in this test's call so the other tests (which assert the exact layer-id ordering WITHOUT skill) stay green โ€” the skill layer is filtered out when skill is null/absent.

Run โ†’ FAIL (skillSourceRefs not on the type / not on the manifest).

  • Step 2: Implement

In AssembleInput (after skill?):

  skill?: string | null; // P3.2 populates; P3.1 stub
skillSourceRefs?: string[]; // P3.2: matched skill asset:version refs (P3.4 trace)

In the spec array, the skill entry (:86):

      { id: "skill", text: input.skill, neverTruncate: false, sourceRefs: input.skillSourceRefs },

(The existing present.map(...) manifest builder already copies sourceRefs when present โ€” :127 โ€” so no other change is needed.)

  • Step 3: Run tests โ†’ PASS (new test + all existing assembler tests still green โ€” the layer-order tests are unaffected because skill stays absent in baseInput). npx tsc --noEmit clean.

  • Step 4: Commit

git add api/src/conversations/assembler.service.ts api/src/conversations/assembler.service.spec.ts
git commit -m "feat(conversations): assembler skill-layer sourceRefs for P3.4 trace (P3.2)"

Task 6: wire SkillTriggerService into both dispatch sites + register in moduleโ€‹

Files:

  • Modify: api/src/conversations/conversations.service.ts (main dispatch assemble() ~:3226; regenerate ~:6516)
  • Modify: api/src/conversations/conversations.module.ts (register SkillTriggerService)
  • Test: extend api/src/conversations/prompt-assembler-wiring.spec.ts

Available at the call sites (verified, no new plumbing โ€” Explore report ยง4): main dispatch has raw dto.content, dispatchOrgId, conv.specialistId, specialistAssignmentId, specialistSystemPrompt (passed as soul:). Regenerate has inboundText (:6365), conv.orgId, conv.specialistId, regenAssignmentId. The engine matches against the raw message text (dto.content / inboundText), exactly like classifyTask does.

  • Step 1: Read both assemble({...}) blocks (main ~3226-3238, regenerate ~6516-6523) and conversations.module.ts providers.

  • Step 2: Write the failing test (extend prompt-assembler-wiring.spec.ts)

Register the real SkillTriggerService + a mock ConfigAssetsService.getPublishedSkills. The harness already registers AssemblerService (real) and a ConfigAssetsService mock (getPublishedSoulMd) โ€” extend that mock with getPublishedSkills.

// In the providers list, extend the ConfigAssetsService mock:
{
provide: ConfigAssetsService,
useValue: {
getPublishedSoulMd: jest.fn().mockResolvedValue(null),
getPublishedSkills: jest.fn().mockResolvedValue([
{ assetId: "sk-1", versionId: "v-1", versionNo: 2, slug: "refunds", updatedAt: new Date("2026-01-01"),
content: { trigger: { description: "", topics: ["refund"], keywords: ["refund"] },
instructions: "Explain the 30-day refund policy.",
responseTemplates: [], escalationRules: [], redLines: [], tools: [] } },
]),
},
},
// ...and add SkillTriggerService to providers.

it("flag ON + skill trigger match: matched skill instructions ride in assembled_context (skill layer)", async () => {
featureFlags.resolve.mockImplementation(flagResolver(true));
await driveSendMessage(); // driveSendMessage sends "Can I get a refund?"

const arg = chatArg(orgAgentClient);
expect(arg.agentContext.assembled_prompt.assembled_context).toContain("Explain the 30-day refund policy.");
const skillLayer = arg.agentContext.assembled_prompt.layer_manifest.find((l: any) => l.id === "skill");
expect(skillLayer).toBeDefined();
expect(skillLayer.sourceRefs).toEqual(["sk-1:2"]);
});

it("flag OFF: no skill layer (skill engine only runs on the assembler path)", async () => {
featureFlags.resolve.mockImplementation(flagResolver(false));
await driveSendMessage();
const arg = chatArg(orgAgentClient);
expect(arg.agentContext).not.toHaveProperty("assembled_prompt"); // byte-equal legacy โ€” no skill anywhere
});

Run โ†’ FAIL (skill not passed at the call site).

  • Step 3: Inject + register. Add SkillTriggerService to the ConversationsService constructor (@Optional() for lean modules โ€” mirror how AssemblerService is @Optional()), and to conversations.module.ts providers.

  • Step 4: Wire BOTH call sites. At the main dispatch, inside the if (assembling) block BEFORE this.assembler.assemble({...}):

    const skillEval = this.skillTrigger
? await this.skillTrigger.evaluate({
orgId: dispatchOrgId,
specialistId: conv.specialistId,
message: dto.content, // raw inbound, same as classifyTask
})
: { skillLayerText: null, sourceRefs: [], abstain: false };

Then add to the assemble({...}) object:

      skill: skillEval.skillLayerText,
skillSourceRefs: skillEval.sourceRefs,

At the regenerate site, same shape but specialistId: conv.specialistId, message: inboundText, orgId conv.orgId ?? undefined.

The skill engine is fail-open internally, so no try/catch is needed at the call site โ€” evaluate never rejects. Guard on this.skillTrigger presence (lean modules). Do NOT run skill evaluation when NOT assembling (flag OFF) โ€” the whole point is byte-equal legacy when OFF; put the evaluate call strictly inside the if (assembling) block, matching the KB/correction gate discipline from P3.1.

  • Step 5: Run tests โ€” new tests PASS; ALL existing prompt-assembler-wiring.spec.ts (flag-OFF byte-equal, assembler-absent edge, Fix-2 dedup), persona-override-mode.spec.ts, specialist-template-key.spec.ts, and the full conversations suite still green. npx tsc --noEmit + npm run lint clean (hard budget โ€” no new warnings).

  • Step 6: Commit

git add api/src/conversations/conversations.service.ts api/src/conversations/conversations.module.ts api/src/conversations/prompt-assembler-wiring.spec.ts
git commit -m "feat(conversations): wire SkillTriggerService into both assembler dispatch sites (P3.2)"

Task 7: docs + full verificationโ€‹

Files:

  • Modify: api/src/conversations/CLAUDE.md, api/src/config-assets/CLAUDE.md

  • Step 1: Docs

    • config-assets/CLAUDE.md: add getPublishedSkills to the config-assets.service.ts key-files line and a Constraint bullet: first runtime skill consumer (P3.2), exception-level fail-open like getPublishedSoulMd, half-valid content skipped, dual-scope filter. Add skill-render.ts (single-source layer-โ‘ฃ renderer + ABSTAIN_POSTURE) to the single-source-renderer rule (soul + skill now both have one-and-only-one renderer). Update the header line that says skill has "NO runtime consumer yet โ€” the assembler that reads skills is Phase 3" โ†’ now consumed by the P3.2 trigger engine.
    • conversations/CLAUDE.md: add a Constraint bullet โ€” the P3.2 skill trigger engine (SkillTriggerService + pure skill-trigger.util.ts keyword matcher + skill-render.ts): on the assembler path (flag ON) BOTH dispatch sites evaluate published skills against the raw message โ†’ matched instructions render into assembler layer โ‘ฃ (skill) with assetId:versionNo sourceRefs; no match โ†’ ABSTAIN_POSTURE (stacks with, does not replace, the agent-side CLARIFICATION_POLICY). Fail-open (skill engine error โ†’ skill layer absent, turn unaffected). Matcher is a strategy seam (keyword shipped; semantic/Haystack is a documented drop-in โ€” DECISION POINT in the plan). Skill layer is truncatable (design ยง6.3). Note: skill.redLines is NOT checked here (P3.5); skill.tools not consumed (P4.7).
  • Step 2: Self-check no machine paths โ€” grep -rnE '/Users/|/home/|/private/tmp' api/src/conversations/CLAUDE.md api/src/config-assets/CLAUDE.md; echo exit=$? โ†’ 1.

  • Step 3: Full verification

    • cd api && npx tsc --noEmit && npm run lint โ†’ clean (hard lint budget).
    • npx jest src/conversations/ src/config-assets/ src/feature-flags/ --runInBand โ†’ all pass; flag-OFF byte-equality intact; no regression in persona-override/specialist-template-key/assembler/wiring.
    • cd agent && <venv>/bin/python -m pytest evals/test_prompt_echo_guard.py evals/test_chat_helpers.py evals/test_prompt_assembly_meta.py -q โ†’ pass (P3.2 is NestJS-only; this confirms the layer-โ‘ฃ addition to assembled_context doesn't trip the agent-side echo/CoT guard). Ignore docx/pdf/png/pandas ModuleNotFoundError (env optics libs, pre-existing).
  • Step 4: Commit docs

git add api/src/conversations/CLAUDE.md api/src/config-assets/CLAUDE.md
git commit -m "docs(P3.2): skill trigger engine + getPublishedSkills + abstain posture contracts"
  • Step 5: No further commit โ€” proceed to Phase-3-task-level review (P3.2 range <P3.1 HEAD>..HEAD). Dispatch code-reviewer: verify flag-OFF byte-equality (skill engine never runs when OFF), fail-open (skill error never degrades the turn), abstain-posture stacking (does not replace CLARIFICATION_POLICY), dual-scope on getPublishedSkills, and the matcher-seam isolation (skeleton matcher-agnostic).

Self-Review Checklistโ€‹

Spec coverage (design ยง6.5 P3.2 row): read published skills โœ… (Task 1); trigger evaluation โœ… (Task 2 keyword matcher); merge/tie-break โœ… (Task 2 ranking โ€” hit-count/updatedAt/slug, no schema field); matched instructions โ†’ layer โ‘ฃ โœ… (Task 3 renderer + Task 6 wire); no-match โ†’ role template + abstain โœ… (Task 3 ABSTAIN_POSTURE + Task 4 service; role template stays agent-side per ยง6.6). P3.4 trace hook โœ… (Task 5 skillSourceRefs).

Matcher seam: SkillTriggerCandidateโ†’SkillMatchResult (Task 2) is the swap point. Service (Task 4) and wire (Task 6) consume only SkillEvaluation โ€” a semantic matcher reimplements Task 2's evaluateSkillTriggers and everything else is untouched. Verified: no downstream code branches on "how" a match happened.

Type consistency: PublishedSkill (Task 1) โ†’ mapped to SkillTriggerCandidate (Task 4 uses Task 2's type) โ†’ MatchedSkill (Task 2) โ†’ RenderableSkill (Task 3) + sourceRefs string. getPublishedSkills return type consumed by SkillTriggerService. skillSourceRefs (Task 5) matches the sourceRefs the service emits (Task 4). assemble input key skill/skillSourceRefs (Task 5) matches the Task 6 call-site keys.

Safety net: flag default OFF โ†’ skill engine never runs โ†’ production byte-identical (Task 6 gates evaluate strictly inside if (assembling)). Fail-open at three levels: getPublishedSkills ([]), SkillTriggerService.evaluate (NO_SKILLS), and no-ConfigAssetsService lean modules. Rollback = leave flag OFF.

Boundary respected: no schema change; no redLines check (P3.5); no tools consumption (P4.7); no Haystack matcher (documented follow-up); crisis-prefix relocation left for the crisis-layer task.

DECISION POINT surfaced: keyword-vs-semantic matcher is flagged for user confirmation; keyword ships as the reversible default; the seam guarantees zero skeleton rework if the user chooses semantic.