Phase 1 — 统一配置底座 + Ops admin + Soul 竖切 · 实施计划
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.Review 节奏(用户已确认):代码任务单次合并 review(不拆 spec/quality 两段);文档任务协调者自审;全部完成后分支级 final review(专查跨任务一致性 + 模块 CLAUDE.md 欠更新)。implementer 必带两条规则:①"预存在失败"必须对分支基点验证;②并行 agent 共享 worktree git index,纯文档任务只写文件不 commit,由协调者收拢提交。
Goal: 落地 master plan Phase 1:统一配置资产底座两表 + 发布状态机(eval pass-through 过渡语义)、Soul asset_type 竖切(Zod schema → CRUD → 导入 → Ops admin UI → runtime-config 下发同源)、persona-override 启发式 cutover flag、few-shot dogfood 灰度。
Architecture: 新建 api/src/config-assets/ 模块承载底座(实体 + Zod 校验 + 状态机 + 审计);expand-only migration;Soul 是第一个(唯一本期)asset_type;下发通道复用既有 agent-runtime 的 runtime-config 端点改读底座 published version,文件系统作 fallback;agent 侧仅 P1.6 的 flag 消费改动。D1–D3 按设计文档建议方案执行(D1 = ADR-037 语义、D2 = 全 PG jsonb + 64KB 溢出阈值、D3 = 装配器短期 agent 侧,本期不动装配器)——评审若推翻需回改的点已在任务内标注。
Tech Stack: NestJS 11 + TypeORM 0.3(PG16) · zod ^4.4.3(api 已有依赖)· BullMQ · Jest(sqlite 本地 / PG CI)· Next.js 16 + Jest/RTL · FastAPI + pytest
上游文档: master plan v2 §四 Phase 1 · 统一配置资产模型设计(下称"设计 §N")· ADR-037 · PRD docs/specs/prd-specialist-value-output.md R1
基线事实(已核查,2026-07-07 @ phase0 merge 8043ae71,含 dev 714516a6):
- 实体注册是显式数组,两处必须同步:运行时
api/src/config/typeorm-data-source.config.ts(buildTypeOrmConfig)+ CLIapi/src/data-source.ts:123(有 "Keep in sync" 注释)。近例:LlmRun(api/src/llm/cost/llm-run.entity.ts,data-source.ts:111/203)。 - Migrations 在
api/migrations/(不是src/migrations/)。当前最大时间戳1806600000000-AddReleaseSignalIndex.ts(CONCURRENTLY+transaction = false的现成模板)。已知碰撞:1806400000000两个文件(dev 既有,别动)。Phase 1 新 migration 从1806700000000起。 - feature-flags:
FLAG_KEYS/DEFAULT_FLAG_VALUES在api/src/feature-flags/feature-flags.constants.ts(3-40/44-72 行);scopeglobal|org|org_specialist(FeatureFlagScopeType@common/entities.ts:996);resolve(key, {orgId, specialistAssignmentId})/isEnabled同签名(feature-flag.service.ts:98);feature_flag_audit实体已存在(common/entities.ts:1034)。correction_fewshot_enabled已注册,默认 OFF。 - AuditService
api/src/common/audit.service.ts:25:log/tryLog(opts: {userId?, orgId?, action, resourceType, resourceId?, payload?, ipAddress?}),业务 tx 外 fire-and-forget;点分 action 先例(feature_flag.toggle、directive.create)——config.publish风格成立。 - NFR5 复用源:
api/src/common/scrub-filesystem-paths.ts导出scrubFilesystemPaths(text, {warnOnly}) → {text, count}。校验器用它做检测(count>0 即拒绝),不做清洗。 - zod ^4.4.3 已在 api 依赖(package.json:119);用例先例
marketing.service.ts、correction-refinement.processor.ts。注意是 zod v4——z.string().date()等 v4 API 可用。 - runtime-config 通道:controller
api/src/agent-runtime/internal-runtime-config.controller.ts(@Controller("v1/internal/org")+@Get(":slug/runtime-config")+AgentTokenGuard,guard 在api/src/agent-api/agent-api.guard.ts);serviceapi/src/agent-runtime/orgs-runtime-config.service.tsreadForSlug(slug)返回{soul_md, config_yaml, skills[]}——当前全部读文件系统(org 目录下 soul.md/config.yaml/skills/),DB 只用于 slug→org 鉴权。 - agent 侧消费:
agent/main.py:163_fetch_runtime_config_if_missing()启动时拉 runtime-config 原子写/opt/data/(RUNTIME_DATA_ROOT,main.py:95);load_org_soul(agent/chat_helpers.py:183)按path 参数 > HUMANWORK_SOUL_PATH > /opt/data/SOUL.md读。.runtime-config.readymarker 幂等短路(main.py:167-173)。 /chat的 persona 线:NestJSagent.client.ts:387-389发specialist.system_prompt(取自specialists.systemPrompt,conversations.service.ts:2592 select)→ agentrouters/chat.py:52放进agent_context["specialist_system_prompt"]→prompts_loader.py:317读取。- persona-override 启发式:
agent/prompts_loader.py:328-332(_FULL_TEMPLATE_SIGNALS+is_full_template,>300 字符或含 4 信号词 → 整体替换角色模板)。meta 上报链(P0.6)已建:build_system_prompt_with_meta→/chat响应audit.prompt_template_used/persona_override_applied(main.py:746-747)→ draftmetadata.assembly(conversations.service.ts:3146-3155, 3228)。 - P0.6 命中率查询面:draft 消息
metadata->'assembly'->>'personaOverrideApplied',分母过滤metadata->'assembly' IS NOT NULL。 - Specialist 实体
api/src/specialists/specialist.entity.ts:表specialists,systemPrompt(:104)promptTemplateKey(:111),catalog 字段已有(catalogSlug:149、isCatalog:155)——catalog scope 的specialist_id有落点。 - repo 内真实导入源:
orgs/_template/SOUL.md、orgs/acmefinancial/SOUL.md、orgs/humanity/SOUL.md+orgs/*/skills/**/SKILL.md(repo 根orgs/)。 - frontend Ops 面:
/ops/specialists单文件组件(frontend/src/app/ops/specialists/page.tsx~2100 行),raw<textarea>在 902-920 行,保存走adminUpdateSpecialist(api.ts:5057 →PATCH /admin/specialists/:id)。导航app/ops/layout.tsxNAV_GROUPS(73-105)(saOnly支持)。页面权限<RequireRole allow={[...]}>(components/ops/RequireRole.tsx)。无任何版本历史/回滚 UI 先例——P1.4 从零建。共享表单原语components/shared/FormField.tsx+ shadcncomponents/ui/*。FE 测试 = Jest+RTL(先例app/ops/specialists/__tests__/)。 - api 测试基建:单元 spec 与
api/test/*.spec.ts同一 jest;本地 sqlite(PG 专属行为不覆盖:partial index、jsonb 默认、ON CONFLICT完整语义);CI 真 PG。新坑(本次 CI 实测):TestingModule 显式实体清单必须含图内所有forFeature实体——引ConfigAssetsModule的 harness 要把两个新实体加进testDbConfig([...])。 - 本地已知预存在失败(分支基点已验):
message-pipeline-e2e/conversations-extended的 sqlite 专属失败(NOW()/FTS)、auto-reply-modeFTS 一例、agenttest_audit_clientbraintrust 3 例、test_attachment_render7 例(venv 缺渲染依赖)。 - agent Python 测试用主 checkout 的
agent/.venv(worktree 无 venv)。主 checkout node_modules 过期,api 测试只在 worktree 跑。
任务依赖:T1→T2→T3→T4→T5→T6(底座串行);T7(导入)/T8-T9(FE) 依赖 T6;T10(下发) 依赖 T6,与 T7-T9 可并行;T11(P1.6) 依赖 T10;T12(P1.7) 独立可先做;T13(文档) 最后。
Task 1: 底座实体 + 注册(P1.1a)
Files:
-
Create:
api/src/config-assets/config-asset.entity.ts -
Create:
api/src/config-assets/config-asset-version.entity.ts -
Modify:
api/src/config/typeorm-data-source.config.ts(实体数组) -
Modify:
api/src/data-source.ts(实体数组,与上同步) -
Test:
api/src/config-assets/config-asset.entity.spec.ts -
Step 1: 写失败测试(实体形状 + CHECK 约束语义在 service 层测,这里先测元数据注册与列映射)
// api/src/config-assets/config-asset.entity.spec.ts
import { DataSource } from "typeorm";
import { SpecialistConfigAsset, CONFIG_ASSET_TYPES, CONFIG_ASSET_SCOPES } from "./config-asset.entity";
import { SpecialistConfigVersion, CONFIG_VERSION_STATUSES } from "./config-asset-version.entity";
describe("config-asset entities", () => {
let ds: DataSource;
beforeAll(async () => {
ds = new DataSource({
type: "better-sqlite3", database: ":memory:", synchronize: true,
entities: [SpecialistConfigAsset, SpecialistConfigVersion],
});
await ds.initialize();
});
afterAll(async () => { await ds.destroy(); });
it("registers both entities with snake_case tables", () => {
expect(ds.getMetadata(SpecialistConfigAsset).tableName).toBe("specialist_config_assets");
expect(ds.getMetadata(SpecialistConfigVersion).tableName).toBe("specialist_config_versions");
});
it("asset defaults: source=manual, no pointers, not archived", async () => {
const repo = ds.getRepository(SpecialistConfigAsset);
const row = await repo.save(repo.create({
assetType: "soul", scope: "org_instance",
orgId: "00000000-0000-0000-0000-000000000001",
specialistId: "00000000-0000-0000-0000-000000000002",
slug: "amy-soul", displayName: "Amy Soul",
createdBy: "00000000-0000-0000-0000-000000000003",
}));
expect(row.source).toBe("manual");
expect(row.publishedVersionId).toBeNull();
expect(row.draftVersionId).toBeNull();
expect(row.archivedAt).toBeNull();
});
it("version defaults: publish_status=draft, eval_run_id null", async () => {
const assets = ds.getRepository(SpecialistConfigAsset);
const asset = await assets.save(assets.create({
assetType: "soul", scope: "org_instance",
orgId: "00000000-0000-0000-0000-000000000001",
specialistId: "00000000-0000-0000-0000-000000000002",
slug: "amy-soul-2", displayName: "Amy Soul",
createdBy: "00000000-0000-0000-0000-000000000003",
}));
const versions = ds.getRepository(SpecialistConfigVersion);
const v = await versions.save(versions.create({
assetId: asset.id, versionNo: 1, content: { a: 1 },
contentHash: "x".repeat(64), authorId: "00000000-0000-0000-0000-000000000003",
}));
expect(v.publishStatus).toBe("draft");
expect(v.evalRunId).toBeNull();
});
it("exports closed unions matching the design doc", () => {
expect(CONFIG_ASSET_TYPES).toEqual(["soul", "skill", "kb_golden_answer", "kb_domain_rule", "tool_binding"]);
expect(CONFIG_ASSET_SCOPES).toEqual(["catalog", "org_instance"]);
expect(CONFIG_VERSION_STATUSES).toEqual(["draft", "publishing", "published", "blocked", "rolled_back", "discarded"]);
});
});
- Step 2: 跑测试确认失败
Run: cd api && npx jest config-asset.entity --no-coverage
Expected: FAIL — module not found。
- Step 3: 实现两个实体(字段/约束逐列对照设计 §2.1/§2.2;ADR-020 docstring 原样声明是 code-review block 项)
// api/src/config-assets/config-asset.entity.ts
import {
Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn,
} from "typeorm";
export const CONFIG_ASSET_TYPES = ["soul", "skill", "kb_golden_answer", "kb_domain_rule", "tool_binding"] as const;
export type ConfigAssetType = (typeof CONFIG_ASSET_TYPES)[number];
export const CONFIG_ASSET_SCOPES = ["catalog", "org_instance"] as const;
export type ConfigAssetScope = (typeof CONFIG_ASSET_SCOPES)[number];
export const CONFIG_ASSET_SOURCES = ["manual", "imported_repo", "loop_proposal"] as const;
export type ConfigAssetSource = (typeof CONFIG_ASSET_SOURCES)[number];
/**
* Unified Specialist config asset (Soul / Skill / KB golden answer / KB domain
* rule / tool binding) — the versioned, audited, publish-gated base layer.
*
* ADR-020 isolation matrix row: `org_instance` rows are **row-1 class
* (Org × Specialist)** — every query MUST filter `org_id` AND `specialist_id`;
* `catalog` rows are platform assets (SuperAdmin surface), `org_id IS NULL`,
* read only through the service layer's explicit catalog branch and never
* mixed into tenant query results. Until DB-level RLS lands (issue #3),
* isolation is enforced at the service-query layer.
*
* Design: docs/superpowers/specs/2026-07-07-unified-config-asset-model.md §2.1.
*/
@Entity("specialist_config_assets")
@Index("idx_config_assets_lookup", ["orgId", "specialistId", "assetType"])
export class SpecialistConfigAsset {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ name: "asset_type", type: "varchar", length: 32 })
assetType: ConfigAssetType;
@Column({ type: "varchar", length: 16 })
scope: ConfigAssetScope;
// NULL for catalog rows (deliberate — NOT the K2 sentinel-org pattern; see
// design §2.1 for why config assets have no fan-in read that would need it).
@Column({ name: "org_id", type: "uuid", nullable: true })
orgId: string | null;
@Column({ name: "specialist_id", type: "uuid" })
specialistId: string;
@Column({ type: "varchar", length: 64 })
slug: string;
@Column({ name: "display_name", type: "varchar", length: 128 })
displayName: string;
// Instance lineage to its catalog archetype (design §7). Always NULL for
// catalog rows. Phase 2 (P2.2) wires materialize/pull; column ships now so
// the importer can stamp lineage without a second migration.
@Column({ name: "archetype_asset_id", type: "uuid", nullable: true })
archetypeAssetId: string | null;
@Column({ name: "published_version_id", type: "uuid", nullable: true })
publishedVersionId: string | null;
@Column({ name: "draft_version_id", type: "uuid", nullable: true })
draftVersionId: string | null;
@Column({ type: "varchar", length: 24, default: "manual" })
source: ConfigAssetSource;
// Asset-level notes that never enter assembly nor the content hash:
// materializedFromHash (design §7), import source path, etc.
@Column({ type: "simple-json", nullable: true })
metadata: Record<string, unknown> | null;
@Column({ name: "created_by", type: "uuid" })
createdBy: string;
@CreateDateColumn({ name: "created_at" })
createdAt: Date;
@UpdateDateColumn({ name: "updated_at" })
updatedAt: Date;
@Column({ name: "archived_at", type: "timestamptz" as any, nullable: true })
archivedAt: Date | null;
}
// api/src/config-assets/config-asset-version.entity.ts
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
export const CONFIG_VERSION_STATUSES = ["draft", "publishing", "published", "blocked", "rolled_back", "discarded"] as const;
export type ConfigVersionStatus = (typeof CONFIG_VERSION_STATUSES)[number];
/**
* Immutable content version of a SpecialistConfigAsset (all asset types share
* this table). Content is Zod-validated jsonb (design §3); >64KB overflows to
* R2 by pointer (design §4 — not implemented until a type can hit it).
*
* ADR-020: attached to its asset row — reads must come through the asset's
* scope check (service layer). Same row-1 class as the asset for org_instance.
*
* State machine: design §5. All transitions are atomic claims
* (UPDATE ... WHERE publish_status = $prev RETURNING *) per the root
* CLAUDE.md idempotency contract #3.
*/
@Entity("specialist_config_versions")
@Index("uq_config_versions_asset_version", ["assetId", "versionNo"], { unique: true })
@Index("idx_config_versions_asset", ["assetId", "createdAt"])
export class SpecialistConfigVersion {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ name: "asset_id", type: "uuid" })
assetId: string;
@Column({ name: "version_no", type: "int" })
versionNo: number;
@Column({ type: "simple-json" })
content: Record<string, unknown>;
@Column({ name: "content_r2_key", type: "varchar", length: 512, nullable: true })
contentR2Key: string | null;
// sha256(canonical-JSON(content)) — import idempotency + archetype pull
// detection both key on it (design §7/§8).
@Column({ name: "content_hash", type: "varchar", length: 64 })
contentHash: string;
@Column({ name: "publish_status", type: "varchar", length: 16, default: "draft" })
publishStatus: ConfigVersionStatus;
// Phase 4 wires EvalRun; until then stays NULL and every publish carries
// audit payload evalGate:"not_wired" (design §5.3 transition semantics).
@Column({ name: "eval_run_id", type: "uuid", nullable: true })
evalRunId: string | null;
@Column({ name: "publish_note", type: "varchar", length: 1000, nullable: true })
publishNote: string | null;
@Column({ name: "author_id", type: "uuid" })
authorId: string;
@CreateDateColumn({ name: "created_at" })
createdAt: Date;
@Column({ name: "published_at", type: "timestamptz" as any, nullable: true })
publishedAt: Date | null;
@Column({ name: "published_by", type: "uuid", nullable: true })
publishedBy: string | null;
}
注意:timestamptz 在 sqlite 驱动下不存在——仓库对这种双驱动列的既有做法是用 type: "datetime" 兼容或工具函数;先 grep archived_at\|timestamptz 看 common/entities.ts 现 行写法照抄(Message.deliveredAt 等列有先例),保持与仓库一致,不要发明新模式。simple-json 同理是仓库 jsonb/sqlite 兼容惯例(先例:Message.metadata,entities.ts:526 附近)——如果现行先例是 type: "jsonb" + sqlite 条件分支,照抄先例。
- Step 4: 注册实体(两处)
api/src/config/typeorm-data-source.config.ts 与 api/src/data-source.ts 的实体数组各加:
import { SpecialistConfigAsset } from "../config-assets/config-asset.entity";
import { SpecialistConfigVersion } from "../config-assets/config-asset-version.entity";
// ...两个 entities 数组末尾追加:
SpecialistConfigAsset,
SpecialistConfigVersion,
(data-source.ts 的 import 路径是 ./config-assets/...。)
- Step 5: 跑测试确认通过
Run: cd api && npx jest config-asset.entity --no-coverage && npx tsc --noEmit
Expected: PASS;tsc clean。
- Step 6: Commit
git add api/src/config-assets/ api/src/config/typeorm-data-source.config.ts api/src/data-source.ts
git commit -m "feat(config-assets): unified config asset base entities (P1.1, design §2)"
Task 2: Expand-only migration(P1.1b)
Files:
-
Create:
api/migrations/1806700000000-CreateSpecialistConfigAssets.ts -
Test: CI 的
api-postgres-migrationsjob 覆盖 forward-apply;本地只验 tsc -
Step 1: 写 migration(设计 §2.3 顺序:先两表后指针 FK,索引
CONCURRENTLY;1806600000000-AddReleaseSignalIndex.ts是transaction = false模板)
// api/migrations/1806700000000-CreateSpecialistConfigAssets.ts
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* P1.1 — unified config asset base tables (expand-only).
* Design: docs/superpowers/specs/2026-07-07-unified-config-asset-model.md §2.
* Order matters: tables first, then the two pointer FKs (NOT VALID → VALIDATE)
* to avoid the create-time circular FK, then indexes CONCURRENTLY.
*/
export class CreateSpecialistConfigAssets1806700000000 implements MigrationInterface {
name = "CreateSpecialistConfigAssets1806700000000";
transaction = false as const; // CREATE INDEX CONCURRENTLY cannot run inside a tx
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS specialist_config_assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_type varchar(32) NOT NULL
CHECK (asset_type IN ('soul','skill','kb_golden_answer','kb_domain_rule','tool_binding')),
scope varchar(16) NOT NULL CHECK (scope IN ('catalog','org_instance')),
org_id uuid NULL REFERENCES organizations(id),
specialist_id uuid NOT NULL REFERENCES specialists(id),
slug varchar(64) NOT NULL,
display_name varchar(128) NOT NULL,
archetype_asset_id uuid NULL REFERENCES specialist_config_assets(id),
published_version_id uuid NULL,
draft_version_id uuid NULL,
source varchar(24) NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual','imported_repo','loop_proposal')),
metadata jsonb NULL,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz NULL,
CONSTRAINT chk_config_assets_scope_shape CHECK (
(scope = 'catalog' AND org_id IS NULL AND archetype_asset_id IS NULL)
OR (scope = 'org_instance' AND org_id IS NOT NULL)
)
)
`);
await q.query(`
CREATE TABLE IF NOT EXISTS specialist_config_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL REFERENCES specialist_config_assets(id) ON DELETE CASCADE,
version_no int NOT NULL,
content jsonb NOT NULL,
content_r2_key varchar(512) NULL,
content_hash varchar(64) NOT NULL,
publish_status varchar(16) NOT NULL DEFAULT 'draft'
CHECK (publish_status IN ('draft','publishing','published','blocked','rolled_back','discarded')),
eval_run_id uuid NULL,
publish_note varchar(1000) NULL,
author_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz NULL,
published_by uuid NULL,
CONSTRAINT uq_config_versions_asset_version UNIQUE (asset_id, version_no)
)
`);
// Pointer FKs after both tables exist (design §2.3 step 3).
await q.query(`ALTER TABLE specialist_config_assets
ADD CONSTRAINT fk_config_assets_published_version
FOREIGN KEY (published_version_id) REFERENCES specialist_config_versions(id) NOT VALID`);
await q.query(`ALTER TABLE specialist_config_assets VALIDATE CONSTRAINT fk_config_assets_published_version`);
await q.query(`ALTER TABLE specialist_config_assets
ADD CONSTRAINT fk_config_assets_draft_version
FOREIGN KEY (draft_version_id) REFERENCES specialist_config_versions(id) NOT VALID`);
await q.query(`ALTER TABLE specialist_config_assets VALIDATE CONSTRAINT fk_config_assets_draft_version`);
// Partial unique + hot-path indexes, CONCURRENTLY (transaction=false above).
await q.query(`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_config_assets_instance_slug
ON specialist_config_assets (asset_type, org_id, specialist_id, slug)
WHERE scope = 'org_instance' AND archived_at IS NULL`);
await q.query(`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_config_assets_catalog_slug
ON specialist_config_assets (asset_type, specialist_id, slug)
WHERE scope = 'catalog' AND archived_at IS NULL`);
await q.query(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_config_assets_lookup
ON specialist_config_assets (org_id, specialist_id, asset_type)
WHERE archived_at IS NULL`);
await q.query(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_config_assets_archetype
ON specialist_config_assets (archetype_asset_id)
WHERE archetype_asset_id IS NOT NULL`);
await q.query(`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_config_versions_one_draft
ON specialist_config_versions (asset_id) WHERE publish_status = 'draft'`);
await q.query(`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_config_versions_one_publishing
ON specialist_config_versions (asset_id) WHERE publish_status = 'publishing'`);
await q.query(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_config_versions_asset
ON specialist_config_versions (asset_id, created_at DESC)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS specialist_config_versions`);
await q.query(`DROP TABLE IF EXISTS specialist_config_assets`);
}
}
先照 1806600000000-AddReleaseSignalIndex.ts 核对 transaction=false 的实际写法(属性 vs 静态字段)与 IF NOT EXISTS/indisvalid 后检模式,保持一致。
- Step 2: tsc + 迁移注册确认
Run: cd api && npx tsc --noEmit && grep -n "1806700000000" -r migrations/ src/data-source.ts
Expected: clean;migration glob 自动拾取(确认 data-source.ts 的 migrations 配置是 glob 而非显式列表;若显式列表则补注册)。
- Step 3: Commit
git add api/migrations/1806700000000-CreateSpecialistConfigAssets.ts
git commit -m "feat(config-assets): expand-only migration for base tables + state-machine partial indexes (P1.1)"
Task 3: 预算常量 + NFR5 校验 refinement(P1.1c)
Files:
-
Create:
api/src/config-assets/config-asset-budgets.ts -
Create:
api/src/config-assets/no-infra-disclosure.ts -
Test:
api/src/config-assets/no-infra-disclosure.spec.ts -
Step 1: 写失败测试
// api/src/config-assets/no-infra-disclosure.spec.ts
import { z } from "zod";
import { noInfraDisclosure } from "./no-infra-disclosure";
import { SOUL_BUDGET_CHARS } from "./config-asset-budgets";
describe("noInfraDisclosure refinement (NFR5)", () => {
const schema = z.string().superRefine(noInfraDisclosure);
it("passes ordinary operator text incl. URLs", () => {
expect(schema.safeParse("Reply warmly. See https://acme.com/policy.").success).toBe(true);
});
it("rejects filesystem paths with a field-level message", () => {
const r = schema.safeParse("Read /opt/data/SOUL.md before answering");
expect(r.success).toBe(false);
if (!r.success) {
expect(r.error.issues[0].message).toMatch(/infrastructure|path/i);
}
});
it("rejects home-dir and relative traversal paths", () => {
expect(schema.safeParse("check ~/secrets/key").success).toBe(false);
expect(schema.safeParse("see ../../etc/passwd").success).toBe(false);
});
});
describe("budget constants", () => {
it("exposes the §6 Soul budget as a single source", () => {
expect(SOUL_BUDGET_CHARS).toBe(8_000);
});
});
- Step 2: 跑测试确认失败
Run: cd api && npx jest no-infra-disclosure --no-coverage → FAIL(module not found)。
- Step 3: 实现
// api/src/config-assets/config-asset-budgets.ts
/**
* Assembly-layer budgets (design §6) — single source shared by the Zod
* validators (save-time rejection) and, from Phase 3 on, the assembler input
* contract sent to the agent. Chars ≈ 4/token. Do NOT re-declare these
* literals anywhere else (validator/assembler drift is the failure mode).
*/
export const SOUL_BUDGET_CHARS = 8_000; // layer ④ — never truncated at runtime
export const REDLINES_BUDGET_CHARS = 4_000; // layer ① — never truncated (Phase 2 consumer)
export const SKILL_BUDGET_CHARS = 16_000; // layer ⑤ (Phase 2 consumer)
export const KB_BUDGET_CHARS = 16_000; // layer ⑦ (Phase 3 consumer)
// api/src/config-assets/no-infra-disclosure.ts
import { z } from "zod";
import { scrubFilesystemPaths } from "../common/scrub-filesystem-paths";
/**
* NFR5 (PRD R1 AC3): operator-visible strings in config-asset content must not
* contain infrastructure disclosures. Reuses the #3500 scrubber's detection as
* a VALIDATOR (reject with field-level error), never as a silent rewrite —
* the operator should fix the text, not have it mangled.
*/
export function noInfraDisclosure(value: string, ctx: z.RefinementCtx): void {
const { count } = scrubFilesystemPaths(value, { warnOnly: true });
if (count > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Content contains a filesystem path or infrastructure detail — remove it (NFR5 / #3500).",
});
}
}
先核对 scrubFilesystemPaths 的真实签名与 warnOnly 语义(api/src/common/scrub-filesystem-paths.ts)——目标是"只检测不改写";若 warnOnly 不是这个语义,就比较 text !== value 或用它导出的检测正则。zod v4 的 superRefine/ZodIssueCode API 若有变化(v4 改为 z.core 命名空间的情况),以本地 node_modules/zod 类型为准调整。
- Step 4: 跑测试确认通过 + Commit
Run: cd api && npx jest no-infra-disclosure --no-coverage
git add api/src/config-assets/config-asset-budgets.ts api/src/config-assets/no-infra-disclosure.ts api/src/config-assets/no-infra-disclosure.spec.ts
git commit -m "feat(config-assets): shared assembly budgets + NFR5 no-infra-disclosure validator (P1.1)"
Task 4: Soul content Zod schema + 注册表(P1.2a)
Files:
-
Create:
api/src/config-assets/schemas/soul.schema.ts -
Create:
api/src/config-assets/schemas/index.ts(asset_type → schema 注册表) -
Test:
api/src/config-assets/schemas/soul.schema.spec.ts -
Step 1: 写失败测试
// api/src/config-assets/schemas/soul.schema.spec.ts
import { SoulContent } from "./soul.schema";
import { CONTENT_SCHEMAS } from "./index";
const valid = {
identity: { displayName: "Amy", roleTitle: "IR Specialist", bio: "Seasoned IR pro." },
voice: { tone: ["warm", "concise"], styleRules: ["Use active voice."] },
boundaries: { never: ["Promise returns."], escalate: ["Legal threats."] },
languages: ["en", "zh-CN"],
};
describe("SoulContent schema (R1.1)", () => {
it("accepts a valid soul", () => {
expect(SoulContent.safeParse(valid).success).toBe(true);
});
it("rejects an empty identity.displayName", () => {
expect(SoulContent.safeParse({ ...valid, identity: { ...valid.identity, displayName: "" } }).success).toBe(false);
});
it("rejects invalid language tags", () => {
expect(SoulContent.safeParse({ ...valid, languages: ["english"] }).success).toBe(false);
});
it("rejects infra disclosure in operator strings (NFR5)", () => {
const bad = { ...valid, voice: { ...valid.voice, styleRules: ["read /opt/data/SOUL.md"] } };
expect(SoulContent.safeParse(bad).success).toBe(false);
});
it("rejects a soul whose serialized size exceeds the §6 budget", () => {
const bloated = { ...valid, identity: { ...valid.identity, bio: "x".repeat(1_900) }, voice: { tone: ["a"], styleRules: Array(12).fill("y".repeat(299)) } };
// 1_900 + 12*299 + envelope > 8_000
expect(SoulContent.safeParse(bloated).success).toBe(false);
});
it("is reachable via the asset_type registry", () => {
expect(CONTENT_SCHEMAS.soul).toBe(SoulContent);
});
});
-
Step 2: 跑测试确认失败 →
npx jest soul.schema --no-coverage -
Step 3: 实现(设计 §3 逐字段;整体预算用
superRefine在对象级校验序列化长度)
// api/src/config-assets/schemas/soul.schema.ts
import { z } from "zod";
import { noInfraDisclosure } from "../no-infra-disclosure";
import { SOUL_BUDGET_CHARS } from "../config-asset-budgets";
const opStr = (max: number) => z.string().max(max).superRefine(noInfraDisclosure);
export const SoulContent = z.object({
identity: z.object({
displayName: opStr(64).min(1),
roleTitle: opStr(128).min(1),
bio: opStr(2_000),
}),
voice: z.object({
tone: z.array(opStr(64)).max(8),
styleRules: z.array(opStr(300)).max(12),
}),
boundaries: z.object({
never: z.array(opStr(300)).max(16),
escalate: z.array(opStr(300)).max(16),
}),
languages: z.array(z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/)).min(1).max(8),
signOff: z.object({ enabled: z.boolean(), template: opStr(200) }).optional(),
}).superRefine((val, ctx) => {
// §6 layer-④ budget: reject at save; runtime never truncates Soul (NFR3).
if (JSON.stringify(val).length > SOUL_BUDGET_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Soul content exceeds the ${SOUL_BUDGET_CHARS}-char assembly budget — trim it (R1 AC3).`,
});
}
});
export type SoulContentT = z.infer<typeof SoulContent>;
// api/src/config-assets/schemas/index.ts
import { ZodType } from "zod";
import { ConfigAssetType } from "../config-asset.entity";
import { SoulContent } from "./soul.schema";
/**
* asset_type → content schema registry. Phase 1 ships soul only; 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,
};
(zod v4 下 .min(1) 需在 .superRefine 前链——如实现时链式顺序报类型错,把 min/max 全部放在 superRefine 之前。)
- Step 4: 跑测试确认通过 + Commit
git add api/src/config-assets/schemas/
git commit -m "feat(config-assets): Soul content Zod schema + type registry (P1.2, R1.1)"
Task 5: ConfigAssetsService CRUD + 模块(P1.1d)
Files:
- Create:
api/src/config-assets/config-assets.service.ts - Create:
api/src/config-assets/config-assets.module.ts - Modify:
api/src/app.module.ts(imports 加ConfigAssetsModule) - Test:
api/src/config-assets/config-assets.service.spec.ts
行为契约(测试即规格):
createAsset({assetType, scope, orgId, specialistId, slug, displayName, content, actor})— Zod 校验 content →INSERT资产行 + version 1 (draft) + 更新draft_version_id。slug 冲突(同作用域未归档)→ConflictException。scope shape 违规(org_instance 无 orgId)→BadRequestException。saveDraft(assetId, {content, actor, orgId, specialistId})— 已有 draft 则覆写内容(draft 可变、version 不可变的边界:draft 状态的 version 行内容可更新,一旦离开 draft 永不再写);无 draft 则建新 version(version_no = max+1)。content 过 Zod;content_hash重算。listAssets({orgId, specialistId, assetType?})— 强制 org+specialist 双过滤(ADR-020 row-1);catalog 走listCatalogAssets({assetType?})独立方法(scope='catalog'显式分支)。均过滤archived_at IS NULL。getAsset(assetId, {orgId, specialistId})— 跨 org 拿不到 → 404 不是 403(安全规则 #1472)。返回资产 + 当前 draft/published version 内容 + 版本历史(最近 20 条,不含 content,含 status/author/created_at)。archiveAsset(assetId, {orgId, specialistId, actor})— 软删(archived_at = now()),审计config.archive。
- Step 1: 写失败测试(sqlite 内存 DataSource + 直接实例化 service;mock AuditService;照
draft-quality.service.spec.ts的直接实例化风格)
// api/src/config-assets/config-assets.service.spec.ts — 核心断言(完整用例覆盖上面 5 条契约)
describe("ConfigAssetsService", () => {
// ds = better-sqlite3 :memory:, entities: [SpecialistConfigAsset, SpecialistConfigVersion]
// svc = new ConfigAssetsService(assetRepo, versionRepo, auditMock, dataSource)
it("createAsset validates content against the type schema and creates v1 draft", async () => {
const a = await svc.createAsset({ assetType: "soul", scope: "org_instance", orgId: ORG, specialistId: SPEC, slug: "amy-soul", displayName: "Amy", content: validSoul, actor: USER });
expect(a.draftVersionId).toBeTruthy();
const v = await versionRepo.findOneByOrFail({ id: a.draftVersionId! });
expect(v.versionNo).toBe(1);
expect(v.publishStatus).toBe("draft");
expect(v.contentHash).toHaveLength(64);
});
it("createAsset rejects invalid content with field-level issues", async () => {
await expect(svc.createAsset({ ...base, content: { identity: {} } }))
.rejects.toMatchObject({ status: 400 });
});
it("listAssets requires BOTH org and specialist filters (ADR-020 row-1)", async () => {
await seedTwoSpecialists();
const rows = await svc.listAssets({ orgId: ORG, specialistId: SPEC });
expect(rows.every((r) => r.specialistId === SPEC)).toBe(true);
});
it("getAsset cross-org returns 404, not 403 (#1472)", async () => {
const a = await seedAsset();
await expect(svc.getAsset(a.id, { orgId: OTHER_ORG, specialistId: SPEC }))
.rejects.toMatchObject({ status: 404 });
});
it("saveDraft mutates the existing draft version in place (no version_no bump)", async () => {
const a = await seedAsset();
await svc.saveDraft(a.id, { content: validSoul2, actor: USER, orgId: ORG, specialistId: SPEC });
const versions = await versionRepo.findBy({ assetId: a.id });
expect(versions).toHaveLength(1);
expect(versions[0].contentHash).not.toBe(hashOf(validSoul));
});
});
-
Step 2: 确认失败 →
npx jest config-assets.service --no-coverage -
Step 3: 实现 service + module
Service 要点(非完整代码,实现时保持这些硬性约束):
-
构造器:
@InjectRepository两个 repo +AuditService+DataSource(多行写包manager.transaction——资产行 + version 行 + 指针更新必须原子,属于"Multi-row writes per request → Strong"层)。 -
Zod 校验统一入口
private validateContent(assetType, content):查CONTENT_SCHEMAS[assetType],无 schema 的类型直接BadRequestException("asset type not yet supported")(Phase 1 只开 soul);safeParse失败 →BadRequestException({message: "invalid content", issues: r.error.issues})。 -
content_hash=createHash("sha256").update(canonicalJson(content)).digest("hex");canonicalJson= 键排序递归序列化(导入幂等依赖它,放config-assets.util.ts导出,importer 复用)。 -
审计:
config.create/config.draft.saved/config.archive,resourceType: "config_asset",payload 带{assetType, scope, slug, versionId?}。审计在业务 tx 外(ADR-019)。 -
Module:
TypeOrmModule.forFeature([SpecialistConfigAsset, SpecialistConfigVersion])+AuditServiceprovider(照feature-flags.module.ts先例),exports service。不要 @Global。app.module.ts imports 追加。 -
Step 4: 确认通过 + tsc + Commit
git add api/src/config-assets/ api/src/app.module.ts
git commit -m "feat(config-assets): asset/version CRUD service with ADR-020 dual-filter enforcement (P1.1)"
Task 6: 发布状态机 + 回滚 + 审计(P1.1e,设计 §5)
Files:
- Create:
api/src/config-assets/config-publish.service.ts - Modify:
api/src/config-assets/config-assets.module.ts - Test:
api/src/config-assets/config-publish.service.spec.ts
行为契约(设计 §5.1 转换表 T1–T7 + §5.3 过渡语义):
-
requestPublish(assetId, versionId, actor, ctx)= T1:原子 claimdraft→publishing(UPDATE ... WHERE publish_status='draft',claim 失败 →ConflictException);审计config.publish.requested。随后同步执行 T2 pass-through(§5.3:Phase 1 没有 eval worker——T1 成功后立即原子 claimpublishing→published+ 指针切换,audit payload 必须带evalGate: "not_wired"与previousVersionId)。不引入 BullMQ worker(YAGNI:§5.4 说本期只建状态与字段;异步化是 P4.1 接 eval 时的事,届时把 pass-through 换成入队)。 -
指针切换与状态 claim 在同一个
manager.transaction:published_version_id换新、旧版本行状态保持published(历史),draft 指针清空。 -
overridePublish(assetId, versionId, actor, note, ctx)= T4:仅blocked→published,publish_note必填(空 →BadRequestException);审计config.publish.override。(Phase 1 无 eval,blocked不可达,但状态机完整实现 + 测试直接造 blocked 行覆盖——Phase 4 接线时零改动。) -
rollback(assetId, targetVersionId, actor, ctx)= T6:目标必须是历史published版本;指针切回,被切离的当前版本标rolled_back;审计config.rollback。 -
discardDraft(assetId, versionId, actor, ctx)= T7:draft→discarded原子 claim,清 draft 指针;审计config.draft.discarded。 -
所有方法先做 ADR-020 双过滤取资产(跨 org → 404)。
-
Step 1: 写失败测试(关键用例)
it("publish: draft→published with pointer switch and not_wired audit marker", async () => {
const a = await seedAssetWithDraft();
await pub.requestPublish(a.id, a.draftVersionId!, USER, CTX);
const fresh = await assetRepo.findOneByOrFail({ id: a.id });
expect(fresh.publishedVersionId).toBe(a.draftVersionId);
expect(fresh.draftVersionId).toBeNull();
expect(audit.tryLog).toHaveBeenCalledWith(expect.objectContaining({
action: "config.publish",
payload: expect.objectContaining({ evalGate: "not_wired", previousVersionId: null }),
}));
});
it("publish claim is atomic — a second concurrent publish of the same draft 409s", async () => {
const a = await seedAssetWithDraft();
await pub.requestPublish(a.id, a.draftVersionId!, USER, CTX);
await expect(pub.requestPublish(a.id, a.draftVersionId!, USER, CTX)).rejects.toMatchObject({ status: 409 });
});
it("replacing publish keeps the old version row status=published and records previousVersionId", async () => { /* 两次 publish,断言旧行状态 + audit payload */ });
it("rollback: pointer returns to the historical version, displaced one marked rolled_back", async () => { /* ... */ });
it("rollback target must be a historical published version — draft target 400s", async () => { /* ... */ });
it("override requires a written reason", async () => {
await expect(pub.overridePublish(a.id, blockedVersionId, USER, "", CTX)).rejects.toMatchObject({ status: 400 });
});
it("discard clears the draft pointer and is terminal", async () => { /* 二次 discard 409 */ });
- Step 2: 确认失败 → Step 3: 实现(原子 claim 一律
UPDATE ... SET publish_status=$next WHERE id=$id AND publish_status=$prev RETURNING *——sqlite 无 RETURNING 支持时用 TypeORMupdate().where()的affected===1判定,仓库既有状态机(expert-queue.service.tsF3)先例照抄)→ Step 4: 通过 + Commit
git commit -m "feat(config-assets): publish state machine with §5.3 pass-through semantics + rollback + audit (P1.1)"
Task 7: REST API — Ops config-assets controller(P1.2b)
Files:
- Create:
api/src/config-assets/config-assets.controller.ts(@Controller("admin/config-assets")) - Create:
api/src/config-assets/dto.ts(class-validator 薄壳:uuid/枚举/必填;content 本体交给 service 的 Zod) - Modify:
api/src/config-assets/config-assets.module.ts - Test:
api/src/config-assets/config-assets.controller.spec.ts
端点契约(对齐 /admin/specialists 的既有面):
| Method+Path | 语义 | 角色 |
|---|---|---|
GET /admin/config-assets?orgId&specialistId&assetType | 实例列表 | superadmin, account_manager |
GET /admin/config-assets/catalog?assetType | catalog 列表 | superadmin |
POST /admin/config-assets | createAsset | instance: AM+SA;catalog: SA |
GET /admin/config-assets/:id?orgId&specialistId | 详情+版本历史 | 同列表 |
PUT /admin/config-assets/:id/draft | saveDraft | 同 create |
POST /admin/config-assets/:id/publish | requestPublish(body: versionId) | 同 create(§5.1 T1 权限) |
POST /admin/config-assets/:id/rollback | rollback(body: targetVersionId) | 同 publish |
POST /admin/config-assets/:id/discard-draft | discardDraft | 同 publish |
DELETE /admin/config-assets/:id | archive | 同 publish |
-
守卫照
/admin/specialists控制器现行组合(JwtAuthGuard+ roles decorator——先读api/src/admin/现行写法照抄)。 -
AM 的 org 归属校验:AM 只能操作自己管理的 org 的资产(照
/am/orgs现行归属检查先例);SA 全通。 -
actor =
req.user.userId,ctx.orgId 从 query/body 与用户归属交叉验证。 -
Step 1: controller spec(mock service,断言:角色 guard 元数据、参数透传、AM 跨 org 403/404 语义)→ Step 2: 失败 → Step 3: 实现 → Step 4: 通过 + Commit
git commit -m "feat(config-assets): admin REST surface for asset CRUD + publish flow (P1.2)"
Task 8: Soul 渲染器 + 导入框架 + 导入器(P1.3 + P1.5 的共享前置)
Files:
- Create:
api/src/config-assets/soul-render.service.ts(SoulContent → soul_md 文本,下发与预览的单一渲染源) - Create:
api/src/config-assets/import/soul-import.service.ts - Create:
api/src/config-assets/import/import-report.ts(dry-run 报告类型 + 格式化) - Create:
api/scripts/import-config-assets.ts(CLI 入口,npx ts-node运行,--apply才落库) - Test:
api/src/config-assets/soul-render.service.spec.ts、api/src/config-assets/import/soul-import.service.spec.ts
渲染契约(R1 AC2 的根基——byte-identical 靠"只有一个渲染函数"实现):
// soul-render.service.ts 的纯函 数核心
renderSoulMd(content: SoulContentT): string
// 输出确定性 markdown:
// # {identity.displayName} — {identity.roleTitle}
// {identity.bio}
// ## Voice\n- tone: ...\n- {styleRules 逐条}
// ## Boundaries\n### Never\n- ...\n### Escalate\n- ...
// ## Languages\n{逗号列表}
// ## Sign-off\n{template}(enabled 时)
// 规则:无值的节整节省略;条目顺序 = 数组顺序;行尾无空白;文件尾单个 \n。
导入契约(设计 §8):
- 源一:
orgs/<slug>/SOUL.md(repo 根,实测存在_template/acmefinancial/humanity三份)。markdown 分节启发式:# 标题→ identity(displayName/roleTitle 按—或-分割)、次级标题按关键词映射 voice/boundaries/languages;无法归类的段落 →voice.styleRules逐段塞入并在报告标unmapped。 - 源二:
specialists.system_prompt列。命中 persona-override 启发式者(>300 字符或含confidence/flag_for_review/{agent_name}/CRITICAL INSTRUCTIONS)不自动导入——报告输出⚠ MANUAL行强制人工裁决(设计 §8 表);未命中者作为identity.bio+voice.styleRules的最小 SoulContent 导入。 - 幂等:resolve 既有资产(同 asset_type+scope+org+specialist+slug)→ 比较
content_hash→ 相同 SKIP、不同 UPDATE(新 draft)、无则 CREATE。source: "imported_repo",资产metadata.importSourcePath记路径。 - dry-run 输出格式照设计 §8 示例(CREATE/UPDATE/SKIP/⚠ MANUAL + unified diff)。
- 导入产生的是 draft,publish 单独人工执行(§5.3 过渡语义生效,审计留痕)。
- Step 1: 渲染器测试(valid soul → 快照式全文断言 + 确定性:同输入两次调用 byte-equal + 空节省略)→ 失败 → 实现 → 通过
- Step 2: 导入器测试(fixture markdown 覆盖:全节命中 / unmapped 段落 / override 命中 systemPrompt 出 MANUAL / hash 幂等 SKIP)→ 失败 → 实现 → 通过
- Step 3: CLI 冒烟
Run: cd api && npx ts-node scripts/import-config-assets.ts --org humanity --dry-run(读 repo orgs/humanity/SOUL.md;DB 用 DATABASE_URL 环境;无 DB 时 dry-run 仍可产报告——resolve 阶段捕获连接错误并降级为 pure-parse 模式)
Expected: 设计 §8 格式的报告输出。
- Step 4: Commit
git commit -m "feat(config-assets): deterministic Soul renderer + SOUL.md/systemPrompt importer with dry-run diff (P1.3)"
Task 9: FE — api.ts 类型与函数(P1.4a)
Files:
-
Modify:
frontend/src/lib/api.ts(interface + 函数,照adminListSpecialists5035-5069 的模式,类型紧邻函数定义) -
Test: FE 无 api.ts 单测先例——由 Task 10 的组件测试覆盖调用面;本步
npx tsc --noEmit即验证 -
Step 1: 添加类型与函数
// frontend/src/lib/api.ts — 追加(位置:adminDeleteSpecialist 之后)
export interface ConfigAssetVersionSummary {
id: string;
versionNo: number;
publishStatus: "draft" | "publishing" | "published" | "blocked" | "rolled_back" | "discarded";
authorId: string;
createdAt: string;
publishedAt?: string | null;
publishNote?: string | null;
}
export interface ConfigAsset {
id: string;
assetType: "soul" | "skill" | "kb_golden_answer" | "kb_domain_rule" | "tool_binding";
scope: "catalog" | "org_instance";
orgId: string | null;
specialistId: string;
slug: string;
displayName: string;
source: "manual" | "imported_repo" | "loop_proposal";
publishedVersionId: string | null;
draftVersionId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ConfigAssetDetail extends ConfigAsset {
draftContent: Record<string, unknown> | null;
publishedContent: Record<string, unknown> | null;
versions: ConfigAssetVersionSummary[];
}
export async function adminListConfigAssets(params: { orgId: string; specialistId: string; assetType?: string }): Promise<ConfigAsset[]> {
const q = new URLSearchParams(params as Record<string, string>).toString();
return apiFetch<ConfigAsset[]>(`/admin/config-assets?${q}`);
}
export async function adminGetConfigAsset(id: string, params: { orgId: string; specialistId: string }): Promise<ConfigAssetDetail> {
const q = new URLSearchParams(params).toString();
return apiFetch<ConfigAssetDetail>(`/admin/config-assets/${id}?${q}`);
}
export async function adminCreateConfigAsset(payload: { assetType: string; scope: string; orgId?: string; specialistId: string; slug: string; displayName: string; content: Record<string, unknown> }): Promise<ConfigAsset> {
return apiFetch<ConfigAsset>(`/admin/config-assets`, { method: "POST", body: JSON.stringify(payload) });
}
export async function adminSaveConfigAssetDraft(id: string, payload: { orgId?: string; specialistId: string; content: Record<string, unknown> }): Promise<ConfigAssetDetail> {
return apiFetch<ConfigAssetDetail>(`/admin/config-assets/${id}/draft`, { method: "PUT", body: JSON.stringify(payload) });
}
export async function adminPublishConfigAsset(id: string, payload: { orgId?: string; specialistId: string; versionId: string }): Promise<ConfigAssetDetail> {
return apiFetch<ConfigAssetDetail>(`/admin/config-assets/${id}/publish`, { method: "POST", body: JSON.stringify(payload) });
}
export async function adminRollbackConfigAsset(id: string, payload: { orgId?: string; specialistId: string; targetVersionId: string }): Promise<ConfigAssetDetail> {
return apiFetch<ConfigAssetDetail>(`/admin/config-assets/${id}/rollback`, { method: "POST", body: JSON.stringify(payload) });
}
export async function adminDiscardConfigAssetDraft(id: string, payload: { orgId?: string; specialistId: string; versionId: string }): Promise<ConfigAssetDetail> {
return apiFetch<ConfigAssetDetail>(`/admin/config-assets/${id}/discard-draft`, { method: "POST", body: JSON.stringify(payload) });
}
- Step 2:
cd frontend && npx tsc --noEmit→ clean → Commit
git commit -m "feat(frontend): config-assets api client surface (P1.4)"
Task 10: FE — Ops admin 骨架页(P1.4b)
Files:
- Create:
frontend/src/app/ops/config-assets/page.tsx(列表 + 类型过滤 + org/specialist 选择) - Create:
frontend/src/app/ops/config-assets/[id]/page.tsx(Soul 表单编辑器 + 版本历史 + 发布/回滚) - Create:
frontend/src/app/ops/config-assets/SoulEditor.tsx(受控表单:identity/voice/boundaries/languages/signOff,用components/shared/FormField.tsx+ shadcn 原语——不要照抄 specialists 页的 inline-style 旧风格) - Create:
frontend/src/app/ops/config-assets/VersionHistory.tsx(版本表 + publish/rollback/discard 按钮 +publish_note必填对话框) - Modify:
frontend/src/app/ops/layout.tsx(NAV_GROUPS"Platform & Tools" 组加{ href: "/ops/config-assets", label: "Config Assets", icon: <lucide 选一>, saOnly: false }——AM 可见(instance 面),catalog 列表在页内按platformRole隐藏) - Modify:
frontend/src/app/ops/specialists/page.tsx(systemPrompt textarea 上方加一行降级提示 + 链接到 config-assets——textarea 保留为 advanced 逃生舱,R1.2 弃用准则) - Test:
frontend/src/app/ops/config-assets/__tests__/soul-editor.test.tsx、__tests__/version-history.test.tsx
UI 行为契约:
- 编辑=草稿:SoulEditor 保存 →
adminSaveConfigAssetDraft;顶栏状态徽章draft/published/draft+published(有 draft 且有 published 时双徽章)。 - Publish 按钮仅在有 draft 时可用 → 确认对话框(提示 §5.3 过渡语义文案"发布即生效——eval 门禁 Phase 4 接线")→
adminPublishConfigAsset。 - 版本历史每行:versionNo/status/author/时间;
published历史行有 Rollback 按钮 → 确认 →adminRollbackConfigAsset。 - 表单校验错误(400 的
issues)逐字段渲染到FormField的 error 槽。 - 页面包
<RequireRole allow={["superadmin", "account_manager"]}>。
- Step 1: RTL 测试先行(SoulEditor:渲染既有 content 到字段/保存回调载荷形状/错误 issues 渲染;VersionHistory:rollback 仅对 published 历史行出现/publish 弹确认)→ Step 2: 失败 → Step 3: 实现两页 + 两组件 + 导航 → Step 4:
npm test(frontend jest)+npm run build→ Step 5: Commit
git commit -m "feat(ops): config-assets admin — Soul editor, version history, publish/rollback flow (P1.4)"
Task 11: 下发通道统一(P1.5)
Files:
- Modify:
api/src/agent-runtime/orgs-runtime-config.service.ts(readForSlug改为:底座有 published soul →renderSoulMd(published content);无 → 现行文件系统 fallback) - Modify:
api/src/agent-runtime/agent-runtime.module.ts(importConfigAssetsModule;记 CI 教训:显式 import,且检查引它的 TestingModule harness 是否要补实体) - Modify:
api/src/conversations/conversations.service.ts(specialistSystemPrompt解析点 :2585-2600:底座有该 Specialist 的 published soul → 用renderSoulMd同源生成并替代specialists.systemPrompt;无 → 现行列值。抽成resolveSpecialistPersona()私有方法) - Create:
api/src/config-assets/soul-preview.controller.ts(GET /admin/config-assets/:id/preview返回{soulMd}——Ops UI 预览用,与下发同一渲染函数,R1 AC2 的 byte-identical 由此成立) - Test:
api/src/agent-runtime/orgs-runtime-config.service.spec.ts(新增用例)、api/src/conversations/specialist-template-key.spec.ts(扩展)、api/src/config-assets/soul-preview.spec.ts
行为契约:
- 读路径优先级:published soul asset > 文件系统/列值。无 flag——底座里没有 published soul 的 org/specialist 自然走 fallback,灰度天然按"谁导入+发布了谁切换"进行(这就是 tracer bullet 的意图;出问题 rollback 资产即回退)。
readForSlug场景:该 org 的 primary specialist 有 published soul →soul_md=renderSoulMd(content);否则原文件读取。config_yaml/skills[]本期不动。/chat线:resolveSpecialistPersona(conv.specialistId, orgId)→ published soul 存在时返回渲染文本(persona header 语义,走specialist.system_prompt既有 wire 字段,agent 侧零改动),同时保留promptTemplateKey线不变。- byte-identical 断言:preview 端点输出与
readForSlug对同一 published version 的soul_md严格相等(同一函数调用,测试直接断言)。 - runtime-config 是 agent 启动时拉取 +
.readymarker 幂等——发布新 soul 不会热更新已运行的 per-org agent(要重启/重建容器)。这是既有行为不是回归;在 Ops UI publish 成功 toast 里注明"per-org runner 下次启动生效;/chat 线即时生效"。
- Step 1: 三处失败测试(readForSlug 底座优先/fallback;resolveSpecialistPersona 同源替代;preview byte-identical)→ Step 2: 失败 → Step 3: 实现 → Step 4: 相关既有套件回归
Run: cd api && npx jest agent-runtime config-assets specialist-template-key conversations --no-coverage
Expected: 全过(sqlite 预存在失败对照基线清单)。
- Step 5: Commit
git commit -m "feat(config-assets): runtime-config + /chat persona read from published soul, filesystem fallback, byte-identical preview (P1.5)"
Task 12: persona-override 启发式 cutover flag(P1.6)
前置(执行者第一步做,结果决定节奏):查 dev 环境 P0.6 命中率——
SELECT metadata->'assembly'->>'personaOverrideApplied' AS applied, COUNT(*)
FROM messages
WHERE metadata->'assembly' IS NOT NULL
GROUP BY 1;
(Railway MCP 连 dev DB,或经既有 admin 分析端点。)命中率 ~0% → 直接推进;显著 >0 → 找出命中的 Specialist,先在 Ops 里把它们的 systemPrompt 迁成 soul 资产(Task 8 导入器的 MANUAL 裁决流),再推进本任务。
Files:
- Modify:
api/src/feature-flags/feature-flags.constants.ts(新 flagpersona_override_heuristic_enabled,默认 true——保持现状,per-Specialist 关闭做 cutover;org_specialistscope 用specialistAssignmentId解析) - Modify:
api/src/common/agent.client.ts+api/src/conversations/conversations.service.ts(dispatch 时 resolve flag → wire 上加personaOverrideMode: "legacy" | "header_only"——agent_context内新键,不是/chat顶层字段,避免破 AGENT_API_SPEC 契约) - Modify:
agent/prompts_loader.py(build_system_prompt_with_meta:agent_context.get("persona_override_mode") == "header_only"时跳过is_full_template启发式——一律按 persona header 注入,meta 记persona_override_applied=False+persona_mode) - Test:
api/src/conversations/persona-override-mode.spec.ts、agent/evals/test_prompt_assembly_meta.py(新增用例)
行为契约:
- flag
true(默认)→ wirepersona_override_mode: "legacy"→ agent 行为与今天逐字节一致(现有test_prompt_assembly_meta.py全部用例不动)。 - flag
false(per-Specialist cutover)→"header_only"→ >300 字符/含信号词的 systemPrompt 也只作 persona header,角色模板保留——ADR-033 L0"永不置换模板"由此达成。 - shadow 对比:P0.6 trace 已含
personaOverrideApplied——cutover 后该字段应恒 false;对照面就是既有 trace 查询,不建新机制。 - agent 侧对缺失键的行为 =
"legacy"(旧 NestJS 兼容,additive 契约)。
- Step 1: agent 侧失败测试(
header_only时长 override 不置换模板 + meta 记 mode;缺键=legacy)→ Step 2: api 侧失败测试(flag OFF→wire header_only;默认 legacy)→ Step 3: 实现两侧 → Step 4: 跑两侧套件
Run: cd agent && python3 -m pytest evals/test_prompt_assembly_meta.py evals/test_prompt_template_routing.py -q
(解释器用 agent/.venv;在 git worktree 里跑时 worktree 没有自己的 venv,复用主 checkout 的 agent/.venv/bin/python 即可——developer-local 事实,不要把任何机器的绝对路径写进本文档) + cd api && npx jest persona-override-mode specialist-template-key --no-coverage
- Step 5: Commit
git commit -m "feat(agent+api): per-Specialist persona-override cutover flag — header_only mode ends template displacement (P1.6, ADR-033 L0)"
Task 13: few-shot dogfood 灰度(P1.7)+ 文档收尾
- Step 1: 灰度操作(运行时操作非代码):Ops feature-flag 面把
correction_fewshot_enabled对 dogfood org(Eleanora/Acme——执行时向用户确认目标 org)设 org-scope ON;feature_flag_audit自动留痕。记录开启时间戳到 PR 描述,供 P0.2 draft-quality 指标做 ON/OFF 对照(对照检查点 = 开启一周后,非本任务内)。 - Step 2: 模块 CLAUDE.md 更新(meta-constraint 是 review block 项):
- 新建
api/src/config-assets/CLAUDE.md:模块契约——ADR-020 row 声明、§5.3 过渡语义(evalGate:"not_wired"的含义与 Phase 4 收口)、渲染器单一来源规则、预算常量单一来源规则、"新 asset_type = schema 文件 + 注册表一行"。 api/CLAUDE.md模块索引表加一行。agent/CLAUDE.mdpitfalls 节补persona_override_mode键与 legacy 默认。api/src/conversations/CLAUDE.md补resolveSpecialistPersona优先级(底座 > 列值)。- 根 CLAUDE.md 隔离矩阵表加 config assets 行。
- 新建
- Step 3: Commit + 分支级 final review(用户偏好:跨任务一致性 + CLAUDE.md 欠更新专项)
git commit -m "docs: config-assets module contracts + CLAUDE.md isolation matrix row (P1 wrap-up)"
验收对照(master plan Phase 1 验收 × 任务)
| 验收 | 任务 |
|---|---|
| 底座两表 + 状态机 + 回滚 + 审计 + NFR5 | T1–T6 |
| §5.3 过渡语义(not_wired 审计可辨认) | T6 |
| Soul schema + CRUD | T4/T5/T7 |
| 导入 dry-run + diff + override MANUAL 裁决 | T8 |
| Ops admin 替代 raw textarea(textarea 降级逃生舱) | T10 |
| runtime-config/chat 读底座、预览 byte-identical(R1 AC2) | T11 |
| persona-override cutover flag + shadow 对照 | T12 |
| few-shot dogfood 灰度 | T13 |
风险与回退
- T11 是唯一动生产读路径的任务:fallback 语义(无 published soul → 现行为)保证未导入的 org 零影响;出问题 rollback soul 资产 = 即时回退。
- T12 默认 flag ON(legacy):merge 即零行为变化;cutover 按 Specialist 手动逐个。
- D2/D3 评审若推翻:D2 推翻(要求字段级 R2 拆分)只影响 T1 的
content_r2_key语义(本期无实际 R2 写入,改动面为零);D3 推翻(装配器 API 侧)不影响本期任何任务(本期不动装配器),只改 Phase 3 计划。 - CI 新坑:任何引
ConfigAssetsModule的既有 TestingModule harness 需补SpecialistConfigAsset/SpecialistConfigVersion实体(本次 FeatureFlag 事故的同类,T5/T11 的 implementer 必查)。