mcpField
G
generate-program

generate-program

github.com/foldkit开发1.1k55 次安装1 周前更新
代码仓库

关于 generate-program

根据需求描述生成完整 Foldkit 程序的技能。它先梳理数据模型、交互、异步任务和界面需求,再要求参考分层示例与生产代码,并在需求含糊时先提出针对性问题。

主要功能

  1. 01分析实体、交互与异步操作
  2. 02识别路由、界面组件和表单校验需求
  3. 03按复杂度读取对应示例
  4. 04参考生产代码质量标准
  5. 05先澄清需求中的行为缺口

适用场景

  1. 01从文字需求生成 Foldkit 应用
  2. 02规划多页面或实时应用结构
  3. 03明确状态转换与边界行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/foldkit/foldkit --skill generate-program

安装后在 Claude Code 中输入/generate-program确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

architecture.md
blindSpots.md
checklist.md
conventions.md
agents/
openai.yaml

详细介绍

根据此描述生成一个完整的 Foldkit 程序:

$ARGUMENTS

阶段 1:分析描述

编写任何代码之前,先分析描述,确定:

  1. 领域实体:将成为 Model 字段的名词(例如,“待办事项”“用户”“分数”)
  2. 用户交互:将成为 Messages 的动词(例如,“添加”“删除”“筛选”“提交”)
  3. 异步操作:将成为 Commands 的外部数据(例如,“获取天气”“保存到 localStorage”)
  4. 实时需求:将成为 Subscriptions 的流式数据(例如,“实时更新”“倒计时”“WebSocket”)
  5. 页面/导航:将成为路由的 URL 结构(例如,“主页”“详情页”)
  6. UI 组件需求:映射到 Foldkit UI 组件的交互式控件(例如,“下拉菜单” → Menu,“模态框” → Dialog,“标签页” → Tabs,“自动完成” → Combobox,“日期选择器” → DatePicker,“文件上传” → FileDrop,“可重新排序的列表” → DragAndDrop,“Toast/通知” → Toast,“悬停提示” → Tooltip,“范围/价格筛选器” → Slider,“长滚动列表” → VirtualList)
  7. 表单验证需求:必填字段、格式检查、异步唯一性检查 → foldkit/fieldValidation 模块(参见阶段 4)
  8. 日期处理:生日、截止日期、日程安排 → Calendar 模块 + @foldkit/ui 中的 DatePicker 或 Calendar
  9. 文件处理:上传、附件、图像 → File 模块 + @foldkit/ui 中的 FileDrop
  10. 远程数据:任何获取、缓存、刷新的数据,或需要重新验证的数据 → AsyncData 模块(参见阶段 4)。不要手动实现加载/错误联合类型
  11. 多状态流程:描述中包含按规则在多个命名步骤之间流转的流程(结账、引导流程、多步审批、连接生命周期)→ 考虑使用 Machine 模块(foldkit/experimental)。将状态转换写成表格后,unreachableStates() 和 deadTransitions() 可以检查从 initial 及任何调用方提供的额外根状态出发,通过声明的 Edges 是否可达。分析无法检测 Machine 之外的状态变化,因此在将检查结果视为应用缺陷之前,应将恢复的、通过深层链接进入的、已水合的及其他从外部进入的状态作为额外根状态传入。将其作为实验性选项提供,并让用户选择。只有两到三个状态的流程使用 defineTaggedUnion 和一个 match。repos/foldkit/examples/state-machine/ 是参考示例
  12. 宿主嵌入:程序在另一个应用中运行(“React 应用中的一个小组件”“将其嵌入现有页面”“宿主需要控制它”)→ 使用 Runtime.makeElement 和 Runtime.embed 生命周期句柄,通过 Flags 传递初始数据,通过 Ports 进行双向持续通信。repos/foldkit/examples/embedding/ 是标准参考示例:由纯 TypeScript 宿主端到端驱动 Foldkit 小组件

继续之前,先向用户展示此分析。

如果描述详尽且没有歧义,请总结分析并确认后再继续。但如果存在缺漏(状态转换不明确、UI 需求模糊、未指定错误处理方式、缺少边缘情况、领域边界含糊、计数器/重置语义不明确),请先提出有针对性的澄清问题。不要问“还有其他需求吗?”之类的开放式问题,而应针对发现的缺漏提出具体问题。

UX/行为方面的缺漏:

  • “待办事项列表应在页面重新加载后保留(localStorage),还是每个会话都从空列表开始?”
  • “API 调用失败时,应用应显示内嵌错误,还是弹出对话框?”
  • “你提到‘用户可以编辑条目’。这是指内联编辑,还是单独的编辑页面?”

领域逻辑方面的缺漏(容易遗漏,修复成本高):

  • “用户跳过一个间隔时,在计算连续记录时是否算作‘已完成’?”
  • “跟踪‘已完成’次数的计数器,是只在操作成功时递增,还是跳过操作时也会递增?”
  • “如果用户在流程中触发重置,计数器会随之重置,还是会在重置后保留?”
  • “你提到‘发生 N 个事件后,触发 X’。这里的 N 个事件是指总事件数,还是自上次触发 X 以来的事件数?”
  • “在一个周期的第 N 次操作时,触发的是哪个操作:周期的第一个还是最后一个?”

领域逻辑问题往往能在代码编写前发现差一错误。如果描述中包含任何计数器、周期、连续记录或“经过 N 次后”之类的说法,请务必询问 0、1 和 N 时的边界情况。

目标是尽早消除歧义,让生成的代码符合用户的实际需求,而不是你的假设。

阶段 2:研读参考示例

阅读架构和约定指南,熟悉相关规则:

  • 架构指南:TEA 结构、文件组织、类型模式
  • 约定指南:命名、Effect-TS 模式、反模式
  • 验证清单:不仅适用于阶段 5,也是代码生成的质量标准。现在先浏览质量标准部分,这样你生成的代码一开始就能达到要求,而不是等到评审时才发现不合格。
  • 盲点:阶段 6 的评审者会据此对你进行评估。现在阅读比之后返工更省事。

这四个文件反映的是不断变化的代码库快照。如果它们与实时源代码(repos/foldkit/ 或 node_modules 中的 .d.ts 文件)不一致,以实时源代码为准。请将这种不一致视为此技能中的一个错误,并在最终报告中说明。

如果你可以使用 context7 MCP 工具,遇到不确定的 API 时,请用它查询 Effect-TS 文档。Effect 是一个大型库。请核实函数签名,不要猜测。

质量标杆

