Skip to main content

Phase 2A — Skill asset_type 接入 + archetype→instance 物化流 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在已验证的统一配置资产底座上接入第二个资产类型 skill(schema + CRUD + 编辑器 + SKILL.md 导入器),并接上 Phase 1 只预留了列、零业务逻辑的 archetype→instance 物化流(copy-on-materialize + 显式 pull-detection),Soul/Skill 通用。

Architecture: 复用底座的"插件公式"——skill asset_type = 一个 Zod schema 文件 + CONTENT_SCHEMAS 一行注册 + 类型化前端编辑器 + 一个 SKILL.md 导入器。版本/发布/审计/回滚/隔离全部复用底座,不新增底座能力。物化流是底座的新增业务逻辑:createAsset 支持 archetypeAssetId 入参 + metadata.materializedFromHash 写入 + 一个 materializeCatalogAsset 方法(深拷贝 catalog published content → org_instance draft)+ pull-detection(比对 hash)。两块都严格遵守 ADR-020 双过滤与 §5.3 过渡发布语义。

Tech Stack: NestJS 11 + TypeORM(PG16) · Zod · Next.js 16 + React 19 · Jest(service spec 用 better-sqlite3 :memory:,schema spec 纯函数)

基线事实(实现时精确对齐,均已核实 @ ede3eed0):

  • 底座在 api/src/config-assets/;9 个 suite / 153 测试在分支基点全绿(npx jest src/config-assets)。
  • CONTENT_SCHEMAS(schemas/index.ts:10)当前只有 soul: SoulContent。加类型 = 加 schemas/<type>.schema.ts + 一行。
  • NFR5 helper pattern:const opStr = (max) => z.string().max(max).superRefine(noInfraDisclosure)(schemas/soul.schema.ts:5)。每个运营可见字符串字段必须用 opStr,不能用裸 z.string()(否则漏 NFR5,code-review block)。
  • 预算常量已就位:config-asset-budgets.ts 已有 SKILL_BUDGET_CHARS = 16_000REDLINES_BUDGET_CHARS = 4_000——直接 import,不新增
  • ConfigAssetsService.createAsset(input: CreateConfigAssetInput)——CreateConfigAssetInput(config-assets.service.ts:45)当前不含 archetypeAssetId;validateContent(:490)读 CONTENT_SCHEMAS[assetType],未注册类型抛 400。
  • CreateConfigAssetDto(dto.ts:58)不含 source/metadata/archetypeAssetId;controller create(config-assets.controller.ts:111)也不透传。
  • publish 方法名是 discardDraft(不是 discard),在 config-publish.service.ts:298
  • 实体列:archetypeAssetId(config-asset.entity.ts:70,已存在,注释标 "Phase 2 (P2.2) wires")、metadata(:86,simple-json)、draftVersionId/publishedVersionId
  • specialist 行的 isCatalog/catalogSlug(specialists/specialist.entity.ts:150,156)是人格档案层的物化,与 config-asset 的 archetypeAssetId(资产层血缘)是两个不同概念——本计划只做后者。
  • SKILL.md 真实源:orgs/{_template,acmefinancial,humanity}/skills/<toolset>/<skill>/SKILL.mdrepo 无 kaito org(设计文档里的 kaito 是示例)。frontmatter:name/description/version/metadata.hermes.{tags,related_skills};正文分节 ## When to invoke / ## Workflow / ## Output expectations 等。
  • 测试风格:service spec 直接 new DataSource(better-sqlite3 :memory:,synchronize:true)+ mock AuditService,不用 Test.createTestingModule;schema spec 纯函数 safeParse
  • 测试 harness 中心化实体清单:api/test/test-db.config.ts:81 withConfigAssetEntities——已含 SpecialistConfigAsset/SpecialistConfigVersion,本计划不新增实体,无需改此文件
  • 前端:api client 在 frontend/src/lib/api.ts:5082+;编辑器 frontend/src/app/ops/config-assets/{page.tsx,[id]/page.tsx,SoulEditor.tsx,VersionHistory.tsx}[id]/page.tsxassetType!=="soul" 目前渲染占位文案。

预存在失败核对规则(给 implementer): 任何"这个测试本来就挂"的判断必须对分支基点 ede3eed0 验证(git stash && git checkout ede3eed0 -- <path> 或在干净 checkout 跑),不是 HEAD~1。config-assets 模块在基点是全绿的,该模块内任何红都是本次引入。


File Structure

P2.1 Skill asset_type(后端):

  • Create api/src/config-assets/schemas/skill.schema.tsSkillContent Zod schema
  • Create api/src/config-assets/schemas/skill.schema.spec.ts — schema 纯函数测试
  • Modify api/src/config-assets/schemas/index.ts — 注册 skill: SkillContent
  • Create api/src/config-assets/import/skill-import.service.ts — SKILL.md 解析 + 导入(CREATE/UPDATE/SKIP)
  • Create api/src/config-assets/import/skill-import.service.spec.ts
  • Modify api/scripts/import-config-assets.ts — CLI 增 --skills 遍历导入
  • Modify api/src/config-assets/config-assets.module.ts — provide/export SkillImportService

P2.1 Skill asset_type(前端):

  • Create frontend/src/app/ops/config-assets/SkillEditor.tsx — 类型化表单编辑器
  • Create frontend/src/app/ops/config-assets/__tests__/skill-editor.test.tsx
  • Modify frontend/src/app/ops/config-assets/[id]/page.tsxassetType==="skill" 渲染 SkillEditor

P2.2 archetype 物化流(后端):

  • Modify api/src/config-assets/config-assets.service.tsCreateConfigAssetInputarchetypeAssetId?;createAsset 写该列;新增 materializeCatalogAsset + detectUpstreamDrift
  • Modify api/src/config-assets/config-assets.service.spec.ts — 物化 + drift 测试
  • Modify api/src/config-assets/dto.tsMaterializeAssetDto
  • Modify api/src/config-assets/config-assets.controller.tsPOST /:id/materialize endpoint
  • Modify frontend/src/lib/api.tsadminMaterializeConfigAsset + drift 字段类型
  • Modify frontend/src/app/ops/config-assets/page.tsx — catalog 行 "Materialize"、drift 行 "upstream changed, pull?"

收尾:

  • Modify api/src/config-assets/CLAUDE.md — skill 类型 + 物化流契约
  • Modify docs/superpowers/specs/2026-07-07-unified-config-asset-model.md — §3 skill schema 从初稿改为"as-built"标注(如与实现有偏差)

Task 1: SkillContent Zod schema

Files:

  • Create: api/src/config-assets/schemas/skill.schema.ts

  • Test: api/src/config-assets/schemas/skill.schema.spec.ts

  • Step 1: Write the failing test

api/src/config-assets/schemas/skill.schema.spec.ts:

import { SkillContent } from "./skill.schema";
import { CONTENT_SCHEMAS } from "./index";
import { SKILL_BUDGET_CHARS } from "../config-asset-budgets";

const valid = () => ({
trigger: {
description: "Screen a name against sanctions/PEP lists.",
topics: ["sanctions", "pep", "aml"],
keywords: ["screen", "sanctions list"],
},
instructions: "Acknowledge the request, then escalate for manual review.",
responseTemplates: [{ name: "escalation", body: "I'm escalating this to a human specialist." }],
escalationRules: ["Always flag_for_review=true on any sanctions hit."],
redLines: [{ id: "no-auto-clear", rule: "Never auto-clear a sanctions hit.", severity: "block" as const }],
tools: ["jumio_lookup"],
});

