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_000、REDLINES_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.md。repo 无 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)+ mockAuditService,不用Test.createTestingModule;schema spec 纯函数safeParse。 - 测试 harness 中心化实体清单:
api/test/test-db.config.ts:81withConfigAssetEntities——已含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.tsx对assetType!=="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.ts—SkillContentZod 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/exportSkillImportService
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.tsx—assetType==="skill"渲染SkillEditor
P2.2 archetype 物化流(后端):
- Modify
api/src/config-assets/config-assets.service.ts—CreateConfigAssetInput增archetypeAssetId?;createAsset写该列;新增materializeCatalogAsset+detectUpstreamDrift - Modify
api/src/config-assets/config-assets.service.spec.ts— 物化 + drift 测试 - Modify
api/src/config-assets/dto.ts—MaterializeAssetDto - Modify
api/src/config-assets/config-assets.controller.ts—POST /:id/materializeendpoint - Modify
frontend/src/lib/api.ts—adminMaterializeConfigAsset+ 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-reportimport 的确切导出名以 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→ 单行 Inputtrigger.topics(逗号分隔) /trigger.keywords(逗号分隔) → Input +splitCsvinstructions→ Textarea(大)escalationRules(换行) → Textarea +splitLinesredLines→ 每条{id, rule, severity}一行(id Input + rule Input + severity Selectblock/flag);MVP 可用一个"每行id|severity|rule"的 Textarea 解析,减少组件复杂度tools(逗号分隔) → Input +splitCsvresponseTemplates: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/commonimport(文件顶部应已有)。(c)findScopedAsset是 private,已在类内可调。(d)detectUpstreamDrift用findScopedAsset做实例侧双过滤(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
soulships in Phase 1" → 改为 "soul + skill ship;kb_*/tool_binding 待接入"。 -
CONTENT_SCHEMAS段:补 skill schema 文件 +SKILL_BUDGET_CHARS/REDLINES_BUDGET_CHARS引用点。 -
新增"Importer"段:
SkillImportService与SoulImportService并列;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 模块(分支基点全绿),任何红都是本次引入,规则已在头部写明对基点验证。