Files
writing-agent/src/skills/creation-flow.ts
T
chenmoranzhiandCursor f37388761b Keep creation as a living DAG and archive playable products separately.
Opening accept no longer locks the graph; session.json stays creation-only while play starts from an instance snapshot. Sidebar products nest run saves, and play reading fills the feed at a denser type size.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 00:52:05 +08:00

2805 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 创作流程:编排产物(设计.创作流程)。
* 可变增量 DAG:有序 steps + 每步 id/中文名 + depends_on;可追加、可同能力多次。
*
* 两层内容(作者细写,运行时只搭骨架):
* - recipes/:配方方法论(适用、核心思路、设计流程、原则)+ 近期起点 steps
* - modules/:共用能力池;编排注入 meta 选型字段,执行注入方法全文
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import { tryParseJsonDoc } from "../parse/json-doc.js";
export const CREATION_FLOW_TAG = "设计.创作流程";
export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤";
/** 确认开干前:待确认的下一步快照(JSON) */
export const CREATION_PROPOSED_STEP_TAG = "创作.待确认步骤";
/** 用户点已完成节点重进:本步继承既有产物("1") */
export const CREATION_INHERIT_EXISTING_TAG = "创作.继承修改";
/** 用户手动选定的初始配方(存 recipe id,或 JSON {id,name}) */
export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方";
/** 进度指针:禁止当作 worker 产物写回或列入验收正文 */
export const PROGRESS_POINTER_TAGS = new Set<string>([
CREATION_CURRENT_STEP_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_INHERIT_EXISTING_TAG,
CREATION_SELECTED_RECIPE_TAG,
"创作.当前单位",
"创作.已验收单位",
"创作.已验收内容",
"创作.能力开场白",
"创作.能力开场状态",
]);
/** 旧名:含「设计.创作流程」。写回/验收过滤请用 isProgressPointerTag */
export const RUNTIME_PINNED_TAGS = new Set<string>([
CREATION_FLOW_TAG,
...PROGRESS_POINTER_TAGS,
]);
export function isProgressPointerTag(tag: string): boolean {
return PROGRESS_POINTER_TAGS.has(tag.trim());
}
export function isRuntimePinnedTag(tag: string): boolean {
return RUNTIME_PINNED_TAGS.has(tag.trim());
}
export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml";
export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml";
export const DESIGN_STEP_WORKER_ID = "design-step";
export const DESIGN_FLOW_WORKER_ID = "design-flow";
const DEFAULT_SKILLS_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../skills",
);
/** 步骤调用参数。普通节点:编排期钉死、执行只读。〔先验产物〕实例步:步内钉「写什么」,执行期写入 params。 */
export type CreationFlowStepParams = Record<string, unknown>;
/** 原型 = 图上可增殖槽位;实例 = 点原型或历史 spawn 出的可执行步。 */
export type CreationFlowStepRole = "prototype" | "instance";
/** 规划层建议追加的原型节点(尚未编入 steps 时展示在图上)。 */
export type CreationFlowPrototypeSuggestion = {
name: string;
/** 建议生成什么 / 为何需要此原型 */
suggestion: string;
depends_on?: string[];
};
/**
* 节点特性。缺省 = 普通执行步。后续会加更多 kind。
* prior-artifact = 先验产物:须先定「写什么」(生成规则、具体实例);编排器可提前规划,步内再钉/修订。
*/
export const MODULE_NODE_KINDS = ["prior-artifact"] as const;
export type ModuleNodeKind = (typeof MODULE_NODE_KINDS)[number];
export const MODULE_NODE_KIND_FLAGS: Record<ModuleNodeKind, string> = {
"prior-artifact": "〔先验产物〕",
};
export function parseModuleNodeKind(raw: unknown): ModuleNodeKind | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim();
return (MODULE_NODE_KINDS as readonly string[]).includes(key)
? (key as ModuleNodeKind)
: undefined;
}
export function isPriorArtifactModule(
module: { kind?: ModuleNodeKind } | null | undefined,
): boolean {
return module?.kind === "prior-artifact";
}
/**
* 本步怎么跑:
* - fresh(缺省)= 从零产出。〔可反复〕再编入新 id 必须用这个——彻底新建,不改旧条。
* - revise = 回头修改:继承 `revises` 所指那步的既有产物继续改。
*/
export type CreationFlowStepMode = "fresh" | "revise";
export type CreationFlowStep = {
/**
* 本局步骤唯一 id(验收与 depends_on 用这个)。
* 同能力可多次出现时必须不同;缺省时程序按 name / name#n 补齐。
*/
id: string;
/** 固定中文名,须 ∈ 模块目录(可重复) */
name: string;
/** 依赖的其它步骤 id(旧稿若 name 唯一也可写 name) */
depends_on: string[];
/**
* prototype = 科技树槽位,点进去增殖实例;instance = 可执行步。
* 缺省 = 普通一次性节点。
*/
role?: CreationFlowStepRole;
/** role=instance:从哪个原型增殖出来 */
from?: string;
/** role=prototype:规划层对本槽位「建议生成什么」的短提示(执行时在步内钉细) */
suggestion?: string;
/**
* 本步调用参数。
* 普通节点:有必填声明时须在进执行前钉齐。
* 〔先验产物〕实例:步内钉「写什么」,不写回原型。
*/
params?: CreationFlowStepParams;
/** 缺省 fresh。revise = 继承旧产物修改,不是再生成一条。 */
mode?: CreationFlowStepMode;
/** mode=revise 时:被改的既有步骤 id(须更前、同 name) */
revises?: string;
};
export type CreationFlowStatus = "open" | "closed";
export type CreationFlow = {
version: 1;
/** 可选一句体验复述(给人看) */
brief?: string;
/**
* open = 当前只是近期 horizon,还可增量追加 / 反复编排同能力;
* closed = 不再扩步(可走收成)。缺省按 closed(兼容旧固定 DAG)。
*/
status?: CreationFlowStatus;
/** 规划层建议追加、尚未编入 steps 的原型节点 */
suggestions?: CreationFlowPrototypeSuggestion[];
steps: CreationFlowStep[];
};
/** catalog 声明:编排进 DAG 时本步需要哪些调用参数 */
export type ModuleParamSpec = {
key: string;
/** 给人看的中文名 */
label: string;
/** 缺省 false;true = validateCreationFlow / 编排必须钉齐 */
required?: boolean;
/** 给编排器的短提示(选项从何来、可否其它) */
hint?: string;
};
export type ModuleCatalogEntry = {
/** 目录文件夹名,如 aesthetics-interaction */
id: string;
name: string;
/** 给 agent 的短声明:用来决定要不要调度这一步 */
declaration: string;
/** 执行期产物 tag(流程 JSON 不写;程序映射) */
artifact: string;
/**
* 可选:默认可反复编排进流程(如生成规则、具体实例)。
* 程序不硬拦;给编排与校验提示。
*/
repeatable?: boolean;
/**
* 可选:创作终节点。选定后程序收口并保存。
* 尚未选定前,用户要补前序节点时应插在本步之前,不要当成「已经不能再编排」。
*/
closer?: boolean;
/**
* 可选:程序步。不抛默认问题、不经「同意并开始」,提案后直接执行;
* 产物仍走验收。适合投影排序这类只排序、几乎不问用户的收成步。
*/
auto?: boolean;
/**
* 可选:节点特性。缺省为普通执行步。
* prior-artifact = 先验产物,须先定写什么;params 是规划产物而非拦执行的必填项。
*/
kind?: ModuleNodeKind;
/**
* 可选:默认问题(开场白)。优先用 prompt.md 的 ```opening 块;
* catalog 写了则作覆盖。程序发出,不经 LLM。
*/
opening?: string;
/**
* 可选:步骤参数声明。
* 普通节点:required 须在进执行前钉齐。
* 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。
*/
params?: ModuleParamSpec[];
/** 来自 prompt.md ```meta:何时该选用(编排选型) */
when?: string;
/** 来自 prompt.md ```meta:何时不该选用 */
when_not?: string;
/** 来自 prompt.md ```meta:与其它能力的边界 */
boundary?: string;
};
/** 本步程序开场白正文(design-step 发出后写入,供 LLM 看见) */
export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白";
/** JSON:{ [步骤中文名]: "shown" | "answered" } */
export const CREATION_MODULE_OPENING_STATE_TAG = "创作.能力开场状态";
export const SLOT_CREATION_MODULE_OPENING_STATE = "creationModuleOpeningState";
export type ModuleOpeningState = Record<string, "shown" | "answered">;
export type ModuleCatalog = {
modules: ModuleCatalogEntry[];
};
/** 初始配方目录条目(短声明,给选型) */
export type RecipeCatalogEntry = {
id: string;
name: string;
declaration: string;
};
export type RecipeCatalog = {
recipes: RecipeCatalogEntry[];
};
/**
* 单份配方详情。
* 方法论字段供编排选型;seed = 近期起点 steps(可为空;name 须 ∈ 模块池)。
*/
export type RecipeDetail = {
id: string;
name: string;
declaration: string;
/** 适用什么体验/任务 */
when?: string;
/** 整套设计方法的核心思路与最终目标 */
core?: string;
/** 设计流程:如何增量选型、何时收成(字符串或条目列表) */
process?: string | string[];
/** 配方特有取舍原则 */
principles?: string | string[];
/**
* @deprecated 旧字段;新配方用 core/process/principles。解析仍可读,格式化时作兜底。
*/
hint?: string;
seed: CreationFlow | null;
};
export type CreationFlowValidation = {
ok: boolean;
errors: string[];
};
/** 工作流计划节点相对执行进度(给人看的三态) */
export type FlowStepRunState = "done" | "current" | "pending";
/** 可反复能力在图上的「再开一条」入口 */
export type CreationFlowSpawnView = {
name: string;
ready: boolean;
blockedReason?: string;
};
export type CreationFlowUserView = {
brief?: string;
status?: CreationFlowStatus;
/** 规划层建议追加的原型(尚未在 steps 里) */
suggestions?: CreationFlowPrototypeSuggestion[];
steps: Array<{
order: number;
id: string;
name: string;
depends_on: string[];
role?: CreationFlowStepRole;
from?: string;
suggestion?: string;
/** 图上短标题:增殖的是哪条规则 / 哪批实例(不等于技能名) */
title?: string;
/** 同能力第几次(>1 时 UI 可标「再来」;回头修改不计入「第 N 次新建」) */
occurrence?: number;
/** fresh=从零新建;revise=回头修改 */
mode?: CreationFlowStepMode;
/** 回头修改时:被改的既有步骤 id */
revises?: string;
/** 目录里的短声明(有则展示) */
declaration?: string;
repeatable?: boolean;
/** 收口节点(开场白):点进去做这一条,不增殖 */
closer?: boolean;
/** 本步调用参数(普通节点编排钉死;先验产物=规划内容) */
params?: CreationFlowStepParams;
/** 参数缺必填项时的提示(给人看);先验产物不拦执行,不出现此项 */
paramsMissing?: string[];
/** 节点特性(如先验产物) */
kind?: ModuleNodeKind;
/** 依赖层级(0 = 无前置);图按列/行排 */
layer: number;
/** 未验收且 depends_on 均已验收 */
ready: boolean;
/** 图上可点进去做这一条(ready 且未完成) */
selectable: boolean;
/** 挡住本步的未完成依赖 id */
blockedBy?: string[];
/** 已执行 / 将要执行 / 未执行 */
runState: FlowStepRunState;
/** 本步产物 tag(已完成节点菜单「查看产物」用) */
artifactTag?: string;
/** 黑板已有该步产物 */
hasArtifact?: boolean;
}>;
parseError?: string;
};
export function parseCreationFlowStepRole(
raw: unknown,
): CreationFlowStepRole | undefined {
if (raw === "prototype" || raw === "instance") return raw;
return undefined;
}
export function isPrototypeStep(
step: Pick<CreationFlowStep, "role"> | null | undefined,
): boolean {
return step?.role === "prototype";
}
export function isInstanceStep(
step: Pick<CreationFlowStep, "role"> | null | undefined,
): boolean {
return step?.role === "instance";
}
/** 图上可点进去跑 design-step 的步(非原型槽位) */
export function isExecutableStep(step: CreationFlowStep): boolean {
return !isPrototypeStep(step);
}
function parsePrototypeSuggestions(raw: unknown): CreationFlowPrototypeSuggestion[] | undefined {
if (!Array.isArray(raw) || raw.length === 0) return undefined;
const out: CreationFlowPrototypeSuggestion[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const name = typeof row.name === "string" ? row.name.trim() : "";
const suggestion =
typeof row.suggestion === "string"
? row.suggestion.trim()
: typeof row.hint === "string"
? row.hint.trim()
: "";
if (!name || !suggestion) continue;
const depsRaw = row.depends_on ?? row.dependsOn ?? [];
const depends_on = Array.isArray(depsRaw)
? depsRaw.map((d) => String(d).trim()).filter(Boolean)
: undefined;
out.push({
name,
suggestion,
...(depends_on?.length ? { depends_on } : {}),
});
}
return out.length ? out : undefined;
}
/** 依赖是否满足:指向原型 = 至少有一条已验收实例;否则 = 该步已验收。 */
export function isCreationDependencySatisfied(
flow: CreationFlow,
depRef: string,
acceptedStepIds: readonly string[],
): boolean {
const depStep = findStepByRef(flow, depRef);
if (!depStep) return false;
if (isPrototypeStep(depStep)) {
return flow.steps.some(
(s) =>
isInstanceStep(s) &&
s.from === depStep.id &&
isStepAccepted(s, acceptedStepIds),
);
}
return isStepAccepted(depStep, acceptedStepIds);
}
export function isPrototypeSelectable(
flow: CreationFlow,
step: CreationFlowStep,
acceptedStepIds: readonly string[],
): boolean {
if (!isPrototypeStep(step)) return false;
return step.depends_on.every((dep) =>
isCreationDependencySatisfied(flow, dep, acceptedStepIds),
);
}
/** 根对象是创作流程 DAG(有 steps,且至少一步带 name) */
export function looksLikeCreationFlowDoc(doc: unknown): boolean {
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false;
const steps = (doc as { steps?: unknown }).steps;
if (!Array.isArray(steps) || steps.length === 0) return false;
return steps.some(
(item) =>
item &&
typeof item === "object" &&
!Array.isArray(item) &&
typeof (item as { name?: unknown }).name === "string" &&
String((item as { name: string }).name).trim().length > 0,
);
}
/** 从任意正文抽取 JSON 对象 */
export function extractJsonObject(raw: string): unknown | null {
const parsed = tryParseJsonDoc(raw);
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed;
}
return null;
}
/** 规范化 steps[].params:仅接受普通对象 */
export function normalizeStepParams(
raw: unknown,
): CreationFlowStepParams | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const out: CreationFlowStepParams = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
const key = k.trim();
if (!key) continue;
out[key] = v;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序)。〔先验产物〕不拦执行,恒为 []。 */
export function missingRequiredStepParams(
step: Pick<CreationFlowStep, "params">,
module: ModuleCatalogEntry | null | undefined,
): string[] {
if (isPriorArtifactModule(module)) return [];
const specs = module?.params ?? [];
if (specs.length === 0) return [];
const params = step.params ?? {};
const missing: string[] = [];
for (const spec of specs) {
if (!spec.required) continue;
const v = params[spec.key];
if (v == null) {
missing.push(spec.key);
continue;
}
if (typeof v === "string" && !v.trim()) {
missing.push(spec.key);
}
}
return missing;
}
/** 给人 / LLM 看的参数摘要 */
export function formatStepParamsForPrompt(
params: CreationFlowStepParams | null | undefined,
): string {
if (!params || Object.keys(params).length === 0) return "(无)";
return Object.entries(params)
.map(([k, v]) => {
const rendered =
typeof v === "string" ? v : JSON.stringify(v, null, 0);
return `- ${k}: ${rendered}`;
})
.join("\n");
}
const PRIOR_ARTIFACT_CONTEXT_TITLE =
"## 【本步对象】须在本步与用户钉「写什么」再产出(不依赖编排层预填)";
/** 〔先验产物〕实例步注入块:params 仅来自本步已钉内容;原型 suggestion 作参考。 */
export function formatPriorArtifactContext(
params: CreationFlowStepParams | null | undefined,
prototypeSuggestion?: string | null,
): string {
const body = formatStepParamsForPrompt(params);
const hint = prototypeSuggestion?.trim()
? `\n\n【原型建议】${prototypeSuggestion.trim()}(规划层提示,可在步内修订或忽略)`
: "";
const note =
!params || Object.keys(params).length === 0
? "尚未钉本步具体写什么。先与用户确认对象/范围,再填产物。"
: "以上为本步已钉对象。用户改对象或范围时,以本步最新认定为准,并重钉对应英文 id。";
return `${PRIOR_ARTIFACT_CONTEXT_TITLE}\n\n${body}\n\n${note}${hint}`;
}
const REVISE_MODE_ALIASES = new Set([
"revise",
"amend",
"edit",
"修改",
"回头修改",
"继承修改",
]);
const FRESH_MODE_ALIASES = new Set([
"fresh",
"new",
"create",
"新建",
"再来",
"再来一次",
]);
/** 解析 DAG 步骤的 mode;无法识别则视为缺省(fresh) */
export function parseCreationFlowStepMode(raw: unknown): CreationFlowStepMode | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim().toLowerCase();
if (!key) return undefined;
if (REVISE_MODE_ALIASES.has(key) || REVISE_MODE_ALIASES.has(raw.trim())) {
return "revise";
}
if (FRESH_MODE_ALIASES.has(key) || FRESH_MODE_ALIASES.has(raw.trim())) {
return "fresh";
}
return undefined;
}
export function isReviseStep(
step: Pick<CreationFlowStep, "mode"> | null | undefined,
): boolean {
return step?.mode === "revise";
}
export function isInheritExistingFlag(raw: unknown): boolean {
if (raw === true) return true;
if (typeof raw !== "string") return false;
const key = raw.trim().toLowerCase();
return key === "1" || key === "true" || key === "revise" || key === "回头修改";
}
/**
* 回头修改不是 DAG 节点:丢掉 mode=revise 步,并把依赖改回原稿 id。
* 旧会话里编排器排过的修订步靠这一层从执行图上拿掉。
*/
export function stripReviseSteps(flow: CreationFlow): CreationFlow {
const idMap = new Map<string, string>();
for (const step of flow.steps) {
if (!isReviseStep(step)) continue;
const origin = step.revises
? flow.steps.find((s) => s.id === step.revises || s.name === step.revises)
: undefined;
idMap.set(step.id, origin?.id || step.revises || step.name);
}
if (idMap.size === 0) return flow;
const resolve = (ref: string): string => {
let cur = ref.trim();
const seen = new Set<string>();
while (idMap.has(cur) && !seen.has(cur)) {
seen.add(cur);
cur = idMap.get(cur)!;
}
return cur;
};
const steps = flow.steps
.filter((s) => !isReviseStep(s))
.map((s) => {
const depends_on = [...new Set(s.depends_on.map(resolve))].filter(
(d) => d && d !== s.id,
);
if (
depends_on.length === s.depends_on.length &&
depends_on.every((d, i) => d === s.depends_on[i])
) {
return s;
}
return { ...s, depends_on };
});
const suggestions = flow.suggestions?.map((sg) => {
if (!sg.depends_on?.length) return sg;
return { ...sg, depends_on: [...new Set(sg.depends_on.map(resolve))] };
});
return {
...flow,
steps,
...(suggestions ? { suggestions } : {}),
};
}
function parseStepRevisesRef(raw: Record<string, unknown>): string | undefined {
for (const key of ["revises", "revise_of", "revises_id"] as const) {
const v = raw[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return undefined;
}
type DraftCreationFlowStep = {
id?: string;
name: string;
depends_on: string[];
role?: CreationFlowStepRole;
from?: string;
suggestion?: string;
params?: CreationFlowStepParams;
mode?: CreationFlowStepMode;
revises?: string;
};
function withStepModeFields(
step: DraftCreationFlowStep,
): Pick<CreationFlowStep, "mode" | "revises"> {
return {
...(step.mode === "revise" ? { mode: "revise" as const } : {}),
...(step.revises ? { revises: step.revises } : {}),
};
}
/**
* 回头修改步:补 revises(最近一条同名更前步),并继承其 params(本步已写的优先)。
*/
export function normalizeReviseSteps(
steps: CreationFlowStep[],
): CreationFlowStep[] {
return steps.map((step, i) => {
if (step.mode !== "revise") return step;
let revises = step.revises?.trim() || "";
if (!revises) {
for (let j = i - 1; j >= 0; j--) {
if (steps[j]?.name === step.name) {
revises = steps[j]!.id;
break;
}
}
}
const origin = revises
? steps.find((s) => s.id === revises || s.name === revises)
: undefined;
const inherited = origin?.params;
const params =
inherited && Object.keys(inherited).length
? { ...inherited, ...(step.params ?? {}) }
: step.params;
return {
...step,
...(revises ? { revises } : {}),
...(params && Object.keys(params).length ? { params } : {}),
};
});
}
/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#n;回头修改 → name·改 */
export function ensureCreationFlowStepIds(
steps: Array<DraftCreationFlowStep>,
): CreationFlowStep[] {
const used = new Set<string>();
const nameCount = new Map<string, number>();
const out: CreationFlowStep[] = [];
for (const raw of steps) {
const name = raw.name.trim();
const n = (nameCount.get(name) ?? 0) + 1;
nameCount.set(name, n);
const revise = raw.mode === "revise";
let id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
if (revise) {
id = n === 1 ? `${name}·改` : `${name}·改`;
if (used.has(id)) {
let i = 2;
while (used.has(`${name}·改#${i}`)) i++;
id = `${name}·改#${i}`;
}
} else {
id = n === 1 ? name : `${name}#${n}`;
}
}
if (used.has(id)) {
let i = 2;
while (used.has(`${id}#${i}`)) i++;
id = `${id}#${i}`;
}
used.add(id);
out.push({
id,
name,
depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean),
...(raw.role ? { role: raw.role } : {}),
...(raw.from ? { from: raw.from } : {}),
...(raw.suggestion ? { suggestion: raw.suggestion } : {}),
...(raw.params ? { params: raw.params } : {}),
...withStepModeFields(raw),
});
}
return normalizeReviseSteps(out);
}
/**
* 补齐 / 规范化原型与实例角色(旧稿 repeatable 无 role → 升为 prototype + instances)。
*/
export function normalizeFlowStepRoles(
steps: CreationFlowStep[],
catalog: ModuleCatalog | null | undefined,
): CreationFlowStep[] {
const repeatableNames = new Set(
(catalog?.modules ?? [])
.filter((m) => m.repeatable === true)
.map((m) => m.name),
);
const prototypeIdByName = new Map<string, string>();
for (const step of steps) {
if (isPrototypeStep(step)) prototypeIdByName.set(step.name, step.id);
}
const out: CreationFlowStep[] = [];
for (const step of steps) {
if (step.role) {
if (isPrototypeStep(step)) {
const { params: _drop, ...rest } = step;
out.push(rest);
} else {
out.push(step);
}
continue;
}
if (!repeatableNames.has(step.name) || isReviseStep(step)) {
out.push(step);
continue;
}
if (!prototypeIdByName.has(step.name)) {
prototypeIdByName.set(step.name, step.id);
const hint =
step.params && Object.keys(step.params).length
? Object.entries(step.params)
.map(
([k, v]) =>
`${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`,
)
.join(";")
: undefined;
const { params: _drop, ...rest } = step;
out.push({
...rest,
role: "prototype",
...(hint && !step.suggestion ? { suggestion: hint } : {}),
});
} else {
out.push({
...step,
role: "instance",
from: prototypeIdByName.get(step.name)!,
});
}
}
return out;
}
/** 按 id 或(唯一)name 解析步骤引用 */
export function findStepByRef(
flow: CreationFlow,
ref: string,
): CreationFlowStep | null {
const key = ref.trim();
if (!key) return null;
const byId = flow.steps.find((s) => s.id === key);
if (byId) return byId;
const byName = flow.steps.filter((s) => s.name === key);
return byName.length === 1 ? byName[0]! : null;
}
/** 验收 / 当前步骤用的单位 id(优先 step.id) */
export function stepUnitId(step: CreationFlowStep): string {
return step.id || step.name;
}
/**
* 验收时记下哪一步做完。只认流程里的真实 step.id:
* LLM 可能把产物误写进「创作.当前步骤」,那种内容不能当进度。
*/
export function pickRecordedStepId(params: {
flow: CreationFlow | null;
alreadyAccepted?: readonly string[];
pinnedUnitId?: string | null;
writtenCurrentStep?: string | null;
summaryHint?: string | null;
}): string | null {
const flow = params.flow;
const tryRef = (ref: string | undefined | null): string | null => {
const key = ref?.trim();
if (!key || key === "flow") return null;
if (!flow) return key;
const step = findStepByRef(flow, key);
return step ? stepUnitId(step) : null;
};
const pinned = tryRef(params.pinnedUnitId);
if (pinned) return pinned;
const written = tryRef(params.writtenCurrentStep);
if (written) return written;
const hinted = tryRef(params.summaryHint);
if (hinted) return hinted;
if (flow) {
const pending = nextPendingStep(flow, params.alreadyAccepted ?? []);
if (pending) return stepUnitId(pending);
}
return null;
}
export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null {
if (!raw?.trim()) return null;
const doc = extractJsonObject(raw);
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const row = doc as Record<string, unknown>;
const stepsRaw = row.steps;
if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) return null;
const drafted: DraftCreationFlowStep[] = [];
for (const item of stepsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const s = item as Record<string, unknown>;
const name = typeof s.name === "string" ? s.name.trim() : "";
if (!name) return null;
const id = typeof s.id === "string" && s.id.trim() ? s.id.trim() : undefined;
const depsRaw = s.depends_on ?? s.dependsOn ?? [];
const depends_on = Array.isArray(depsRaw)
? depsRaw.map((d) => String(d).trim()).filter(Boolean)
: [];
const params = normalizeStepParams(s.params);
const mode =
parseCreationFlowStepMode(s.mode) ?? parseCreationFlowStepMode(s.intent);
const revises = parseStepRevisesRef(s);
const role = parseCreationFlowStepRole(s.role);
const from =
typeof s.from === "string" && s.from.trim() ? s.from.trim() : undefined;
const suggestion =
typeof s.suggestion === "string" && s.suggestion.trim()
? s.suggestion.trim()
: undefined;
drafted.push({
id,
name,
depends_on,
...(role ? { role } : {}),
...(from ? { from } : {}),
...(suggestion ? { suggestion } : {}),
...(params ? { params } : {}),
...(mode ? { mode } : {}),
...(revises ? { revises } : {}),
});
}
const steps = ensureCreationFlowStepIds(drafted);
const brief =
typeof row.brief === "string" && row.brief.trim() ? row.brief.trim() : undefined;
const statusRaw = typeof row.status === "string" ? row.status.trim() : "";
const status: CreationFlowStatus | undefined =
statusRaw === "open" || statusRaw === "closed" ? statusRaw : undefined;
const suggestions = parsePrototypeSuggestions(row.suggestions);
return { version: 1, brief, status, ...(suggestions ? { suggestions } : {}), steps };
}
export function parseModuleCatalog(raw: string): ModuleCatalog | null {
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return null;
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const modulesRaw = (doc as Record<string, unknown>).modules;
if (!Array.isArray(modulesRaw)) return null;
const modules: ModuleCatalogEntry[] = [];
for (const item of modulesRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const m = item as Record<string, unknown>;
const name = typeof m.name === "string" ? m.name.trim() : "";
const declaration =
typeof m.declaration === "string" ? m.declaration.trim() : "";
const artifact = typeof m.artifact === "string" ? m.artifact.trim() : "";
const id =
typeof m.id === "string" && m.id.trim()
? m.id.trim()
: name
? slugFromName(name)
: "";
if (!name || !declaration || !artifact || !id) continue;
const opening =
typeof m.opening === "string" && m.opening.trim()
? m.opening.trim()
: undefined;
const repeatable = m.repeatable === true;
const closer = m.closer === true;
const auto = m.auto === true;
const kind = parseModuleNodeKind(m.kind);
const params = parseModuleParamSpecs(m.params);
modules.push({
id,
name,
declaration,
artifact,
...(repeatable ? { repeatable: true } : {}),
...(closer ? { closer: true } : {}),
...(auto ? { auto: true } : {}),
...(kind ? { kind } : {}),
...(opening ? { opening } : {}),
...(params ? { params } : {}),
});
}
if (modules.length === 0) return null;
return { modules };
}
function parseModuleParamSpecs(raw: unknown): ModuleParamSpec[] | undefined {
if (!Array.isArray(raw) || raw.length === 0) return undefined;
const out: ModuleParamSpec[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const key = typeof row.key === "string" ? row.key.trim() : "";
const label = typeof row.label === "string" ? row.label.trim() : "";
if (!key || !label) continue;
const hint =
typeof row.hint === "string" && row.hint.trim()
? row.hint.trim()
: undefined;
out.push({
key,
label,
...(row.required === true ? { required: true } : {}),
...(hint ? { hint } : {}),
});
}
return out.length > 0 ? out : undefined;
}
/**
* 能力 prompt.md 可切割块(fence 语言标签 = 块 id)。
* 标准块见 MODULE_SECTION_IDS;程序只认 ```id … ```,不认散文标题 alone。
*/
export const MODULE_SECTION_IDS = [
"meta",
"opening",
"task",
"principles",
"probe",
"output",
"checklist",
"examples",
] as const;
export type ModuleSectionId = (typeof MODULE_SECTION_IDS)[number];
export type ModulePromptSections = {
/** 原文 */
raw: string;
/** 按 fence 标签切出的块;缺块则为空串 */
blocks: Partial<Record<ModuleSectionId, string>> & Record<string, string>;
};
/**
* 切割能力文档:抽取全部 ```lang … ``` 块。
* 同一 lang 多次出现时拼接(中间空行)。
*/
export function parseModulePromptSections(promptMd: string): ModulePromptSections {
const blocks: Record<string, string> = {};
if (!promptMd?.trim()) return { raw: promptMd ?? "", blocks };
const re = /```([a-zA-Z][\w-]*)\s*\r?\n([\s\S]*?)```/g;
let m: RegExpExecArray | null;
while ((m = re.exec(promptMd)) !== null) {
const id = m[1]!.toLowerCase();
const body = m[2]!.trim();
if (!body) continue;
blocks[id] = blocks[id] ? `${blocks[id]}\n\n${body}` : body;
}
return { raw: promptMd, blocks };
}
export function getModuleSection(
sections: ModulePromptSections,
id: ModuleSectionId | string,
): string | null {
const body = sections.blocks[id.toLowerCase()]?.trim();
return body || null;
}
/** 把 YAML 字段收成可展示的字符串(支持 string / string[]) */
export function coerceYamlTextField(raw: unknown): string | undefined {
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (Array.isArray(raw)) {
const lines = raw
.map((x) => (typeof x === "string" ? x.trim() : ""))
.filter(Boolean);
return lines.length ? lines.map((l) => `- ${l}`).join("\n") : undefined;
}
return undefined;
}
/**
* 从 prompt.md 的 ```meta 块解析选型字段。
* catalog 负责索引/params;when/when_not/boundary 以 meta 为准。
*/
export function parseModuleMetaFromPrompt(promptMd: string): {
declaration?: string;
when?: string;
when_not?: string;
boundary?: string;
} | null {
const metaRaw = getModuleSection(parseModulePromptSections(promptMd), "meta");
if (!metaRaw) return null;
let doc: unknown;
try {
doc = parseYaml(metaRaw);
} catch {
return null;
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const row = doc as Record<string, unknown>;
const declaration = coerceYamlTextField(row.declaration);
const when = coerceYamlTextField(row.when);
const when_not = coerceYamlTextField(row.when_not);
const boundary = coerceYamlTextField(row.boundary);
if (!declaration && !when && !when_not && !boundary) return null;
return {
...(declaration ? { declaration } : {}),
...(when ? { when } : {}),
...(when_not ? { when_not } : {}),
...(boundary ? { boundary } : {}),
};
}
/**
* 用各能力 prompt.md 的 meta 充实目录条目(编排选型用)。
* declaration:meta 有则覆盖 catalog;when/when_not/boundary:仅来自 meta。
*/
export async function enrichModuleCatalogWithMeta(
catalog: ModuleCatalog,
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<ModuleCatalog> {
const modules = await Promise.all(
catalog.modules.map(async (m) => {
const prompt = await loadModulePrompt(skillPackRoot, m.id, skillsRoot);
if (!prompt) return m;
const meta = parseModuleMetaFromPrompt(prompt);
if (!meta) return m;
return {
...m,
...(meta.declaration ? { declaration: meta.declaration } : {}),
...(meta.when ? { when: meta.when } : {}),
...(meta.when_not ? { when_not: meta.when_not } : {}),
...(meta.boundary ? { boundary: meta.boundary } : {}),
};
}),
);
return { modules };
}
/**
* 从能力 prompt.md 抽取默认问题(开场白)。
* 只认 ```opening … ```(能力标准块)。
*/
export function extractModuleOpening(promptMd: string): string | null {
return getModuleSection(parseModulePromptSections(promptMd), "opening");
}
/**
* 拼给 LLM 的方法正文:标准块按固定顺序;无标准块时回退全文。
* 不含 opening(开场已由程序发出)。
*/
export function formatModulePromptForLlm(promptMd: string): string {
const { blocks } = parseModulePromptSections(promptMd);
const order: ModuleSectionId[] = [
"meta",
"task",
"principles",
"probe",
"output",
"checklist",
"examples",
];
const parts: string[] = [];
for (const id of order) {
const body = blocks[id]?.trim();
if (body) parts.push(`## ${id}\n\n\`\`\`${id}\n${body}\n\`\`\``);
}
if (parts.length === 0) return promptMd.trim();
return parts.join("\n\n");
}
export function parseModuleOpeningState(
raw: string | unknown | null | undefined,
): ModuleOpeningState {
if (raw == null) return {};
const text =
typeof raw === "string"
? raw.trim()
: typeof raw === "object"
? JSON.stringify(raw)
: String(raw);
if (!text) return {};
try {
const doc = JSON.parse(text) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {};
const out: ModuleOpeningState = {};
for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
if (v === "shown" || v === "answered") out[k] = v;
}
return out;
} catch {
return {};
}
}
export function stringifyModuleOpeningState(state: ModuleOpeningState): string {
return JSON.stringify(state);
}
/**
* 序列化「设计.创作流程」(程序 seed / 验收写回共用)。
*/
export function stringifyCreationFlow(flow: CreationFlow): string {
return JSON.stringify({
version: 1,
...(flow.brief ? { brief: flow.brief } : {}),
...(flow.status ? { status: flow.status } : {}),
...(flow.suggestions?.length ? { suggestions: flow.suggestions } : {}),
steps: flow.steps,
});
}
/**
* 编排层重排流程时,已验收步骤按原样保留:
* 缺了就按原位置补回,改了名字/参数就还原。编排只能动未验收步。
*/
export function mergeCreationFlowPreservingAccepted(params: {
prevRaw: string | null | undefined;
nextRaw: string;
acceptedStepIds: readonly string[];
}): { raw: string; restored: string[] } {
const nextParsed = parseCreationFlow(params.nextRaw);
const prev = parseCreationFlow(params.prevRaw);
if (!nextParsed) return { raw: params.nextRaw, restored: [] };
const next = stripReviseSteps(nextParsed);
if (!prev) {
return { raw: stringifyCreationFlow(next), restored: [] };
}
const accepted = prev.steps.filter(
(s) => isStepAccepted(s, params.acceptedStepIds) && !isReviseStep(s),
);
if (accepted.length === 0) {
return { raw: stringifyCreationFlow(next), restored: [] };
}
const restored: string[] = [];
const steps = [...next.steps];
for (let i = 0; i < accepted.length; i++) {
const frozen = accepted[i]!;
const at = steps.findIndex((s) => s.id === frozen.id);
if (at < 0) {
steps.splice(Math.min(i, steps.length), 0, frozen);
restored.push(frozen.id);
continue;
}
const current = steps[at]!;
const changed =
current.name !== frozen.name ||
JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) ||
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null) ||
(current.mode ?? "fresh") !== (frozen.mode ?? "fresh") ||
(current.revises ?? "") !== (frozen.revises ?? "") ||
(current.role ?? "") !== (frozen.role ?? "") ||
(current.from ?? "") !== (frozen.from ?? "") ||
(current.suggestion ?? "") !== (frozen.suggestion ?? "");
if (changed) {
steps[at] = frozen;
restored.push(frozen.id);
}
}
return {
raw: stringifyCreationFlow(
stripReviseSteps({ ...next, version: 1, steps }),
),
restored,
};
}
/**
* 配方近期起点 → 可写入黑板的开局 DAG。
* 开局节点跟剧本(配方 seed.steps)走,不在运行时写死某一步。
* steps 为空则返回 null(仍只当选型参考,不预置流程)。
*/
export function creationFlowFromRecipeSeed(
detail: Pick<RecipeDetail, "seed">,
): CreationFlow | null {
const seed = detail.seed;
if (!seed?.steps?.length) return null;
return {
version: 1,
...(seed.brief ? { brief: seed.brief } : {}),
status: seed.status ?? "open",
steps: ensureCreationFlowStepIds(seed.steps),
};
}
/**
* 已坐在配方开局步(无 depends_on)且「用户.需求」有首句时:
* 跳过程序再抛模块 opening——首句即该步对话,不是「先开场再进 DAG」。
*/
export function shouldSkipModuleOpening(opts: {
demand: string;
dependsOn?: string[] | null;
phase?: "shown" | "answered" | null;
}): boolean {
if (opts.phase === "shown" || opts.phase === "answered") return false;
if (!String(opts.demand ?? "").trim()) return false;
const deps = opts.dependsOn ?? [];
return deps.length === 0;
}
/** 无 id 时的兜底(目录仍应显式写 id) */
function slugFromName(name: string): string {
const map: Record<string, string> = {
美学纲领与交互范式: "aesthetics-interaction",
交互范式: "interaction",
美学纲领: "aesthetics",
叙事指南: "narrative",
叙事指南与故事推进: "narrative",
实现机制: "mechanism",
舞台骨架: "world-blueprint",
世界蓝图与人文地理: "world-blueprint", // 旧称
世界蓝图: "world-blueprint", // 旧简称
生成规则: "generation-rules",
具体实例: "concrete-instances",
拓扑图谱: "topology",
设计状态栏: "status-bar",
设计监控栏: "status-bar",
变量设计与更新规则: "variable-design",
变量控制上下文: "variable-context",
设计回复格式: "reply-format",
正文组成: "reply-format",
游玩拓扑: "worker-spec",
"Worker 规格": "worker-spec", // 旧称,等同游玩拓扑
细化终稿: "refine",
开场白与开场变量: "opening-setup",
开场白: "opening-setup",
};
return map[name] ?? name;
}
export async function loadModuleCatalog(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<ModuleCatalog | null> {
const fullPath = path.join(skillsRoot, skillPackRoot, MODULE_CATALOG_FILENAME);
try {
const raw = await readFile(fullPath, "utf8");
const catalog = parseModuleCatalog(raw);
if (!catalog) return null;
return enrichModuleCatalogWithMeta(catalog, skillPackRoot, skillsRoot);
} catch {
return null;
}
}
/** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => {
const flags = [
m.repeatable ? "〔可反复〕" : "",
m.closer ? "〔收口〕" : "",
m.auto ? "〔程序步〕" : "",
m.kind ? MODULE_NODE_KIND_FLAGS[m.kind] : "",
]
.filter(Boolean)
.join("");
const parts: string[] = [`- ${m.name}${flags}:${m.declaration}`];
if (m.when) parts.push(` 何时用:${indentMultiline(m.when, " ")}`);
if (m.when_not) parts.push(` 何时不用:${indentMultiline(m.when_not, " ")}`);
if (m.boundary) parts.push(` 边界:${indentMultiline(m.boundary, " ")}`);
if (m.params && m.params.length > 0) {
if (isPriorArtifactModule(m)) {
parts.push(
` 规划产物字段(可提前写入 params;空则步内钉):${m.params
.map((p) => {
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label}${hint})`;
})
.join(";")}`,
);
} else {
parts.push(
` 编排参数:${m.params
.map((p) => {
const req = p.required ? "必填" : "可选";
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label},${req}${hint})`;
})
.join(";")}`,
);
}
}
return parts.join("\n");
});
return [
"【能力 · 可选工序】",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入;标〔收口〕的是终节点:排在细化终稿之后作最后一步;尚未选定开场前,用户要补前序节点(如 NPC)时插在收口之前,勿以「收口已排入」拒绝追加;选定后才结束创作并保存;标〔程序步〕的确认编排后直接执行(不抛默认问题、不经同意并开始),产物仍验收。",
"标〔先验产物〕(生成规则、具体实例):编排层只排 role=prototype 槽位 + suggestion;禁止在原型写 params、禁止预排 instance。「写什么」在用户点原型增殖后的实例步内钉。",
"其它有「编排参数」的步骤:确认开干前写齐必填 params;缺参时用 askUser 选项+其它。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta)。",
"〔可反复〕再编入新 id = 彻底新建一条。已完成节点要改:禁止排 mode=revise 新步;用户会在图上点该节点重进。禁止把改旧稿写成又一条 fresh。",
"不要把能力执行全文塞进本步;执行由 design-step 注入。",
lines.join("\n"),
].join("\n");
}
/** 多行字段:首行接在标签后,续行缩进 */
function indentMultiline(text: string, indent: string): string {
const lines = text.split(/\r?\n/);
if (lines.length <= 1) return text;
return [lines[0], ...lines.slice(1).map((l) => `${indent}${l}`)].join("\n");
}
export function parseRecipeCatalog(raw: string): RecipeCatalog | null {
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return null;
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
const recipesRaw = (doc as Record<string, unknown>).recipes;
if (!Array.isArray(recipesRaw)) return null;
const recipes: RecipeCatalogEntry[] = [];
for (const item of recipesRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const r = item as Record<string, unknown>;
const name = typeof r.name === "string" ? r.name.trim() : "";
const declaration =
typeof r.declaration === "string" ? r.declaration.trim() : "";
const id =
typeof r.id === "string" && r.id.trim()
? r.id.trim()
: name
? slugFromName(name)
: "";
if (!name || !declaration || !id) continue;
recipes.push({ id, name, declaration });
}
if (recipes.length === 0) return null;
return { recipes };
}
export async function loadRecipeCatalog(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<RecipeCatalog | null> {
const fullPath = path.join(skillsRoot, skillPackRoot, RECIPE_CATALOG_FILENAME);
try {
const raw = await readFile(fullPath, "utf8");
return parseRecipeCatalog(raw);
} catch {
return null;
}
}
/**
* 解析单份 recipe.yaml(when / core / process / principles / brief / steps)。
* 兼容旧字段 hint。steps 空或缺失 → seed 为 null(仍可作选型参考)。
*/
export function parseRecipeYaml(
raw: string,
meta: RecipeCatalogEntry,
): RecipeDetail {
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return {
id: meta.id,
name: meta.name,
declaration: meta.declaration,
seed: null,
};
}
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
return {
id: meta.id,
name: meta.name,
declaration: meta.declaration,
seed: null,
};
}
const row = doc as Record<string, unknown>;
const when =
typeof row.when === "string" && row.when.trim()
? row.when.trim()
: undefined;
const core = coerceYamlTextField(row.core);
const process = normalizeRecipeListOrText(row.process);
const principles = normalizeRecipeListOrText(row.principles);
const hint =
typeof row.hint === "string" && row.hint.trim()
? row.hint.trim()
: undefined;
const name =
typeof row.name === "string" && row.name.trim()
? row.name.trim()
: meta.name;
const seedParsed = parseCreationFlow(JSON.stringify({
brief: typeof row.brief === "string" ? row.brief : undefined,
status: "open",
steps: Array.isArray(row.steps) ? row.steps : [],
}));
const seed = seedParsed;
return {
id: meta.id,
name,
declaration: meta.declaration,
when,
...(core ? { core } : {}),
...(process ? { process } : {}),
...(principles ? { principles } : {}),
...(hint ? { hint } : {}),
seed,
};
}
/** process / principles:保留数组,或收成单字符串 */
function normalizeRecipeListOrText(
raw: unknown,
): string | string[] | undefined {
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (Array.isArray(raw)) {
const items = raw
.map((x) => (typeof x === "string" ? x.trim() : ""))
.filter(Boolean);
return items.length ? items : undefined;
}
return undefined;
}
export async function loadRecipeDetail(
skillPackRoot: string,
entry: RecipeCatalogEntry,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<RecipeDetail> {
const fullPath = path.join(
skillsRoot,
skillPackRoot,
"recipes",
entry.id,
"recipe.yaml",
);
try {
const raw = await readFile(fullPath, "utf8");
return parseRecipeYaml(raw, entry);
} catch {
return {
id: entry.id,
name: entry.name,
declaration: entry.declaration,
seed: null,
};
}
}
export async function loadAllRecipeDetails(
skillPackRoot: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<RecipeDetail[]> {
const catalog = await loadRecipeCatalog(skillPackRoot, skillsRoot);
if (!catalog) return [];
const out: RecipeDetail[] = [];
for (const entry of catalog.recipes) {
out.push(await loadRecipeDetail(skillPackRoot, entry, skillsRoot));
}
return out;
}
/**
* 解析用户选定的配方引用。
* 接受纯 id / 中文名,或 JSON `{ "id": "…" }` / `{ "name": "…" }`。
*/
export function parseSelectedRecipeRef(
raw: string | null | undefined,
): string | null {
if (!raw?.trim()) return null;
const trimmed = raw.trim();
try {
const doc = JSON.parse(trimmed) as unknown;
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
const row = doc as Record<string, unknown>;
const id = typeof row.id === "string" ? row.id.trim() : "";
const name = typeof row.name === "string" ? row.name.trim() : "";
return id || name || null;
}
} catch {
/* plain string */
}
return trimmed;
}
export function findRecipeCatalogEntry(
catalog: RecipeCatalog | null | undefined,
ref: string,
): RecipeCatalogEntry | null {
if (!catalog || !ref.trim()) return null;
const key = ref.trim();
return (
catalog.recipes.find((r) => r.id === key || r.name === key) ?? null
);
}
/** 给人 / API 看的配方目录短列表(不是给 agent 选型) */
export function formatRecipeCatalogForAgent(catalog: RecipeCatalog): string {
const lines = catalog.recipes.map(
(r) => `- ${r.name}:${r.declaration}`,
);
return `【可选配方】(须由用户手动选择)\n${lines.join("\n")}`;
}
/** 注入 design-flow:用户已选配方(方法论 + 近期起点) */
export function formatSelectedRecipeForAgent(detail: RecipeDetail): string {
const lines: string[] = [
`【用户已选配方 · ${detail.name}】`,
"这是用户手动选定的设计方法,不是锁死流水线。",
"产出**增量工作流计划(DAG)**:只排近期要做的步骤;已验收步保留,可追加同技能多次调用。",
"按用户表述与配方方法论增删改未验收步骤与依赖;步骤名只能从【能力】选。",
"能力「何时用 / 何时不用」以【能力 · 可选工序】为准;本配方不重复罗列各能力调用条件。",
"禁止改选其它配方;若用户要换配方,须等用户重新选定后再编排。",
];
if (detail.declaration) lines.push(`简介:${detail.declaration}`);
if (detail.when) lines.push(`适用:${detail.when}`);
if (detail.core) {
lines.push("核心思路:");
lines.push(detail.core);
}
if (detail.process) {
lines.push("设计流程:");
lines.push(formatRecipeFieldBlock(detail.process));
}
if (detail.principles) {
lines.push("原则:");
lines.push(formatRecipeFieldBlock(detail.principles));
}
if (!detail.core && !detail.process && !detail.principles && detail.hint) {
lines.push(`调味提示(旧字段):${detail.hint}`);
}
if (detail.seed?.steps.length) {
const stepsJson = JSON.stringify(
{
brief: detail.seed.brief,
status: detail.seed.status ?? "open",
steps: detail.seed.steps,
},
null,
2,
);
lines.push("建议近期 steps(增量起点,可改;勿一次排完全程):");
lines.push("```json");
lines.push(stepsJson);
lines.push("```");
} else {
lines.push("建议 steps:(待作者完善 recipe.yaml;可从【能力】自行编排近期 horizon)");
}
return lines.join("\n");
}
function formatRecipeFieldBlock(value: string | string[]): string {
if (Array.isArray(value)) {
return value.map((l) => `- ${l}`).join("\n");
}
return value;
}
/** design-flow 一次注入:流程进度 + 已选配方 + 技能池 */
export function formatDesignFlowContentBlocks(params: {
selectedRecipe?: RecipeDetail | null;
modules?: ModuleCatalog | null;
missingSelection?: boolean;
flow?: CreationFlow | null;
acceptedStepIds?: readonly string[];
filledArtifactTags?: readonly string[];
}): string[] {
const blocks: string[] = [
formatFlowProgressForAgent({
flow: params.flow,
acceptedStepIds: params.acceptedStepIds,
catalog: params.modules,
filledArtifactTags: params.filledArtifactTags,
}),
];
if (params.missingSelection) {
blocks.push(
[
"【配方】用户尚未手动选择。",
"禁止自行猜测或替用户选定配方。",
"请 askUser 请用户从可用配方中选择,或等待用户在界面选定后再编排。",
].join("\n"),
);
} else if (params.selectedRecipe) {
blocks.push(formatSelectedRecipeForAgent(params.selectedRecipe));
}
if (params.modules) {
blocks.push(formatModuleCatalogForAgent(params.modules));
}
return blocks;
}
/**
* 编排器必须看见的进度:哪些步已做完、草案里已有什么、哪些能力才允许再编入。
* 裸 JSON id 列表不够;此块由程序钉死。
*/
export function formatFlowProgressForAgent(params: {
flow?: CreationFlow | null;
acceptedStepIds?: readonly string[];
catalog?: ModuleCatalog | null;
filledArtifactTags?: readonly string[];
}): string {
const accepted = (params.acceptedStepIds ?? []).filter(
(id) => Boolean(id?.trim()) && id.trim() !== "flow",
);
const flow = params.flow ? stripReviseSteps(params.flow) : null;
const catalog = params.catalog ?? null;
const filled = new Set(params.filledArtifactTags ?? []);
const repeatableNames = new Set(
(catalog?.modules ?? [])
.filter((m) => m.repeatable === true)
.map((m) => m.name),
);
const doneLines: string[] = [];
const doneNames = new Set<string>();
const doneIds = new Set<string>();
const markDone = (
name: string,
id: string | undefined,
why: string,
repeatable: boolean,
) => {
const flag = repeatable
? "〔可反复:mode=fresh 再追加 = 彻底新建〕"
: "〔不可反复:禁止再排新建,也禁止排 mode=revise〕";
const idBit = id ? `(id: ${id})` : "";
doneLines.push(`- ${name}${idBit} · ${why} ${flag}`);
doneNames.add(name);
if (id) doneIds.add(id);
};
if (flow) {
for (const step of flow.steps) {
if (isPrototypeStep(step)) continue;
const acceptedHere = isStepAccepted(step, accepted);
const art = catalog ? artifactTagForStep(step.name, catalog) : null;
const hasArtifact = Boolean(art && filled.has(art));
const repeatable = repeatableNames.has(step.name);
if (acceptedHere || (!repeatable && hasArtifact)) {
markDone(
step.name,
step.id,
acceptedHere ? "已验收" : "产物已在黑板",
repeatable,
);
}
}
}
for (const id of accepted) {
if (doneIds.has(id)) continue;
if (flow?.steps.some((s) => isStepAccepted(s, [id]))) continue;
markDone(id, id, "已验收", repeatableNames.has(id));
}
if (catalog) {
for (const mod of catalog.modules) {
if (mod.repeatable === true) continue;
if (!mod.artifact || !filled.has(mod.artifact)) continue;
if (doneNames.has(mod.name)) continue;
markDone(mod.name, undefined, "产物已在黑板", false);
}
}
const draftLines: string[] = [];
if (flow) {
for (const step of flow.steps) {
if (isPrototypeStep(step)) continue;
if (doneIds.has(step.id) || isStepAccepted(step, accepted)) continue;
draftLines.push(
`- ${step.name}(id: ${step.id}) · 已在草案,保留原 id;不要当作新规划再写一遍`,
);
}
}
const repeatableList = (catalog?.modules ?? [])
.filter((m) => m.repeatable === true)
.map((m) => m.name);
const prototypeLines: string[] = [];
if (flow) {
for (const step of flow.steps) {
if (!isPrototypeStep(step)) continue;
const sug = step.suggestion?.trim()
? ` · 建议:${step.suggestion.trim()}`
: "";
prototypeLines.push(
`- ${step.name}(id: ${step.id},role=prototype)${sug} · 保留;用户在图上点增殖,禁止预排实例或写 params`,
);
}
}
const suggestionLines = (flow?.suggestions ?? []).map(
(s) =>
`- ${s.name} · ${s.suggestion}${s.depends_on?.length ? ` · 依赖 ${s.depends_on.join("、")}` : ""}`,
);
const lines = [
"【流程进度】(程序钉死,必须遵守)",
"你只排 DAG 骨架:节点 + depends_on +(repeatable 能力)原型槽位 + suggestions。不决定用户先跑哪个。",
"〔可反复〕须写 role=prototype,可带 suggestion(建议生成什么);禁止预排 instance、禁止在原型上写 params。「写什么」在实例步 design-step 里钉。",
"已完成的非反复技能:禁止再排新建,禁止排 mode=revise。改已完成节点由用户在图上点选重进,不要在 DAG 里加修订步。",
"要改已有实例:用户点该实例重进。点原型增殖 = 全新 instance(role=instance,from=原型 id)。",
"草案里已有的步骤:原样保留,不要重排一遍开局。",
];
const closerPending =
catalog && flow
? flow.steps.filter((step) => {
const mod = catalog.modules.find((m) => m.name === step.name);
return Boolean(mod?.closer) && !isStepAccepted(step, accepted);
})
: [];
if (closerPending.length) {
lines.push(
`草案已有〔收口〕${closerPending
.map((s) => `「${s.name}」`)
.join("、")}但尚未选定。用户要求补节点(如 NPC、生成规则、具体实例)时:把新步插在收口之前,保持收口为最后一步,并将 status 改回 open。禁止以「收口已排入」为由拒绝追加。`,
);
}
if (doneLines.length) {
lines.push("", "已完成:", ...doneLines);
} else {
lines.push(
"",
"已完成:尚无已验收步骤。若草案已有开局步,那是配方预置起点,不是你新规划的。",
);
}
if (draftLines.length) {
lines.push("", "草案已有、尚未验收(保留,勿重排):", ...draftLines);
}
if (prototypeLines.length) {
lines.push("", "原型槽位(保留,勿改成 instance):", ...prototypeLines);
}
if (suggestionLines.length) {
lines.push(
"",
"建议追加的原型(可编入 steps 为 role=prototype):",
...suggestionLines,
);
}
lines.push(
"",
repeatableList.length
? `目录〔可反复〕:${repeatableList.join("、")}(排骨架时用 role=prototype)`
: "目录〔可反复〕:以能力目录为准。",
);
return lines.join("\n");
}
/** 解析并加载用户已选配方详情 */
export async function resolveSelectedRecipeDetail(params: {
skillPackRoot: string;
selectedRecipeRef?: string | null;
skillsRoot?: string;
}): Promise<RecipeDetail | null> {
const ref = parseSelectedRecipeRef(params.selectedRecipeRef);
if (!ref) return null;
const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT;
const catalog = await loadRecipeCatalog(params.skillPackRoot, skillsRoot);
const entry = findRecipeCatalogEntry(catalog, ref);
if (!entry) return null;
return loadRecipeDetail(params.skillPackRoot, entry, skillsRoot);
}
export function validateCreationFlow(
flow: CreationFlow,
catalog: ModuleCatalog | null,
): CreationFlowValidation {
const errors: string[] = [];
const seenIds = new Set<string>();
const allowed = catalog
? new Set(catalog.modules.map((m) => m.name))
: null;
const reportedFreshDup = new Set<string>();
for (let i = 0; i < flow.steps.length; i++) {
const step = flow.steps[i]!;
if (seenIds.has(step.id)) {
errors.push(`步骤 id「${step.id}」重复`);
}
seenIds.add(step.id);
if (allowed && !allowed.has(step.name)) {
errors.push(`「${step.name}」不在模块目录中`);
}
const mod = catalog?.modules.find((m) => m.name === step.name);
if (isReviseStep(step)) {
const ref = step.revises?.trim() ?? "";
if (!ref) {
errors.push(
`「${step.id}」是回头修改,但未标明 revises(应指向更前的同名步骤 id)`,
);
} else {
const origin = findStepByRef(flow, ref);
if (!origin) {
errors.push(
`「${step.id}」回头修改「${ref}」,但流程中没有该步骤`,
);
} else if (origin.id === step.id) {
errors.push(`「${step.id}」不能回头修改自己`);
} else if (origin.name !== step.name) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但能力名不同(${step.name} ≠ ${origin.name})`,
);
} else {
const originIndex = flow.steps.findIndex((s) => s.id === origin.id);
if (originIndex >= i) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但「${origin.id}」未排在其前面`,
);
}
}
}
} else if (isPrototypeStep(step)) {
if (step.params && Object.keys(step.params).length) {
errors.push(`原型「${step.id}」不应带 params(「写什么」在实例步内钉)`);
}
continue;
}
if (
catalog &&
mod &&
mod.repeatable !== true &&
!reportedFreshDup.has(step.name) &&
flow.steps.filter((s) => s.name === step.name && !isReviseStep(s)).length >
1
) {
reportedFreshDup.add(step.name);
errors.push(
`「${step.name}」出现多次新建,但目录未标 repeatable(非反复技能请点已完成节点重进,不要再排新建)`,
);
}
for (const dep of step.depends_on) {
const depStep = findStepByRef(flow, dep);
if (!depStep) {
errors.push(`「${step.id}」依赖「${dep}」,但流程中没有该步骤`);
continue;
}
const depIndex = flow.steps.findIndex((s) => s.id === depStep.id);
if (depIndex >= i) {
errors.push(
`「${step.id}」依赖「${depStep.id}」,但「${depStep.id}」未排在其前面`,
);
}
}
// 必填 params 改在「确认开干」时钉齐,不再阻挡编排产出空壳步骤
}
return { ok: errors.length === 0, errors };
}
/** 进 design-step 前:本步必填编排参数是否已齐。〔先验产物〕不拦。 */
export function validateStepReadyToRun(
step: CreationFlowStep,
catalog: ModuleCatalog | null,
): CreationFlowValidation {
const mod = catalog?.modules.find((m) => m.name === step.name);
if (!mod?.params?.length) return { ok: true, errors: [] };
const missing = missingRequiredStepParams(step, mod);
if (missing.length === 0) return { ok: true, errors: [] };
const labels = missing
.map((key) => {
const spec = mod.params!.find((p) => p.key === key);
return spec ? `${key}(${spec.label})` : key;
})
.join("、");
return {
ok: false,
errors: [`「${step.id}」缺少必填编排参数:${labels}(确认开干前请补齐)`],
};
}
/** 写回某步 params(确认下一节点时由用户钉死) */
export function patchCreationFlowStepParams(
flow: CreationFlow,
stepId: string,
params: CreationFlowStepParams,
): CreationFlow {
const id = stepId.trim();
return {
...flow,
steps: flow.steps.map((s) =>
s.id === id
? {
...s,
params: Object.keys(params).length ? { ...params } : undefined,
}
: s,
),
};
}
export function artifactTagForStep(
name: string,
catalog: ModuleCatalog | null,
): string | null {
return catalog?.modules.find((m) => m.name === name)?.artifact ?? null;
}
export function finalizeCreationFlow(
flow: CreationFlow,
catalog: ModuleCatalog | null | undefined,
): CreationFlow {
const stripped = stripReviseSteps(flow);
return {
...stripped,
steps: normalizeFlowStepRoles(stripped.steps, catalog),
};
}
const FLOW_TITLE_MAX = 28;
function flowParamText(
params: CreationFlowStepParams | undefined,
key: string,
): string {
const v = params?.[key];
if (typeof v === "string") return v.trim();
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
function firstFlowTitle(...vals: Array<string | undefined>): string | undefined {
for (const v of vals) {
const t = v?.trim();
if (t) return t;
}
return undefined;
}
function clampFlowTitle(raw: string, max = FLOW_TITLE_MAX): string {
const t = raw.trim();
if (t.length <= max) return t;
return `${t.slice(0, max - 1)}…`;
}
function asTitleRecord(v: unknown): Record<string, unknown> | null {
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
return v as Record<string, unknown>;
}
/** 建议文案的短标题:冒号前;无冒号且足够短则整句。 */
export function titleFromSuggestion(suggestion?: string): string | undefined {
const t = suggestion?.trim();
if (!t) return undefined;
const colon = t.search(/[::]/);
if (colon > 0 && colon <= FLOW_TITLE_MAX) {
const head = t.slice(0, colon).trim();
const rest = t.slice(colon + 1).trim();
if (head && rest) return head;
}
if (t.length <= FLOW_TITLE_MAX) return t;
return undefined;
}
function titleFromStepParams(
params: CreationFlowStepParams | undefined,
): string | undefined {
const target =
flowParamText(params, "target") ||
flowParamText(params, "object") ||
flowParamText(params, "对象") ||
flowParamText(params, "生成对象");
if (target) return clampFlowTitle(target);
const batch = flowParamText(params, "batch_goal");
if (batch) return clampFlowTitle(batch);
const ruleId = flowParamText(params, "rule_id");
if (ruleId) return clampFlowTitle(ruleId);
return undefined;
}
function titleFromAcceptedSummary(
summary: string | undefined,
skillName: string,
): string | undefined {
const t = summary?.trim();
if (!t) return undefined;
const parts = t
.split(/\s*·\s*/)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length >= 2 && (parts[0] === skillName || parts[0] === "生成规则" || parts[0] === "具体实例")) {
return clampFlowTitle(parts[1]!);
}
if (t.length <= FLOW_TITLE_MAX) return t;
return undefined;
}
function titleFromAcceptedContent(content: unknown): string | undefined {
const row = asTitleRecord(content);
if (!row) return undefined;
const body = asTitleRecord(row.正文) ?? row;
const pinned = asTitleRecord(body.本步参数);
if (typeof pinned?.target === "string" && pinned.target.trim()) {
return clampFlowTitle(pinned.target);
}
const nec = asTitleRecord(body.必要性判断);
if (typeof nec?.生成对象 === "string" && nec.生成对象.trim()) {
return clampFlowTitle(nec.生成对象);
}
const rules = Array.isArray(body.rules) ? body.rules : [];
for (const item of rules) {
const rec = asTitleRecord(item);
if (typeof rec?.对象 === "string" && rec.对象.trim()) {
return clampFlowTitle(rec.对象);
}
}
if (typeof row.brief === "string" && row.brief.trim() && row.brief.trim().length <= FLOW_TITLE_MAX) {
return row.brief.trim();
}
return undefined;
}
export type FlowStepTitleHint = {
summary?: string;
content?: unknown;
};
/** 图上「增殖的是啥」短标题:验收内容 > 规划参数 > 建议文案。 */
export function deriveFlowStepTitle(params: {
name: string;
stepParams?: CreationFlowStepParams;
suggestion?: string;
prototypeSuggestion?: string;
hint?: FlowStepTitleHint;
}): string | undefined {
const raw = firstFlowTitle(
titleFromAcceptedContent(params.hint?.content),
titleFromAcceptedSummary(params.hint?.summary, params.name),
titleFromStepParams(params.stepParams),
titleFromSuggestion(params.suggestion),
titleFromSuggestion(params.prototypeSuggestion),
);
if (!raw || raw === params.name) return undefined;
return raw;
}
export function formatCreationFlowForUser(
flow: CreationFlow,
catalog?: ModuleCatalog | null,
acceptedStepIds: readonly string[] = [],
filledArtifactTags: readonly string[] = [],
titleHints: Readonly<Record<string, FlowStepTitleHint>> = {},
): CreationFlowUserView {
const normalized = finalizeCreationFlow(flow, catalog ?? null);
const decl = new Map(
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
);
const seenInstanceName = new Map<string, number>();
const layers = computeStepLayers(normalized);
const prototypeNames = new Set(
normalized.steps.filter(isPrototypeStep).map((s) => s.name),
);
const pendingSuggestions = (normalized.suggestions ?? []).filter(
(s) => !prototypeNames.has(s.name),
);
const filled = new Set(filledArtifactTags);
const byId = new Map(normalized.steps.map((s) => [s.id, s] as const));
return {
brief: normalized.brief,
status: normalized.status,
...(pendingSuggestions.length ? { suggestions: pendingSuggestions } : {}),
steps: normalized.steps.map((s, i) => {
const revise = isReviseStep(s);
const proto = isPrototypeStep(s);
let occurrence: number | undefined;
if (!revise && isInstanceStep(s)) {
const n = (seenInstanceName.get(s.name) ?? 0) + 1;
seenInstanceName.set(s.name, n);
occurrence = n;
}
const mod = decl.get(s.name);
const paramsMissing = proto
? []
: missingRequiredStepParams(s, mod);
const accepted = isStepAccepted(s, acceptedStepIds);
const ready = proto
? isPrototypeSelectable(normalized, s, acceptedStepIds)
: isStepReady(normalized, s, acceptedStepIds);
const blockedBy =
accepted || ready
? undefined
: s.depends_on.filter(
(dep) =>
!isCreationDependencySatisfied(
normalized,
dep,
acceptedStepIds,
),
);
const runState: FlowStepRunState = proto
? "pending"
: accepted
? "done"
: "pending";
const parent = s.from ? byId.get(s.from) : undefined;
const title = deriveFlowStepTitle({
name: s.name,
stepParams: s.params,
suggestion: s.suggestion,
prototypeSuggestion: parent?.suggestion,
hint: titleHints[s.id],
});
return {
order: i + 1,
id: s.id,
name: s.name,
depends_on: s.depends_on,
...(s.role ? { role: s.role } : {}),
...(s.from ? { from: s.from } : {}),
...(s.suggestion ? { suggestion: s.suggestion } : {}),
...(title ? { title } : {}),
occurrence,
...(revise ? { mode: "revise" as const } : {}),
...(s.revises ? { revises: s.revises } : {}),
declaration: mod?.declaration,
repeatable: mod?.repeatable,
...(isCloserModule(mod, s.name) ? { closer: true } : {}),
...(mod?.kind ? { kind: mod.kind } : {}),
layer: layers.get(s.id) ?? 0,
ready,
selectable: ready,
...(blockedBy?.length ? { blockedBy } : {}),
runState,
...(mod?.artifact ? { artifactTag: mod.artifact } : {}),
...(mod?.artifact && filled.has(mod.artifact) ? { hasArtifact: true } : {}),
...(!proto && s.params ? { params: s.params } : {}),
...(paramsMissing.length > 0 ? { paramsMissing } : {}),
};
}),
};
}
export function findModuleByName(
catalog: ModuleCatalog | null | undefined,
name: string,
): ModuleCatalogEntry | null {
if (!catalog) return null;
return catalog.modules.find((m) => m.name === name) ?? null;
}
/** 已验收步骤名列表(JSON 数组或换行文本) */
export function parseAcceptedSteps(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.map((x) => String(x).trim()).filter(Boolean);
}
if (typeof raw !== "string" || !raw.trim()) return [];
try {
const doc = JSON.parse(raw);
if (Array.isArray(doc)) {
return doc.map((x) => String(x).trim()).filter(Boolean);
}
} catch {
/* fall through */
}
return raw
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* 某步是否已验收:认 step.id;兼容旧会话只记了中文 name(且当时 name 唯一)。
*/
export function isStepAccepted(
step: CreationFlowStep,
acceptedStepIds: readonly string[],
): boolean {
const done = new Set(acceptedStepIds);
if (done.has(step.id)) return true;
// 旧稿:验收列表里是中文名,且 id 就是 name
if (step.id === step.name && done.has(step.name)) return true;
return false;
}
/** 未验收且 depends_on 均已满足(原型步不可执行,恒 false) */
export function isStepReady(
flow: CreationFlow,
step: CreationFlowStep,
acceptedStepIds: readonly string[],
): boolean {
if (isPrototypeStep(step)) return false;
if (isStepAccepted(step, acceptedStepIds)) return false;
return step.depends_on.every((dep) =>
isCreationDependencySatisfied(flow, dep, acceptedStepIds),
);
}
/**
* 当前所有可点进去的步骤(依赖已齐、尚未验收)。
* 编排器排出的回头修改步不进入可执行队列。
*/
export function listReadySteps(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): CreationFlowStep[] {
if (!flow?.steps.length) return [];
return flow.steps.filter(
(step) =>
isExecutableStep(step) &&
!isReviseStep(step) &&
isStepReady(flow, step, acceptedStepIds),
);
}
export function listSelectablePrototypes(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): CreationFlowStep[] {
if (!flow?.steps.length) return [];
return flow.steps.filter((step) =>
isPrototypeSelectable(flow, step, acceptedStepIds),
);
}
/**
* 流程中下一个待做步骤:未验收,且 depends_on 均已验收。
*/
export function nextPendingStep(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): CreationFlowStep | null {
const ready = listReadySteps(flow, acceptedStepIds);
return ready[0] ?? null;
}
/** 依赖层级:根=0,某步 = max(依赖层级)+1。环则记 0。 */
export function computeStepLayers(flow: CreationFlow): Map<string, number> {
const memo = new Map<string, number>();
const visiting = new Set<string>();
const layerOf = (stepId: string): number => {
if (memo.has(stepId)) return memo.get(stepId)!;
if (visiting.has(stepId)) return 0;
const step = findStepByRef(flow, stepId);
if (!step || step.depends_on.length === 0) {
memo.set(stepId, 0);
return 0;
}
visiting.add(stepId);
let max = 0;
for (const dep of step.depends_on) {
const depStep = findStepByRef(flow, dep);
if (!depStep) continue;
max = Math.max(max, layerOf(depStep.id) + 1);
}
visiting.delete(stepId);
memo.set(stepId, max);
return max;
};
for (const step of flow.steps) layerOf(step.id);
return memo;
}
export function isCloserModule(
module: { closer?: boolean } | null | undefined,
name?: string,
): boolean {
if (module?.closer === true) return true;
const t = (name ?? "").trim();
return t === "开场白与开场变量" || t.startsWith("开场白与开场变量#") || t === "开场白";
}
export function hasAcceptedCloserStep(
flow: CreationFlow | null | undefined,
acceptedStepIds: readonly string[],
catalog?: ModuleCatalog | null,
): boolean {
if (flow?.steps.length) {
return flow.steps.some(
(s) =>
isCloserModule(findModuleByName(catalog, s.name), s.name) &&
isStepAccepted(s, acceptedStepIds),
);
}
return acceptedStepIds.some((id) => isCloserModule(null, id));
}
export function findCloserStepIndex(
flow: CreationFlow,
catalog?: ModuleCatalog | null,
): number {
return flow.steps.findIndex((s) =>
isCloserModule(findModuleByName(catalog, s.name), s.name),
);
}
/** 同能力新 id:name / name#n */
export function allocateCreationStepId(
existingIds: Iterable<string>,
name: string,
): string {
const used = new Set(
[...existingIds].map((id) => id.trim()).filter(Boolean),
);
const base = name.trim();
if (!base) return "步骤";
if (!used.has(base)) return base;
let n = 2;
while (used.has(`${base}#${n}`)) n += 1;
return `${base}#${n}`;
}
const CONCRETE_INSTANCE_NAME = "具体实例";
const GENERATION_RULE_NAME = "生成规则";
function lastAcceptedNonCloser(
flow: CreationFlow,
catalog: ModuleCatalog | null | undefined,
acceptedStepIds: readonly string[],
): CreationFlowStep | null {
for (let i = flow.steps.length - 1; i >= 0; i--) {
const step = flow.steps[i]!;
if (!isStepAccepted(step, acceptedStepIds)) continue;
if (isCloserModule(findModuleByName(catalog, step.name), step.name)) {
continue;
}
return step;
}
return null;
}
/** 新开一条可反复能力时钉的 depends_on(优先跟原型步) */
export function spawnDependsOn(
flow: CreationFlow,
catalog: ModuleCatalog | null | undefined,
moduleName: string,
acceptedStepIds: readonly string[],
): string[] {
const prototype = flow.steps.find(
(s) => s.name === moduleName && isPrototypeStep(s),
);
if (prototype?.depends_on.length) {
return prototype.depends_on.filter((dep) =>
Boolean(findStepByRef(flow, dep)),
);
}
const existing = flow.steps.find(
(s) => s.name === moduleName && !isReviseStep(s) && !isPrototypeStep(s),
);
if (existing?.depends_on.length) {
return existing.depends_on.filter((dep) => Boolean(findStepByRef(flow, dep)));
}
if (moduleName === CONCRETE_INSTANCE_NAME) {
const rules = flow.steps.filter(
(s) =>
s.name === GENERATION_RULE_NAME &&
isInstanceStep(s) &&
isStepAccepted(s, acceptedStepIds),
);
if (rules.length) return [rules[rules.length - 1]!.id];
}
const last = lastAcceptedNonCloser(flow, catalog, acceptedStepIds);
return last ? [last.id] : [];
}
export function repeatableSpawnBlockedReason(
flow: CreationFlow,
catalog: ModuleCatalog | null | undefined,
moduleName: string,
acceptedStepIds: readonly string[],
): string | undefined {
const mod = findModuleByName(catalog, moduleName);
if (!mod?.repeatable) return "不是可反复能力";
if (!acceptedStepIds.some((id) => id.trim() && id.trim() !== "flow")) {
return "先完成至少一步再追加";
}
if (moduleName === CONCRETE_INSTANCE_NAME) {
const hasRule = flow.steps.some(
(s) =>
s.name === GENERATION_RULE_NAME &&
isInstanceStep(s) &&
isStepAccepted(s, acceptedStepIds),
);
if (!hasRule) return "先完成一条生成规则";
}
const deps = spawnDependsOn(flow, catalog, moduleName, acceptedStepIds);
const blocked = deps.filter(
(dep) => !isCreationDependencySatisfied(flow, dep, acceptedStepIds),
);
if (blocked.length) return `还差:${blocked.join("、")}`;
return undefined;
}
export function listRepeatableSpawns(
flow: CreationFlow,
catalog: ModuleCatalog,
acceptedStepIds: readonly string[],
): CreationFlowSpawnView[] {
return catalog.modules
.filter((m) => m.repeatable === true && !m.closer)
.filter(
(m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)),
)
.map((m) => {
const blockedReason = repeatableSpawnBlockedReason(
flow,
catalog,
m.name,
acceptedStepIds,
);
return {
name: m.name,
ready: !blockedReason,
...(blockedReason ? { blockedReason } : {}),
};
});
}
export function hasSelectableCreationWork(
flow: CreationFlow | null,
catalog: ModuleCatalog | null | undefined,
acceptedStepIds: readonly string[],
): boolean {
if (listReadySteps(flow, acceptedStepIds).length > 0) return true;
if (listSelectablePrototypes(flow, acceptedStepIds).length > 0) return true;
if (!flow || !catalog) return false;
return listRepeatableSpawns(flow, catalog, acceptedStepIds).some((s) => s.ready);
}
function allocateInstanceStepId(
prototypeId: string,
flow: CreationFlow,
): string {
let n = flow.steps.filter((s) => s.from === prototypeId).length + 1;
while (flow.steps.some((s) => s.id === `${prototypeId}#${n}`)) n += 1;
return `${prototypeId}#${n}`;
}
/**
* 点原型槽位:增殖一条 instance 并写入 DAG(插在收口之前)。
*/
export function spawnInstanceFromPrototype(params: {
flow: CreationFlow;
catalog: ModuleCatalog | null | undefined;
prototypeId: string;
acceptedStepIds: readonly string[];
}): { flow: CreationFlow; step: CreationFlowStep } | { error: string } {
const prototype = findStepByRef(params.flow, params.prototypeId.trim());
if (!prototype || !isPrototypeStep(prototype)) {
return { error: `找不到原型节点:${params.prototypeId}` };
}
if (!isPrototypeSelectable(params.flow, prototype, params.acceptedStepIds)) {
const blocked = prototype.depends_on.filter(
(dep) =>
!isCreationDependencySatisfied(
params.flow,
dep,
params.acceptedStepIds,
),
);
return {
error: blocked.length
? `还差:${blocked.join("、")}`
: `「${prototype.name}」暂不可增殖`,
};
}
const mod = findModuleByName(params.catalog, prototype.name);
if (!mod) return { error: `未知能力:${prototype.name}` };
const instanceDeps = resolveInstanceDependsOn({
flow: params.flow,
catalog: params.catalog,
prototype,
acceptedStepIds: params.acceptedStepIds,
});
const id = allocateInstanceStepId(prototype.id, params.flow);
const step: CreationFlowStep = {
id,
name: prototype.name,
role: "instance",
from: prototype.id,
depends_on: instanceDeps,
mode: "fresh",
};
const steps = [...params.flow.steps];
const protoIndex = steps.findIndex((s) => s.id === prototype.id);
if (protoIndex >= 0) {
steps.splice(protoIndex + 1, 0, step);
} else {
const closerAt = findCloserStepIndex(params.flow, params.catalog);
if (closerAt >= 0) {
steps.splice(closerAt, 0, step);
} else {
steps.push(step);
}
}
const closerAt = findCloserStepIndex(
{ ...params.flow, steps },
params.catalog,
);
if (closerAt >= 0) {
const closer = steps[closerAt]!;
if (!closer.depends_on.includes(id)) {
steps[closerAt] = {
...closer,
depends_on: [...closer.depends_on, id],
};
}
}
return {
flow: {
...params.flow,
version: 1,
status: params.flow.status === "closed" ? "open" : params.flow.status,
steps,
},
step,
};
}
/**
* 误点增殖后尚未验收:从图上撤掉这条 instance,并解开其它步对它的依赖。
* 普通一次性节点、已验收实例原样返回。
*/
export function removeUnstartedInstance(
flow: CreationFlow,
stepId: string,
acceptedStepIds: readonly string[],
): CreationFlow {
const step = findStepByRef(flow, stepId.trim());
if (!step || !isInstanceStep(step) || isStepAccepted(step, acceptedStepIds)) {
return flow;
}
return {
...flow,
steps: flow.steps
.filter((s) => s.id !== step.id)
.map((s) => ({
...s,
depends_on: s.depends_on.filter((d) => d !== step.id),
})),
};
}
/** 实例步 depends_on:具体实例钉最近已验收规则实例;其余跟原型。 */
function resolveInstanceDependsOn(params: {
flow: CreationFlow;
catalog: ModuleCatalog | null | undefined;
prototype: CreationFlowStep;
acceptedStepIds: readonly string[];
}): string[] {
if (params.prototype.name === CONCRETE_INSTANCE_NAME) {
const rules = params.flow.steps.filter(
(s) =>
s.name === GENERATION_RULE_NAME &&
isInstanceStep(s) &&
isStepAccepted(s, params.acceptedStepIds),
);
if (rules.length) return [rules[rules.length - 1]!.id];
}
return params.prototype.depends_on.filter((dep) =>
Boolean(findStepByRef(params.flow, dep)),
);
}
/**
* 在 DAG 里新编一条可反复能力(兼容:优先找原型增殖,无原型则 legacy 新建)。
*/
export function spawnRepeatableCreationStep(params: {
flow: CreationFlow;
catalog: ModuleCatalog | null | undefined;
moduleName: string;
acceptedStepIds: readonly string[];
}): { flow: CreationFlow; step: CreationFlowStep } | { error: string } {
const name = params.moduleName.trim();
const prototype = params.flow.steps.find(
(s) => s.name === name && isPrototypeStep(s),
);
if (prototype) {
return spawnInstanceFromPrototype({
flow: params.flow,
catalog: params.catalog,
prototypeId: prototype.id,
acceptedStepIds: params.acceptedStepIds,
});
}
const mod = findModuleByName(params.catalog, name);
if (!mod) return { error: `未知能力:${name}` };
if (mod.repeatable !== true) return { error: `「${name}」不能反复新开` };
if (isCloserModule(mod, name)) return { error: `「${name}」是收口,不能增殖` };
const blocked = repeatableSpawnBlockedReason(
params.flow,
params.catalog,
name,
params.acceptedStepIds,
);
if (blocked) return { error: blocked };
const protoId = allocateCreationStepId(
params.flow.steps.map((s) => s.id),
name,
);
const protoStep: CreationFlowStep = {
id: protoId,
name,
role: "prototype",
depends_on: spawnDependsOn(
params.flow,
params.catalog,
name,
params.acceptedStepIds,
),
};
const steps = [...params.flow.steps];
const closerAt = findCloserStepIndex(params.flow, params.catalog);
if (closerAt >= 0) {
steps.splice(closerAt, 0, protoStep);
} else {
steps.push(protoStep);
}
const withProto: CreationFlow = {
...params.flow,
steps,
};
return spawnInstanceFromPrototype({
flow: withProto,
catalog: params.catalog,
prototypeId: protoId,
acceptedStepIds: params.acceptedStepIds,
});
}
/** 当前已列出的步骤是否都已验收(不管 status) */
export function areListedStepsAccepted(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): boolean {
if (!flow?.steps.length) return false;
return flow.steps.every((s) => isStepAccepted(s, acceptedStepIds));
}
/**
* 流程是否收束完毕:listed steps 全验收,且 status 非 open。
* status=open 或缺省但还要扩步 → 应再调 design-flow。
* 缺省 status:兼容旧固定 DAG,视为 closed。
*/
export function isCreationFlowComplete(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): boolean {
if (!areListedStepsAccepted(flow, acceptedStepIds)) return false;
if (flow?.status === "open") return false;
return true;
}
/**
* 当前步骤做完、但 DAG 仍 open → 需要再编排(追加 / 关闭)。
*/
export function needsFlowExpansion(
flow: CreationFlow | null,
acceptedStepIds: readonly string[],
): boolean {
if (!flow?.steps.length) return true;
if (nextPendingStep(flow, acceptedStepIds)) return false;
return flow.status === "open";
}
export async function loadModulePrompt(
skillPackRoot: string,
moduleId: string,
skillsRoot = DEFAULT_SKILLS_ROOT,
): Promise<string | null> {
const fullPath = path.join(
skillsRoot,
skillPackRoot,
"modules",
moduleId,
"prompt.md",
);
try {
return await readFile(fullPath, "utf8");
} catch {
return null;
}
}
/** 依赖步骤 → 产物 tag(执行期注入;按依赖步的能力 name 映射) */
export function dependencyArtifactTags(
step: CreationFlowStep,
catalog: ModuleCatalog | null,
flow?: CreationFlow | null,
): string[] {
if (!catalog) return [];
const tags: string[] = [];
for (const dep of step.depends_on) {
const depName = flow
? findStepByRef(flow, dep)?.name ?? dep
: dep;
const art = artifactTagForStep(depName, catalog);
if (art) tags.push(art);
}
return [...new Set(tags)];
}
export type DesignStepBinding = {
step: CreationFlowStep;
module: ModuleCatalogEntry;
depTags: string[];
modulePrompt: string;
/** 程序开场白;无则本步直接调 LLM。回头修改不抛 opening。 */
opening: string | null;
/** 回头修改时注入的既有产物 tag */
inheritTag: string | null;
/** 实例步对应原型的规划建议(参考,步内钉) */
prototypeSuggestion?: string | null;
};
/**
* 解析 design-step 本轮绑定:当前步骤、模块 prompt、依赖 tag、产物 tag。
*/
export async function resolveDesignStepBinding(params: {
skillPackRoot: string;
flowRaw: string | null | undefined;
currentStepName?: string | null;
acceptedStepNames?: readonly string[];
skillsRoot?: string;
/** 用户点已完成节点重进:注入既有产物,不走开场 */
inheritExisting?: boolean;
}): Promise<DesignStepBinding | null> {
const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT;
const catalog = await loadModuleCatalog(params.skillPackRoot, skillsRoot);
const flow = parseCreationFlow(params.flowRaw);
if (!catalog || !flow) return null;
const accepted = params.acceptedStepNames ?? [];
let step: CreationFlowStep | null = null;
const named = params.currentStepName?.trim();
if (named) {
step = findStepByRef(flow, named);
}
if (!step) {
step = nextPendingStep(flow, accepted);
}
if (!step || isPrototypeStep(step)) return null;
const module = findModuleByName(catalog, step.name);
if (!module) return null;
let prototypeSuggestion: string | null = null;
if (step.from) {
const proto = findStepByRef(flow, step.from);
prototypeSuggestion = proto?.suggestion?.trim() || null;
}
const modulePromptRaw =
(await loadModulePrompt(params.skillPackRoot, module.id, skillsRoot)) ??
`# ${module.name}\n\n(模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md)`;
const inherit =
Boolean(params.inheritExisting) || isReviseStep(step);
const inheritTag = inherit ? module.artifact : null;
const opening =
inherit || module.auto
? null
: module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
return {
step,
module,
depTags: dependencyArtifactTags(step, catalog, flow),
modulePrompt: formatModulePromptForLlm(modulePromptRaw),
opening,
inheritTag,
prototypeSuggestion,
};
}