describe("SkillContent", () => {
it("accepts a well-formed skill", () => {
expect(SkillContent.safeParse(valid()).success).toBe(true);
});

it("defaults tools to [] when omitted", () => {
const { tools, ...noTools } = valid();
const r = SkillContent.safeParse(noTools);
expect(r.success).toBe(true);
if (r.success) expect(r.data.tools).toEqual([]);
});

it("requires at least one trigger topic", () => {
const bad = valid();
bad.trigger.topics = [];
expect(SkillContent.safeParse(bad).success).toBe(false);
});

it("rejects an invalid redline severity", () => {
const bad = valid();
(bad.redLines[0] as { severity: string }).severity = "warn";
expect(SkillContent.safeParse(bad).success).toBe(false);
});

it("rejects a redline id with illegal chars", () => {
const bad = valid();
bad.redLines[0].id = "No Spaces";
expect(SkillContent.safeParse(bad).success).toBe(false);
});

it("rejects a tool slug with illegal chars", () => {
const bad = valid();
bad.tools = ["Bad-Slug"]; // hyphen not in [a-z0-9_]
expect(SkillContent.safeParse(bad).success).toBe(false);
});

it("rejects filesystem-path infra disclosure in instructions (NFR5)", () => {
const bad = valid();
bad.instructions = "Read the file at /opt/data/SOUL.md and follow it.";
expect(SkillContent.safeParse(bad).success).toBe(false);
});

it("rejects content over the SKILL budget", () => {
const bad = valid();
bad.instructions = "x".repeat(SKILL_BUDGET_CHARS + 1);
expect(SkillContent.safeParse(bad).success).toBe(false);
});

it("is registered under the skill asset_type", () => {
expect(CONTENT_SCHEMAS.skill).toBe(SkillContent);
});
});
  • Step 2: Run test to verify it fails

Run: cd api && npx jest src/config-assets/schemas/skill.schema.spec.ts Expected: FAIL — Cannot find module './skill.schema'.

  • Step 3: Write the schema

api/src/config-assets/schemas/skill.schema.ts:

import { z } from "zod";
import { noInfraDisclosure } from "../no-infra-disclosure";
import { SKILL_BUDGET_CHARS } from "../config-asset-budgets";

// NFR5: every operator-visible string field runs the infra-disclosure
// validator. Same helper shape as soul.schema.ts — do NOT use a bare
// z.string() for an operator-visible field.
const opStr = (max: number) => z.string().max(max).superRefine(noInfraDisclosure);

