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
SkillTriggerMatcherinterface, 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 rowmetadata.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
SkillContentZod schema (no newpriority/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.redLinesinto a red-line check โ that's P3.5. P3.2 renders onlytrigger-matchedinstructions(+ optionallyresponseTemplatesreferences) into layer โฃ. - Does NOT populate
skill.toolsat 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โ purerenderSkillLayer(skills: RankedSkill[]): string. The single-source SkillContentโlayer-โฃ-string renderer (mirrorsrenderSoulMd'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โ pureevaluateSkillTriggers(input): SkillMatchResult. The keyword matcher + ranking. Deterministic, never-throws (mirrorstask-classification.util.ts). Defines theSkillTriggerMatcherseam.api/src/conversations/skill-trigger.util.spec.tsโ pure unit test.api/src/conversations/skill-trigger.service.tsโ@Injectable() SkillTriggerService: fetches published skills (viaConfigAssetsService), runs the pure matcher, renders the layer, returns{ skillLayerText, sourceRefs, abstain }. Exception-level fail-open (returns "no skills" on any error โ mirrorsgetPublishedSoulMd).api/src/conversations/skill-trigger.service.spec.tsโ service test (mock ConfigAssetsService).
Modify:
api/src/config-assets/config-assets.service.tsโ addgetPublishedSkills({orgId, specialistId}): Promise<PublishedSkill[]>(parallelsgetPublishedSoulMd; 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โ addskillSourceRefs?: string[]toAssembleInputand thread it onto theskilllayer spec entry (so the manifest carries skill ids+versions for P3.4 trace โ today onlykbcarriessourceRefs).api/src/conversations/conversations.service.tsโ at BOTH assembler call sites (main dispatchassemble()~line 3226; regenerate ~line 6516), whenassembling, callSkillTriggerService.evaluate(...)and passskill:+skillSourceRefs:intoassemble({...}).api/src/conversations/conversations.module.tsโ registerSkillTriggerService.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 specconfig-assets-published-skills.spec.tsif 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), andskill.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 andconfig-asset.entity.ts/config-asset-version.entity.tsare authoritative โ align to the actual columns, do not copy these names blind.IsNullis already imported forgetPublishedSoulMd; the logger field name matches the existing one in this service.
-
Step 4: Run tests โ PASS (4).
npx tsc --noEmitclean. -
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 --noEmitclean. -
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 --noEmitclean. -
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 --noEmitclean. -
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"]);
});
baseInputin the existing spec has noskillfield; addskill+skillSourceRefsonly 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 whenskillis 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
skillstays absent inbaseInput).npx tsc --noEmitclean. -
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 dispatchassemble()~:3226; regenerate ~:6516) - Modify:
api/src/conversations/conversations.module.ts(registerSkillTriggerService) - 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) andconversations.module.tsproviders. -
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
SkillTriggerServiceto theConversationsServiceconstructor (@Optional()for lean modules โ mirror howAssemblerServiceis@Optional()), and toconversations.module.tsproviders. -
Step 4: Wire BOTH call sites. At the main dispatch, inside the
if (assembling)block BEFOREthis.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 โ
evaluatenever rejects. Guard onthis.skillTriggerpresence (lean modules). Do NOT run skill evaluation when NOT assembling (flag OFF) โ the whole point is byte-equal legacy when OFF; put theevaluatecall strictly inside theif (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 lintclean (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: addgetPublishedSkillsto theconfig-assets.service.tskey-files line and a Constraint bullet: first runtime skill consumer (P3.2), exception-level fail-open likegetPublishedSoulMd, half-valid content skipped, dual-scope filter. Addskill-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+ pureskill-trigger.util.tskeyword 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) withassetId:versionNosourceRefs; 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.redLinesis NOT checked here (P3.5);skill.toolsnot 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 toassembled_contextdoesn'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 ongetPublishedSkills, 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.