以下两个代码库是生成应用的_质量标准_。它们不仅仅是“可供模仿的模式”,更代表了“需要达到的工艺水准”:

  • ${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/:生产级多页面应用:Submodels、OutMessage、update/view 分解、柯里化的处理器提取、订阅模式、领域模块。
  • ${CLAUDE_SKILL_DIR}/../../packages/website/src/:生产级 Foldkit 网站:页面组织、共享视图原语、路由驱动的渲染、惯用的领域分离。

生成之前,请从每个代码库中至少抽查一个文件(了解 update.ts 的结构、处理器的提取方式或领域文件的组织方式),并在输出中达到同等的工艺水准。生成的代码应当与手工编写的标杆代码难以区分。

然后阅读与应用复杂度相匹配的层级专属示例文件。**务必至少阅读一个层级专属示例。**切勿仅凭记忆生成代码。

复杂度级别

第 1 级:单页、无异步操作、状态极少: 阅读 ${CLAUDE_SKILL_DIR}/../../examples/counter/src/main.ts

第 2 级:计时器、订阅、简单的有状态应用: 阅读 ${CLAUDE_SKILL_DIR}/../../examples/stopwatch/src/main.ts(通过订阅实现计时器,Duration 字段模式)和 ${CLAUDE_SKILL_DIR}/../../examples/todo/src/main.ts(通过 Flags 使用 localStorage 实现 CRUD)

第 3 级:异步操作、加载/错误状态、API 调用、表单验证: 阅读 ${CLAUDE_SKILL_DIR}/../../examples/weather/src/main.ts(使用 HttpClient 处理 HTTP)和 ${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts(使用 foldkit/fieldValidation;参见第 4 阶段的“表单验证”部分)

第 4 级:URL 路由、多页面、查询参数: 阅读 ${CLAUDE_SKILL_DIR}/../../examples/routing/src/main.ts 和 ${CLAUDE_SKILL_DIR}/../../examples/query-sync/src/main.ts

第 5 级:复杂状态、嵌套领域模型、CRUD、拖放: 阅读 ${CLAUDE_SKILL_DIR}/../../examples/shopping-cart/src/main.ts(嵌套领域 Schema、购物车状态)和 ${CLAUDE_SKILL_DIR}/../../examples/kanban/src/main.ts(使用 DragAndDrop 实现 CRUD、使用 Flags 从 localStorage 恢复状态、订阅)

第 6 级:子模型、OutMessage、多步骤表单、身份验证流程、多模块应用: 阅读 ${CLAUDE_SKILL_DIR}/../../examples/auth/src/main.ts(使用 Submodels 和 OutMessage 实现登录/注册、受保护路由)和 ${CLAUDE_SKILL_DIR}/../../examples/job-application/src/main.ts(多步骤表单,在 step/ 中使用深度嵌套的 Submodels、DatePicker、FileDrop、Listbox,以及用于日期处理的 Calendar 模块)

第 7 级:实时功能、WebSocket、托管资源、生产级应用: 阅读 ${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/update.ts,然后查看其 page/home/ 和 page/room/ 目录,了解完整的 Submodel/OutMessage 模式。

阅读目标级别及所有较低级别的示例。第 4 级应用也应体现第 1 至 3 级的模式。

第 2.5 阶段:识别 Foldkit UI 组件的使用场景

Foldkit 提供无障碍 UI 组件,可自动处理键盘导航、ARIA 属性和焦点管理。这些组件位于独立的软件包 @foldkit/ui 中,并通过名称导入:

import { Button, Dialog, Input } from '@foldkit/ui'

foldkit 软件包没有 Ui 命名空间。使用 Dialog.view,而非 Ui.Dialog.view。如果想避免将 barrel 文件打包进 bundle,也可以使用深层导入(@foldkit/ui/dialog)。

生成代码之前,先检查应用的哪些部分可以使用内置组件:

用户需求组件免费提供的功能
模态框/对话框/确认框Dialog焦点锁定、按 Escape 关闭、滚动锁定、背景遮罩
选项卡式内容Tabs方向键导航、aria-selected、roving tabindex
下拉菜单Menu方向键、输入搜索、aria-expanded、点击外部关闭
自动补全/标签输入框Combobox筛选、方向键选择、aria-activedescendant
选择下拉框Select键盘选择、aria-selected、定位
从选项中单选RadioGroup方向键循环切换、aria-checked、只读导航
开/关切换Switch空格键切换、aria-checked
布尔选项Checkbox空格键切换、aria-checked、不确定状态
可展开区域Disclosure按 Enter/空格切换、aria-expanded
悬停/点击时显示的浮动内容Popover定位、点击外部关闭、焦点管理
悬停提示Tooltip显示延迟、键盘关闭、定位、aria-describedby
单选列表Listbox方向键、输入搜索、aria-selected
文本输入框Input统一的样式/行为封装
多行文本Textarea自动调整大小、统一样式
表单分组Fieldset禁用状态传播、分组
样式化按钮Button统一的点击/键盘处理
行内日历网格Calendar月份导航、键盘导航、aria-selected、日期约束
日期输入框 + 弹出层DatePicker日历弹出层、输入掩码、键盘导航、约束
文件上传区域FileDrop拖放、点击浏览、接收筛选、验证
可重新排序的列表DragAndDrop指针 + 键盘拖动、放置区域、播报区域
临时通知Toast自动关闭、悬停时暂停、堆叠、role=status/alert
数值范围/价格筛选器Slider方向键/Home/End 键、aria-valuenow、多滑块范围
长滚动列表VirtualList窗口化渲染、滚动锚定、测量项目高度
站点/分区导航Nav标记当前页面、键盘遍历、地标语义

这个包并非只有一种形态。

有状态子模型拥有自己的 Model、Message、update 和(大多数情况下的)OutMessage,并通过 h.submodel 嵌入:Menu、Listbox、Combobox、Calendar、DatePicker、Dialog、Popover、RadioGroup、Tabs、Tooltip、FileDrop、DragAndDrop、Slider、VirtualList,以及通过 Toast.make(PayloadSchema) 构建后的 Toast。

无状态渲染辅助函数没有 Model。你可以直接用 ViewConfig 和自己的 h 调用 view,并将值存储在自己的 Model 中:Button、Input、Textarea、Select、Fieldset、Checkbox、Switch、Disclosure、Nav。 不要想当然地认为这种划分始终如此,因为有些组件已经跨越了这条界线(Checkbox、Switch 和 Disclosure 变成了受控渲染辅助工具;RadioGroup 变成了 Submodel;Tabs 和 Slider 则将选择状态移到了父 Model)。请阅读组件的 public.d.ts:导出 Model 和 update 表示它是 Submodel;仅导出 view 和 ViewConfig / ViewInputs 类型则表示它是渲染辅助工具。渲染辅助工具不需要 Got* Message。

使用有状态的 Submodel:

  1. 将它的 Model 加入你的 Model:confirmDialog: Dialog.Model
  2. 添加一个 Got* Message:GotConfirmDialogMessage,包含 { message: Dialog.Message }
  3. 在 init 中初始化:confirmDialog: Dialog.init({ id: 'confirm-dialog' })
  4. 在 update 中委托处理:GotConfirmDialogMessage: ({ message }) => ...
  5. 通过 h.submodel 在 view 中嵌入:h.submodel({ slotId: 'confirm-dialog', view: Dialog.view, model: model.confirmDialog, toParentMessage: message => Message.GotConfirmDialogMessage({ message }) })(对于 view 接收这些输入的组件,还要添加 viewInputs)

始终优先使用 Foldkit UI 组件,而不是手动实现交互控件。 它们将无障碍支持设为默认,而非事后补救。

特别是表单输入: 表单中的每个文本输入框、文本区域和按钮都必须使用 Input、Textarea 和 Button。这是强制要求,即使 view 的构建器 h 也提供原生 input / textarea HTML 元素。表单示例(examples/form/src/main.ts)定义了 inputFieldView 和 textareaFieldView 辅助函数,它们使用标签和验证反馈封装了 Input.view 和 Textarea.view。请照此模式编写辅助函数。原生 input / textarea 适用于非表单场景(搜索框、内联编辑器),也就是你有意在组件层以下操作的情况;即便如此,也应首先考虑使用组件。

如果应用使用 UI 组件,务必先阅读 ui-showcase 示例,了解组件的接线方式。这是 Foldkit UI 集成模式的权威参考:

  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/main.ts:根级接线、Got* 委托、toParentMessage 辅助函数
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/message.ts:组件 Message 的结构
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/model.ts:组件 Model 的组合方式
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/update.ts:组件更新的委托方式
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/subscriptions.ts:需要将 Subscription 提升到父级的组件(DragAndDrop、Slider)
  • ${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/ui/toast.ts:使用 Toast 时请阅读。它的独特之处在于,它通过 Toast.make(PayloadSchema) 按负载 schema 参数化,并返回一个供你导入的类型化模块

examples/ui-showcase/src/ 下的目录名称过去曾发生变化。请先列出目录内容,不要盲目相信这些路径。

对于使用 DatePicker、FileDrop 或其他近期新增组件的应用,还请阅读 job-application 示例(见下文第 6 层)。这是这些组件协同使用的最完整实际集成示例。

阶段 3:确定文件组织方式

根据应用的复杂程度安排文件结构。所有规模的应用都采用相同的架构;只有文件组织方式会变化。

各文件的职责

除了下面按层级划分的布局,还要遵循这些“架构放置”规则,以免 model.ts 过于臃肿:

  • model.ts 存放 Model schema,以及作为 Model 字段的任何 schema(或组合成字段的 schema,例如表单状态/提交状态联合类型)。除此之外不存放其他内容。
  • command.ts 存放命令向外部系统发送或从外部系统接收的负载 schema,尤其是 saveState 序列化、flags 反序列化的持久化 schema。持久化 schema 属于命令层,而非模型层;它通常看起来像 Model 的一个子集,但并不是 Model 的一部分。
  • domain/*.ts 存放领域实体 schema 及其纯操作。
  • message.ts 仅存放消息。
  • route.ts 存放路由变体和路由器流水线。

一个常见错误是(因为 kanban 将 SavedBoard 放在 model.ts 中),把持久化 schema 放到 model.ts,理由是“它是 schema”。它是持久化层的 schema,而不是 Model 的 schema。把它移到实际使用它的位置。

单文件(Tier 1-2,约 300 行以内):

src/main.ts          ← Model, Message, init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run

拆分命令和消息(Tier 3,包含异步操作):

src/main.ts          ← Model, init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run
src/message.ts       ← Message definitions
src/command.ts       ← Command functions

**重要规则:**如果提取 command.ts,就必须同时提取 message.ts。命令会引用 Message 构造器(例如,Message.SucceededFetchWeather({...}))作为其 Effect 返回值。如果 Messages 留在 main.ts 中,而 Commands 放在 command.ts 中,那么 command.ts 会从 main.ts 导入内容,而 main.ts 又会使用 command.ts 中的 Commands,形成循环导入。先将 Messages 拆出,然后 main.ts 和 command.ts 都从 message.ts 导入 Message 命名空间。

完整拆分(Tier 4-5,包含多个关注点):

src/main.ts          ← init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run
src/model.ts         ← Model schema
src/message.ts       ← Message definitions
src/command.ts       ← Command functions
src/route.ts         ← Route parser (if routing)
src/view.ts          ← View functions (if view is large)
src/domain/          ← Shared domain schemas (if multiple entities)

子模型目录(Tier 6-7,包含独立模块):

src/main.ts          ← Root init, update, view
src/entry.ts         ← Runtime.makeApplication + Runtime.run
src/model.ts         ← Root model (contains submodels)
src/message.ts       ← Root messages + Got* bridging
src/command.ts       ← Shared commands
src/route.ts         ← Route parser
src/domain/          ← Shared domain schemas
src/page/
  featureA/
    main.ts          ← Submodel init, update, view
    message.ts       ← Submodel messages + OutMessage
    command.ts       ← Submodel commands
  featureB/
    ...

阶段 3.3:架构草图(仅 Tier 4+)

对于 Tier 4+ 应用(路由、领域模块、多个实体、子模型),请在生成实现代码之前先制作一份简洁的草图。Tier 1-3 应用足够小,可以一次性生成;如果 Tier 4+ 应用的结构有误,就会耗费大量精力。

草图包含五个部分。在对话中逐项给出,获得确认后,再搭建脚手架:

  1. 文件树:你将创建的确切路径。遵循阶段 3 的组织方式。
  2. Model 结构:顶层 Schema.Struct 字段及其类型。只需给出结构,不必提供完整 schema。
  3. 消息列表:你计划定义的每条 Message,按类别分组(点击、输入、命令、输出消息)。
  4. 路由列表:如果包含路由,列出每个 defineRouteUnion 变体、其参数及其映射到的路径。
  5. 领域操作:对于 domain/ 中的每个文件,列出它将提供的操作(Link.byNewest、Link.filterByTag 等)。

Tier 4 链接收藏应用的示例:

### Sketch

Files:
  src/main.ts, entry.ts, model.ts, message.ts, command.ts, route.ts
  src/domain/link.ts, index.ts
  src/story.test.ts, scene.test.ts

Model:
  route: AppRoute
  links: ReadonlyArray<Link>
  newLinkForm: NewLinkForm (url: Field<string>, title/description/tagsInput: string, submitState)

Messages:
  Clicks: ClickedSaveLink, ClickedDeleteLink
  Inputs: UpdatedLinkUrl, UpdatedLinkTitle, UpdatedLinkDescription, UpdatedLinkTagsInput, BlurredLinkUrl
  Commands: SubmittedNewLinkForm, SucceededSaveLinks, FailedSaveLinks
  Routing: ClickedLink, ChangedUrl, CompletedNavigateInternal, CompletedLoadExternal
  Toggles: ToggledFavorite

Routes:
  AppRoute.Home       → /
  AppRoute.NewLink    → /new
  AppRoute.TagFilter  → /tag/:tag
  AppRoute.NotFound   → /* fallback

Domain:
  Link: schema + byNewest, filterByTag, toggleFavorite, remove, updateById

给出草图后,请用户确认或调整。在用户确认之前,不要开始搭建脚手架或生成代码。如果用户直接确认(例如“看起来不错,继续”),就继续。如果用户进行了调整,就迭代修改草图。不要基于用户尚未批准的版本编写代码。

这一步经常让人忍不住跳过,因为 Agent 觉得“自己知道该怎么做”。跳过这一步,最终可能交付一个完整生成的应用,却发现需要调整结构。这是代价高昂的迭代方式。草图则是一种低成本的迭代方式。

阶段 3.5:搭建项目脚手架

生成代码之前,先使用 create-foldkit-app 搭建一个可运行的项目:

npx create-foldkit-app@latest

不带任何标志运行即可进入交互式提示;选择计数器示例作为基础(最简单的起点),并使用用户偏好的包管理器。生成的项目包含:

  • package.json,包含所有 Foldkit 和 Effect 依赖项
  • vite.config.ts,包含 Tailwind 和 Foldkit Vite 插件
  • tsconfig.json,包含严格的 TypeScript 设置
  • index.html,包含根容器
  • src/styles.css,包含 Tailwind 导入
  • FOLDKIT.md,包含 Foldkit 约定,以及一个指向它的 AGENTS.md 存根文件
提供 Foldkit 子树

脚手架搭建完成后,询问用户是否要将 Foldkit 作为 git 子树引入项目,这样后续 AI 会话就能直接从用户的项目中查阅完整的源代码、示例和文档。先提交脚手架代码,为子树提供可合并的基础提交:

git init          # if not already a git repo
git add .
git commit -m "chore: initial commit"
git subtree add --prefix=repos/foldkit https://github.com/foldkit/foldkit.git "foldkit@$(node -p "require('./node_modules/foldkit/package.json').version")" --squash

foldkit@<version> 标签会将子树固定到脚手架刚安装的版本,因此引入的源代码、示例和文档会与应用编译所使用的 API 保持一致。如果改为引入 main,可能会让后续会话看到应用尚未安装的版本中的示例和 API。Canary 安装(x.y.z-canary.<commit>)没有标签;请改为固定到版本名称所指向提交的完整哈希,该哈希可在 https://github.com/foldkit/foldkit/commit/<commit> 展开查看。

这一步是可选的,但强烈建议执行。生成的 AGENTS.md 中包含一行 subtree_prompted: false,Agent 会在后续会话中检查这一行。如果子树不存在且此标志为 false,Agent 会询问是否要添加子树。在此处提前处理,意味着用户下次启动 AI 会话时就已具备完整上下文。如果用户拒绝,请将这一行更新为 subtree_prompted: true,以免再次询问。

升级 Foldkit 包后,要重新固定子树,请运行相同的命令,并将 add 替换为 pull。

替换脚手架代码

然后,将 src/main.ts 中的计数器示例代码替换为生成的应用代码(并根据需要添加其他源文件)。

阶段 3.7:了解 Foldkit API

编写任何代码之前,先阅读要使用的每个 Foldkit 模块的类型签名。猜测签名会浪费时间:每次猜错都会导致一次 tsc 错误、重新阅读、修改和再次类型检查。花五分钟阅读,可以省下三十分钟反复迭代的时间。

要阅读的确切文件

对于计划使用的每个 Foldkit 模块,请阅读以下路径中的 .d.ts 文件。阅读公开接口即可,无需了解内部实现。在工作笔记中整理一份简短的签名速查表,这样生成代码时就不必反复核对。

# Every app
<project>/node_modules/foldkit/dist/index.d.ts          # top-level re-exports: the authoritative list of what `foldkit` exposes
<project>/node_modules/foldkit/dist/html/index.d.ts     # HtmlBuilder<Message>, element signatures, Attribute<Message>, inertHtml
<project>/node_modules/foldkit/dist/message/public.d.ts # defineMessageUnion()
<project>/node_modules/foldkit/dist/schema/public.d.ts  # defineTaggedUnion(), taggedStruct()
<project>/node_modules/foldkit/dist/struct/index.d.ts   # modifyFields(): check nested-update signature
<project>/node_modules/foldkit/dist/update/public.d.ts  # Update.Return, Update.ReturnWithOutMessage, Update.foldChildInit, Update.foldChildInits, Update.withOutMessage, Update.combine, Update.refresh
<project>/node_modules/foldkit/dist/runtime/runtime.d.ts # ApplicationInit, RoutingApplicationInit, makeApplication, makeElement

# If using routing
<project>/node_modules/foldkit/dist/route/public.d.ts   # defineRouteUnion, literal, slash, string, int, Route.root, Route.mapTo, Route.oneOf, Route.parseUrlWithFallback
<project>/node_modules/foldkit/dist/url/index.d.ts      # toString
<project>/node_modules/foldkit/dist/navigation/index.d.ts # pushUrl, load: all return Effect<void> (no Effect.ignore needed)

# If using async / side effects
<project>/node_modules/foldkit/dist/command/index.d.ts  # Command.define: config object with args/messages/interrupt/execute. Command.mapMessages for parent<-child mapping
<project>/node_modules/foldkit/dist/asyncData/public.d.ts # AsyncData: Idle/Loading/Refreshing/Failure/Stale/Success + Schema, match, isPending, hasData, revalidate
<project>/node_modules/foldkit/dist/http/public.d.ts     # Http.layer: provide it to Commands that use HttpClient
<project>/node_modules/foldkit/dist/dom/index.d.ts      # focus, advanceFocus, scrollIntoView, showDialog, closeDialog, clickElement, lockScroll, unlockScroll, inertOthers, restoreInert, detectElementMovement, waitForAnimationSettled. For time/random/delay use Effect's Clock, Random, Effect.sleep + Duration directly. For UUIDs use Crypto.Crypto's randomUUIDv4 with BrowserCrypto.layer.

# If using subscriptions
<project>/node_modules/foldkit/dist/subscription/index.d.ts # Subscription.make<Model, Message>, Subscription.lift, Subscription.aggregate

# If using mount / managed-resource / custom-element
<project>/node_modules/foldkit/dist/mount/public.d.ts            # Mount.define (one-shot) / Mount.defineStream (continuous): per-instance VNode lifecycle
<project>/node_modules/foldkit/dist/managedResource/public.d.ts  # ManagedResource.make / lift / aggregate + tag: for stateful runtime objects keyed on Model condition
<project>/node_modules/foldkit/dist/customElement/index.d.ts     # CustomElement.define: for typed bindings to native web components

# If the host application drives the program
<project>/node_modules/foldkit/dist/port/public.d.ts    # Port.inbound / outbound / emit / stream / subscription

# If using forms
<project>/node_modules/foldkit/dist/fieldValidation/public.d.ts # Field (tagged union), makeRules({required?, rules}), match, validate, allValid; rule constructors on the Rule namespace (Rule.url(options), Rule.email, Rule.minLength, Rule.pattern, Rule.fromSchema, ...)
# Rule.Rule is [Predicate, Rule.RuleMessage], NOT {test, message}. Field.Invalid has `errors: NonEmptyArray<string>`, not `error: string`.

# If using any UI component (SEPARATE PACKAGE)
<project>/node_modules/@foldkit/ui/dist/<component>/public.d.ts  # Model, Message, init, update, view (Submodel-shaped) or ViewConfig (render-helper-shaped), OutMessage when applicable
# Check: is it a Submodel (Menu/Listbox/Combobox/Calendar/DatePicker/Dialog/Popover/RadioGroup/Tabs/Tooltip/FileDrop/DragAndDrop/Slider/VirtualList/Toast) embedded via h.submodel, or a stateless render helper (Button/Input/Textarea/Select/Fieldset/Checkbox/Switch/Disclosure/Nav) called directly? Submodels carry their own Model/Message/update/OutMessage; render helpers don't. Check ViewInputs (for Submodels) or ViewConfig (for helpers) for the slot callbacks (toView, itemToConfig, etc.).

# If using dates
<project>/node_modules/foldkit/dist/calendar/index.d.ts # CalendarDate, today.local (returns Effect<CalendarDate>); for raw millis use Clock.currentTimeMillis

如果上面的路径不存在,请列出包中的 dist/ 并查找对应模块。.d.ts 才是权威依据,而非本文档。 如果两者不一致,应以 .d.ts 为准;这种不一致属于此技能中的错误,值得报告。

速查表中要记录的内容

对于要调用的每个符号,写一行:

h: HtmlBuilder<Message> (view parameter, supplied by the runtime): { div, input (VOID), textarea (VALUE-ONLY), button, Class, Href, For, Id, Role, OnClick(Message), OnInput(value=>Message), OnBlur(Message), OnSubmit(Message), keyed, empty, submodel, ... }
Route.mapTo(schema)(parser): curried
pushUrl(path): Effect<void>  // NOT fallible, no Effect.ignore needed
urlToString(url: Url): string
Update.Return<Model, Message>: { model: Model; commands?: ReadonlyArray<Command.Command<Message>>; outMessage?: never }
Command.mapMessages(commands: ReadonlyArray<Command.Command<ChildMessage>> | undefined, toParentMessage): ReadonlyArray<Command.Command<ParentMessage>>
AsyncData.Schema(DataSchema, ErrorSchema): { schema, Idle(), Loading(), Success({data}), Failure({error}), ... }
AsyncData.match(value, { onIdle, onLoading, onRefreshing, onFailure, onStale, onSuccess })
  // handlers take BARE values, except onStale:
  //   onIdle: () => B          onLoading: () => B
  //   onRefreshing: (data) => B     onSuccess: (data) => B
  //   onFailure: (error) => B       onStale: ({ error, data }) => B
Command.define(name, { args, messages, execute }): every input is a named field.
  `execute` binds at DEFINITION and receives the decoded args object directly, so
  you destructure the fields themselves; the call site passes args:
  const Fetch = Command.define('Fetch', {
    args: { id: Schema.String },
    messages: [Ok, Err],
    execute: ({ id }) => ...,
  })
  update: [Fetch({ id })]     // NOT Fetch({ id })(effect)
Document: NOT generic, and `body` is a single Html, not an array
Input.view({ id, value, onInput, isInvalid?, type?, placeholder?, toView: (attrs) => Html }, h)
  // from '@foldkit/ui', NOT Ui.Input
  // attrs: { label: ReadonlyArray<Attribute<M>>, input: ..., description: ... }
Field (schema): NotValidated | Validating | Valid | Invalid(errors: NonEmpty<Rule Message>)
生成器经常遇到的具体 API 陷阱

请将这些内容记录在速查表中,并在生成代码时保持可见:

  • input、br 和其他 void 元素只能接受属性:input([...]),绝不能写成 input([...], [])。textarea 也只能接受属性;使用 h.Value(text) 设置其内容,绝不能使用子元素或 h.InnerHTML。button 接受子元素。
  • **接受子元素的元素可以省略 children 参数。**没有子元素时省略它:写成 div([Class('divider')]),而不是 div([Class('divider')], [])。属性参数仍为必填,因此没有属性和子元素的元素写作 div([])。keyed 跟在元素后面:写成 h.keyed('li')(key, [attrs]),而不是 h.keyed('li')(key, [attrs], []);对于没有子元素的情况,使用 h.keyed('textarea')(key, [h.Value(text)])。
  • UrlRequest 标签是 Internal 和 External,不是 InternalUrl / ExternalUrl。
  • OnClick 和 OnSubmit 直接接受 Message,而不是 () => Message。只有 OnInput 接受 (value) => Message,因为它需要输入值。
  • keyed、empty 是构建器 h 的属性,视图会将它作为最后一个参数接收。它们不是 foldkit/html 的顶层导出。
  • 属性辅助函数各有专用用途:Value(...)、Type(...)、Placeholder(...)、Href(...)、Target(...)、Rel(...)、Rows(n)、Id(...)、For(...)、Role(...)、AriaLabel(...)。没有通用的 Attr('...', '...')。
  • **ApplicationInit<Model, Message, Flags> 不带 URL 参数。**对于路由应用,使用 RoutingApplicationInit<Model, Message, Flags>:第二个参数是 url: Url。
  • **Route.mapTo 接受路由 schema,而不是工厂函数。**写成 pipe(literal('new'), Route.mapTo(AppRoute.NewLink))。不要写成 Route.mapTo(() => AppRoute.NewLink())。
  • **Effect.ignore 仅用于可能失败的 Effect。**使用 pushUrl(path).pipe(Effect.as(Message()))。不要使用 Effect.ignore,因为 pushUrl 返回的是 Effect<void>。
  • Command.define 接受带有 messages 数组的配置对象:Command.define('Fetch', { messages: [Message.SucceededFetch, Message.FailedFetch], execute })。messages 为必填项,且始终是数组,即使只有一个 Message 也是如此:Command.define('ReadClock', { messages: [Message.RecordedTime], execute })。
  • makeRules 接受 { required?: Rule.RuleMessage, rules: Array<Rule.Rule> },其中 Rule.Rule = [Predicate, Rule.RuleMessage]:这是一个元组,不是 { test, message }。规则构造函数位于 Rule 命名空间中(Rule.url({ message })、Rule.email(message?)、Rule.minLength(n, message?)、Rule.pattern(regex, message?)、Rule.fromSchema(schema, message))。
  • **Field.Invalid 的字段是 errors: NonEmptyArray<string>,不是 error: string。**使用 Array.headNonEmpty(errors) 获取第一条消息;使用 Rule.resolveMessage(message, value) 将规则消息解析为最终字符串。
  • **路由变体保留在 AppRoute 上,并省去重复的 Route 后缀。**写成 AppRoute.Home 和 AppRoute.NewLink,不要使用名为 HomeRoute 和 NewLinkRoute 的同级绑定。
  • 路由器可调用以生成路径:homeRouter() 返回 '/',tagFilterRouter({ tag: 'foo' }) 返回 '/tag/foo'。绝不要手动拼接 URL。
  • **UI 组件来自 @foldkit/ui,而不是 foldkit 上的 Ui 命名空间。**使用 import { Dialog, Input } from '@foldkit/ui',然后调用 Dialog.view(...)。foldkit 包没有 Ui 导出。
  • HttpClient 和 HttpClientRequest 来自 effect/http,而不是 @effect/platform。使用 Effect.provide(effect, Http.layer) 将客户端提供给 Command 的 Effect,其中 Http 从 foldkit 导入。@effect/platform-browser 是另一项功能,用于 BrowserKeyValueStore 和 BrowserCrypto。
  • **单个子级 init 或启动结果使用 Update.foldChildInit,多个子级结果进入一个父级 Model 时使用 Update.foldChildInits。**对于接收输入的子级更新,使用 Update.foldChild;对于只接收其 Model 的子级辅助函数,使用 Update.foldChildStep。这些 fold 会通过 toParentMessage 提升子级的 Commands。只有在底层辅助函数或路由门控初始化中,才直接使用 Command.mapMessages。
  • **单次使用的 update 返回类型应内联。**当 matcher 仅使用一次时,使用 Message.match<Update.Return<Model, Message>>。如果另一个 matcher、辅助函数或导出签名会复用该类型,则创建 UpdateReturn 类型别名。match 泛型会约束整个 update,因此省略多余的返回类型注释。以相同方式,在 handler 内通过 domain union 自身的 match 或 matchOrElse 泛型约束其匹配,例如 Submission.match<UpdateReturn>(submission, { ... });Match.withReturnType<UpdateReturn>() 仅用于 Effect Match(部分 Message 匹配、共享的多标签 handler,或没有自身 matcher 的 union)。
  • **保留普通返回值的 OutMessage 防护。**当 update 不能发出 OutMessage 时,使用 Update.Return<Model, Message>。这样可以防止包含 OutMessage 的结果进入只保留其 Model 和 Commands 的代码。没有 outMessage 的结果仍可用于需要 Update.ReturnWithOutMessage<Model, Message, OutMessage> 的场景。缺少该字段表示此 update 未发出 OutMessage。手写的普通返回类型必须保留 outMessage?: never 字段。
  • **仅省略静态为空的 Commands。**静态上没有 Commands 的 producer 应省略 commands。producer 返回计算得出的集合时,应直接返回,不要检查它是否为空。绝不要写 commands: []。
  • **将 update 类结果放在一起。**将每个结果绑定到一个以操作命名的值,并使用点号访问。操作名称与函数名冲突时,使用尾随下划线,例如 init_。不要解构或重命名 model、commands 或 outMessage。子级 fold 的 write 参数应根据下一个子级 Model 命名,例如 nextSettings。将可选 Commands 直接传给 Command.mapMessages;仅当下一个操作需要具体数组时,才使用 result.commands ?? []。点号访问能让操作及其所有返回字段保持可见,但不能阻止有人忽略 outMessage。
  • **完整使用 update 组合子所对应的模式。**当后续 Step 应接收前一个 Step 生成的 Model 时,使用 Update.combine。它接受两个或更多 Step。不要用 Update.combine 包装单个 Step;应直接调用该操作。组合多个 Step 时,将内联 Step 参数命名为 stepModel。构造新结果时,如果 OutMessage 已确定,应直接将其包含在内。对于已有的普通返回值或类型为 OutMessage | undefined 的值,使用 Update.withOutMessage:将已有返回值 pipe 到该辅助函数,并将新结果字面量作为第一个参数。单个子级 init 或启动结果使用 Update.foldChildInit,多个子级结果进入一个父级 Model 时使用 Update.foldChildInits,对于接收输入的子级更新使用 Update.foldChild,对于只接收其 Model 的子级辅助函数使用 Update.foldChildStep。仅当至少一个子级 OutMessage 应继续传递给当前 Submodel 的父级时,才添加 toParentOutMessage。对于部分转发,应匹配每个子级变体,并对在此处停止的变体返回 undefined。如果所有变体都在此处停止,则省略 toParentOutMessage。foldOutMessage 仍会在本地处理每个变体,包括继续向上传递的变体。绝不要添加 toParentOutMessage: () => undefined。
  • 针对 Model 数组分支时使用 Array.match,不要使用谓词。Array.isArrayEmpty 和 Array.isArrayNonEmpty(注意名称:不是 isEmptyArray / isNonEmptyArray)接受可变的 Array<A>,因此都无法用于 Schema.Array(...) 解码出的 ReadonlyArray。Array.match 接受 ReadonlyArray,也是示例所使用的方法。
  • empty 和 keyed 是 h 的属性,因此绝不能放在 foldkit/html 的导入列表中。导入类型(import type { Document, Html, HtmlBuilder } from 'foldkit/html'),并从视图的构建器访问 h.empty / h.keyed。

第 4 阶段:生成应用

严格遵循架构和约定指南生成文件。将所有源文件写入脚手架项目的 src/ 目录。每个文件都遵循以下规则:

模型
  • 使用 Effect Schema 类型定义为 Schema.Struct
  • 使用判别联合表示状态:Idle | Loading | Error | Ok,多值状态绝不使用布尔值
  • 对可能缺失的字段使用 Option。绝不使用空字符串或 null
  • 为类型为 Option 的字段添加 maybe 前缀:maybeCurrentUser、maybeError
  • 对于远程数据,使用 AsyncData 模块,而不是手动定义联合类型。AsyncData.Schema(WeatherData, Schema.String) 会返回 { schema, Idle, Loading, Refreshing, Failure, Stale, Success };将 schema 放入 Model,并使用构造函数创建值。Refreshing 和 Stale 状态至关重要:它们可以让重新加载时当前数据继续显示,也可以在重新加载失败时保留数据,而不是将其丢弃。repos/foldkit/examples/weather/src/main.ts 是标准用法。调用 AsyncData.match 前先阅读其签名:处理器接收裸值(onSuccess: data => ...、onFailure: error => ...),只有 onStale 接收 { error, data }
  • 对于非远程多值状态(表单步骤、编辑器模式、连接阶段),使用 defineTaggedUnion() 声明完整联合类型。请参阅 conventions.md 中的 Discriminated Unions for State
  • 如果应用的多个模块会引用多个领域实体,请将共享 Schema 提取到 src/domain/(例如 domain/product.ts、domain/session.ts)。购物车和身份验证示例展示了这种模式;有关领域模块的组织方式及其适用时机,请阅读 ${CLAUDE_SKILL_DIR}/../../packages/website/src/page/projectOrganization.ts
消息

同时声明 Message 联合类型及其类型:

export const Message = defineMessageUnion({
  ClickedSubmit: {},
  UpdatedEmail: { value: Schema.String },
  SucceededLogin: { user: User },
  FailedLogin: { error: Schema.String },
  CompletedFocusInput: {},
})
export type Message = typeof Message.Type

将 defineMessageUnion() 声明与 type Message 类型别名放在一起。通过命名空间构造变体,例如 Message.ClickedSubmit() 和 Message.UpdatedEmail({ value })。绝不要从 Message 或 OutMessage 解构构造函数;每个调用点都应清楚显示所属命名空间。

只要放得下,每个 case 的载荷对象都应写在一行内。对于较长的载荷,让 Oxfmt 自动换行,以便声明仍然易于浏览,每行一个变体。

按类别为消息命名:

  • Clicked*:按钮或链接点击
  • Updated*:输入值变化(使用 { value: Schema.String }),以及订阅带来的外部状态更新(UpdatedRoom、UpdatedPlayerProgress)
  • Submitted*:表单提交
  • Succeeded* / Failed*:成对用于可能实际失败的命令
  • Completed*:其他所有 Command 结果,根据 Command 命名(动词 + 对象:CompletedFocusInput、CompletedGenerateCardId)
  • Got*:通过 OutMessage 模式返回的子模块结果
  • Pressed*:键盘输入
  • Blurred*:失去焦点
  • Selected*:从列表中作出选择
  • Toggled*:二元状态切换

每条消息都必须表达明确含义。不要使用 NoOp。

Flags(如果初始 Model 需要副作用)
  • 为初始 Model 所需的副作用数据定义 Flags Schema
  • 将 flags 定义为计算这些值的 Effect<Flags>(例如读取 localStorage、获取当前时间等)
  • 在全新浏览器启动时,将 flags 传给 Runtime.run(application, { flags })。已水合的应用调用 Runtime.hydrate(application),并且只使用服务端编码的 Flags 载荷。@foldkit/vite-plugin 会将共享的部署标识编译到 Foldkit 中,以协调客户端和服务端构建
  • 将结果传入 init。绝不要在模块级别或直接在 init 中执行副作用
  • 完整模式请参阅 architecture.md 中的 Flags 部分
初始化
  • 返回 Update.Return<Model, Message> 记录。如果初始化时不会静态创建任何命令,则省略 commands;直接返回计算得出的命令集合
  • 如果使用 Flags,则将其作为第一个参数接收。例如:(flags: Flags) => ({ model: initialModel(flags) }) 或 (flags: Flags, url: Url) => ({ model: initialModel(flags, url) })
  • 包含启动命令(初始获取数据、聚焦第一个输入框等)
  • 使用可调用的 Schema 构造函数创建初始 Model:Model({ field: value })
更新
  • 当返回类型只在该匹配器中出现时,使用 Message.match<Update.Return<Model, Message>>(message, {...})。如果其他匹配器、辅助函数或导出签名也会复用该类型,则创建一个 UpdateReturn 别名。Update.ReturnWithOutMessage<Model, Message, OutMessage> 是 Submodel 的对应类型。通过所属联合类型的 match 穷尽匹配 OutMessage;当泛型子项会缩窄 Schema 支持的负载类型时,将结构上经过细化的 OutMessage 类型作为第二个泛型传入。手写的普通返回类型只有在包含 outMessage?: never 时才等价。绝不切换。对于带回退的部分匹配,使用 defineTaggedUnion 或 defineRouteUnion 命名空间的 matchOrElse。对于部分 Message 匹配、多个标签共用的处理器,以及没有自身匹配器的联合类型,继续使用 Effect Match
  • 如果 update、init、boot 或组件辅助函数在静态上不会创建 Commands,则省略 commands。直接返回计算出的 Commands 集合,不要检查它是否为空。绝不要写字面量 commands: []
  • 组合 update、init、boot 或组件辅助函数的结果时,将整个结果绑定到一个以操作命名的值,并使用点号访问。若名称与函数冲突,则使用带尾随下划线的名称,例如 init_。不要解构或重命名 model、commands 或 outMessage。子级折叠函数的 write 参数应以接收的下一个子 Model 命名。将可选的 Commands 直接传给 Command.mapMessages;只有在下一操作需要具体数组时,才使用 result.commands ?? []。点号访问可将操作及其所有返回字段一并显式保留,但不能阻止他人忽略 outMessage。对于单个 init 或 boot 结果,使用 Update.foldChildInit;当多个子结果进入一个父 Model 时,使用 Update.foldChildInits;对于接收输入的子级 update,使用 Update.foldChild;对于只接收其 Model 的子级辅助函数,使用 Update.foldChildStep
  • 当后续 Step 应接收前一个 Step 生成的 Model 时,使用 Update.combine。它接收两个或更多 Step。不要用 Update.combine 包装单个 Step;直接调用该操作。组合多个 Step 时,将内联 Step 参数命名为 stepModel。构造新结果时,如果已知 OutMessage,就直接包含它。对于已有的普通返回值或类型为 OutMessage | undefined 的值,使用 Update.withOutMessage:将已有返回值传入该辅助函数的管道,并将新结果字面量作为第一个参数。只有在至少有一个子 OutMessage 应继续传递给当前 Submodel 的父级时,才添加 toParentOutMessage。对于部分转发,匹配每个子级变体,并对在此处停止传递的变体返回 undefined。如果所有变体都在此处停止传递,则省略 toParentOutMessage。foldOutMessage 仍会在本地处理每个变体,包括继续向上传递的变体
  • 使用 modifyFields(model, { field: () => newValue }) 执行不可变更新
  • 当 Succeeded* 处理器需要写入多个缓存并启动重新获取时,使用 Update.combine(model, [step, step, ...]) 依次执行,并使用 Update.refresh({ read, revalidate, write, load }) 构建重新获取步骤;只有缓存实际包含数据时才重新加载。examples/route-transitions/src/main.ts 展示了这两种用法
  • 在 modifyFields 中,如果更新只依赖该字段的当前值,则使用点自由字段转换器:items: Array.map(updateItem)、count: Number.increment、priceSlider: Slider.reflectRange({ min: minPrice, max: maxPrice })。对于来自 Messages、子级更新、Commands 或其他 Model 字段的替换值,使用 () => value
  • 当某个分支超过约 15 行时,将复杂处理器提取为独立函数
  • 对于 Submodel,返回 Update.ReturnWithOutMessage<Model, Message, OutMessage>。没有内容需要报告时,省略 outMessage
  • 请参阅 architecture.md 中的 OutMessage 模式。子模块通过 outMessage 向父模块发送信号;父模块通过 Update.foldChild、Update.foldChildInit 或 Update.foldChildInits 上的 foldOutMessage 处理该值
Commands
  • 使用 Command.define 定义 Command,其第二个参数是配置对象:args(可选)声明参数 Schema,messages 列出 Command 可能产生的所有 Message,execute 则包含 Effect。带参数时,形式如下:Command.define('Fetch', { args: { id: Schema.String }, messages: [Message.SucceededFetch, Message.FailedFetch], execute: ({ id }) => Effect }):execute 在定义时绑定并接收参数,而 update 返回 Fetch({ id })
  • 若要让 Command 可中断,请添加 interrupt。interrupt: true 会按 Command 名称为每次调用设置键;interrupt: { keyFields, toKey } 则选择用于标识调用的参数,并据此生成键,以便分别取消并发调用。所选字段会成为 Definition 的 Interrupt 构造函数所需的参数
  • 始终将定义赋给 PascalCase 常量。切勿在管道链中内联定义
  • 定义应放在其产生的位置,并与 update 函数就近放置
  • 让 TypeScript 推断返回类型。不要显式添加 Command<typeof A> 注解
  • 多步骤异步操作使用 Effect.gen
  • 对可能失败的 Effect,始终使用 Effect.catch(() => Effect.succeed(Message.FailedX(...)))。Command 绝不会抛出异常。**例外:**如果 Effect 在类型层面上不会失败(例如 Clock.currentTimeMillis、Random.nextIntBetween 等),则无需使用 catch,也无需 Failed* Message。遵循类型:如果没有错误通道,就无需捕获错误。
  • 使用 Effect.provide 提供服务
  • 工厂函数按动作命名:使用 fetchWeather,而非 fetchWeatherCommand
  • 根据 Command 的 execute 主体所执行的效果为每个 Command 命名,而不要按 update 处理其结果后引发的 Model 转换命名。若计时器只是在 update 开始关闭操作前等待,则应命名为 WaitBeforeDismissal,而不是 DismissAfter
  • 对于没有实际失败可能的 Command,返回以 Command 命名的 Completed* Message,包括携带载荷的情况:DetermineStartTime → CompletedDetermineStartTime,而不是 DeterminedStartTime
  • DOM 操作使用 Foldkit 的 Dom 模块(Dom.focus、Dom.scrollIntoView、Dom.showDialog、Dom.lockScroll 等),其他操作使用 Effect 内置功能(Clock.currentTimeMillis、Random.nextIntBetween、Effect.sleep(Duration.millis(...)))。生成 UUID 时,使用带有平台 Crypto layer 的 Crypto.Crypto 服务的 randomUUIDv4。参见 architecture.md 中的 DOM 和 Effect 辅助工具
  • 发起 HTTP 请求时,使用 effect/http 中的 HttpClient 和 HttpClientRequest,并通过 Effect.provide(effect, Http.layer) 为客户端提供依赖,其中 Http 来自 foldkit。参见 examples/weather/src/main.ts 中的模式
  • 在适用时,让 Update.foldChildInit、Update.foldChildInits、Update.foldChild 或 Update.foldChildStep 通过 toParentMessage 重新标记子 Submodel 的 Commands。仅在较低层级的辅助工具或受路由控制的初始化中直接使用 Command.mapMessages
表单验证

当应用中有需要验证的表单输入(必填字段、格式检查、异步唯一性检查)时,请使用 foldkit/fieldValidation。不要手动实现验证状态。

import {
  Field,
  Invalid,
  NotValidated,
  Rule,
  Valid,
  Validating,
  allValid,
  makeRules,
  validate,
} from 'foldkit/fieldValidation'

const nameRules = makeRules({
  rules: [Rule.minLength(2, 'Name must be at least 2 characters')],
})

const emailRules = makeRules({
  required: 'Email is required',
  rules: [Rule.email('Please enter a valid email address')],
})

const Model = Schema.Struct({
  name: Field(Schema.String),
  email: Field(Schema.String),
  // ...
})

Field(valueSchema) 会构建一个带标签的联合类型:NotValidated | Validating | Valid | Invalid。值 Schema 应与用户编辑时控件实际持有的值相匹配,而不是与你要解析成的类型相匹配:文本输入使用 Field(Schema.String),多选使用 Field(Schema.Array(Schema.String))。除非需要验证生命周期,否则复选框的布尔值通常在 Model 中保持为普通的 Schema.Boolean。规则单独放在 makeRules 中。在更新处理程序中使用 validate(rules)(value) 来转换字段状态,并使用 allValid([[state, rules], ...]) 控制提交;每次调用只能控制一种字段值类型(跨类型时用 && 组合调用)。若要将字段设为可选,请从 makeRules 中省略 required。

权威参考:${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts(基于版本的取消机制实现异步电子邮件唯一性检查)以及 ${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/(跨子模型的多步表单验证)。

日期和文件上传

日期处理(生日、截止日期、日程安排):

  • 使用 Calendar 模块:Calendar.CalendarDate、Calendar.today.local(返回用户时区当天日期的 Effect)、Calendar.make(year, month, day)、Calendar.addDays 等。
  • 在 UI 中使用 @foldkit/ui 的 DatePicker(输入框 + 弹出日历)或 Calendar(内嵌网格)
  • 如有需要,通过 Flags 初始化日期。请参阅 job-application 示例,该示例在其 Flags Effect 中使用 Calendar.today.local

文件上传(简历、图片、附件):

  • 使用 File 模块处理文件原语
  • 使用 @foldkit/ui 的 FileDrop 创建支持拖放和点击浏览并带验证功能的区域
  • FileDrop.ReceivedFiles 携带 files: NonEmptyArray<File>,因此成功路径永远不必处理空列表。若未收到任何文件,拖放操作会通过单独的 OutMessage RejectedNonFiles 返回;请处理它,以显示类似“只接受文件”的提示
  • 权威参考:${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/attachments/
视图
  • 每个视图都会将类型化的 Html builder h 作为最后一个参数:运行时会将它传给根视图,而 Submodel.defineView<Model, Message> 会将子模型自己的 builder 传给每个 Submodel 视图。应用代码不会自行构造 builder。通过 h 使用元素、属性和事件处理器:h.div、h.Class、h.OnClick。子模型使用自己的 Message 类型分发消息,父模型则在嵌入位置通过 toParentMessage 声明包装方式。
  • 提取的视图辅助函数将 h: HtmlBuilder<Message> 作为最后一个参数;调用方负责传递它。在视图内部,始终使用视图自己的 h。只有在作用域内没有 builder 时(通常是模块作用域),才使用 foldkit/html 中的 inertHtml,并将其别名设为 ih(类型为 HtmlBuilder<never>,因此无法用它构建处理器)。
  • 使用 h.Class(...) 设置 Tailwind 类
  • 使用 clsx 包中的 clsx 有条件地组合类:h.Class(clsx('base-classes', { 'active-class': isActive, 'bg-blue-500': variant === 'Primary' }))。当类取决于模型状态、布尔标志或判别联合标签时,使用 clsx。切勿使用字符串拼接、模板字符串或 && 表达式。
  • 通过联合类型对模型状态进行模式匹配:State.match(model.state, {...})
  • 使用 Option.match 根据 Option 字段有条件地渲染
  • 切勿为分支添加 key。视图函数是 differ 的身份边界;vite 插件会为每个函数的返回值添加标记,因此,通过不同视图函数渲染的分支切换时会替换子树。如果切换时必须重置 DOM 状态,请将同一标签的内联三元表达式提取为命名视图函数
  • 将复杂区块委托给提取的视图函数
  • 将事件连接到 Message:h.OnClick(Message.ClickedSubmit())(直接传入 Message,而不是回调),h.OnInput(value => Message.UpdatedEmail({ value }))(将值映射为 Message 的回调)
  • 当交互方式匹配时,使用 Foldkit UI 组件(例如,用 Dialog 实现模态框,用 Tabs 实现标签页内容等)
运行时接线
  • 对于拥有页面的应用,使用 Runtime.makeApplication。对于带有 URL 路由的应用,添加 routing: { onUrlRequest, onUrlChange }。view 返回一个 Document({ title, lang?, dir?, canonical?, ogUrl?, body })。从 Model 中的类型化路由派生 canonical,切勿从地址栏派生。运行时会应用 title、lang 和 dir。对于 canonical 和 ogUrl,之后若省略该字段,会恢复到首次由客户端写入前记录的值,或移除运行时创建的元素。若省略 ogUrl,则使用显式指定的 canonical
  • 对于嵌入在非本应用拥有的页面中的小组件,使用 Runtime.makeElement。view 返回 Html,运行时绝不会触碰文档的 <head> 或 <html> 元素。无需 routing 配置
  • 完整模式请参阅 架构说明 中的 With and Without URL Routing 部分
  • 对于带有路由的程序,加入 ClickedLink 和 ChangedUrl Message,并在 update 中正确处理 UrlRequest.Internal / UrlRequest.External
  • 对于拥有页面的应用,始终以 Runtime.run(application) 结束。当宿主应用控制程序生命周期时,改用 Runtime.embed(element) 结束,并将返回的句柄交给宿主;宿主端参照 repos/foldkit/examples/embedding/src/host.ts,小组件端参照其 main.ts
  • 将保存 makeApplication 结果的变量命名为 application,将保存 makeElement 结果的变量命名为 element
路由(如果有多个页面)
  • 使用双向解析器:defineRouteUnion()、string()、int()、literal()、slash()、Route.mapTo()、Route.oneOf()
  • 在一个名为 AppRoute 的 defineRouteUnion({ Home: {}, NewLink: {}, NotFound: { path: Schema.String } }) 中声明所有路由。每个变体都使用该命名空间:AppRoute.Home。不要解构变体,也不要重复使用旧的 HomeRoute 后缀。
  • 当 Model 或 Schema 只接受 AppRoute 的一部分时,使用 AppRoute.subset(['Home', 'Person'])。它只包含调用中列出的标签。没有 omit,因为排除列表会悄然接受以后添加的所有 Route。如果某个模块需要其中一个变体的类型,请在联合类型旁导出 type PersonRoute = typeof AppRoute.Person.Type。
  • 将每个路由构建为 Router:const homeRouter = pipe(Route.root, Route.mapTo(AppRoute.Home))。Router 可调用:homeRouter() 返回 '/',tagFilterRouter({ tag: 'foo' }) 返回 '/tag/foo'。这是双向解析器的打印端。
  • 绝不要用模板字符串手动构造路径。 使用 Href(homeRouter()),而不是 Href('/')。使用 pushUrl(newLinkRouter()),而不是 pushUrl('/new')。使用 Href(tagFilterRouter({ tag: tagName })),而不是 Href(`/tag/${encodeURIComponent(tagName)}`)。Router 会处理编码,并将 URL 结构集中在一处,这样重构时只需修改一个文件,而不是每个调用处。
  • 通过各自的视图函数渲染每个路由;identity 会处理切换,因此路由分支永远不会设置键。只有在一个共享视图函数根据路由参数渲染不同实体时(例如根据 slug 渲染详情页),才按实体 ID 设置键。
  • 在 Commands 中使用 foldkit/navigation 的 pushUrl 进行程序化导航。在 ClickedLink 处理器的 Internal 分支中,使用 foldkit/url 的 urlToString(url)。绝不要手动通过 url.pathname + search + hash 重构 URL;这种方式会悄然丢失 ? 前缀和 hash。
  • 在 ClickedLink 处理器中,不要预先更新 model.route。pushUrl 解析后,运行时会触发 ChangedUrl,进而更新路由。预先更新会导致重复写入。
订阅(如果需要实时更新)
  • 使用 Subscription.make<Model, Message>()(entry => ({ key: entry(fields, callbacks) })) 定义。构建器回调会接收 entry(fields, callbacks) 辅助函数。fields 是原始字段映射(不使用 Schema.Struct 包装),callbacks 包含 modelToDependencies、dependenciesToStream 和可选的 equivalence
  • modelToDependencies 从 Model 中提取 Subscription 参数
  • dependenciesToStream 根据依赖项构建 Stream<Message>
  • Subscription 会根据 Model 状态自动启动和停止。绝不要手动管理
  • 对于不依赖 Model 的 Subscription(始终处于活动状态),将 {} 作为 entry 的 fields 参数传入,并从 modelToDependencies 返回 {}
  • 要嵌入子 Subscription,请使用 Subscription.lift(childRecord)<Parent, Parent>({ read, toParentMessage })。它的 read 返回 Option<ChildModel>,与 Update.foldChild 和 ManagedResource.lift 一致;None 会停止所有子 Stream,且不会读取子依赖项。对于始终存在的子项,请使用 Option.some。每个被提升的条目都会将其依赖项包装在 GatedDependencies 中。在父级的 lift 调用中添加 when,以便根据子项不可见的父级事实进行门控(例如页面子 Model 所属的路由);门控由父级负责,并读取父 Model,门控关闭时会拆除该条目的 Stream。when: parentModel => boolean 会对每个条目进行门控;when: { entryName: parentModel => boolean } 只会对指定条目添加条件;子项不存在时仍会停止所有条目,因此子项绝不会为了适配父级门控而拆分自己的记录。要组合多个记录,请使用 Subscription.aggregate(...records),它会从这些记录中读取 Model、Message 和所有 Effect 服务
托管资源(如果状态运行时处理)
  • 使用 ManagedResource.make<Model, Message>()(entry => ({ key: entry(requirementsSchema, config) })) 定义。requirementsSchema 是位置参数中的第一个参数(通常为 Schema.Option(...));config 包含 resource 标签、modelToMaybeRequirements、acquire/release Effects,以及 onAcquired/onReleased/onAcquireError Messages
  • modelToMaybeRequirements 返回 Option.some(params) 以获取资源(或在参数变化时重新获取),返回 Option.none() 以释放资源。资源会像 Subscriptions 一样,根据 Model 状态自动获取和释放
  • 对于没有参数的资源,使用 Schema.Option(Schema.Null),并返回 Option.some(null)
  • 使用 ManagedResource.ServicesOf<typeof managedResources> 读取服务联合类型
  • 要嵌入子 Submodel 的资源,请使用 ManagedResource.lift(childRecord)<Parent, Parent>({ read, toParentMessage })(其 read 返回 Option<ChildModel>,因此提升后的 requirements 必须用 Schema.Option 包装)。使用 ManagedResource.aggregate(...records) 合并记录;它会从记录中读取 Model 和 Message
  • 应用生命周期内的句柄应放在 resources 中,而不是这里;这里没有 persistent

阶段 4.5:验证前自检

运行 tsc 或打开浏览器之前,先对生成的文件做一次快速的机械检查。第 6 阶段的审查者会发现这些问题,但写入时发现比完整审查一轮后再发现成本更低。跳过这一步,会让第一轮审查充斥着本可避免的问题。

先运行 lint 脚本,再对 src/ 执行 checklist.md 中的“机械扫描”部分。 脚手架接入了 @foldkit/oxlint-plugin,其规则用于结构检查:带键的行、路由打印、空对象标签调用、外部链接上的 Rel、modifyFields 内的展开运算符、Got* 包装、Command 命名。将每条 foldkit(...) 诊断都视为阻断项;这是输出中为这些规则标记的方式。随后运行的 grep 会检查规则未覆盖的内容(API 漂移、@foldkit/ui 的采用情况、Effect 惯用写法、未成对的标签、非 Option 上的 maybe*、span([]) 占位内容、多余的 Effect.ignore、焦点轮廓重置),以及规则范围较窄而有意留下的边界情况:通过命名空间调用的空对象调用、展开属性数组中的 _blank、使用 .id 以外的字段作为键的映射行、modifyFields 更新对象自身中的展开运算符,以及构造函数返回值上的 as 类型转换——只有脚手架的 oxlint 配置会拒绝最后一种情况。lint 全绿也不能排除这些问题。每个命中项都必须修复或添加 // NOTE: 说明。

然后逐一检查你编写的文件:

  • 每个文件的导入项:是否确实使用了每个符号?(Lint 会检查这一点,但在这里发现可以避免之后再跑一轮 lint。)
  • 联合类型中的每个 Message:update 是否有对应分支?view 是否会分发它?
  • 每个状态变体:是否有路径会进入该状态?激活时 view 是否会渲染不同内容?
  • 每个 Command:是否经过测试?它的 Succeeded* 或 Completed* Message 是否在 update 中有处理逻辑?

每个文件花约 2 分钟阅读,可让每个未解决项的审查循环节省约 15 分钟。

阶段 5:验证和测试

门槛:宣布第 5 阶段完成前,以下四条命令必须全部成功

进入第 6 阶段前,运行以下全部四条命令,并修复它们发现的所有问题。不是一条,也不是三条,而是全部四条:

npm run format      # run FIRST: rewrites files
npm run lint
npm run typecheck
npm run test

使用项目脚手架创建时所用的包管理器:npm run、pnpm run、yarn 或 bun run。如果缺少脚本,请通过该包管理器的 exec 运行项目本地二进制文件(pnpm exec oxfmt、npm exec --no-install oxfmt、yarn exec oxfmt、bun x --no-install oxfmt)。避免使用裸 npx,因为本地未安装该二进制文件时,它会从注册表获取并运行软件包。

先运行 format,因为它会改写文件;如果最后才运行它,那么 tsc/test 就会针对未格式化的代码通过,之后 pre-commit hook 又会重新格式化代码,造成用户还得清理的差异。先运行 format,才能确保 lint/typecheck/test 验证的是将要提交的确切代码。

每条命令发现的问题类型各不相同:

  • Format 会按照项目风格重新调整空格、缩进、尾随逗号和换行。这不是“检查”,而是规范化。生成的代码很少会碰巧符合 Oxfmt 的精确格式;缺少这一步,每次 git commit 都会产生一大堆纯格式差异。
  • Lint 会检查未使用的导入、未使用的变量和风格规则违规。这类问题很容易漏掉,因为生成的代码经常会“为了完整性”导入某个符号,但实际上并未引用(例如,从 fieldValidation 导入 NotValidated、Invalid,但它们只作为字符串字面量出现在 Match.tag 键中)。tsc 不会标记这些问题。
  • Typecheck 会检查 API 使用错误、参数形状错误、缺少必需属性和结构性类型错误。它不会检查未使用的导入。
  • Tests 会检查行为回归,但不会检查上述两类问题。

如果项目没有 format/lint 脚本,请检查 package.json,并通过包管理器的 exec 直接运行二进制文件(pnpm exec oxfmt / pnpm exec oxlint src,或 npm / yarn / bun 的对应命令)。不要因为“没有脚本”就跳过任何一项。脚手架创建的 create-foldkit-app 项目始终会配置这两项。

宣布第 5 阶段完成前,修复上述四项命令的所有输出。“类型检查通过且测试通过”还不够。代码未格式化且存在未使用的导入,就达不到要求。

先处理类型错误

然后使用 foldkit/test 编写测试。有两种测试风格。根据风格为每个测试文件命名,并将其放在被测代码旁边:Story 测试(状态机,驱动 update)使用 story.test.ts,Scene 测试(渲染视图)使用 scene.test.ts。名称描述的是测试的工作方式,而不是源文件,因此无论 update 和 view 位于 main.ts 还是各自独立的文件中,都适用。如果一个文件夹中有多项同类测试(同级页面、组件变体),则在名称前加上测试对象:login.story.test.ts。Scene 可在任意层级运行:Submodel.defineView 会生成一个普通的 (model, h) => Html,因此页面的视图可以不做修改地传给 scene;声明了 ViewInputs 的 Submodel 则会接收第二个参数,测试通过 withViewInputs(view, defaults) 提供该参数。withViewInputs 返回的工厂接受每个测试各自的覆盖值,但 toView 除外。页面级 Scene 使用 expectOutMessage / expectNoOutMessage 断言 Submodel 发出的 OutMessage,并使用名称对应原因的步骤驱动非视图生命周期原因产生的 Messages:Subscription.emit(仅当 Message 没有对应的 DOM 操作时使用;如果有,请点击实际按钮)、ManagedResource.acquire / release / failAcquire(需满足条目中的 modelToMaybeRequirements 条件,因此先驱动 Model 转换),以及 CustomElement.emit(类型由 spec 的事件 Schemas 决定)。处理程序返回 Option.none() 的交互会让事件继续传递,Scene 要求明确说明这一点:接着调用 expectIgnored(),否则 Scene 会在下一次交互或场景结束时失败。当事件应被处理时,使用 expectHandled();例如,按键被消费并阻止浏览器默认行为时,就需要这个断言。在页面文件夹中放置 scene.test.ts,测试由该页面负责的行为;对于跨页面的流程,则在根目录放置 scene.test.ts,覆盖父级如何折叠 OutMessage、父级提升的 Command、路由变更,以及父级计算的视图输入。

从 foldkit/story 或 foldkit/scene 中以命名导入的方式导入步骤:import { Command, given, message, model, story } from 'foldkit/story'。一个测试文件只需要使用这两个模块中的一个,这样调用点就能保持简洁。少数情况下,如果单个文件同时测试 Story 和 Scene,则改为导入命名空间(import { Scene, Story } from 'foldkit'),以便区分 Story.given 和 Scene.given。

Story 测试(story.test.ts)直接测试 update 函数。发送 Messages,并断言 Model 和 Commands。参考以下示例:

  • ${CLAUDE_SKILL_DIR}/../../examples/weather/src/story.test.ts:简单的 Command 解析(成功路径 + 错误路径)
  • ${CLAUDE_SKILL_DIR}/../../examples/auth/src/page/loggedOut/page/login.story.test.ts:包含 OutMessage 断言、字段验证的 Submodel
  • ${CLAUDE_SKILL_DIR}/../../packages/website/src/search/story.test.ts:多步骤交互(方向键循环切换、处理过期结果)

编写覆盖以下内容的 story 流水线:

  • 成功路径:从开始到结束的主要用户流程
  • 错误路径:使用对应的 Failed* Message 解析每个可能失败的 Command
  • 多步骤交互:至少有一项测试串联多个 Messages 和 Command 解析
  • 边界情况:空状态、边界条件、忽略的输入(例如过期结果、重复提交)

为 @foldkit/ui 组件编写 Command 解析器之前,先检查其 test 命名空间。例如,对于刚显示且会自动关闭的 toast,使用 Toast.test.drainEntry({ entryId })。

Scene 测试(scene.test.ts)通过渲染后的视图进行测试。使用可访问性定位器(role、label、text)与元素交互,并断言用户看到的内容。Tier 3+ 应用必须包含 scene.test.ts。评审流程会将缺少该文件视为阻塞项(BLOCKER),而不是质量项。没有例外。不要“推迟”编写。参考以下示例:

  • ${CLAUDE_SKILL_DIR}/../../examples/weather/src/scene.test.ts:基本的 Scene 流程,包含表单交互和 Command 解析
  • ${CLAUDE_SKILL_DIR}/../../examples/auth/src/scene.test.ts:多页面应用的根级 Scene,通过根视图驱动登录流程
  • ${CLAUDE_SKILL_DIR}/../../examples/auth/src/page/loggedOut/page/login.scene.test.ts:同一应用中的页面级 Scene,驱动页面自身的 update/view,而非根级的一对函数
  • ${CLAUDE_SKILL_DIR}/../../examples/kanban/src/scene.test.ts:使用 within 进行范围查询、使用 toHaveValue、显式测试数据

编写覆盖以下内容的 scene 流水线:

  • 视图渲染:初始视图包含预期元素(标题、输入框、按钮)
  • 用户交互:点击、输入、提交会产生可见变化
  • 加载状态:提交时显示加载指示器
  • 错误状态:Command 执行失败时,视图中显示错误消息
  • 范围查询:使用 within(parent, child) 组合单个范围内的 Locator(适合一次性范围断言或可复用的命名 Locator)。使用 inside(parent, ...steps) 将整个步骤块限定在同一父级范围内。嵌套步骤引用的每个 Locator 都会在父级子树内解析。两个或更多步骤共用一个范围时,使用 inside;仅需一次范围限定时,使用 within。
  • 优先使用无障碍 Locator:label(...)、role(...)、text(...),而非 placeholder(...) 或 CSS 选择器

运行项目的测试脚本(npm run test,或使用包管理器的等效命令)以验证测试通过。

然后按照验证清单逐项检查,发现结构性遗漏。继续之前,修复所有剩余问题。

阶段 5.5:视觉与无障碍验证

代码审查和自动化测试无法发现渲染错误或无障碍方面的不足。进入阶段 6 之前,请完成以下两项检查:

视觉检查

启动开发服务器(npm run dev)并在浏览器中打开应用。逐个点击所有路由,并与每个表单交互。留意以下问题:

  • 输入框缺少背景(Tailwind preflight 会移除浏览器默认的白色背景;每个原生 input 或 textarea 都必须显式设置 bg-white,也就是所有未通过 @foldkit/ui 中的 Input 或 Textarea 渲染的输入框)
  • 文本颜色过浅或字号过小,难以阅读
  • 元素重叠、间距异常、布局偏移
  • 光标或悬停状态不自然
  • 交互元素上的焦点环不可见或缺失

许多视觉错误无法通过类型检查、测试和代码审查发现。能发现这些问题的只有:(1)检查渲染结果;(2)使用默认值合理的 @foldkit/ui 组件。

如果应用有 UI,**在加载应用并逐一点击检查之前,不要声称阶段 5 已完成。**如果你的环境无法运行浏览器,请在最终报告中明确说明,而不是默默跳过检查。

无障碍检查

检查 checklist.md 中的 Accessibility 和 Foldkit UI 部分。两部分都包含可机械执行的 grep 命令。请在 src/ 上运行这些命令。此处不再重复列出;清单是这些 grep 命令的权威参考,在多个文件中重复列出会导致内容不一致。

无障碍项目不是“锦上添花”,而是非视觉用户能否正确使用产品的关键。

阶段 6:子 Agent 审查循环

自我审查不如由他人从全新视角进行审查。在阶段 5 通过后,启动子 Agent,依据质量标准审查生成的代码,并持续迭代,直到他们确认通过。

循环机制

最多运行 三轮。每轮:

  1. 使用 Agent 工具并设置 subagent_type: general-purpose,启动一个审查子 Agent。
  2. 阅读子 Agent 的回复。
  3. 如果回复为 PASS,退出循环并继续执行阶段 7。
  4. 如果回复包含 BLOCKERS 或 QUALITY 项,逐项修复,然后进入下一轮。
  5. 如果轮次预算耗尽,NICE-TO-HAVE 项可以推迟到阶段 7 的未来工作列表中。

第 3 轮结束后,如果仍有问题,则退出循环,并将未解决的事项作为“已知的打磨项”带入阶段 7。不要悄悄交付被标记的问题代码。明确说明仍有哪些问题尚未解决。

审查子 Agent 提示词

使用大致如下的提示词。只调整文件列表和项目路径。保持评审标准、输出格式、盲点检查清单和设定标准的说明不变。

You are reviewing a freshly generated Foldkit program. The bar is:
this code should be indistinguishable in quality from hand-written
code in `packages/typing-game/client/src/` or `packages/website/src/`.
Not "works." Not "structurally valid." Typing-game quality.

Read FIRST, in this order:
1. The generated source files, project-relative: list every one
   (src/main.ts, src/update.ts, ...).
2. ${CLAUDE_SKILL_DIR}/architecture.md
3. ${CLAUDE_SKILL_DIR}/conventions.md
4. ${CLAUDE_SKILL_DIR}/checklist.md
5. ${CLAUDE_SKILL_DIR}/blindSpots.md
6. At least one exemplar file matching the generated app's complexity,
   from the foldkit repo (vendored at repos/foldkit/ when the project
   has the subtree):
   - Tier 1-2: packages/foldkit/src/runtime/runtime.ts (quality calibration)
   - Tier 3-4: examples/weather/src/main.ts OR examples/form/src/main.ts
   - Tier 5: examples/kanban/src/update.ts AND examples/kanban/src/domain/
   - Tier 6: examples/job-application/src/update.ts AND examples/auth/src/page/
   - Tier 7: packages/typing-game/client/src/page/room/

Every path above is either project-relative or resolved through
${CLAUDE_SKILL_DIR}, the same way the rest of this skill refers to its
own files. Do not rewrite them as absolute paths. An absolute path is
machine-specific, and hand-pasting one is what made an earlier version
of this prompt unusable anywhere but the machine it was written on.

Then walk the entire checklist.md against the generated code. Every item.
Then walk every entry in blindSpots.md and report one line per entry:
`<slug>: clean | flagged at <file:line>: <issue>`. Silence is not a pass.
Also read at least one of the generated files side-by-side with the
exemplar you chose. Ask: does this look like it was written by the
same hand?

Output format. Exactly this structure:

## BLOCKERS
Items that are structurally wrong, logically buggy, or violate
conventions. Must fix.
Each item: `path/to/file.ts:line: what's wrong; what to fix`.
If none: write `None.`

## QUALITY
Items that work but fall short of the bar: generic naming, inline
handlers that should be extracted, missing domain/ directory,
native methods instead of Effect modules in pipes, views that
should be decomposed, etc. These should be fixed.
Each item: `path/to/file.ts:line: the gap; the idiomatic version`.
Cite the exemplar when possible: "typing-game does this as X at
page/home/update/handleKeyPressed.ts:33-40".
If none: write `None.`

## NICE-TO-HAVE
Polish items that would push quality further but aren't required:
additional tests, slightly better names, minor refactors.
If none: write `None.`

## VERDICT
One of:
- `PASS`: the code is at the bar. Ship it.
- `NEEDS-WORK`: there are BLOCKERS or QUALITY items to address.

Do NOT write code. Review only. Be specific, be brutal, don't grade on
a curve. If you're unsure whether something is at the bar, compare it
to the exemplar. If the exemplar wouldn't write it that way, flag it.

Before finishing, confirm the generator ran all four gates (format,
lint, typecheck, test) and showed the output of each. If it claims
Phase 5 complete but a gate's output wasn't shown, flag it as a
BLOCKER naming the gate: "Run `npm run lint`: unverified." Lint
catches unused imports that tsc does not, and those leak into review
as noise.
循环结束后
  • 如果任意一轮的结论为 PASS → 继续执行阶段 7,不附加任何保留意见。
  • 如果第 3 轮结束后的结论为 NEEDS-WORK → 继续执行阶段 7,但在 Explain 输出的“已知的打磨项”部分列出尚未解决的 BLOCKERS 和 QUALITY 项。用户应该能看到这些问题。
轮次之间:真正修复,而不只是表示已知悉

轮次之间,生成器 MUST 确实修复审查者标记的每个 BLOCKER 和 QUALITY 项。除非轮次预算已耗尽,否则不要用“我会记下来”或“暂缓处理”等说法搁置问题。审查者在第 N 轮标记的 QUALITY 项,如果到第 N+1 轮仍然存在,就是流程失败。这意味着生成器读过审查结果,却没有采取行动。

常见的失败模式:审查者将 .length > 0 检查标记为 QUALITY,并指出可用 Array.match/String.isNonEmpty 修复。生成器转而处理其他问题;第 2 轮审查再次标记同一问题,而生成器却认为“第 2 轮没有 BLOCKERS,所以已经没问题了”。其实并非如此。未经分类处理的 QUALITY 项会成为悄然交付的技术债。

运行第 N+1 轮之前,先简要书面比较“第 N 轮标记的问题”和“我所做的更改”。如果两份清单不一致,就回去补上遗漏的修复,再运行下一轮。

阶段 7:说明

生成程序(并通过审查)后,向用户介绍所构建的内容:

  1. 生成的文件:列出每个文件,用一句话说明其内容,以及为何单独放在一个文件中(或为何所有内容都放在一个文件中)
  2. 架构决策:解释关键的建模选择,例如:使用了哪些判别联合类型及其原因、集成了哪些 Foldkit UI 组件、是否需要 Flags 及其原因、领域抽取方面的决策等。
  3. 审查结果:说明运行了多少轮审查以及最终结论。如果为 PASS,请明确说明。如果第 3 轮结束后为 NEEDS-WORK,请在“已知的打磨项”下逐字列出尚未解决的事项,让用户了解审查者标记但未修复的问题。
  4. 如何运行:提醒用户启动开发服务器,并说明他们应该看到什么
  5. 如何扩展:给出具体的后续步骤:“要添加书签编辑功能,请定义 ClickedEditBookmark 和 UpdatedEditTitle 消息,在 Model 中添加一个 Editing 变体,并在 update 中处理这两条消息”
  6. 何时重构:提及程序的文件组织何时已无法满足需求的迹象(例如:“如果 update 超过约 20 个分支,可以考虑提取一个子 Model”)

评论

支持 Markdown