// R2.1: trigger + instructions + response templates + structured redLines +
// reserved `tools` field (R6/P4.7 consumes skills∩bindings). #3447: archetype
// and instance share this one schema.
export const SkillContent = z
.object({
trigger: z.object({
description: opStr(500),
topics: z.array(opStr(64)).min(1).max(12),
keywords: z.array(opStr(64)).max(24).default([]),
}),
instructions: opStr(12_000).pipe(z.string().min(1)),
responseTemplates: z
.array(
z.object({
name: opStr(64),
body: opStr(4_000),
}),
)
.max(8)
.default([]),
escalationRules: z.array(opStr(300)).max(12).default([]),
redLines: z
.array(
z.object({
id: z.string().regex(/^[a-z0-9-]{1,48}$/),
rule: opStr(500).pipe(z.string().min(1)),
severity: z.enum(["block", "flag"]),
}),
)
.max(24)
.default([]),
tools: z.array(z.string().regex(/^[a-z0-9_]{1,64}$/)).default([]),
})
.superRefine((val, ctx) => {
// §6 layer-⑤ budget: reject at save; runtime never truncates (NFR3).
if (JSON.stringify(val).length > SKILL_BUDGET_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Skill content exceeds the ${SKILL_BUDGET_CHARS}-char assembly budget — trim it.`,
});
}
});

export type SkillContentT = z.infer<typeof SkillContent>;

Note for implementer: opStr(N).pipe(z.string().min(1)) 强制"非空且过 NFR5 且限长"。instructions/redLines[].rule 用它;不能只写 opStr(N).min(1)——.superRefine.min 需重新链在 string 上,用 .pipe 最稳。跑测试确认 instructions: "" 被拒。

  • Step 4: Register in CONTENT_SCHEMAS

Modify api/src/config-assets/schemas/index.ts — 现状:

export const CONTENT_SCHEMAS: Partial<Record<ConfigAssetType, ZodType>> = {
soul: SoulContent,
};

改为(顶部加 import,注册加一行):

import { ZodType } from "zod";
import { ConfigAssetType } from "../config-asset.entity";
import { SoulContent } from "./soul.schema";
import { SkillContent } from "./skill.schema";

/**
* asset_type → content schema registry. Adding a type = add its schema file +
* one entry here (design §1 non-goal: no generic-platform extension points
* beyond the five known types).
*/
export const CONTENT_SCHEMAS: Partial<Record<ConfigAssetType, ZodType>> = {
soul: SoulContent,
skill: SkillContent,
};
  • Step 5: Run test to verify it passes

Run: cd api && npx jest src/config-assets/schemas/skill.schema.spec.ts Expected: PASS (9 tests).

  • Step 6: Commit
git add api/src/config-assets/schemas/skill.schema.ts api/src/config-assets/schemas/skill.schema.spec.ts api/src/config-assets/schemas/index.ts
git commit -m "feat(config-assets): SkillContent Zod schema + registry entry (P2.1)"

Task 2: skill CRUD 通过底座(集成测试,零新代码)

底座的 createAsset/saveDraft/publish 已按 CONTENT_SCHEMAS[assetType] 动态校验——注册 skill 后 CRUD 应自动可用。此 Task 用一个 service 级测试证明该断言,不写新实现代码(YAGNI 验证)。

Files:

  • Test: api/src/config-assets/config-assets.service.spec.ts(追加 describe 块)

  • Step 1: 读现有 spec 的 setup 复用

Run: cd api && sed -n '1,170p' src/config-assets/config-assets.service.spec.ts 确认:new DataSource + sqlite :memory:remapForSqlite()、mock audit、beforeEach 清表、如何 seed 一个 org_instance asset。照抄同款 setup。

  • Step 2: Write the failing test

config-assets.service.spec.ts 末尾追加(复用文件顶部已建的 service/assetRepo/versionRepo 与常量;若常量名不同,以文件实际为准):

describe("skill asset_type via base (P2.1)", () => {
const skillContent = {
trigger: { description: "Screen sanctions.", topics: ["sanctions"], keywords: [] },
instructions: "Escalate to a human.",
responseTemplates: [],
escalationRules: [],
redLines: [],
tools: [],
};

it("creates + reads a skill org_instance asset through the base", async () => {
const asset = await service.createAsset({
assetType: "skill",
scope: "org_instance",
orgId: ORG_ID,
specialistId: SPECIALIST_ID,
slug: "sanctions-check",
displayName: "Sanctions Check",
content: skillContent,
actor: ACTOR_ID,
});
expect(asset.assetType).toBe("skill");

const detail = await service.getAsset(asset.id, { orgId: ORG_ID, specialistId: SPECIALIST_ID });
expect(detail.draftContent).toMatchObject({ instructions: "Escalate to a human." });
});

it("rejects an invalid skill (empty topics) with 400", async () => {
await expect(
service.createAsset({
assetType: "skill",
scope: "org_instance",
orgId: ORG_ID,
specialistId: SPECIALIST_ID,
slug: "bad-skill",
displayName: "Bad",
content: { ...skillContent, trigger: { description: "x", topics: [], keywords: [] } },
actor: ACTOR_ID,
}),
).rejects.toThrow(/invalid content|BadRequest/i);
});
});

Implementer: 用文件里已定义的 ORG_ID/SPECIALIST_ID/ACTOR_ID 常量名(Step 1 已读出实际拼写)。如果文件用别的常量名(如 orgId/specialistId 局部),对齐它。

  • Step 3: Run test to verify it passes immediately

Run: cd api && npx jest src/config-assets/config-assets.service.spec.ts -t "skill asset_type via base" Expected: PASS — 无需改实现。若 FAIL(如校验没触发),说明 Task 1 注册没生效,回查 CONTENT_SCHEMAS

  • Step 4: Commit
git add api/src/config-assets/config-assets.service.spec.ts
git commit -m "test(config-assets): skill CRUD flows through base unchanged (P2.1)"

Task 3: SKILL.md 导入器

复制 soul-import.service.ts 的 CREATE/UPDATE/SKIP + hash 幂等骨架,替换解析逻辑为 SKILL.md(YAML frontmatter + markdown 分节)→ SkillContent

Files:

  • Create: api/src/config-assets/import/skill-import.service.ts

  • Test: api/src/config-assets/import/skill-import.service.spec.ts

  • Modify: api/src/config-assets/config-assets.module.ts

  • Step 1: 读 soul-import 骨架

Run: cd api && sed -n '500,640p' src/config-assets/import/soul-import.service.ts 确认 SoulImportTarget 结构、importSoul 的 findOne 条件、knownHashes SKIP 判定、CREATE 调 createAsset(source:"imported_repo", metadata:{importSourcePath})、UPDATE 调 saveDraft + unifiedDiff。skill 导入器结构照此复刻。

  • Step 2: Write the failing test

api/src/config-assets/import/skill-import.service.spec.ts:

import { parseSkillMarkdown } from "./skill-import.service";

const SAMPLE = `---
name: sanctions-check
description: Screen a name against sanctions, PEP, and adverse-media lists.
version: 1.0.0
metadata:
hermes:
tags: [kyc, sanctions, pep]
related_skills: [jumio-lookup]
---

# sanctions-check — Sanctions Screening

Use this skill when the question is about sanctions screening.

## When to invoke

- The customer asks to screen a name against sanctions lists.

## Workflow

1. Acknowledge the request.
2. Escalate for manual review.

## Output expectations

Always flag_for_review=true on a hit.
`;

describe("parseSkillMarkdown", () => {
it("maps frontmatter name+description+tags into trigger", () => {
const parsed = parseSkillMarkdown(SAMPLE);
expect(parsed.valid).toBe(true);
expect(parsed.slug).toBe("sanctions-check");
expect(parsed.content.trigger.description).toContain("sanctions");
expect(parsed.content.trigger.topics).toEqual(["kyc", "sanctions", "pep"]);
});

it("folds the body sections into instructions", () => {
const parsed = parseSkillMarkdown(SAMPLE);
expect(parsed.content.instructions).toContain("When to invoke");
expect(parsed.content.instructions).toContain("Escalate for manual review");
});

it("defaults redLines/tools/responseTemplates to empty (human fills later)", () => {
const parsed = parseSkillMarkdown(SAMPLE);
expect(parsed.content.redLines).toEqual([]);
expect(parsed.content.tools).toEqual([]);
expect(parsed.warnings).toContain("redLines empty — fill structured red lines manually");
});

it("marks invalid when frontmatter has no name", () => {
const parsed = parseSkillMarkdown(SAMPLE.replace("name: sanctions-check\n", ""));
expect(parsed.valid).toBe(false);
});

it("produces content that passes SkillContent schema", () => {
const { SkillContent } = require("../schemas/skill.schema");
const parsed = parseSkillMarkdown(SAMPLE);
expect(SkillContent.safeParse(parsed.content).success).toBe(true);
});
});
  • Step 3: Run test to verify it fails

Run: cd api && npx jest src/config-assets/import/skill-import.service.spec.ts Expected: FAIL — Cannot find module './skill-import.service'.

  • Step 4: Write the parser + service

api/src/config-assets/import/skill-import.service.ts:

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { IsNull, Repository } from "typeorm";
import * as yaml from "js-yaml";
import { SpecialistConfigAsset } from "../config-asset.entity";
import { SpecialistConfigVersion } from "../config-asset-version.entity";
import { ConfigAssetsService } from "../config-assets.service";
import { computeContentHash, canonicalJson } from "../config-assets.util";
import { SkillContent, SkillContentT } from "../schemas/skill.schema";
import { ImportItemReport, unifiedDiff } from "./import-report";

export interface ParsedSkill {
slug: string;
displayName: string;
content: SkillContentT;
valid: boolean;
warnings: string[];
errorReason?: string;
}

const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;

/**
* Pure parse of a SKILL.md (YAML frontmatter + markdown body) into SkillContent.
* Exported so the CLI can dry-run without a DB (degraded pure-parse mode).
* Design §8: frontmatter → trigger; body → instructions; redLines/tools are a
* human follow-up (导入报告列 TODO), defaulted empty here.
*/
export function parseSkillMarkdown(markdown: string): ParsedSkill {
const warnings: string[] = [];
const fm = FRONTMATTER_RE.exec(markdown);
let meta: Record<string, unknown> = {};
if (fm) {
try {
meta = (yaml.load(fm[1]) as Record<string, unknown>) ?? {};
} catch {
return emptyInvalid("frontmatter is not valid YAML");
}
}
const name = typeof meta.name === "string" ? meta.name.trim() : "";
if (!name) return emptyInvalid("frontmatter has no `name`");

const description = typeof meta.description === "string" ? meta.description.trim() : "";
const hermes = ((meta.metadata as Record<string, unknown>)?.hermes ?? {}) as Record<string, unknown>;
const tags = Array.isArray(hermes.tags) ? (hermes.tags as unknown[]).map(String) : [];

const body = fm ? markdown.slice(fm[0].length) : markdown;
const instructions = body.trim();

const topics = tags.length ? tags.slice(0, 12) : [name];
if (!tags.length) warnings.push("no frontmatter tags — trigger.topics defaulted to [name]");
warnings.push("redLines empty — fill structured red lines manually");
if (!description) warnings.push("no frontmatter description — trigger.description defaulted to name");

const content: SkillContentT = {
trigger: {
description: (description || name).slice(0, 500),
topics,
keywords: [],
},
instructions: instructions.slice(0, 12_000),
responseTemplates: [],
escalationRules: [],
redLines: [],
tools: [],
};

const parsed = SkillContent.safeParse(content);
if (!parsed.success) {
return { slug: name, displayName: name, content, valid: false, warnings, errorReason: "parsed content fails SkillContent schema" };
}
return { slug: name, displayName: name, content: parsed.data, valid: true, warnings };
}

function emptyInvalid(reason: string): ParsedSkill {
return {
slug: "",
displayName: "",
content: {
trigger: { description: "", topics: [], keywords: [] },
instructions: "",
responseTemplates: [],
escalationRules: [],
redLines: [],
tools: [],
},
valid: false,
warnings: [],
errorReason: reason,
};
}

export interface SkillImportTarget {
orgId: string;
orgSlug: string;
specialistId: string;
specialistLabel: string;
actor: string;
}

@Injectable()
export class SkillImportService {
constructor(
@InjectRepository(SpecialistConfigAsset) private readonly assetRepo: Repository<SpecialistConfigAsset>,
@InjectRepository(SpecialistConfigVersion) private readonly versionRepo: Repository<SpecialistConfigVersion>,
private readonly configAssets: ConfigAssetsService,
) {}

/**
* Resolve by (skill, org_instance, org, specialist, slug) → compare
* content_hash against draft AND published → SKIP / UPDATE / CREATE.
* Mirror of SoulImportService.importSoul. Writes go through
* ConfigAssetsService (validation, tx, version numbering, audit reuse).
*/
async importSkill(params: {
target: SkillImportTarget;
parsed: ParsedSkill;
sourcePath: string;
sourceChars: number;
apply: boolean;
}): Promise<ImportItemReport> {
const { target, parsed, sourcePath, sourceChars, apply } = params;
const label = `${parsed.slug}@${target.orgSlug}`;
const base: ImportItemReport = { assetType: "skill", label, action: "SKIP", sourcePath, sourceChars, warnings: parsed.warnings };

if (!parsed.valid) {
return { ...base, action: "ERROR", errorReason: parsed.errorReason ?? "invalid skill" };
}

const contentHash = computeContentHash(parsed.content);
const existing = await this.assetRepo.findOne({
where: {
assetType: "skill",
scope: "org_instance",
orgId: target.orgId,
specialistId: target.specialistId,
slug: parsed.slug,
archivedAt: IsNull(),
},
});

if (!existing) {
if (apply) {
await this.configAssets.createAsset({
assetType: "skill",
scope: "org_instance",
orgId: target.orgId,
specialistId: target.specialistId,
slug: parsed.slug,
displayName: parsed.displayName,
content: parsed.content as Record<string, unknown>,
actor: target.actor,
source: "imported_repo",
metadata: { importSourcePath: sourcePath },
});
}
return { ...base, action: "CREATE", contentHash };
}

const versions = await this.versionRepo.find({ where: { assetId: existing.id } });
const knownHashes = versions
.filter((v) => v.publishStatus === "draft" || v.publishStatus === "published")
.map((v) => v.contentHash);
if (knownHashes.includes(contentHash)) {
return { ...base, action: "SKIP", contentHash, warnings: [], detailLines: [] };
}

const current = versions.find((v) => v.publishStatus === "draft") ?? versions.find((v) => v.publishStatus === "published");
const diff = current ? unifiedDiff(canonicalJson(current.content), canonicalJson(parsed.content)) : undefined;

if (apply) {
await this.configAssets.saveDraft(existing.id, {
content: parsed.content as Record<string, unknown>,
actor: target.actor,
orgId: target.orgId,
specialistId: target.specialistId,
});
}
return { ...base, action: "UPDATE", contentHash, previousHash: current?.contentHash, diff };
}
}

Implementer 校验点: (a) js-yaml 是否已在 api/package.json 依赖?先 cd api && node -e "require('js-yaml')"——若报错,先 npm i js-yaml @types/js-yaml(soul importer 若已用别的 frontmatter 方案,改用它,勿引第二个 YAML 库)。(b) ImportItemReport/unifiedDiff./import-report import 的确切导出名以 Step 1 读到的为准。(c) saveDraft 若 UPDATE 时资产已有非 draft 状态,底座会新建 version——这是底座既有行为,不在此处理。

  • Step 5: Run test to verify it passes

Run: cd api && npx jest src/config-assets/import/skill-import.service.spec.ts Expected: PASS (5 tests).

  • Step 6: Register in module

Modify api/src/config-assets/config-assets.module.ts — providers 与 exports 各加 SkillImportService(与 SoulImportService 并列):

// import 顶部
import { SkillImportService } from "./import/skill-import.service";
// providers 数组加 SkillImportService
// exports 数组加 SkillImportService
  • Step 7: Run module test to confirm DI graph still builds

Run: cd api && npx jest src/config-assets/config-assets.controller.spec.ts Expected: PASS(controller spec 走完整 TestingModule,证明新 provider 不破坏图)。若报 "No metadata for SpecialistConfigVersion" 之类,检查 module TypeOrmModule.forFeature 是否已含两实体(应已含)。

  • Step 8: Commit
git add api/src/config-assets/import/skill-import.service.ts api/src/config-assets/import/skill-import.service.spec.ts api/src/config-assets/config-assets.module.ts
git commit -m "feat(config-assets): SKILL.md importer — frontmatter+body → SkillContent, hash-idempotent (P2.1)"

Task 4: CLI 遍历导入 skills

import-config-assets.ts 增 skill 遍历:扫 orgs/<slug>/skills/**/SKILL.md,每个文件走 SkillImportService

Files:

  • Modify: api/scripts/import-config-assets.ts

  • Step 1: 读现有 CLI 结构

Run: cd api && sed -n '100,290p' scripts/import-config-assets.ts 确认:如何读 orgs/<slug>/SOUL.md、如何手工装配 new ConfigAssetsService(...)、specialists 查询(join org_specialist_assignments)、dry-run vs --apply、exit code 规则。

  • Step 2: 增 skill 遍历(手工验证,无单测)

main() 里,SOUL.md 处理之后,增 skill 段(实现要点,implementer 按 Step 1 的实际变量名对齐):

// 手工装配 SkillImportService(与 SoulImportService 同款,复用同一 assetRepo/versionRepo/configAssets)
const skillImport = new SkillImportService(assetRepo, versionRepo, configAssets);

// 扫 orgs/<slug>/skills/**/SKILL.md
import { globSync } from "glob"; // 若 repo 无 glob,用 fs.readdirSync 递归;先查 package.json
const skillFiles = globSync("skills/**/SKILL.md", { cwd: resolve(orgDir) });
for (const rel of skillFiles) {
const abs = resolve(orgDir, rel);
const md = readFileSync(abs, "utf8");
const parsed = parseSkillMarkdown(md);
const item = await skillImport.importSkill({
target: { orgId, orgSlug, specialistId, specialistLabel, actor },
parsed,
sourcePath: `orgs/${orgSlug}/${rel}`, // repo-relative — never an absolute machine path (NFR5)
sourceChars: md.length,
apply,
});
report.items.push(item);
}

关键约束: sourcePath 必须是 repo 相对路径(orgs/<slug>/skills/...),绝不能是本机绝对路径——它会写进 metadata.importSourcePath 并可能出现在报告/审计里(no-machine-paths-in-repo-docs 记忆 + NFR5)。soul importer 已这么做,照抄。

  • Step 3: Dry-run 手工验证

Run:

cd api && DATABASE_URL= npx ts-node scripts/import-config-assets.ts --org acmefinancial --dry-run

Expected: 无 DB 时降级 pure-parse,输出含 [skill] escalation-draft@acmefinancial ... (from orgs/acmefinancial/skills/kyc-ops/escalation-draft/SKILL.md ...) 三个 kyc-ops skill 的 PARSED 行 + ⚠ redLines empty 警告。确认输出无 /Users/ 或本机绝对路径

ts-node/入口用法与 Step 1 读到的不同,以实际为准。

  • Step 4: Commit
git add api/scripts/import-config-assets.ts
git commit -m "feat(config-assets): CLI imports orgs/*/skills/**/SKILL.md (P2.1)"

Task 5: SkillEditor 前端表单

Files:

  • Create: frontend/src/app/ops/config-assets/SkillEditor.tsx

  • Test: frontend/src/app/ops/config-assets/__tests__/skill-editor.test.tsx

  • Modify: frontend/src/app/ops/config-assets/[id]/page.tsx

  • Step 1: 读 SoulEditor 作为模板

Run: cd frontend && sed -n '1,200p' src/app/ops/config-assets/SoulEditor.tsx 确认:props 形状(draftContent/onSave/busy/issues?)、splitCsv/splitLines helper、errorFor(prefix) 如何把 Zod issue path 映射到 FormField、root-path issue 作 form-level error、用哪些 UI 组件(FormField/Input/Textarea/Button)。SkillEditor 照此风格。

  • Step 2: Write the failing test

frontend/src/app/ops/config-assets/__tests__/skill-editor.test.tsx(仿现有 soul-editor.test.tsx 的渲染+断言风格——先 sed -n '1,60p' src/app/ops/config-assets/__tests__/soul-editor.test.tsx 读它用 RTL 还是别的):

import { render, screen, fireEvent } from "@testing-library/react";
import { SkillEditor } from "../SkillEditor";

const draft = {
trigger: { description: "Screen sanctions.", topics: ["sanctions", "pep"], keywords: ["screen"] },
instructions: "Escalate to a human.",
responseTemplates: [],
escalationRules: ["Flag on any hit."],
redLines: [{ id: "no-auto-clear", rule: "Never auto-clear.", severity: "block" }],
tools: ["jumio_lookup"],
};

describe("SkillEditor", () => {
it("renders trigger description and instructions from draft content", () => {
render(<SkillEditor draftContent={draft} onSave={jest.fn()} busy={false} issues={[]} />);
expect(screen.getByDisplayValue("Screen sanctions.")).toBeInTheDocument();
expect(screen.getByDisplayValue("Escalate to a human.")).toBeInTheDocument();
});

it("serialises comma/newline fields back into structured content on save", () => {
const onSave = jest.fn();
render(<SkillEditor draftContent={draft} onSave={onSave} busy={false} issues={[]} />);
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
trigger: expect.objectContaining({ topics: ["sanctions", "pep"] }),
tools: ["jumio_lookup"],
redLines: expect.arrayContaining([expect.objectContaining({ id: "no-auto-clear", severity: "block" })]),
}),
);
});
});
  • Step 3: Run test to verify it fails

Run: cd frontend && npx jest src/app/ops/config-assets/__tests__/skill-editor.test.tsx Expected: FAIL — Cannot find module '../SkillEditor'.

  • Step 4: Write SkillEditor

frontend/src/app/ops/config-assets/SkillEditor.tsx — 受控表单,字段映射:

  • trigger.description → 单行 Input
  • trigger.topics (逗号分隔) / trigger.keywords (逗号分隔) → Input + splitCsv
  • instructions → Textarea(大)
  • escalationRules (换行) → Textarea + splitLines
  • redLines → 每条 {id, rule, severity} 一行(id Input + rule Input + severity Select block/flag);MVP 可用一个"每行 id|severity|rule"的 Textarea 解析,减少组件复杂度
  • tools (逗号分隔) → Input + splitCsv
  • responseTemplates:MVP 可只读展示或留空数组(导入器也默认空);表单可后续增。先满足测试:onSave 输出 responseTemplates: draftContent.responseTemplates ?? [](原样透传,不在 UI 编辑)。

实现骨架(与 SoulEditor 同 helper/组件;splitCsv/splitLines/errorFor 若 SoulEditor 里是文件内私有,复制过来或抽到共享 util):

"use client";
import { useState } from "react";
// import 与 SoulEditor 同款 UI 组件 + FormField + splitCsv/splitLines/errorFor

export interface SkillEditorProps {
draftContent: Record<string, any> | null;
onSave: (content: Record<string, unknown>) => void;
busy: boolean;
issues: { path: string; message: string; code?: string }[];
}

export function SkillEditor({ draftContent, onSave, busy, issues }: SkillEditorProps) {
const c = draftContent ?? {};
const t = c.trigger ?? {};
const [description, setDescription] = useState<string>(t.description ?? "");
const [topics, setTopics] = useState<string>((t.topics ?? []).join(", "));
const [keywords, setKeywords] = useState<string>((t.keywords ?? []).join(", "));
const [instructions, setInstructions] = useState<string>(c.instructions ?? "");
const [escalation, setEscalation] = useState<string>((c.escalationRules ?? []).join("\n"));
const [tools, setTools] = useState<string>((c.tools ?? []).join(", "));
const [redLinesRaw, setRedLinesRaw] = useState<string>(
(c.redLines ?? []).map((r: any) => `${r.id}|${r.severity}|${r.rule}`).join("\n"),
);

const handleSave = () => {
const redLines = splitLines(redLinesRaw)
.map((line) => line.split("|"))
.filter((p) => p.length >= 3)
.map(([id, severity, ...rest]) => ({ id: id.trim(), severity: severity.trim(), rule: rest.join("|").trim() }));
onSave({
trigger: { description: description.trim(), topics: splitCsv(topics), keywords: splitCsv(keywords) },
instructions: instructions.trim(),
responseTemplates: c.responseTemplates ?? [],
escalationRules: splitLines(escalation),
redLines,
tools: splitCsv(tools),
});
};

// render FormFields (errorFor("trigger.description"), errorFor("instructions"), etc.)
// + a form-level error banner for root-path issues, per SoulEditor pattern
// + <Button onClick={handleSave} disabled={busy}>Save draft</Button>
}

Implementer: splitCsv/splitLines/errorFor 必须与 SoulEditor 完全一致(Step 1 读出的实现)。若它们在 SoulEditor 里是文件内私有函数,抽到 frontend/src/app/ops/config-assets/editorUtils.ts 共享并让 SoulEditor 也 import(DRY;但这是纯重构,SoulEditor 测试须仍绿)。

  • Step 5: Run test to verify it passes

Run: cd frontend && npx jest src/app/ops/config-assets/__tests__/skill-editor.test.tsx Expected: PASS(2 tests)。

  • Step 6: Wire into detail page

Modify frontend/src/app/ops/config-assets/[id]/page.tsx — 找到 assetType === "soul" 渲染 <SoulEditor> 的分支(约 :228),增 skill 分支:

// import 顶部
import { SkillEditor } from "../SkillEditor";
// 渲染分支
{detail.assetType === "soul" && <SoulEditor ... />}
{detail.assetType === "skill" && (
<SkillEditor
key={detail.draftVersionId ?? detail.publishedVersionId ?? "new"}
draftContent={detail.draftContent}
onSave={handleSave}
busy={busy}
issues={issues}
/>
)}
{detail.assetType !== "soul" && detail.assetType !== "skill" && (
/* 保留原占位文案 */
)}

handleSave/busy/issues 复用页面已有的(soul 用同一套)。onSave 的签名要与页面 handleSave 期望一致(Step 1/前面读到的 adminSaveConfigAssetDraft 调用)。

  • Step 7: Run detail-page test if one exists + build

Run: cd frontend && npx jest src/app/ops/config-assets && npx tsc --noEmit -p tsconfig.json Expected: 相关测试 PASS,tsc 无新错。

  • Step 8: Commit
git add frontend/src/app/ops/config-assets/SkillEditor.tsx frontend/src/app/ops/config-assets/__tests__/skill-editor.test.tsx frontend/src/app/ops/config-assets/[id]/page.tsx
# 若抽了 editorUtils.ts + 改了 SoulEditor:一并 add
git commit -m "feat(ops): SkillEditor typed form + wire into config-assets detail page (P2.1)"

Task 6: archetypeAssetId 入参 + createAsset 写入(P2.2 基础)

底座 createAsset 当前不接收 archetypeAssetId。先扩展入参与写入,再在 Task 7 建物化方法。

Files:

  • Modify: api/src/config-assets/config-assets.service.ts

  • Test: api/src/config-assets/config-assets.service.spec.ts

  • Step 1: Write the failing test

config-assets.service.spec.ts 追加:

describe("archetypeAssetId lineage (P2.2)", () => {
it("persists archetypeAssetId + metadata.materializedFromHash when supplied", async () => {
const instance = await service.createAsset({
assetType: "soul",
scope: "org_instance",
orgId: ORG_ID,
specialistId: SPECIALIST_ID,
slug: "soul",
displayName: "Amy",
content: MINIMAL_SOUL, // reuse whatever valid soul content the file already defines
actor: ACTOR_ID,
archetypeAssetId: "11111111-1111-1111-1111-111111111111",
metadata: { materializedFromHash: "abc123" },
});
const row = await assetRepo.findOneByOrFail({ id: instance.id });
expect(row.archetypeAssetId).toBe("11111111-1111-1111-1111-111111111111");
expect(row.metadata).toMatchObject({ materializedFromHash: "abc123" });
});
});

MINIMAL_SOUL:用文件里已有的合法 soul content 常量;若没有,内联一个最小合法体(identity/voice/boundaries/languages)。

  • Step 2: Run test to verify it fails

Run: cd api && npx jest src/config-assets/config-assets.service.spec.ts -t "archetypeAssetId lineage" Expected: FAIL — TS 报 archetypeAssetId 不在 CreateConfigAssetInput,或该列未写入(row.archetypeAssetId 为 null)。

  • Step 3: Extend input + write path

Modify api/src/config-assets/config-assets.service.ts:

CreateConfigAssetInput(:45)增字段:

export interface CreateConfigAssetInput {
// ... existing fields ...
metadata?: Record<string, unknown> | null;
archetypeAssetId?: string | null; // P2.2: catalog→instance lineage
}

createAsset 建 asset 行处(:145 附近,assetRepo.create({...}))增:

const asset = this.assetRepo.create({
// ... existing ...
source: input.source ?? "manual",
metadata: input.metadata ?? null,
archetypeAssetId: input.archetypeAssetId ?? null,
});

Implementer: 以 Step 1 读到的 createAsset 实际写法为准——只增这一列的赋值,不动其它逻辑。CHECK 约束(catalog 行 archetype_asset_id IS NULL)由 DB/迁移保证;service 层不强制,由调用方(物化流只在 org_instance 用)保证。

  • Step 4: Run test to verify it passes

Run: cd api && npx jest src/config-assets/config-assets.service.spec.ts -t "archetypeAssetId lineage" Expected: PASS。

  • Step 5: Commit
git add api/src/config-assets/config-assets.service.ts api/src/config-assets/config-assets.service.spec.ts
git commit -m "feat(config-assets): createAsset accepts archetypeAssetId + metadata (P2.2 base)"

Task 7: materializeCatalogAsset + detectUpstreamDrift

Files:

  • Modify: api/src/config-assets/config-assets.service.ts

  • Test: api/src/config-assets/config-assets.service.spec.ts

  • Step 1: Write the failing test

追加(复用 setup):

describe("materializeCatalogAsset (P2.2)", () => {
async function seedPublishedCatalogSoul() {
const cat = await service.createAsset({
assetType: "soul", scope: "catalog", orgId: null,
specialistId: ARCHETYPE_SPECIALIST_ID, slug: "soul",
displayName: "Archetype Amy", content: MINIMAL_SOUL, actor: ACTOR_ID,
});
// publish the catalog draft so it has a published version
await publishSvc.requestPublish(cat.id, cat.draftVersionId!, ACTOR_ID, { orgId: null, specialistId: ARCHETYPE_SPECIALIST_ID });
return service.getAsset(cat.id, { orgId: null, specialistId: ARCHETYPE_SPECIALIST_ID });
}

it("deep-copies catalog published content into an org_instance draft with lineage", async () => {
const cat = await seedPublishedCatalogSoul();
const inst = await service.materializeCatalogAsset(cat.id, {
orgId: ORG_ID, specialistId: SPECIALIST_ID, actor: ACTOR_ID,
});
expect(inst.scope).toBe("org_instance");
expect(inst.orgId).toBe(ORG_ID);
expect(inst.archetypeAssetId).toBe(cat.id);
const detail = await service.getAsset(inst.id, { orgId: ORG_ID, specialistId: SPECIALIST_ID });
expect(detail.draftContent).toMatchObject(MINIMAL_SOUL);
// materializedFromHash == catalog published version hash
const catPubHash = cat.versions.find((v) => v.id === cat.publishedVersionId)!.contentHash;
expect((inst.metadata as any).materializedFromHash).toBe(catPubHash);
});

it("detectUpstreamDrift reports drift when catalog re-published with new content", async () => {
const cat = await seedPublishedCatalogSoul();
const inst = await service.materializeCatalogAsset(cat.id, { orgId: ORG_ID, specialistId: SPECIALIST_ID, actor: ACTOR_ID });
// no drift right after materialize
expect((await service.detectUpstreamDrift(inst.id, { orgId: ORG_ID, specialistId: SPECIALIST_ID })).drifted).toBe(false);
// change catalog: new draft + publish
await service.saveDraft(cat.id, { content: { ...MINIMAL_SOUL, identity: { ...MINIMAL_SOUL.identity, bio: "changed" } }, actor: ACTOR_ID, orgId: null, specialistId: ARCHETYPE_SPECIALIST_ID });
const catAfter = await service.getAsset(cat.id, { orgId: null, specialistId: ARCHETYPE_SPECIALIST_ID });
await publishSvc.requestPublish(cat.id, catAfter.draftVersionId!, ACTOR_ID, { orgId: null, specialistId: ARCHETYPE_SPECIALIST_ID });
const drift = await service.detectUpstreamDrift(inst.id, { orgId: ORG_ID, specialistId: SPECIALIST_ID });
expect(drift.drifted).toBe(true);
});

it("rejects materializing a catalog asset with no published version (404/400)", async () => {
const cat = await service.createAsset({
assetType: "soul", scope: "catalog", orgId: null,
specialistId: ARCHETYPE_SPECIALIST_ID, slug: "unpublished",
displayName: "X", content: MINIMAL_SOUL, actor: ACTOR_ID,
});
await expect(
service.materializeCatalogAsset(cat.id, { orgId: ORG_ID, specialistId: SPECIALIST_ID, actor: ACTOR_ID }),
).rejects.toThrow();
});
});

Implementer: 需要 ConfigPublishService 实例(publishSvc)。若 service spec 尚未 new 它,在 setup 里 const publishSvc = new ConfigPublishService(audit as any, ds)(构造签名见 config-publish.service.ts:64:audit, dataSource)。ARCHETYPE_SPECIALIST_ID 新增一个测试常量(任意 uuid,catalog 行的 specialistId 指向 archetype specialist)。

  • Step 2: Run test to verify it fails

Run: cd api && npx jest src/config-assets/config-assets.service.spec.ts -t "materializeCatalogAsset" Expected: FAIL — service.materializeCatalogAsset is not a function

  • Step 3: Implement the two methods

ConfigAssetsService 增(放在 archiveAsset 之后、findScopedAsset 之前):

/**
* P2.2 copy-on-materialize (design §7): deep-copy a catalog asset's PUBLISHED
* content into a new org_instance asset (draft), stamping archetype lineage +
* the source content hash. The new instance then goes through the normal
* publish flow (T1–T2) — materialize does NOT auto-publish. ADR-020: writes an
* org_instance row (Org × Specialist); the catalog read is the explicit
* SuperAdmin/catalog branch (org_id IS NULL on the source).
*/
async materializeCatalogAsset(
catalogAssetId: string,
params: { orgId: string; specialistId: string; actor: string },
): Promise<SpecialistConfigAsset> {
const catalog = await this.assetRepo.findOne({
where: { id: catalogAssetId, scope: "catalog", archivedAt: IsNull() },
});
if (!catalog) throw new NotFoundException("catalog asset not found"); // 404 not 403 (#1472)
if (!catalog.publishedVersionId) {
throw new BadRequestException("catalog asset has no published version to materialize");
}
const pub = await this.versionRepo.findOneByOrFail({ id: catalog.publishedVersionId });

return this.createAsset({
assetType: catalog.assetType,
scope: "org_instance",
orgId: params.orgId,
specialistId: params.specialistId,
slug: catalog.slug,
displayName: catalog.displayName,
content: structuredClone(pub.content), // deep copy — no shared reference
actor: params.actor,
source: "manual",
archetypeAssetId: catalog.id,
metadata: { materializedFromHash: pub.contentHash },
});
}

/**
* P2.2 upstream-drift detection (design §7): compare the catalog asset's
* current published content_hash against the hash recorded at materialize time
* (metadata.materializedFromHash). Never mutates — the Ops UI turns `drifted`
* into an "upstream changed, pull update?" prompt; the pull itself is an
* explicit human action (a future task), never silent propagation (R2.6/R4.6).
*/
async detectUpstreamDrift(
instanceAssetId: string,
ctx: ConfigAssetCtx,
): Promise<{ drifted: boolean; currentUpstreamHash: string | null; materializedFromHash: string | null }> {
const instance = await this.findScopedAsset(this.assetRepo, instanceAssetId, ctx);
const materializedFromHash =
(instance.metadata as Record<string, unknown> | null)?.materializedFromHash as string | undefined ?? null;
if (!instance.archetypeAssetId || !materializedFromHash) {
return { drifted: false, currentUpstreamHash: null, materializedFromHash };
}
const upstream = await this.assetRepo.findOne({ where: { id: instance.archetypeAssetId } });
const currentUpstreamHash = upstream?.publishedVersionId
? (await this.versionRepo.findOneByOrFail({ id: upstream.publishedVersionId })).contentHash
: null;
return {
drifted: currentUpstreamHash !== null && currentUpstreamHash !== materializedFromHash,
currentUpstreamHash,
materializedFromHash,
};
}

Implementer 校验点: (a) structuredClone 在 Node 18+ 全局可用——确认 api 的 Node 版本 ≥18(cd api && node -v);若不可用,用 JSON.parse(JSON.stringify(...))。(b) NotFoundException/BadRequestException@nestjs/common import(文件顶部应已有)。(c) findScopedAsset 是 private,已在类内可调。(d) detectUpstreamDriftfindScopedAsset 做实例侧双过滤(ADR-020),但读 upstream(catalog)时按 id 直取——这是允许的,因为 catalog 是平台资产、instance 已通过 scope 检查证明调用方有权看这条 instance 的血缘。

  • Step 4: Run test to verify it passes

Run: cd api && npx jest src/config-assets/config-assets.service.spec.ts -t "materializeCatalogAsset" Expected: PASS(3 tests)。

  • Step 5: Run full module suite (no regression)

Run: cd api && npx jest src/config-assets Expected: 全绿(原 153 + 新增)。

  • Step 6: Commit
git add api/src/config-assets/config-assets.service.ts api/src/config-assets/config-assets.service.spec.ts
git commit -m "feat(config-assets): materializeCatalogAsset + detectUpstreamDrift (P2.2)"

Task 8: 物化 REST endpoint + DTO

Files:

  • Modify: api/src/config-assets/dto.ts

  • Modify: api/src/config-assets/config-assets.controller.ts

  • Test: api/src/config-assets/config-assets.controller.spec.ts

  • Step 1: Write the failing test

config-assets.controller.spec.ts 追加一个 case(仿现有 create/publish 的 controller 测试风格,先读文件确认它是 TestingModule 还是直接 new controller + mock service):

it("POST /:id/materialize materializes a catalog asset into an org instance", async () => {
// mock service.materializeCatalogAsset to return a fake instance
// call controller.materialize(id, { orgId, specialistId }, user)
// assert service.materializeCatalogAsset called with (id, { orgId, specialistId, actor: user.userId })
});

Implementer 按该 spec 文件既有的 mock/断言方式落地(Step 起点先 sed -n '1,80p')。

  • Step 2: DTO

Modify api/src/config-assets/dto.ts — 加:

export class MaterializeAssetDto {
@IsUUID()
orgId!: string;

@IsUUID()
specialistId!: string;
}
  • Step 3: Controller endpoint

Modify api/src/config-assets/config-assets.controller.ts — 在 publish/rollback 附近加(@PlatformRoles 与其它写端点一致:superadmin + account_manager;instance 目标 org 走 assertOrgAccess):

@Post(":id/materialize")
@HttpCode(201)
@PlatformRoles("superadmin", "account_manager")
async materialize(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: MaterializeAssetDto,
@CurrentUser() user: AuthUser,
) {
await this.assertOrgAccess(user, dto.orgId); // AM 只能物化到自己负责的 org
return this.configAssets.materializeCatalogAsset(id, {
orgId: dto.orgId,
specialistId: dto.specialistId,
actor: user.userId,
});
}

Implementer:@CurrentUser/AuthUser/assertOrgAccess/PlatformRoles 的 import 与既有端点一致(Step 1 读到)。user.userId 的实际字段名以文件中其它端点用的为准。

  • Step 4: Run test + build

Run: cd api && npx jest src/config-assets/config-assets.controller.spec.ts && npx tsc --noEmit -p tsconfig.json Expected: PASS,tsc 无新错。

  • Step 5: Commit
git add api/src/config-assets/dto.ts api/src/config-assets/config-assets.controller.ts api/src/config-assets/config-assets.controller.spec.ts
git commit -m "feat(config-assets): POST /:id/materialize endpoint + DTO (P2.2)"

Task 9: 前端 materialize + drift UI

Files:

  • Modify: frontend/src/lib/api.ts

  • Modify: frontend/src/app/ops/config-assets/page.tsx

  • Step 1: api client

Modify frontend/src/lib/api.ts — 在 config-assets 段(:5082+)加:

export async function adminMaterializeConfigAsset(
id: string,
body: { orgId: string; specialistId: string },
): Promise<ConfigAsset> {
return apiFetch<ConfigAsset>(`/admin/config-assets/${id}/materialize`, { method: "POST", body });
}

apiFetch 用法、ConfigAsset 类型以现有 adminPublishConfigAsset 等为模板对齐。

  • Step 2: List-page catalog action

Modify frontend/src/app/ops/config-assets/page.tsx — 在 catalog section(superadmin 渲染的 adminListCatalogConfigAssets 结果)每行加一个 "Materialize" 按钮:选中当前 org+specialist 后调 adminMaterializeConfigAsset(catalogAsset.id, { orgId, specialistId }),成功后刷新 instance 列表 + toast。

MVP 落点:list 页顶部已有 org/specialist 选择器,materialize 目标 = 当前选中的 org+specialist。若未选,按钮 disabled + 提示"先选 org 和 specialist"。

  • Step 3: Drift 提示(轻量)

instance 列表每行:若该 asset archetypeAssetId != null,展示一个小徽标。drift 检测不在列表页 N+1 调用——加一个"Check upstream" 按钮或在详情页调用 detectUpstreamDrift(需要一个 GET endpoint)。

范围裁剪(YAGNI): 完整 drift-pull UI 是后续任务。本 Task 只做:(a) materialize 按钮打通;(b) instance 行显示"materialized from archetype"徽标(纯 archetypeAssetId != null 判断,无需额外请求)。真正的 "upstream changed, pull?" 对比 + 三方 diff + pull 动作留作独立 follow-up(记在 CLAUDE.md 待办)。这样本批次交付一个完整可用的 materialize,不被 drift UI 的复杂度拖大。

  • Step 4: Build

Run: cd frontend && npx tsc --noEmit -p tsconfig.json && npx jest src/app/ops/config-assets Expected: 无新错,测试绿。

  • Step 5: Commit
git add frontend/src/lib/api.ts frontend/src/app/ops/config-assets/page.tsx
git commit -m "feat(ops): materialize catalog asset into org instance + lineage badge (P2.2)"

Task 10: 文档收尾(CLAUDE.md + 设计文档 as-built)

Files:

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

  • Modify: docs/superpowers/specs/2026-07-07-unified-config-asset-model.md

  • Step 1: 更新 api/src/config-assets/CLAUDE.md

  • 顶部"only soul ships in Phase 1" → 改为 "soul + skill ship;kb_*/tool_binding 待接入"。

  • CONTENT_SCHEMAS 段:补 skill schema 文件 + SKILL_BUDGET_CHARS/REDLINES_BUDGET_CHARS 引用点。

  • 新增"Importer"段:SkillImportServiceSoulImportService 并列;SKILL.md frontmatter→trigger、body→instructions、redLines/tools 需人工补的约定;CLI --skills 遍历;sourcePath 必须 repo-相对(NFR5)。

  • 新增"Archetype materialization (P2.2)"段:materializeCatalogAsset(copy-on-materialize,不 auto-publish)、detectUpstreamDrift(比对 metadata.materializedFromHash,只读)、archetypeAssetId 列现已 wire;pull 动作(三方 diff + 生成新 draft)是未完成 follow-up(诚实标注)。

  • Pitfalls 段:补"skill schema 的 opStr 用法同 soul""物化只写 org_instance 行,catalog 读走显式分支"。

  • Step 2: 更新设计文档 §3 skill schema 为 as-built

如实现与初稿有偏差(如 keywords/responseTemplates/escalationRules 加了 .default([])instructions.pipe(min(1))),在 §3 skill 块加一行 as-built 注记,保持文档=真相(Meta-constraint)。

  • Step 3: Commit
git add api/src/config-assets/CLAUDE.md docs/superpowers/specs/2026-07-07-unified-config-asset-model.md
git commit -m "docs(config-assets): skill type + archetype materialization contracts (P2.1/P2.2)"

Task 11: 全模块验证 + CI gate 预检

Files: 无(纯验证)

  • Step 1: API 全 config-assets 套件

Run: cd api && npx jest src/config-assets Expected: 全绿。

  • Step 2: API lint + 相关 e2e(若有)

Run: cd api && npx eslint src/config-assets --max-warnings 0 Expected: 0 error。

  • Step 3: 前端 CI 两道 gate 预检(记忆 frontend-ci-gates-gotchas)

Run:

cd frontend && npm run lint 2>&1 | tail -20   # 确认未超 --max-warnings 预算
bash ../scripts/regression-gate.sh 2>&1 | tail -20 # 全仓扫描;确认没触发 #1961 confirm( 规则

Expected: 两道都过。若 regression-gate 因新代码里的 confirm( 报错,把该调用重命名 confirmDialog(记忆里的既定解法),不要抬 lint 预算。

  • Step 4: 前端 build + tsc

Run: cd frontend && npx tsc --noEmit -p tsconfig.json Expected: 无错。

  • Step 5: 确认无本机绝对路径入库(记忆 no-machine-paths-in-repo-docs)

Run: cd .. && git diff --cached --name-only | xargs grep -l "/Users/\|/private/tmp" 2>/dev/null; git grep -n "/Users/" -- api/src/config-assets frontend/src/app/ops/config-assets Expected: 无命中(计划文档本身也不含本机路径)。

  • Step 6: 分支级最终状态

Run: git log --oneline dev..HEAD Expected: 10 个左右 feat/test/docs commit,无 WIP。


Self-Review notes(计划作者自查,已执行)

  • Spec 覆盖: master plan P2.1(schema+编辑器+clone/绑定+SKILL.md 导入器)→ Task 1/3/4/5;P2.2(copy-on-materialize + "upstream changed, pull?")→ Task 6/7/8/9。裁剪声明: P2.1 的"clone + 绑定"里的"绑定"(skill↔specialist 关联)在底座里已由 asset 的 specialistId 天然表达(每个 skill 资产就绑定在一个 specialist 上),无需独立绑定表;"clone" 由物化流(Task 7)覆盖(catalog skill → org instance)。P2.2 的 pull 动作(三方 diff + 生成 draft)裁为 follow-up,Task 9 Step 3 + Task 10 已诚实标注——这是为把批次 A 控制在可 review 规模的刻意取舍,不是遗漏。
  • 类型一致性: SkillContent/SkillContentT 贯穿 Task 1/3/5;materializeCatalogAsset/detectUpstreamDrift 方法名在 Task 7/8/9 一致;archetypeAssetId/materializedFromHash 拼写贯穿 Task 6/7/9/10。
  • 占位符扫描: 每个写代码的 step 都有完整代码或"以 Step 1 读到的实际为准"的精确对齐指令(因底座既有代码的确切局部变量名需 implementer 现读——这是对现有代码的对齐,不是 TBD)。
  • 预存在失败: Task 11 + 每个 Task 的"Run to verify it fails/passes"锚定在 config-assets 模块(分支基点全绿),任何红都是本次引入,规则已在头部写明对基点验证。