cardbuddy 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,6 +77,7 @@ import {
77
77
  deleteHistory,
78
78
  listHistories,
79
79
  mergeServerTurn,
80
+ mergeIncomingMessages,
80
81
  } from './chatHistory.js'
81
82
  // 回合日志(P2):事件流按 seq 落盘 .cache/ai-turns/<cardId>.jsonl —— 断线续跑的回放源、
82
83
  // 收尾时 reduce 成服务端权威历史;客户端崩溃 / 关标签页不再丢整轮轨迹。
@@ -97,13 +98,73 @@ const REPO_ROOT = SNAPSHOT_ROOT
97
98
  * 命中量正好是 64 的整数倍 —— 所以「别动历史」比「少带点内容」值钱得多。
98
99
  *
99
100
  * 由此定下三条纪律,后面所有逻辑都得遵守:
100
- * ① 同一个东西只有一个截断口径(TOOL_RESULT_MAX_CHARS)——轮内 live 上下文与下一回合前端回传的
101
+ * ① 同一个东西只有一个截断口径(toolResultLimit)——轮内 live 上下文与下一回合前端回传的
101
102
  * 历史必须是同一串字符,否则每过一轮前缀就在「第一个超长工具结果」处整段漂移。
102
103
  * ② 历史只追加、不回改:裁剪只能从「上次折到哪」继续往后折(粘滞),不允许把已折叠的还原、
103
104
  * 也不允许为了省 token 回头改写更早的消息。
104
105
  * ③ 真发生改写时要留痕(prefixDrift):命中率掉下来得能一眼看出是哪一步、哪一条开始漂的。
105
106
  */
106
- const TOOL_RESULT_MAX_CHARS = 12000
107
+
108
+ /**
109
+ * 按工具分档的截断上限(字符)。
110
+ *
111
+ * 为什么不能一刀切:12000 是给 card_read 读 137KB 大卡 schema 设的,却套在所有工具上。
112
+ * 而 `TRIM_KEEP_RECENT = 10` 意味着**最近 10 条工具结果常驻上下文** —— 一刀切 12000 时
113
+ * 常驻底噪就是 10×12000 = 12 万字符(每步固定背 8~9 万 token),而其中绝大部分来自
114
+ * 「本不需要这么大」的工具:fs_list 列个目录、skill_exec 跑个退出码、fs_read 读个切片索引。
115
+ * 实测 gen-1 那一轮103 步、平均 98737 token/步,这就是主因(不是系统提示词)。
116
+ *
117
+ * 各档取值与依据:
118
+ * - card_read 12000:大卡 schema 确实要这么多,且它是「读全卡」的唯一入口,不能砍;
119
+ * - skill_read 10000:与 clipSkillDoc 的续读锚点协议配套(见 SKILL_READ_MAX 注释),
120
+ * 砍太多会让 ir-format.md(17KB)从 2 次读变成 3 次读,反而更贵;
121
+ * - skill_exec 3000:脚本 stdout 大头是 JSON 报告,而报告已摘要化(见 briefSkillReport),
122
+ * 3000 足够容下一份摘要 + 日志行;--detail 的全量报告会超限被截,模型要全量走 fs_read 读落盘文件;
123
+ * - fs_read 4000 / fs_list 2000:切图索引、小 JSON、目录清单的真实体量;
124
+ * - card_grep 4000:它返回的是「JSON 路径 + 片段」,多给也不会让模型改得更准;
125
+ * - 其余 6000:留足余量又不至于让底噪翻倍。
126
+ *
127
+ * ⚠️ 改这张表的唯一正确姿势:**只改这里**,三处调用点(推前端 / live 上下文 / 回放)都走
128
+ * toolResultLimit()。任何一处自己 slice 都会在轮边界改写历史 ⇒ 前缀缓存整段失效(纪律 ①)。
129
+ */
130
+ const TOOL_RESULT_MAX_BY_TOOL = {
131
+ card_read: 12000,
132
+ skill_read: 10000,
133
+ card_grep: 4000,
134
+ skill_exec: 3000,
135
+ fs_read: 4000,
136
+ fs_write: 2000,
137
+ fs_list: 2000,
138
+ fs_copy: 1000,
139
+ ir_patch: 2000,
140
+ card_patch: 2000,
141
+ card_validate: 4000,
142
+ card_render_status: 6000,
143
+ card_node_models: 4000,
144
+ card_screenshot: 1000,
145
+ }
146
+ const TOOL_RESULT_MAX_DEFAULT = 6000
147
+ /**
148
+ * 统一截断口径的**唯一出口**(纪律 ①)。三处调用点必须都用它:
149
+ * - SSE 推给前端的 tool-result(前端原样存进对话记录、下一回合回传)
150
+ * - 轮内 live 上下文 currentMessages.push
151
+ * - 回放侧 toOpenAiMessages
152
+ * @param {string} toolName
153
+ * @returns {number} 该工具的上限
154
+ */
155
+ function toolResultLimit(toolName) {
156
+ return TOOL_RESULT_MAX_BY_TOOL[toolName] || TOOL_RESULT_MAX_DEFAULT
157
+ }
158
+ /**
159
+ * 按工具档位截断的唯一出口(三处调用点共用)。
160
+ * @param {string} toolName
161
+ * @param {string} text 工具产出的完整文本
162
+ * @returns {string} 截断后的文本(与入参逐字相同当未超限)
163
+ */
164
+ function clipToolResult(toolName, text) {
165
+ const s = typeof text === 'string' ? text : String(text ?? '')
166
+ return s.slice(0, toolResultLimit(toolName))
167
+ }
107
168
 
108
169
  /**
109
170
  * 让网关在流末尾回 usage(OpenAI 兼容网关默认**不回**):没有 usage 就看不到缓存命中量,
@@ -186,6 +247,48 @@ function skillsDir(root) {
186
247
  function skillRefsDir(root) {
187
248
  return path.join(skillsDirFor(root), 'dsl-card-generator', 'references')
188
249
  }
250
+
251
+ /**
252
+ * references 的阶段归类(按 `buildFlow` 分层注入索引)。
253
+ *
254
+ * 为什么分:全量 27 条索引 = 905 字符,改卡轮里其中 11 条(IR 契约 / 设计稿映射 / 读图规则 / 切图约定)
255
+ * 完全用不上——模型看不到就不可能去读,是纯噪音。但**分类不能漏**:漏掉的类在建卡轮就是「该读却找不到」,
256
+ * 代价远大于多列几百字符。故每条都显式登记,未登记的走 `always`(宁可多列不可漏列)。
257
+ */
258
+ const REF_STAGE = {
259
+ always: [
260
+ 'rules.md',
261
+ 'quickref.md',
262
+ 'component_specs.md',
263
+ 'component_rules.md',
264
+ 'dsl_structure.md',
265
+ 'event_patterns.md',
266
+ 'expression_engine.md',
267
+ 'amount_formatting.md',
268
+ 'field_naming.md',
269
+ 'field_name_dict.json',
270
+ 'platform_jsapi.md',
271
+ 'scripts_reference.md',
272
+ 'previewer_contract.md',
273
+ 'card_index.json',
274
+ 'card_templates.json',
275
+ 'message-center-pattern.md',
276
+ ],
277
+ // 只有走建卡流程(读设计稿 / 写 IR / 视觉闭环)才用得上
278
+ build: [
279
+ 'ir-format.md',
280
+ 'spec_to_schema_patterns.md',
281
+ 'image_to_card.md',
282
+ 'slices_convention.md',
283
+ 'semantic_adjudication.md',
284
+ 'iteration_protocol.md',
285
+ 'image_diff.md',
286
+ 'generation_pitfalls.md',
287
+ 'card_to_summary.md',
288
+ 'ui_fidelity_rules.md',
289
+ 'recipes.md',
290
+ ],
291
+ }
189
292
  /** 参考索引的一句话说明:够模型选对文件即可,全文不进上下文 */
190
293
  const SKILL_REF_HINTS = {
191
294
  'rules.md': '卡片生成铁律(card_write 前必读:结构 / 绑定 / 样式红线)',
@@ -203,12 +306,19 @@ const SKILL_REF_HINTS = {
203
306
  'spec_to_schema_patterns.md': 'spec 标注 → schema 的映射范式(设计稿建卡照抄字段形状)',
204
307
  'card_index.json': '全卡索引(组件 / 范式标签 / 名称):找最相似的已有卡当范本',
205
308
  'card_templates.json': '卡片模板库:card_scaffold 的 from 模板来源',
206
- 'generation_pitfalls.md': '生成高频坑位清单(IR / schema 写完自查一遍)',
309
+ 'generation_pitfalls.md': '生成高频坑位清单(IR / schema 写完自查一遍;长文用 skill_read section= 取节)',
207
310
  'previewer_contract.md': '预览器渲染契约:绝对布局 AbsoluteLayout 等字段形状的唯一权威说明',
208
311
  'ir-format.md': '紧凑 IR 字段契约(大卡建新卡时省 token 的中间表示)',
209
312
  'ui_fidelity_rules.md': '保真规则:间距 / 字号 / 行高 / 媒体降级等还原度纪律',
210
313
  'image_to_card.md': '设计稿 → 卡片的读图与语义块划分规则',
211
314
  'slices_convention.md': '切图资产落点与引用约定(Image 节点用哪张图)',
315
+ // 以下六条原先无 hint、全部落到 fallback「特定主题参考」——模型不知道何时该读,等于索引里没有它
316
+ 'semantic_adjudication.md': '语义裁决协议(Workflow A 步骤 1:展示值 vs 输入值 / 单一消费等判定细则)',
317
+ 'iteration_protocol.md': '视觉闭环迭代协议(多轮节奏 / 停滞回退 / 预算纪律)',
318
+ 'image_diff.md': '视觉验证清单(截图到手后按七维度逐项核对,别凭「看起来对」收工)',
319
+ 'message-center-pattern.md': '消息中心类卡范式(标题未读数 + 分类副标题 + 消息列表 + 底部动作)',
320
+ 'platform_jsapi.md': '平台 JSAPI(callJSAPI 调原生 / 客户端;契约以导出卡实际调用代码为准)',
321
+ 'scripts_reference.md': '校验脚本全集命令表(gate / validate / lint / preflight 等,含参数)',
212
322
  }
213
323
  /** 跨 skill 的固定入口:路径相对 skills/,与动态列出的 references 一起进「按需参考索引」 */
214
324
  const SKILL_DOC_ENTRIES = [
@@ -271,13 +381,168 @@ function styleKeySection(root) {
271
381
  * 此前整份注入两份 SKILL.md + rules.md(约 125KB / 数万 token),上下文臃肿会显著放大
272
382
  * 推理模型的空转思考(实测单轮 reasoning 4 分钟不收敛、界面一直「思考中」)。
273
383
  */
274
- function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove = DEFAULT_AUTO_APPROVE, root = '' } = {}) {
384
+ /**
385
+ * 判断本轮要不要注入「建卡协议」那几段(S0~S6)。
386
+ *
387
+ * 存在的理由:系统提示词模板主体实测 18977 字符,其中 S0~S6(设计稿建卡协议)占 13627(72%),
388
+ * 而它**每一步都被无条件重发**——用户只是「改个颜色」「查一下为什么报错」时,这 13627 字符纯噪音。
389
+ * 实测 gen-1 那一轮 103 步、平均 98737 token/步,重发量按 18977 字符/步算是 195 万 token。
390
+ *
391
+ * **判据刻意偏保守**:宁可多注入(浪费 1.3 万字符),不可漏注入(模型不知道要走S0~S6 就直接
392
+ * 自己摸索,实测是十几个工具步骤零产出)。三个判据任一命中即视为「走建卡流程」:
393
+ * ① 挂载了附件且路由判为设计稿类输入(designFlow,服务端机械判定,最强信号);
394
+ * ② 挂载了任何附件 —— 用户挂了目录/图片,八成就是要建卡(哪怕路由判不出来);
395
+ * ③ 没有附件但当前卡片是空的 —— 「帮我做个 X 卡片」是纯文本描述建卡,
396
+ * 没有 S4/S4.5/S6 模型就只能 card_write硬写(实测整份 schema 进参数必被输出上限截断)。
397
+ * 判据③读的是卡片是否已有内容:改已有卡不需要建卡协议,从零建卡需要。
398
+ *
399
+ * @param {Array} attachments 本轮挂载的附件
400
+ * @param {boolean} designFlow describeAttachments 的路由判定结果
401
+ * @param {{empty?: boolean}} [opts] empty=当前卡片是否为空卡(无 schema 或 schema 只有根节点)
402
+ * @returns {{buildFlow: boolean, reason: string}} buildFlow 直接进 buildSystemPrompt
403
+ */
404
+ function classifyBuildIntent(attachments, designFlow, opts = {}) {
405
+ if (designFlow) return { buildFlow: true, reason: 'design' }
406
+ if (attachments.length) return { buildFlow: true, reason: 'attachment' }
407
+ if (opts.empty) return { buildFlow: true, reason: 'empty-card' }
408
+ return { buildFlow: false, reason: 'edit-existing' }
409
+ }
410
+
411
+ /**
412
+ * 当前卡片是否「空到需要建卡协议」—— classifyBuildIntent 的判据③。
413
+ *
414
+ * 判据:读不出 schema、或 schema 里除根节点外没有任何组件 ⇒ 空卡。
415
+ *「用户正在编辑卡片 ID: xxx」但目录里没文件(新建未落盘)也算空——那种情况下用户说
416
+ * 「帮我做个 X 卡片」就是要从零建,而没有 S4/S4.5 模型只能 card_write 整份硬写(必被输出上限截断)。
417
+ *
418
+ * 读失败一律当**非空**(保守):宁可多注入 1.3 万字符,也别在读卡失败时误判成空卡而漏掉协议。
419
+ * 失败率极低(本地 fs 读一个 json),代价不对称。
420
+ *
421
+ * @param {string} root 项目根
422
+ * @param {string} [cardId] 当前卡片 id(空 ⇒ 没有卡上下文,按非空处理)
423
+ * @returns {boolean}
424
+ */
425
+ function isCardEmptyForBuildIntent(root, cardId) {
426
+ if (!cardId) return false
427
+ try {
428
+ const file = path.join(root, 'src', 'cards', cardId, 'schema.json')
429
+ if (!fs.existsSync(file)) return true
430
+ const schema = JSON.parse(fs.readFileSync(file, 'utf8'))
431
+ // 组件节点只可能挂在 schemaNode.properties(数组)—— 见 MEMORY §4「组件数据在 properties 不是 item」
432
+ const stack = [schema]
433
+ let components = 0
434
+ while (stack.length && components < 2) {
435
+ const cur = stack.pop()
436
+ if (!cur || typeof cur !== 'object') continue
437
+ if (Array.isArray(cur)) {
438
+ for (const it of cur) stack.push(it)
439
+ continue
440
+ }
441
+ // 有 name 的对象就是组件节点(根节点没有 name)
442
+ if (typeof cur.name === 'string' && cur.name) components++
443
+ for (const v of Object.values(cur)) {
444
+ if (v && typeof v === 'object') stack.push(v)
445
+ }
446
+ }
447
+ return components === 0
448
+ } catch {
449
+ return false
450
+ }
451
+ }
452
+
453
+ /**
454
+ * 设计稿建卡协议(S0~S6):**只在判定为「走建卡流程」时注入**这一段。
455
+ *
456
+ * 为什么单独抽出来:这一段实测 13627 字符,占系统提示词模板主体(18977)的 72%,
457
+ * 而它每一步都被无条件重发(gen-1 那一轮 103 步 ≈ 195 万 token)。
458
+ * 用户只是「改个颜色 / 查一下报错」时它整段是噪音;反之少了它,模型不知道要走 S0~S6,
459
+ * 会自己去摸索(实测十几个工具步骤零产出)。判据见 classifyBuildIntent(刻意偏保守)。
460
+ *
461
+ * ⚠️ 改动纪律:这段是反引号模板字面量,插反引号会炸语法而 Edit 照样报success ——
462
+ * 改完必须跑 `node --check server/core/ai-chat.js`。里面的 `\\`` 与 ${} 都是字面量,不要"顺手修"。
463
+ */
464
+ const BUILD_PROTOCOL_SECTION = `## 设计稿建卡协议(Stage Machine:逐阶段推进,上一阶段产物齐了才进下一阶段;用户挂载设计稿目录 / 图片并要求建卡时严格执行,不要自由发挥)
465
+ S0 路由:输入形态已由服务端机械判定,写在「用户挂载的本地文件」段(kind / preprocess / next):照它执行,不要再调 card_route_input;该段缺失才自己调 card_route_input({ path })。
466
+ S1 要素包:kind=meaxure-export(Sketch MeaXure 标注导出)必须先有 sketch-spec-to-card 要素包,禁止直接读 preview/ 整图建卡。
467
+ - fs_read 判定里给出的 manifest(通常 UI/manifest.json)找目标画板条目:status=generated ⇒ 产物已在;status=pending ⇒ 先看 preprocess.alsoFound(同一导出的其它产物目录,平铺与按稿分片常并存),有就直接用;确实都没有才补跑:
468
+ ① skill_exec script=skills/sketch-spec-to-card/scripts/parse_meaxure.py,args=["--src","<导出目录>","--list"] 拿画板清单(**每行行尾自带该画板的 --pick,板号 1 起**);
469
+ ② args=["--src","<导出目录>","--out","UI","--draft","<稿名>","--pick","<照抄清单行尾>","--build"] 只产该画板(**照抄行尾的 --pick,禁止把序号换算成 0 起的 --indices**——实测换算错一位,白跑 34s 解析 + 一步 LLM 才发现;多张逗号分隔,连板写 7~9、同板连卡写 8.1~8.2);
470
+ ③ script=skills/sketch-spec-to-card/scripts/cluster_states.py,args=["--out","UI/<稿名>"]。
471
+ - 要素包落点记作 <bundle>:传了 --draft 在 UI/<稿名>/<画板名>/,否则 UI/<画板名>/;以 parse_meaxure.py 打印的落盘路径为准。仓库可能两种布局并存,manifest 说的画板在 UI/ 下找不到就用 fs_list UI 找同名目录,别急着判定产物缺失。
472
+ S1.4 批量建卡(用户一次点了多张卡 / 回了「全部」):**一次** parse_meaxure --pick 出全部要素包(别每卡重跑一遍解析),然后**同一个回合内**按「S2→S5 走完一张,再走下一张」连续推进——每张卡各写各的 IR、各跑各的 expand + iterate。禁止每张卡各起一个回合:新回合的首步要把整段对话重发一遍(实测 1.3-3 万 token 一步),还要把同一个要素包重读一遍,纯浪费。
473
+ S1.5 选卡门禁(多画板 / 多卡画板必需;SKILL.md §13 + rules.md §13.4):目标目录定下后先跑 skill_exec script=skills/dsl-card-generator/scripts/spec_tree.mjs,args=["--list-cards","<目标目录>"] 拿「画板序号.卡片序号」全量清单(一个画板含 N 张卡展开为 K.1~K.N,行下自带 "图: <card-N.png 绝对路径>");S0 挂载段已带同一份命令就直接复用,不要重复跑。
474
+ - 只有 1 张卡、或用户已明说目标(序号如 2.3 / 画板名 / 卡片名)⇒ 直接进 S2,不要为走流程停下来问。
475
+ - 多张卡且用户未指定 ⇒ **本轮到此为止**:把清单写进正文 + 明确提问,正文最后**单独一行输出 [需用户确认]**,然后停止输出、等用户回序号再继续。没有这行标记会被判定「任务未完成就停手」而自动续跑(实测多花一整轮重发清单)。聊天面板**没有**弹选项工具(不存在 request_user_input),不要去调用或寻找弹窗工具,也不要替用户挑。
476
+ 清单**逐行配图**:每个卡片行下紧跟一行 Markdown 图片,写成 !\[画板名\](该卡 card-N.png 的完整绝对路径),与序号一一对应——**必须是 !\[\](...) 包起来的图片语法**;parse_meaxure 输出里的 "图: <路径>" 行是给你看的提示,照抄成裸路径渲染端不显示(实测照抄后用户 0 张图可看);缺图显式标缺失,禁止把图堆到清单末尾。末尾追加一项「全部」,用户回「全部」才算显式批量授权(产了几张卡要在正文报)。
477
+ **清单必须以普通 Markdown 列表逐行输出,禁止用代码块围栏(\`\`\` 或 ~~~)或 4 空格缩进包裹清单的任何部分**——渲染端把代码块当纯文本展示,里面的图片语法一行都不会转成预览图,选卡协议整段失效(实测 8 张配图全灭)。parse_meaxure --list 的输出**照抄行内容即可,围栏与缩进不要照抄**。
478
+ **路径必须写完整绝对路径**(以 /Users/… 或 C:\… 打头,一字不改照抄),**禁止用省略号(… 或 ...)代替前缀**(实测「…/积分助手-4-xx.png」这种写法渲染端取不到图,用户那几行就只剩一串路径=配图全丢),**禁止把路径包进反引号或写进代码块**(反引号 / 代码块里的是给人抄的文本,不会渲染成图片)。清单长不是省前缀的理由:图显示不出来才是真的白写。
479
+ 只回画板序号默认取该画板第 1 张卡。
480
+ - **禁止**自行挑画板 / 挑卡、**禁止**「先建一张看看」、**禁止**未经同意批量产卡。
481
+ S2 审计(一步代替反复探索):skill_exec script=skills/dsl-card-generator/scripts/spec_audit.mjs,args=["--spec","<bundle>/spec.json","--card","N","--slices-dir","<bundle>/slices","--project-slices","src/slices","--design","<bundle>/card-N.png","--save",".cache/ai-scratch/<id>.audit.json"]。
482
+ 切片引用名先查 <bundle>/slices/index.json(--build 已生成:每张切图的真实相对路径与大小),照它写引用、按 R31 命名规则消毒(禁用字符 # ? % & + 不进引用名),expand_ir 会递归命中嵌套子目录里的真身;**禁止 fs_list 逐层摸索**(实测 6 次才找到嵌套真名,纯浪费)。
483
+ 它交叉审计 spec + 聚焦截图 + 切图目录,给出:bands(水平行与行内元素序)、gaps(padLeft / padRight / padTop / padBottom / rowGaps)、texts(文案 / 字号 / 字重 / 颜色 / 行高 / 对齐)、shapes(装饰条)、slices(存在性 / 复用 / 是否烘焙背景与标签文字)、colors(背景采样点)、missing(缺哪张切图 + 建议裁剪矩形)。
484
+ 它交叉审计 spec + 聚焦截图 + 切图目录,另给 bgGradientHint(顶部渐变带的起止色/跨度 + IR 正确写法)与 slices[].crops(reused-differs 时「矩形 → 该用哪张 _alt 图」的映射,exists=true 直接引用、false 才需裁)。
485
+ IR 的几何与样式一律照抄审计值;**禁止 fs_read spec.json**(几百 KB 只会拿到截断片段,重复读纯浪费步数),禁止反复 fs_list 摸索结构。要看层级树才跑 spec_tree.mjs。
486
+ **审计 JSON 只认落盘文件**:--save 已把整份审计写到 .cache/ai-scratch/<id>.audit.json,下游脚本(crop_slices --from-audit)一律引用该文件。**禁止用 fs_write 手抄 / 节选审计内容**——实测手抄的 156 字节「摘要」让 crop_slices 裁出错名错位的切图,还自造了违反命名约定的新切片。
487
+ S3 补切图:S2 的 missing 非空 ⇒ skill_exec script=skills/dsl-card-generator/scripts/crop_slices.mjs,args=["--design","<bundle>/card-N.png","--card-width","<卡宽>","--out","src/slices","--from-audit",".cache/ai-scratch/<id>.audit.json"],从聚焦截图派生缺失切图(审计已落盘,直接引用)。切图唯一落点 src/slices/ 扁平命名,IR 里 Image.value 用 /slices/<名>。
488
+ 只缺一两张时用 --crop <名>@x,y,w,h(可多条),x/y/w/h 直接抄审计 missing[].crop 的**卡片本地 1x 坐标**,脚本自己按 design 宽 ÷ 卡宽换算像素。**禁止用 sips / convert / magick 等系统图像命令裁图**:它们不在 shell 白名单,且要手算缩放比与裁剪偏移,实测必错。
489
+ 切图用法看 slices[].baked:label=true ⇒ 切图已含标签文字,tile 用单个 Image(96×84 之类照 rect 抄);label=false ⇒ 需要 Image + Text 组合;verdict=reused-differs ⇒ 同一张切图被标到内容不同的多个位置,除第一个外必须用审计 slices[].crops 给出的 _alt 名(exists=true 直接引用,exists=false 由 crop_slices 派生)——**禁止自造新名字**(实测自造 icon_xx_左/右 让同一设计内容在 src/slices 下出现三套名字)。
490
+ S4 写 IR(全文只写这一次;**单次输出上限截断过 ⇒ 必须分片写**,见 2c 与 ir-format.md §2c:骨架 fs_write → 每区块一片 pNN.json → skill_exec ir_merge.mjs --ir <ir> --part …(可多条) 合并一次 → 再 expand_ir):fs_write .cache/ai-scratch/<id>.ir.json。结构照 bands 一条一段;gaps.rowGaps 映射成 flex.gap / padding(paddingType:"1" + 四边值);texts 逐条转 Text(fontSize / fontWeight / color / lineHeight 全抄,**lineHeight 必写**,否则渲染器按 1.5 倍字号撑高、整卡累计漂移几个像素);标题 + 底部装饰条**整段照抄 ir-format.md §2d 的片段**(AbsoluteLayout(align:"BottomLeft") > [Row(条, s.position.top/left), Text(标题, s.position.top/left)],子节点顺序 [装饰条, 标题];几何读标注差值 position.top = 标注条.y − 标注标题.y,别套「行高 + gap」的公式;条子不得排在流式里、不得用 margin 顶位(正负都不行,lint r64 硬拦))。
491
+ **类型开关铁律(渲染器静默丢弃类缺陷的唯一防线)**:margin / padding 组写单边值(marginTop 等)必须同组写 marginType:"1" / paddingType:"1"(expand_ir 会自动补;手写 schema 漏了会被 lint 报 r44-*-no-type);背景三选一:纯色 backgroundType:"01"+backgroundColor、图片 "02"+backgroundImage、渐变 "03"+backgroundGradient。审计给了 bgGradientHint 就照抄它的 ir 字段;**禁止自造 gradientColor / gradientDirection,禁止把 linear-gradient(...) 塞进 backgroundColor**——这三者都会静默失效(实测模型连试 3 轮全无效后放弃并编造理由)。 **水平内缩禁止写成「子节点 width:100% + marginLeft/Right」**:width:100% 已等于父容器内容宽,水平 margin 只会把节点推出父容器右缘、被 overflow:hidden 裁掉(父容器按列排布时水平方向是交叉轴,flexShrink 救不回来)——间距要放**父容器** paddingLeft/Right,或删掉 width 靠 stretch 补宽(lint r63-width100-with-hmargin ERROR 拦截,pitfalls §25)。
492
+ 落点卡默认 = 当前正在编辑的卡片 ID(见「当前卡片上下文」):IR 的 id 与 iterate 的 --out 一律 src/cards/<当前卡片ID>/,即覆盖当前卡 / 空卡是默认行为。**「建卡」「生成卡片」「用这张画板做一张卡」都不是「新建一张」**——那是在说「用设计稿出一张卡」,落点仍是当前卡;只有用户明确说「新建一张 / 另存为新卡 / 换个新 id / 不要覆盖当前卡」才自拟 ID,且自拟前先用一句正文写明「将新建卡片 <新id>,不覆盖当前卡 <当前卡id>」,让用户当场看得见(实测:把「建卡」读成「新建」,界面里凭空多出一张卡而对话里没有任何交代)。当前卡已有内容时,动手前一句正文说明将覆盖、原内容留 .history 快照可回滚。
493
+ S4.5 变量抽取(真实卡片不是截图:这步漏了,产物就是 dictionary / data.json 全空的死图):先 skill_read ir-format.md 的「2b. 变量绑定」看契约,再决定哪些文本绑变量——
494
+ 下限 = S2 审计的 bindHints[](amount / percent / date / masked / count / level),再按语义补正则认不出的动态值(用户名 / 客户层级 / 卡片名称 / 状态词);静态标签(区块标题 / 按钮 / 说明文字)才写字面量。
495
+ - 动态值:Text 主值用 v:"$key"(条件文案写表达式如 "$showAll ? '收起' : '查看更多'",金额展示照 recipe_snippets/money-format.json,别把「¥7,000.00」拆成字面量)。
496
+ - IR 顶层**显式写 dictionary.inputParams**:{key,name,type,defaultValue,desc,rule:{type:"",options:{}},children:[]},defaultValue 一律取设计稿字面量——expand_ir 用它生成 data.json,渲染即设计稿文案,S5 像素闭环才不受绑定影响。
497
+ - 重复行 / tile 组(审计 listCandidates[]、或用户明说要列表):数组变量 + listScope 插槽。item 的字段**已逐个展开到作用域** ⇒ 规范形三种:$<字段名>(字段名语义化自取)、$$(整个 item)、$_index(行索引)。写成 $item.<字段> 也不影响结果——expand_ir 在扩展开头会把它自动归一化成规范形(作用域里本来就没有 item 这个键,schema 只存规范形;schema 级另有 autofix_schema.py 兜底)。范式照 recipe_snippets/list-expand-collapse.json 或 src/cards/account 的 account_details。结构确实不规则时允许保持静态,但要在正文说明理由。
498
+ - 命名语义化(memberLevel / totalAmount / assetRows),禁 text1 / val2;写完在正文列一句绑定清单(key → 设计稿文案)。
499
+ S4.9 内容就绪门禁(**像素自循环的前置条件**;不达标时 iterate 会把本轮整体挡掉——不 render、不比对像素、不消耗迭代轮次):
500
+ IR 写完、变量抽完后,先跑 skill_exec script=skills/dsl-card-generator/scripts/readiness.mjs,args=["--id","<落点卡>","--spec","<bundle>/spec.json","--design","<bundle>/card-N.png","--json"]。退出码 0=就绪 / 3=未就绪(**3 不是脚本报错,是内容没准备好**)。
501
+ 五道**声明性**判定(不需要渲染,所以能挡在最贵的一步之前):A 组件契约(属性键 / 样式组 / 样式子键必须在 component-meta.json + style-meta.json 白名单内)· B 要素齐全(设计稿每条文案在 schema 里找得到)· C 变量绑定完备($var 的根变量必须在 dictionary.inputParams / mocks / data.json / 事件 setData 里存在)· D 图片地址已配置(每个 Image 必须有非空 value)· E 切图路径已配置(每个 /slices/... 引用在 src/slices 里有对应文件)。
502
+ blockers 非空 ⇒ **先补内容,别进 S5**。这一步存在的唯一理由:内容没齐时像素分毫无参考价值。实测事故(2026-09-24 gen-1):跳过这步,在图片地址都没配的情况下连刷 10 轮像素(match 卡在 87-90.46),两回合烧掉 623 万 prompt token 全部作废。
503
+ 三条高频坑(readiness 会在 blockers 里逐条点名,不要靠猜):① Image 的图片地址属性键**只能是 value**,写成 image 会被运行时按 itemKey 绑定失败后**整条静默丢弃** ⇒ value 为空 ⇒ 破图,而 validate / lint / preflight 全绿;② from:"2" 的值必须是静态路径、from:"1" 的值必须是 $表达式,写反 ⇒ 字符串直接进 url(...) 或表达式求值失败 ⇒ 画布空白;③ 切图**引用名与磁盘文件名必须同源**(crop_slices 按引用名落盘;引用一个 src/slices 里不存在的名字 ⇒ 渲染 404、图空白、控制台零报错)。
504
+ **iterate 每轮会自动再跑这道门禁**:报告里 gate.stage=content(或 verdict 以 BLOCKED(内容未就绪)开头)表示本轮被挡——没跑 render、没跑 pixelDiff、**没消耗迭代轮次**。按 gate.verdict 里的 blockers 补齐内容后**直接重跑 iterate**即可;禁止在被挡时反复重跑空转,更禁止去调像素(那时候没有像素分可调)。
505
+ 逃生门 --no-readiness(或环境变量 DSL_CARD_SKIP_READINESS=1)只在排查「门禁本身误判」时用,正常流程禁止。
506
+ S5 收敛循环(唯一门禁 = iterate.mjs 报告里的 gate 字段:内部已跑 autofix + 真 validate + lint + preflight + **内容就绪(S4.9)** + render + 并集像素对比,别再单独跑 validate_schema.py):
507
+ skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<落点卡>/","--design","<bundle>/card-N.png","--width","<卡宽>","--spec","<bundle>/spec.json"]。
508
+ 修 IR 用 **ir_patch**(点路径 JSON 补丁,一次可多条,形如 ops=[{"path":"children.0.s.flex.gap","value":"0"}])——**它是改 IR 的首选**:结构化参数不吃引号转义、原子写、回旧值→新值摘要;也可用 iterate.mjs 的 --set(字符串字面量语法,注意引号转义,例:--set children.0.s.flex={"gap":"0"})。数字段=数组下标;中段不存在会响亮报错,按报错改路径。**两条路径都不是「整份重写」**——同轮内对同一草稿文件第 2 次全量 fs_write 会收到提醒、第 3 次起被拒(要重排结构才传 rewrite:"true")。
509
+ **收敛 = 报告 gate.passed === true**(**内容就绪(S4.9)** ∧ validate/lint/preflight 零 error ∧ **elements.missing=0** ∧ match ≥ 95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x| ≤ 2)。gate 是脚本判的,正文只能引用它的 verdict / failed,不得自行宣布达标。像素对比按并集画布算,删内容 / 高度塌缩只会降分,不可能凑分。
510
+ **要素是固定真值:先内容、后骨架、最后样式(三段式门禁,逐段推进)**。S2 审计里的每条文本 / 每张切图都是这张卡的既定要素,S5 只许改**样式与布局**(尺寸 / 间距 / 颜色 / 字号 / flex / 位置),**不许丢要素、不许自造内容**。报告带 gate.stage 与 gate.checks[].name === 'readiness.content':
511
+ · stage=content(gate.blocked=true)⇒ 内容层没齐(要素 / 变量绑定 / 图片地址 / 切图落盘),**本轮根本没 render**。照 verdict 里的 blockers 补内容,别调像素、别重跑空转。
512
+ · stage=structure(elements.missing>0 或 validate/lint/preflight 未过)⇒ **先补要素,别调像素**:把设计稿的文案补回对应节点;绑定变量的把 defaultValue 改成设计稿真值。改样式不会把丢掉的文案变回来。
513
+ · stage=pixel ⇒ 内容与结构都齐了,才进入样式 / 布局收敛(一轮只改一个分项)。
514
+ 实测事故(2026-09-21):模型把设计稿的产品名 / 利率 / 期限整批换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素照样刷到 89%、validate/lint 全绿,而卡的**内容真值整批丢失**,直到人工比对设计稿才发现。报告 elements.invented 会把这类「schema 里有、设计稿里没有」的自造文案逐条点名,elements.missing 列出漏掉的要素。
515
+ 报告还带:contentOffset1x + contentOffsetHint(内容整体平移时先修对齐,别逐行调间距)、**contributors + contributorsNote(分项归因:每个差异热区给出最可能出问题的具体节点)**、widthMismatch(--width 与设计稿卡面宽不符 ⇒ 先改 --width,否则 match/dy 都不可信)、lint.warningDetails、preflight.details、renderImage(渲染原图路径)、diffImageNote(并排图顶部色条是工具标题栏,不是卡片内容)、stalled(**连续 3 轮未刷新历史最好成绩**——震荡下滑同样触发——附 best 与回退命令)、best(历史最好成绩 {match,round})。
516
+ **contributors 是「差在哪个节点」的唯一答案,用它替代整份重写 IR**:数组每项 = 一个热区(hotspot/diffPct)+ elements[](按最内层优先排序的具体节点:cid / cls / text / rect / h / hSibling(同类中位高度)/ designH / dH(与设计稿高度差)/ flags / hotCover)。读法:flags 里 text-clipped ⇒ 该节点文本被裁剪,查 isMaxLine / maxLine / lineHeight;vs-design ±N ⇒ 该节点高度与设计稿差 N,查它自己的 paddingTop/Bottom 与 lineHeight;vs-siblings ±N ⇒ 同类元素里只有它高度不同,查它自己的 padding/margin(**不是整层 gap**);bottom-edge / right-edge ⇒ 差异贴边界外,查下边距 / 圆角 / 阴影。**一轮只改 contributors[0].elements[0] 这一个节点的一个属性**,改完立刻重跑 iterate 看是否刷新最好成绩——同时改多项无法归因。designH/dH 为 null 表示没给 --spec 或文本匹配不上,此时只能靠 flags 与同类中位判断,**不要自己编设计稿高度**。
517
+ **报告里出现 envBlocked / verdict 以 BLOCKED 开头 ⇒ 本机没有可用的 python,validate 根本没跑**:这是环境问题,改 schema、继续 --set 都修不好它——立刻停手如实告诉用户(装 Python 或设 DSL_CARD_PYTHON=<解释器路径>)后重跑,别把它当门禁缺口反复重试。
518
+ **stalled 出现 ⇒ 第一件事是回退,不是接着调**(它=连续 3 轮没刷新最好成绩,说明 --set 已到天花板):① skill_exec script=skills/dsl-card-generator/scripts/ir_restore.mjs args=["--best",".cache/ai-scratch/<落点卡>.ir.best.json","--ir",".cache/ai-scratch/<落点卡>.ir.json"] 回退到历史最好成绩那一轮(写回前自动备份当前 IR),**回退后立刻重跑一轮 iterate**——旧报告的 schemaHash 已失效,--gate 会直接判 FAIL;② 之后一轮只改 gate.failed 里的**一个**分项;③ 同一项连改两轮都不动才换证据链:跑 diff_bands.mjs(args=["--id","<落点卡>","--design","<bundle>/card-N.png","--render","<报告 renderImage>"])拿逐带 dy/dh 与分区主色,再对照 references/generation_pitfalls.md 的「类型开关+值 / 静默丢弃」清单。**禁止**在 stalled 之后继续盲调 --set、**禁止**再全量重写 IR。实测事故:match 卡 73% 连调 6 轮无效,根因是 6 处 margin 漏 marginType,diff_bands 一轮就能指认;另一条轨迹 r10 做到 92.42 后 13 轮越改越差、烧掉 707 万 token 才被人工中止——正因为旧判据非要「完全不变」才触发,震荡下滑落在盲区里。
519
+ match 不升反降、heightDelta 非 0、或 hotspots 集中在原本已有内容的区域 ⇒ 说明在删内容凑分:回退该轮 --set 换思路。diffImage 每轮重写(报告里的路径),只给用户预览。
520
+ **取证禁令**:禁止用 shell_exec 写 node -e pngjs 像素脚本(行扫描 / 取色 / 自算 diff)——diff_bands.mjs、报告 hotspots、contentOffset1x、审计 colors/bgGradientHint 已经覆盖全部取证需求,实测手写脚本 4 次全是浪费步数;禁止 fs_read 任何图片;禁止在 S5 之前调 card_screenshot(渲染还没意义);禁止把 src/cards/<卡>/<卡>.png 当渲染图(那是设计稿归档,渲染原图是报告里的 renderImage)。同理**禁止自造分析 / 补丁脚本**(往草稿区写 .cjs/.mjs 再跑、node -e 扫像素或改 JSON 都在此列,实测白烧 11 轮)与**用 sed -i / 重定向直接改 IR、schema**(会绕过 validate、原子写与 .history 快照):改 IR 用 ir_patch(首选:点路径 JSON 补丁、原子、不吃引号转义)或 iterate.mjs --set,改卡片内容用 card_patch / card_write。
521
+ 每轮用一两句正文汇报:gate.verdict / match% / heightDelta / 这轮修了什么,不要闷头连调。
522
+ S6 收尾门禁:① skill_exec script=skills/dsl-card-generator/scripts/readiness.mjs,args=["--id","<落点卡>","--spec","<bundle>/spec.json","--design","<bundle>/card-N.png"],**必须 exit 0(就绪)**——内容层的四类缺口(要素 / 变量绑定 / 图片地址 / 切图落盘)在收尾时同样不许放过;② skill_exec script=skills/dsl-card-generator/scripts/check_fidelity.mjs,args=["--spec","<bundle>/spec.json","--schema","src/cards/<落点卡>/schema.json","--card","N"],按它报的文本缺失 / 字号偏差 / 颜色偏差修到无 ERROR;③ skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--gate","--out","src/cards/<落点卡>/"] 拿机器判定(读最近一轮落盘报告,schema 在报告之后被改过会直接判 FAIL;结论可能是 pass / stalled / **blocked-content** / iterating 四种,blocked-content 表示内容仍未就绪,不是收敛)。
523
+ 正文汇报必须**逐字引用 --gate 的 verdict 与 failed 列表** + 落点卡 + 补了哪些切图 + 回滚方式。gate 未过禁止写「门禁全绿 / 已完成 / 验收通过」,必须列出未达标项、原因与下一步;禁止把未达标归因于「测量噪声 / 工具合成产物」——pixelDiff 只用渲染原图与设计原图,合成图色条不参与计算(见 diffImageNote)。
524
+ 绑定对齐:check_fidelity 会按 data.json / defaultValue 把 $ 绑定解析回文案再对齐——报 missing-text 而该文本已绑定 ⇒ 是 mock 与设计稿不一致,改 defaultValue / data.json,**不是拆回字面量**。收尾自查:bindHints 每一项要么已绑定、要么正文说明为何保持字面量。
525
+ `
526
+
527
+ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove = DEFAULT_AUTO_APPROVE, root = '', buildFlow = false, buildReason = '' } = {}) {
275
528
  const refsDir = skillRefsDir(root)
276
529
  const styleKeys = styleKeySection(root)
277
530
  // 缓存键带上实际命中的技能库:仓库内与消费者项目(init 释放的副本)文档集可能不同;
278
531
  // 再带上 style-meta 的 mtime:合法样式键表直接决定提示词内容,meta 改了必须重算。
279
- const cacheKey = `${shellAllowed ? 'shell' : 'base'}${vision ? '+vision' : ''}+aa:${autoApprove}+sk:${skillsDir(root)}+skm:${styleKeys.mtime}`
532
+ // buildFlow 必须进键:同一进程内相邻两轮可能一条「改已有卡」(不注入建卡协议)、
533
+ // 一条「按设计稿建卡」(注入),键不带就会串味——后一轮拿到前一轮的精简提示词。
534
+ const cacheKey = `${shellAllowed ? 'shell' : 'base'}${vision ? '+vision' : ''}+bf:${buildFlow ? 1 : 0}+aa:${autoApprove}+sk:${skillsDir(root)}+skm:${styleKeys.mtime}`
280
535
  if (_systemPromptCache && _systemPromptCache.key === cacheKey) return _systemPromptCache.text
536
+ /**
537
+ * 建卡协议段的注入内容。
538
+ *
539
+ * 不注入时**必须留一行显式说明**,而不是无声消失 —— 否则「模型为什么不知道要跑 readiness / iterate」
540
+ * 变成不可排查的灵异问题:用户挂个新附件让它建卡、它却按改已有卡的套路硬写 schema。
541
+ * 那行字把「本轮没注入建卡协议」与「可以 skill_read SKILL.md 取回」同时说清。
542
+ */
543
+ const buildSection = buildFlow
544
+ ? BUILD_PROTOCOL_SECTION
545
+ : '## 建卡协议(本轮未注入)\n本轮判定为「修改已有卡片」,故未注入 S0~S6 设计稿建卡协议。若用户其实要**从零建卡**(挂设计稿 / 描述一张新卡 / 当前卡片是空的),先 skill_read dsl-card-generator/SKILL.md 取回完整建卡协议再动手,不要按「改已有卡」的套路 card_write 整份硬写。'
281
546
  // 免确认策略要如实写进协议:模型若以为「每次都要用户点确认」,会主动停下来等批准 / 把命令甩给用户
282
547
  const skillAuto = autoApproves(autoApprove, 'skill_exec')
283
548
  const shellAuto = autoApproves(autoApprove, 'shell_exec')
@@ -293,6 +558,9 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
293
558
  lines.push(`- ${entry.path}(${Math.max(1, Math.round(size / 1024))}KB):${entry.hint}`)
294
559
  }
295
560
  try {
561
+ // 按阶段过滤:改卡轮不列建卡专属文档(ir-format / 设计稿映射 / 读图规则 / 切图约定…)。
562
+ // 漏列的代价是「该读却找不到」,所以 REF_STAGE 未登记的一律走 always(见 REF_STAGE 注释)。
563
+ const buildSet = new Set(REF_STAGE.build)
296
564
  for (const name of fs.readdirSync(refsDir)) {
297
565
  let st
298
566
  try {
@@ -301,16 +569,21 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
301
569
  continue
302
570
  }
303
571
  if (!st.isFile()) continue
572
+ if (!buildFlow && buildSet.has(name)) continue
304
573
  lines.push(`- ${name}(${Math.max(1, Math.round(st.size / 1024))}KB):${SKILL_REF_HINTS[name] || '特定主题参考'}`)
305
574
  }
306
- // 范式片段是子目录,不列出来模型根本不知道存在(曾经为此反推 expand_ir 源码猜字段形状)
575
+ // 范式片段是子目录,不列出来模型根本不知道存在(曾经为此反推 expand_ir 源码猜字段形状)。
576
+ // 文件名清单限流 12 个:18 个全列是 448 字符,占索引段一半;模型要的是「有哪些主题」不是完整清单,
577
+ // 剩下的让 fs_list skill_read 按需列(片段都<1.5KB,拿到名字直接读很便宜)。
307
578
  const snippets = fs
308
579
  .readdirSync(path.join(refsDir, 'recipe_snippets'))
309
580
  .filter((f) => f.endsWith('.json'))
310
581
  .sort()
311
582
  if (snippets.length) {
583
+ const shown = snippets.slice(0, 12)
312
584
  lines.push(
313
- `- recipe_snippets/(按主题的现成范式片段,skill_read path=dsl-card-generator/references/recipe_snippets/<文件名> 取用):${snippets.join(' / ')}`,
585
+ `- recipe_snippets/(按主题的现成范式片段,skill_read path=dsl-card-generator/references/recipe_snippets/<文件名> 取用):${shown.join(' / ')}` +
586
+ (snippets.length > shown.length ? ` …等共 ${snippets.length} 个(fs_list 可列全)` : ''),
314
587
  )
315
588
  }
316
589
  } catch {
@@ -319,7 +592,7 @@ function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove =
319
592
  const _text = `你是 CardBuddy 编辑器内置的卡片编辑助手:帮用户读取、修改、生成低代码卡片 schema.json。
320
593
  工作协议:
321
594
  1. 先用 card_read / card_node_models 了解卡片现状;修改保持最小 diff,不要重写无关节点。
322
- 2. 写之前按需 skill_read 参考文档(path = 下面索引里的文件名):常规修改只读最相关的 1-2 个文件——表达式 / 格式化问题先读 expression_engine.md;card_write 前读 rules.md;不要一次读全部。**「不要一次读全部」针对的是整份大文档**:recipe_snippets/*.json 这类小片段(常 <2KB、读一次 0.3s)不确定就直接读,别为了「省着读」而在思考里猜字段形状——实测模型因为「被告知别读太多文件」而拒绝读一个 1KB 片段,转而在思考里硬推十几分钟。文档比一屏长时(如 ir-format.md)skill_read 会在尾巴给出 offset(读到「已截断」就照它再读一次):别拿前半段契约猜后半段字段。
595
+ 2. 写之前按需 skill_read 参考文档(path = 下面索引里的文件名):常规修改只读最相关的 1-2 个文件——表达式 / 格式化问题先读 expression_engine.md;card_write 前读 rules.md;不要一次读全部。**「不要一次读全部」针对的是整份大文档**:recipe_snippets/*.json 这类小片段(常 <2KB、读一次 0.3s)不确定就直接读,别为了「省着读」而在思考里猜字段形状——实测模型因为「被告知别读太多文件」而拒绝读一个 1KB 片段,转而在思考里硬推十几分钟。**长文档一律用 skill_read 的 section= 参数按节取,不要从 0 开始按 offset 顺序读**(section 传小节序号或标题关键词;读到的截断锚点里会带完整小节目录,序号照着数即可):实测 generation_pitfalls.md 有 9 万字符、顺序读要 9-10 次 tool_result 共 10 万字符,按节读一节平均 1.9k 字符、省 90%+,而且不会「读到半截内容猜后半段字段」。
323
596
  2b. 大卡(card_read 回喂被截断者)修改流程:card_grep 定位 JSON 路径 → card_patch 按路径最小写;严禁 card_write 整包重写大卡。小卡可直接 card_write。
324
597
  2c. **从零建卡(尤其设计稿建卡)不要在工具参数里吐整份 schema**:光参数就上万 token,会被单次输出上限截断,整轮白跑。正路是——先 skill_read ir-format.md 看字段契约 → fs_write 把紧凑 IR 写到 .cache/ai-scratch/<id>.ir.json(比 schema 小 4-5 倍)→ skill_exec 跑 skills/dsl-card-generator/scripts/expand_ir.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<id>/","--slices-from","<要素包>/slices,src/slices"](自带 autofix + 真 validate,报错会原样回传;引用的切图找不到时会列出搜索目录里名字沾边的候选)→ 报错就修。**IR 全文只写这一次**:之后所有调整都用 iterate.mjs 的 --set 点路径原子微调(一轮可带多条);整份重写 IR 一次要 40-110s 纯输出浪费,只有结构级推翻才允许重写。**一次写不进去就分片**:预计紧凑 JSON > 10KB、本轮已被单次输出上限截断过(finish_reason=length)、或上一轮写 IR 中途被打断过 ⇒ 改走「骨架 + 每区块一片 .cache/ai-scratch/<id>.pNN.json + ir_merge.mjs 合并一次」(格式与边界见 ir-format.md §2c)。分片是给「一次写不进去 / 写了会丢」兜底,每片要多付一个 LLM 步骤(实测 40-140s/步),小卡别拆。
325
598
  2e. **flex / 尺寸红线(validate 与 lint 直接拦,别试错)**:枚举值 kebab-case(flex-start / flex-end / space-between;写 flexEnd 即 validate ERROR);Column / Row 默认 gap=4,不需要间距就显式 gap:"0";justifyContent:space-between 的 Row 必须 rect.width:"100%"(lint R19);Row 父级里的 Row / AbsoluteLayout 子节点默认 width:100%,会互相把对方挤出画布——定宽子簇要显式 rect.width,且并列子节点至少一个 flexShrink:"1"(lint R35)。
@@ -339,69 +612,15 @@ ${styleKeys.text}
339
612
  - 渲染自检 / 组件统计用 \`card_render_status\`,看卡内容用 \`card_read\`(或 describe_card.mjs)。**它们不互相替代,但也不必串成一串全跑**:用户只说「检查一下」时跑 ①(卡正在改就加 ②),别把 ③ 也拉上——③ 读的是上一轮落盘报告,没跑过 iterate 的卡它只会判 FAIL。
340
613
  - 脚本名一律以磁盘为准:\`skill_exec\` 只跑 skills/<skill>/scripts/ 下真实存在的文件;报错会附上同目录的真实候选,照着里面的名字重发,**别继续猜下一个名字**。
341
614
 
342
- ## 设计稿建卡协议(Stage Machine:逐阶段推进,上一阶段产物齐了才进下一阶段;用户挂载设计稿目录 / 图片并要求建卡时严格执行,不要自由发挥)
343
- S0 路由:输入形态已由服务端机械判定,写在「用户挂载的本地文件」段(kind / preprocess / next):照它执行,不要再调 card_route_input;该段缺失才自己调 card_route_input({ path })。
344
- S1 要素包:kind=meaxure-export(Sketch MeaXure 标注导出)必须先有 sketch-spec-to-card 要素包,禁止直接读 preview/ 整图建卡。
345
- - fs_read 判定里给出的 manifest(通常 UI/manifest.json)找目标画板条目:status=generated ⇒ 产物已在;status=pending ⇒ 先看 preprocess.alsoFound(同一导出的其它产物目录,平铺与按稿分片常并存),有就直接用;确实都没有才补跑:
346
- ① skill_exec script=skills/sketch-spec-to-card/scripts/parse_meaxure.py,args=["--src","<导出目录>","--list"] 拿画板清单(**每行行尾自带该画板的 --pick,板号 1 起**);
347
- ② args=["--src","<导出目录>","--out","UI","--draft","<稿名>","--pick","<照抄清单行尾>","--build"] 只产该画板(**照抄行尾的 --pick,禁止把序号换算成 0 起的 --indices**——实测换算错一位,白跑 34s 解析 + 一步 LLM 才发现;多张逗号分隔,连板写 7~9、同板连卡写 8.1~8.2);
348
- ③ script=skills/sketch-spec-to-card/scripts/cluster_states.py,args=["--out","UI/<稿名>"]。
349
- - 要素包落点记作 <bundle>:传了 --draft 在 UI/<稿名>/<画板名>/,否则 UI/<画板名>/;以 parse_meaxure.py 打印的落盘路径为准。仓库可能两种布局并存,manifest 说的画板在 UI/ 下找不到就用 fs_list UI 找同名目录,别急着判定产物缺失。
350
- S1.4 批量建卡(用户一次点了多张卡 / 回了「全部」):**一次** parse_meaxure --pick 出全部要素包(别每卡重跑一遍解析),然后**同一个回合内**按「S2→S5 走完一张,再走下一张」连续推进——每张卡各写各的 IR、各跑各的 expand + iterate。禁止每张卡各起一个回合:新回合的首步要把整段对话重发一遍(实测 1.3-3 万 token 一步),还要把同一个要素包重读一遍,纯浪费。
351
- S1.5 选卡门禁(多画板 / 多卡画板必需;SKILL.md §13 + rules.md §13.4):目标目录定下后先跑 skill_exec script=skills/dsl-card-generator/scripts/spec_tree.mjs,args=["--list-cards","<目标目录>"] 拿「画板序号.卡片序号」全量清单(一个画板含 N 张卡展开为 K.1~K.N,行下自带 "图: <card-N.png 绝对路径>");S0 挂载段已带同一份命令就直接复用,不要重复跑。
352
- - 只有 1 张卡、或用户已明说目标(序号如 2.3 / 画板名 / 卡片名)⇒ 直接进 S2,不要为走流程停下来问。
353
- - 多张卡且用户未指定 ⇒ **本轮到此为止**:把清单写进正文 + 明确提问,正文最后**单独一行输出 [需用户确认]**,然后停止输出、等用户回序号再继续。没有这行标记会被判定「任务未完成就停手」而自动续跑(实测多花一整轮重发清单)。聊天面板**没有**弹选项工具(不存在 request_user_input),不要去调用或寻找弹窗工具,也不要替用户挑。
354
- 清单**逐行配图**:每个卡片行下紧跟一行 Markdown 图片,写成 !\[画板名\](该卡 card-N.png 的完整绝对路径),与序号一一对应——**必须是 !\[\](...) 包起来的图片语法**;parse_meaxure 输出里的 "图: <路径>" 行是给你看的提示,照抄成裸路径渲染端不显示(实测照抄后用户 0 张图可看);缺图显式标缺失,禁止把图堆到清单末尾。末尾追加一项「全部」,用户回「全部」才算显式批量授权(产了几张卡要在正文报)。
355
- **清单必须以普通 Markdown 列表逐行输出,禁止用代码块围栏(\`\`\` 或 ~~~)或 4 空格缩进包裹清单的任何部分**——渲染端把代码块当纯文本展示,里面的图片语法一行都不会转成预览图,选卡协议整段失效(实测 8 张配图全灭)。parse_meaxure --list 的输出**照抄行内容即可,围栏与缩进不要照抄**。
356
- **路径必须写完整绝对路径**(以 /Users/… 或 C:\… 打头,一字不改照抄),**禁止用省略号(… 或 ...)代替前缀**(实测「…/积分助手-4-xx.png」这种写法渲染端取不到图,用户那几行就只剩一串路径=配图全丢),**禁止把路径包进反引号或写进代码块**(反引号 / 代码块里的是给人抄的文本,不会渲染成图片)。清单长不是省前缀的理由:图显示不出来才是真的白写。
357
- 只回画板序号默认取该画板第 1 张卡。
358
- - **禁止**自行挑画板 / 挑卡、**禁止**「先建一张看看」、**禁止**未经同意批量产卡。
359
- S2 审计(一步代替反复探索):skill_exec script=skills/dsl-card-generator/scripts/spec_audit.mjs,args=["--spec","<bundle>/spec.json","--card","N","--slices-dir","<bundle>/slices","--project-slices","src/slices","--design","<bundle>/card-N.png","--save",".cache/ai-scratch/<id>.audit.json"]。
360
- 切片引用名先查 <bundle>/slices/index.json(--build 已生成:每张切图的真实相对路径与大小),照它写引用、按 R31 命名规则消毒(禁用字符 # ? % & + 不进引用名),expand_ir 会递归命中嵌套子目录里的真身;**禁止 fs_list 逐层摸索**(实测 6 次才找到嵌套真名,纯浪费)。
361
- 它交叉审计 spec + 聚焦截图 + 切图目录,给出:bands(水平行与行内元素序)、gaps(padLeft / padRight / padTop / padBottom / rowGaps)、texts(文案 / 字号 / 字重 / 颜色 / 行高 / 对齐)、shapes(装饰条)、slices(存在性 / 复用 / 是否烘焙背景与标签文字)、colors(背景采样点)、missing(缺哪张切图 + 建议裁剪矩形)。
362
- 它交叉审计 spec + 聚焦截图 + 切图目录,另给 bgGradientHint(顶部渐变带的起止色/跨度 + IR 正确写法)与 slices[].crops(reused-differs 时「矩形 → 该用哪张 _alt 图」的映射,exists=true 直接引用、false 才需裁)。
363
- IR 的几何与样式一律照抄审计值;**禁止 fs_read spec.json**(几百 KB 只会拿到截断片段,重复读纯浪费步数),禁止反复 fs_list 摸索结构。要看层级树才跑 spec_tree.mjs。
364
- **审计 JSON 只认落盘文件**:--save 已把整份审计写到 .cache/ai-scratch/<id>.audit.json,下游脚本(crop_slices --from-audit)一律引用该文件。**禁止用 fs_write 手抄 / 节选审计内容**——实测手抄的 156 字节「摘要」让 crop_slices 裁出错名错位的切图,还自造了违反命名约定的新切片。
365
- S3 补切图:S2 的 missing 非空 ⇒ skill_exec script=skills/dsl-card-generator/scripts/crop_slices.mjs,args=["--design","<bundle>/card-N.png","--card-width","<卡宽>","--out","src/slices","--from-audit",".cache/ai-scratch/<id>.audit.json"],从聚焦截图派生缺失切图(审计已落盘,直接引用)。切图唯一落点 src/slices/ 扁平命名,IR 里 Image.value 用 /slices/<名>。
366
- 只缺一两张时用 --crop <名>@x,y,w,h(可多条),x/y/w/h 直接抄审计 missing[].crop 的**卡片本地 1x 坐标**,脚本自己按 design 宽 ÷ 卡宽换算像素。**禁止用 sips / convert / magick 等系统图像命令裁图**:它们不在 shell 白名单,且要手算缩放比与裁剪偏移,实测必错。
367
- 切图用法看 slices[].baked:label=true ⇒ 切图已含标签文字,tile 用单个 Image(96×84 之类照 rect 抄);label=false ⇒ 需要 Image + Text 组合;verdict=reused-differs ⇒ 同一张切图被标到内容不同的多个位置,除第一个外必须用审计 slices[].crops 给出的 _alt 名(exists=true 直接引用,exists=false 由 crop_slices 派生)——**禁止自造新名字**(实测自造 icon_xx_左/右 让同一设计内容在 src/slices 下出现三套名字)。
368
- S4 写 IR(全文只写这一次;**单次输出上限截断过 ⇒ 必须分片写**,见 2c 与 ir-format.md §2c:骨架 fs_write → 每区块一片 pNN.json → skill_exec ir_merge.mjs --ir <ir> --part …(可多条) 合并一次 → 再 expand_ir):fs_write .cache/ai-scratch/<id>.ir.json。结构照 bands 一条一段;gaps.rowGaps 映射成 flex.gap / padding(paddingType:"1" + 四边值);texts 逐条转 Text(fontSize / fontWeight / color / lineHeight 全抄,**lineHeight 必写**,否则渲染器按 1.5 倍字号撑高、整卡累计漂移几个像素);标题 + 底部装饰条**整段照抄 ir-format.md §2d 的片段**(AbsoluteLayout(align:"BottomLeft") > [Row(条, s.position.top/left), Text(标题, s.position.top/left)],子节点顺序 [装饰条, 标题];几何读标注差值 position.top = 标注条.y − 标注标题.y,别套「行高 + gap」的公式;条子不得排在流式里、不得用 margin 顶位(正负都不行,lint r64 硬拦))。
369
- **类型开关铁律(渲染器静默丢弃类缺陷的唯一防线)**:margin / padding 组写单边值(marginTop 等)必须同组写 marginType:"1" / paddingType:"1"(expand_ir 会自动补;手写 schema 漏了会被 lint 报 r44-*-no-type);背景三选一:纯色 backgroundType:"01"+backgroundColor、图片 "02"+backgroundImage、渐变 "03"+backgroundGradient。审计给了 bgGradientHint 就照抄它的 ir 字段;**禁止自造 gradientColor / gradientDirection,禁止把 linear-gradient(...) 塞进 backgroundColor**——这三者都会静默失效(实测模型连试 3 轮全无效后放弃并编造理由)。 **水平内缩禁止写成「子节点 width:100% + marginLeft/Right」**:width:100% 已等于父容器内容宽,水平 margin 只会把节点推出父容器右缘、被 overflow:hidden 裁掉(父容器按列排布时水平方向是交叉轴,flexShrink 救不回来)——间距要放**父容器** paddingLeft/Right,或删掉 width 靠 stretch 补宽(lint r63-width100-with-hmargin ERROR 拦截,pitfalls §25)。
370
- 落点卡默认 = 当前正在编辑的卡片 ID(见「当前卡片上下文」):IR 的 id 与 iterate 的 --out 一律 src/cards/<当前卡片ID>/,即覆盖当前卡 / 空卡是默认行为。**「建卡」「生成卡片」「用这张画板做一张卡」都不是「新建一张」**——那是在说「用设计稿出一张卡」,落点仍是当前卡;只有用户明确说「新建一张 / 另存为新卡 / 换个新 id / 不要覆盖当前卡」才自拟 ID,且自拟前先用一句正文写明「将新建卡片 <新id>,不覆盖当前卡 <当前卡id>」,让用户当场看得见(实测:把「建卡」读成「新建」,界面里凭空多出一张卡而对话里没有任何交代)。当前卡已有内容时,动手前一句正文说明将覆盖、原内容留 .history 快照可回滚。
371
- S4.5 变量抽取(真实卡片不是截图:这步漏了,产物就是 dictionary / data.json 全空的死图):先 skill_read ir-format.md 的「2b. 变量绑定」看契约,再决定哪些文本绑变量——
372
- 下限 = S2 审计的 bindHints[](amount / percent / date / masked / count / level),再按语义补正则认不出的动态值(用户名 / 客户层级 / 卡片名称 / 状态词);静态标签(区块标题 / 按钮 / 说明文字)才写字面量。
373
- - 动态值:Text 主值用 v:"$key"(条件文案写表达式如 "$showAll ? '收起' : '查看更多'",金额展示照 recipe_snippets/money-format.json,别把「¥7,000.00」拆成字面量)。
374
- - IR 顶层**显式写 dictionary.inputParams**:{key,name,type,defaultValue,desc,rule:{type:"",options:{}},children:[]},defaultValue 一律取设计稿字面量——expand_ir 用它生成 data.json,渲染即设计稿文案,S5 像素闭环才不受绑定影响。
375
- - 重复行 / tile 组(审计 listCandidates[]、或用户明说要列表):数组变量 + listScope 插槽。item 的字段**已逐个展开到作用域** ⇒ 规范形三种:$<字段名>(字段名语义化自取)、$$(整个 item)、$_index(行索引)。写成 $item.<字段> 也不影响结果——expand_ir 在扩展开头会把它自动归一化成规范形(作用域里本来就没有 item 这个键,schema 只存规范形;schema 级另有 autofix_schema.py 兜底)。范式照 recipe_snippets/list-expand-collapse.json 或 src/cards/account 的 account_details。结构确实不规则时允许保持静态,但要在正文说明理由。
376
- - 命名语义化(memberLevel / totalAmount / assetRows),禁 text1 / val2;写完在正文列一句绑定清单(key → 设计稿文案)。
377
- S4.9 内容就绪门禁(**像素自循环的前置条件**;不达标时 iterate 会把本轮整体挡掉——不 render、不比对像素、不消耗迭代轮次):
378
- IR 写完、变量抽完后,先跑 skill_exec script=skills/dsl-card-generator/scripts/readiness.mjs,args=["--id","<落点卡>","--spec","<bundle>/spec.json","--design","<bundle>/card-N.png","--json"]。退出码 0=就绪 / 3=未就绪(**3 不是脚本报错,是内容没准备好**)。
379
- 五道**声明性**判定(不需要渲染,所以能挡在最贵的一步之前):A 组件契约(属性键 / 样式组 / 样式子键必须在 component-meta.json + style-meta.json 白名单内)· B 要素齐全(设计稿每条文案在 schema 里找得到)· C 变量绑定完备($var 的根变量必须在 dictionary.inputParams / mocks / data.json / 事件 setData 里存在)· D 图片地址已配置(每个 Image 必须有非空 value)· E 切图路径已配置(每个 /slices/... 引用在 src/slices 里有对应文件)。
380
- blockers 非空 ⇒ **先补内容,别进 S5**。这一步存在的唯一理由:内容没齐时像素分毫无参考价值。实测事故(2026-09-24 gen-1):跳过这步,在图片地址都没配的情况下连刷 10 轮像素(match 卡在 87-90.46),两回合烧掉 623 万 prompt token 全部作废。
381
- 三条高频坑(readiness 会在 blockers 里逐条点名,不要靠猜):① Image 的图片地址属性键**只能是 value**,写成 image 会被运行时按 itemKey 绑定失败后**整条静默丢弃** ⇒ value 为空 ⇒ 破图,而 validate / lint / preflight 全绿;② from:"2" 的值必须是静态路径、from:"1" 的值必须是 $表达式,写反 ⇒ 字符串直接进 url(...) 或表达式求值失败 ⇒ 画布空白;③ 切图**引用名与磁盘文件名必须同源**(crop_slices 按引用名落盘;引用一个 src/slices 里不存在的名字 ⇒ 渲染 404、图空白、控制台零报错)。
382
- **iterate 每轮会自动再跑这道门禁**:报告里 gate.stage=content(或 verdict 以 BLOCKED(内容未就绪)开头)表示本轮被挡——没跑 render、没跑 pixelDiff、**没消耗迭代轮次**。按 gate.verdict 里的 blockers 补齐内容后**直接重跑 iterate**即可;禁止在被挡时反复重跑空转,更禁止去调像素(那时候没有像素分可调)。
383
- 逃生门 --no-readiness(或环境变量 DSL_CARD_SKIP_READINESS=1)只在排查「门禁本身误判」时用,正常流程禁止。
384
- S5 收敛循环(唯一门禁 = iterate.mjs 报告里的 gate 字段:内部已跑 autofix + 真 validate + lint + preflight + **内容就绪(S4.9)** + render + 并集像素对比,别再单独跑 validate_schema.py):
385
- skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<落点卡>/","--design","<bundle>/card-N.png","--width","<卡宽>","--spec","<bundle>/spec.json"]。
386
- 修 IR 用 **ir_patch**(点路径 JSON 补丁,一次可多条,形如 ops=[{"path":"children.0.s.flex.gap","value":"0"}])——**它是改 IR 的首选**:结构化参数不吃引号转义、原子写、回旧值→新值摘要;也可用 iterate.mjs 的 --set(字符串字面量语法,注意引号转义,例:--set children.0.s.flex={"gap":"0"})。数字段=数组下标;中段不存在会响亮报错,按报错改路径。**两条路径都不是「整份重写」**——同轮内对同一草稿文件第 2 次全量 fs_write 会收到提醒、第 3 次起被拒(要重排结构才传 rewrite:"true")。
387
- **收敛 = 报告 gate.passed === true**(**内容就绪(S4.9)** ∧ validate/lint/preflight 零 error ∧ **elements.missing=0** ∧ match ≥ 95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x| ≤ 2)。gate 是脚本判的,正文只能引用它的 verdict / failed,不得自行宣布达标。像素对比按并集画布算,删内容 / 高度塌缩只会降分,不可能凑分。
388
- **要素是固定真值:先内容、后骨架、最后样式(三段式门禁,逐段推进)**。S2 审计里的每条文本 / 每张切图都是这张卡的既定要素,S5 只许改**样式与布局**(尺寸 / 间距 / 颜色 / 字号 / flex / 位置),**不许丢要素、不许自造内容**。报告带 gate.stage 与 gate.checks[].name === 'readiness.content':
389
- · stage=content(gate.blocked=true)⇒ 内容层没齐(要素 / 变量绑定 / 图片地址 / 切图落盘),**本轮根本没 render**。照 verdict 里的 blockers 补内容,别调像素、别重跑空转。
390
- · stage=structure(elements.missing>0 或 validate/lint/preflight 未过)⇒ **先补要素,别调像素**:把设计稿的文案补回对应节点;绑定变量的把 defaultValue 改成设计稿真值。改样式不会把丢掉的文案变回来。
391
- · stage=pixel ⇒ 内容与结构都齐了,才进入样式 / 布局收敛(一轮只改一个分项)。
392
- 实测事故(2026-09-21):模型把设计稿的产品名 / 利率 / 期限整批换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素照样刷到 89%、validate/lint 全绿,而卡的**内容真值整批丢失**,直到人工比对设计稿才发现。报告 elements.invented 会把这类「schema 里有、设计稿里没有」的自造文案逐条点名,elements.missing 列出漏掉的要素。
393
- 报告还带:contentOffset1x + contentOffsetHint(内容整体平移时先修对齐,别逐行调间距)、**contributors + contributorsNote(分项归因:每个差异热区给出最可能出问题的具体节点)**、widthMismatch(--width 与设计稿卡面宽不符 ⇒ 先改 --width,否则 match/dy 都不可信)、lint.warningDetails、preflight.details、renderImage(渲染原图路径)、diffImageNote(并排图顶部色条是工具标题栏,不是卡片内容)、stalled(**连续 3 轮未刷新历史最好成绩**——震荡下滑同样触发——附 best 与回退命令)、best(历史最好成绩 {match,round})。
394
- **contributors 是「差在哪个节点」的唯一答案,用它替代整份重写 IR**:数组每项 = 一个热区(hotspot/diffPct)+ elements[](按最内层优先排序的具体节点:cid / cls / text / rect / h / hSibling(同类中位高度)/ designH / dH(与设计稿高度差)/ flags / hotCover)。读法:flags 里 text-clipped ⇒ 该节点文本被裁剪,查 isMaxLine / maxLine / lineHeight;vs-design ±N ⇒ 该节点高度与设计稿差 N,查它自己的 paddingTop/Bottom 与 lineHeight;vs-siblings ±N ⇒ 同类元素里只有它高度不同,查它自己的 padding/margin(**不是整层 gap**);bottom-edge / right-edge ⇒ 差异贴边界外,查下边距 / 圆角 / 阴影。**一轮只改 contributors[0].elements[0] 这一个节点的一个属性**,改完立刻重跑 iterate 看是否刷新最好成绩——同时改多项无法归因。designH/dH 为 null 表示没给 --spec 或文本匹配不上,此时只能靠 flags 与同类中位判断,**不要自己编设计稿高度**。
395
- **报告里出现 envBlocked / verdict 以 BLOCKED 开头 ⇒ 本机没有可用的 python,validate 根本没跑**:这是环境问题,改 schema、继续 --set 都修不好它——立刻停手如实告诉用户(装 Python 或设 DSL_CARD_PYTHON=<解释器路径>)后重跑,别把它当门禁缺口反复重试。
396
- **stalled 出现 ⇒ 第一件事是回退,不是接着调**(它=连续 3 轮没刷新最好成绩,说明 --set 已到天花板):① skill_exec script=skills/dsl-card-generator/scripts/ir_restore.mjs args=["--best",".cache/ai-scratch/<落点卡>.ir.best.json","--ir",".cache/ai-scratch/<落点卡>.ir.json"] 回退到历史最好成绩那一轮(写回前自动备份当前 IR),**回退后立刻重跑一轮 iterate**——旧报告的 schemaHash 已失效,--gate 会直接判 FAIL;② 之后一轮只改 gate.failed 里的**一个**分项;③ 同一项连改两轮都不动才换证据链:跑 diff_bands.mjs(args=["--id","<落点卡>","--design","<bundle>/card-N.png","--render","<报告 renderImage>"])拿逐带 dy/dh 与分区主色,再对照 references/generation_pitfalls.md 的「类型开关+值 / 静默丢弃」清单。**禁止**在 stalled 之后继续盲调 --set、**禁止**再全量重写 IR。实测事故:match 卡 73% 连调 6 轮无效,根因是 6 处 margin 漏 marginType,diff_bands 一轮就能指认;另一条轨迹 r10 做到 92.42 后 13 轮越改越差、烧掉 707 万 token 才被人工中止——正因为旧判据非要「完全不变」才触发,震荡下滑落在盲区里。
397
- match 不升反降、heightDelta 非 0、或 hotspots 集中在原本已有内容的区域 ⇒ 说明在删内容凑分:回退该轮 --set 换思路。diffImage 每轮重写(报告里的路径),只给用户预览。
398
- **取证禁令**:禁止用 shell_exec 写 node -e pngjs 像素脚本(行扫描 / 取色 / 自算 diff)——diff_bands.mjs、报告 hotspots、contentOffset1x、审计 colors/bgGradientHint 已经覆盖全部取证需求,实测手写脚本 4 次全是浪费步数;禁止 fs_read 任何图片;禁止在 S5 之前调 card_screenshot(渲染还没意义);禁止把 src/cards/<卡>/<卡>.png 当渲染图(那是设计稿归档,渲染原图是报告里的 renderImage)。同理**禁止自造分析 / 补丁脚本**(往草稿区写 .cjs/.mjs 再跑、node -e 扫像素或改 JSON 都在此列,实测白烧 11 轮)与**用 sed -i / 重定向直接改 IR、schema**(会绕过 validate、原子写与 .history 快照):改 IR 用 ir_patch(首选:点路径 JSON 补丁、原子、不吃引号转义)或 iterate.mjs --set,改卡片内容用 card_patch / card_write。
399
- 每轮用一两句正文汇报:gate.verdict / match% / heightDelta / 这轮修了什么,不要闷头连调。
400
- S6 收尾门禁:① skill_exec script=skills/dsl-card-generator/scripts/readiness.mjs,args=["--id","<落点卡>","--spec","<bundle>/spec.json","--design","<bundle>/card-N.png"],**必须 exit 0(就绪)**——内容层的四类缺口(要素 / 变量绑定 / 图片地址 / 切图落盘)在收尾时同样不许放过;② skill_exec script=skills/dsl-card-generator/scripts/check_fidelity.mjs,args=["--spec","<bundle>/spec.json","--schema","src/cards/<落点卡>/schema.json","--card","N"],按它报的文本缺失 / 字号偏差 / 颜色偏差修到无 ERROR;③ skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--gate","--out","src/cards/<落点卡>/"] 拿机器判定(读最近一轮落盘报告,schema 在报告之后被改过会直接判 FAIL;结论可能是 pass / stalled / **blocked-content** / iterating 四种,blocked-content 表示内容仍未就绪,不是收敛)。
401
- 正文汇报必须**逐字引用 --gate 的 verdict 与 failed 列表** + 落点卡 + 补了哪些切图 + 回滚方式。gate 未过禁止写「门禁全绿 / 已完成 / 验收通过」,必须列出未达标项、原因与下一步;禁止把未达标归因于「测量噪声 / 工具合成产物」——pixelDiff 只用渲染原图与设计原图,合成图色条不参与计算(见 diffImageNote)。
402
- 绑定对齐:check_fidelity 会按 data.json / defaultValue 把 $ 绑定解析回文案再对齐——报 missing-text 而该文本已绑定 ⇒ 是 mock 与设计稿不一致,改 defaultValue / data.json,**不是拆回字面量**。收尾自查:bindHints 每一项要么已绑定、要么正文说明为何保持字面量。
403
- ${vision ? '你有视觉能力:S5 里 match 不达标可直接读注入的并排对比图定位差异(哪一行 / 哪个颜色 / 哪段间距)再 --set;数值指标仍是收敛判据,图只用于定位。' : '当前模型无视觉:不要声称看过图;S2 的审计值 + S5 的数值指标就是你的眼睛,所有尺寸颜色文案取审计值,缺时按 references/ui_fidelity_rules.md 降级规则处理,禁止凭空估数值。'}
404
- 步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾(判据含 --ir 文件的指纹:**改过 IR 再原样重跑同一条命令不算打转**,按「改一版 → 重跑一次拿新指标」的节奏走是安全的;输入一字未变还反复跑同一条命令才会被拦) ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:S2 / S3 / S5 各尽量一次调用解决;同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
615
+ ${buildSection}
616
+ ${buildFlow
617
+ ? vision
618
+ ? '你有视觉能力:S5 里 match 不达标可直接读注入的并排对比图定位差异(哪一行 / 哪个颜色 / 哪段间距)再 --set;数值指标仍是收敛判据,图只用于定位。'
619
+ : '当前模型无视觉:不要声称看过图;S2 的审计值 + S5 的数值指标就是你的眼睛,所有尺寸颜色文案取审计值,缺时按 references/ui_fidelity_rules.md 降级规则处理,禁止凭空估数值。'
620
+ : vision
621
+ ? '你有视觉能力:card_screenshot 产出的渲染截图可直接读,用来核对布局与配色是否贴合设计稿。'
622
+ : '当前模型无视觉:不要声称看过卡片截图;一切以 card_read / card_validate 的文本结果为准,颜色与间距不要凭空估数值。'}
623
+ 步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾(判据含 --ir 文件的指纹:**改过 IR 再原样重跑同一条命令不算打转**,按「改一版 → 重跑一次拿新指标」的节奏走是安全的;输入一字未变还反复跑同一条命令才会被拦) ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:${buildFlow ? 'S2 / S3 / S5 各尽量一次调用解决;' : ''}同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
405
624
 
406
625
  ## 收尾协议(不要停下来等「继续」)
407
626
  - 任务没做完就不要交还控制权:接着调用工具推进到下一个门禁。**禁止**输出「下一步(请确认或直接说「继续」)」这类等待式收尾——用户已开启自动续跑,停手只会让任务白等一轮。
@@ -421,21 +640,33 @@ const SKILL_READ_TOOL = {
421
640
  type: 'function',
422
641
  function: {
423
642
  name: 'skill_read',
424
- description: '读取系统提示词「按需参考索引」中列出的建卡参考文档(文件名),返回正文。文档比一屏长时只返回一段,尾巴上给出下一段 offset——需要后半段就直接带 offset 再读一次,不要凭半截内容猜。只在需要时读,不要全读。',
643
+ description:
644
+ '读取系统提示词「按需参考索引」中列出的建卡参考文档(文件名),返回正文。' +
645
+ '**文档较长时强烈建议先用 section 参数只读要的那一节**,而不是从头 offset 顺序读:' +
646
+ 'references 里多份文档带完整小节目录,按目录里的序号或标题关键词取一节即可(省 90%+ 回喂)。' +
647
+ '不带 section 时从 offset 处读一屏,尾巴给出下一段 offset 与完整小节目录(照抄目录里的序号续读更稳)。只在需要时读,不要全读。',
425
648
  parameters: {
426
649
  type: 'object',
427
650
  properties: {
428
651
  path: { type: 'string', description: '索引中的文件名,如 expression_engine.md' },
652
+ section: {
653
+ type: 'string',
654
+ description:
655
+ '只读这一节:可传小节**序号**(如 "9",对着返回的目录数)或标题里的**关键词**(如 "视觉还原")。' +
656
+ '传了它就不要再传 offset。首次读长文档建议 section="" 拿目录,或直接带 section 取目标节。' +
657
+ '只想看有哪些节,传 section="目录"(或 "列表"/"toc")即可,只回目录不给正文。',
658
+ },
429
659
  offset: { type: 'integer', description: '续读起点(字符偏移)。照抄上次返回尾巴里的 offset,不要自己估算。' },
430
660
  },
431
661
  required: ['path'],
432
662
  },
433
663
  },
434
664
  }
435
- // skill_read 的自有上限**必须**等于统一截断口径:以前这里写 30000 > TOOL_RESULT_MAX_CHARS,
436
- // 文案「已截断」还没送出就被外层按 12000 又切一刀——模型拿到的是 12000 字、且**看不到任何截断提示**,
665
+ // skill_read 的自有上限**必须**等于它在分档表里的那一档:以前这里写 30000 > 统一截断口径,
666
+ // 文案「已截断」还没送出就被外层又切一刀——模型拿到的是被砍过的内容、且**看不到任何截断提示**,
437
667
  // 实测 ir-format.md(17285 字符)每次读都静默丢 31%,模型拿着前半段契约猜后半段字段。
438
- const SKILL_READ_MAX = TOOL_RESULT_MAX_CHARS
668
+ // 走toolResultLimit('skill_read') 而不是写死数字:分档表是唯一来源,这里只是把它取出来。
669
+ const SKILL_READ_MAX = TOOL_RESULT_MAX_BY_TOOL.skill_read
439
670
  /**
440
671
  * 续读锚点预留:尾巴要写「读了哪段 / 下一段 offset / 下文小节索引」,正文得让出这点地方。
441
672
  * 取 240 是「够尾巴写满 6 个小节标题」的量级;真超了 clipSkillDoc 会再让正文,不漏字。
@@ -594,12 +825,14 @@ const FS_READ_TOOL = {
594
825
  type: 'function',
595
826
  function: {
596
827
  name: 'fs_read',
597
- description: '读取仓库根(或用户挂载目录)内的任意文本文件,返回正文(超长截断)。读卡片优先 card_read / card_grep;读参考文档优先 skill_read;本工具用于其余仓库文件与挂载附件。',
828
+ description:
829
+ '读取仓库根(或用户挂载目录)内的任意文本文件,返回正文。读卡片优先 card_read / card_grep;读参考文档优先 skill_read;本工具用于其余仓库文件与挂载附件。**文件较长时用 offset 续读**(返回值超长时尾巴会给出下一段的 offset,照抄即可;.md 还会附小节目录)。',
598
830
  parameters: {
599
831
  type: 'object',
600
832
  properties: {
601
833
  path: { type: 'string', description: '相对仓库根的路径或绝对路径(挂载目录内)' },
602
- maxChars: { type: 'number', description: '返回上限,默认 20000 字符' },
834
+ offset: { type: 'integer', description: '续读起点(字符偏移)。照抄上次返回尾巴里的 offset,不要自己估算。' },
835
+ maxChars: { type: 'number', description: '本次返回上限,默认 4000 字符(与 fs_read 的上下文档位一致)' },
603
836
  },
604
837
  required: ['path'],
605
838
  },
@@ -1233,8 +1466,29 @@ function resolveInsideRoots(root, raw, extraRoots = [], verb = '读') {
1233
1466
  return abs
1234
1467
  }
1235
1468
 
1236
- /** 只读文件:限定仓库根 + 用户挂载目录;目录 / 图片给「换哪个工具」的可操作提示,不浪费步数 */
1237
- function fsRead(root, { path: p, maxChars = 20000 }, extraRoots = []) {
1469
+ /**
1470
+ * fs_read 的**自有上限必须等于它在分档表里的那一档**(fs_read = 4000),否则外层 clipToolResult
1471
+ * 会从中间切一刀:① 模型拿到的是半截内容;② **内层写的「已截断」提示正好落在被切掉的那一半里,
1472
+ * 连「还有下文」这件事都看不到** —— 这与 skill_read 当年写死 30000(> 统一档 12000)是同一个坑,
1473
+ * 当时实测 ir-format.md 每次读都静默丢 31%。走 toolResultLimit('fs_read'),不写死数字。
1474
+ */
1475
+ const FS_READ_MAX = TOOL_RESULT_MAX_BY_TOOL.fs_read
1476
+
1477
+ /**
1478
+ * fs_read 的续读锚点预留(与 SKILL_READ_TRAILER_RESERVE 同量级)。
1479
+ * 锚点里要写「读了哪段 / 下一段 offset / 小节目录(.md 才有)」,正文必须让出这些。
1480
+ */
1481
+ const FS_READ_TRAILER_RESERVE = 240
1482
+
1483
+ /**
1484
+ * 只读文件:限定仓库根 + 用户挂载目录;目录 / 图片给「换哪个工具」的可操作提示,不浪费步数。
1485
+ *
1486
+ * offset 续读(2026-10-09 补):此前**只有 maxChars、没有 offset**,而 maxChars 默认 20000
1487
+ * 又大于 fs_read 的 4000 档 ⇒ 25KB 的 iterate 全文报告只能拿到前 4000 字符,且看不到任何截断提示。
1488
+ * 提示语却写着「需要全量明细用 fs_read 读 report 路径」—— 模型照做,拿到的仍是残缺 JSON。
1489
+ * 现在与 skill_read 同一套协议:正文一屏 + 尾巴给下一段 offset(.md 再附小节目录)。
1490
+ */
1491
+ function fsRead(root, { path: p, maxChars = 0, offset = 0 }, extraRoots = []) {
1238
1492
  const abs = resolveInsideRoots(root, p, extraRoots)
1239
1493
  if (!fs.existsSync(abs)) throw new Error(`路径不存在:${p}`)
1240
1494
  const st = fs.statSync(abs)
@@ -1244,8 +1498,55 @@ function fsRead(root, { path: p, maxChars = 20000 }, extraRoots = []) {
1244
1498
  }
1245
1499
  if (st.size > 4 * 1024 * 1024) throw new Error('文件大于 4MB:请改用 card_grep 定位或分段读取')
1246
1500
  const text = fs.readFileSync(abs, 'utf8')
1247
- const cap = Number(maxChars) > 0 ? Number(maxChars) : 20000
1248
- return text.length <= cap ? text : `${text.slice(0, cap)}\n…(已截断,省略 ${text.length - cap} 字符)`
1501
+ // 预算取「模型给的 maxChars」与「fs_read 档位」的较小值:写死 20000 会被外层从中间切断,
1502
+ // 而 maxChars 是模型无法自知的隐藏上限 —— 给一个大值却只回一屏,等于骗模型。
1503
+ const asked = Number(maxChars) > 0 ? Math.floor(Number(maxChars)) : FS_READ_MAX
1504
+ const budget = Math.max(1, Math.min(asked, FS_READ_MAX))
1505
+ return clipPlainText(text, offset, p, budget)
1506
+ }
1507
+
1508
+ /**
1509
+ * 通用纯文本一屏裁剪 + 续读锚点(fs_read 用;与 clipSkillDoc 同协议但不做小节解析)。
1510
+ * 为什么单独一份而不是直接复用 clipSkillDoc:clipSkillDoc 的锚点会带 `##` 小节目录,
1511
+ * 而 fs_read 读的是仓库任意文件(IR / JSON / 审计产物,多半没有 ##)——
1512
+ * 对着 JSON 生成小节目录是噪音。目录只在真有 ## 时才给。
1513
+ * @param {string} text 全文
1514
+ * @param {number} offset 续读起点(字符偏移);越界时退到末尾半屏
1515
+ * @param {string} rel 回显给模型的文件标识(写进锚点,方便它照抄下一次调用)
1516
+ * @param {number} budget 本次返回的硬上限(含锚点)
1517
+ */
1518
+ function clipPlainText(text, offset = 0, rel = '', budget = FS_READ_MAX) {
1519
+ const total = text.length
1520
+ if (total <= budget) return text
1521
+ let start = Math.floor(Number(offset) || 0)
1522
+ if (!(start > 0)) start = 0
1523
+ // 越界 offset(文件被改短 / 模型抄错数字):退到末尾半屏,给最接近的内容比回一句报错有用
1524
+ if (start >= total) start = Math.max(0, total - Math.max(1, Math.floor(budget / 2)))
1525
+ const head = start > 0 ? `…(续读:自第 ${start} 字符起)\n` : ''
1526
+ const trailer = (end) => {
1527
+ const sections = /\.md$/i.test(String(rel)) ? docSections(text) : []
1528
+ const how = sections.length > 1
1529
+ ? `**本文件小节目录(共 ${sections.length} 节;fs_read path=${rel || '<同一个 path>'} offset=${end} 续读下一段,或只取你要的那部分)**:\n${docToc(sections)}\n`
1530
+ : `续读:fs_read path=${rel || '<同一个 path>'} offset=${end}(照抄这个数字,不要自己估算)`
1531
+ return `\n…(已截断:全文 ${total} 字符,本次 ${start}..${end},余 ${total - end} 字符。${how})`
1532
+ }
1533
+ let end = Math.min(total, start + Math.max(1, budget - head.length - FS_READ_TRAILER_RESERVE))
1534
+ // 一屏就够读到尾:不加锚点(否则模型会以为还有下文,白跑一次)
1535
+ if (end >= total) return head + text.slice(start)
1536
+ let body = text.slice(start, end)
1537
+ let tail = trailer(end)
1538
+ for (let i = 0; i < 3; i++) {
1539
+ const over = head.length + body.length + tail.length - budget
1540
+ if (over <= 0) break
1541
+ body = body.slice(0, Math.max(0, body.length - over))
1542
+ end = start + body.length
1543
+ tail = trailer(end)
1544
+ }
1545
+ // 硬保证:返回串一律 ≤ budget(超了等于把锚点交给外层切掉,回到静默丢字的老毛病)
1546
+ const spare = budget - head.length - tail.length
1547
+ if (spare <= 0) return (head + tail).slice(0, budget)
1548
+ if (body.length > spare) body = body.slice(0, spare)
1549
+ return head + body + tail
1249
1550
  }
1250
1551
 
1251
1552
  /**
@@ -1849,6 +2150,13 @@ const SKILL_EXIT_SEMANTICS = {
1849
2150
  'render_baseline.mjs': { 70: { kind: 'diagnostic', note: '正常结果:发现魔法值(历史取值,勿按数字猜语义)' } },
1850
2151
  }
1851
2152
 
2153
+ /**
2154
+ * iterate 报告超过这个字符数才值得裁一刀(兜底裁剪的启用阈值)。
2155
+ * 定 8000 的依据:摘要实测 ~3000 字符;低于 8000 的报告(--gate 模式等)裁了也就在边界附近,
2156
+ * 却要额外付一次 JSON.parse + stringify 与历史改写风险 —— 不划算。
2157
+ */
2158
+ const ITERATE_REPORT_BRIEF_MAX = 8000
2159
+
1852
2160
  /** 退出码语义查询:0 返回 null(无需标注),未登记的非 0 按 fail 处理 */
1853
2161
  function skillExitInfo(scriptRel, code) {
1854
2162
  if (Number(code) === 0) return null
@@ -2017,6 +2325,262 @@ function cardIdFromToolArgs(toolName, args) {
2017
2325
  /** 技能脚本执行:路径形状校验 → 解释器解析 → 审批(或按策略免确认)→ 跑(cwd=工程根;
2018
2326
  * 脚本认工程根走 DSL_CARD_PROJECT_ROOT > cwd,**不按自身位置推算** —— 发布态脚本住在包内快照里,
2019
2327
  * 按自身位置推会把 UI/ 产物写进 node_modules,见 tools/tests/cardbuddy-project-anchor.test.mjs) */
2328
+ /**
2329
+ * iterate 报告的服务端兜底摘要(**裁旧版全量报告**,不是重实现摘要)。
2330
+ *
2331
+ * 为什么不只靠脚本自己摘要:宿主把历史工具结果原样带回每一步重发,报告是单轮体积最大的一项
2332
+ * (全量 6~15KB,15 轮迭代 = 9~22 万字符常驻)。但 skills/ 是可被 DSL_SKILLS_ROOT 指到旧副本的
2333
+ * (消费者工程没跑 init、包内快照比仓库旧),那时脚本不会摘要 —— 裁剪必须落在宿主侧才普适。
2334
+ *
2335
+ * 为什么不能 import skill 的 summarizeReport 来复用:发布态构建期把 server 与 skills 内联进同一个
2336
+ * 包,运行时 import 路径不稳(消费者工程的 skills/ 可能根本不存在);而 summarizeReport 的裁剪口径
2337
+ * 本身简单(首条 contributors + 清单前 3 条 + 总数),在这里复刻一份并**用 summary 标记幂等**是可控的。
2338
+ * 两边口径不一致的风险由tools/tests/iterate-report-brief.test.mjs 的字段清单钉住。
2339
+ *
2340
+ * 裁剪原则:**只减体积,不动语义**。S5 决策依赖的 gate / pixelDiff.match / size / best / stalled /
2341
+ * rounds / conclusion / 首条 contributors 的 elements[] 一个不少;被削掉的只有「本轮不看也要重发」的部分
2342
+ * (第 2..N 热区、逐元素度量、清单全文、像素中间量)。
2343
+ */
2344
+ const ITERATE_LIST_KEEP = 3
2345
+ /** 清单类字段封成 {total, sample, more};已封装的(对象形态)原样放过,避免二次裁剪清空 */
2346
+ function briefListField(arr, map = (x) => x) {
2347
+ if (arr && !Array.isArray(arr) && typeof arr === 'object') return arr.total != null || arr.sample ? arr : undefined
2348
+ const a = Array.isArray(arr) ? arr : []
2349
+ if (!a.length) return undefined
2350
+ return {
2351
+ total: a.length,
2352
+ sample: a.slice(0, ITERATE_LIST_KEEP).map(map),
2353
+ more: a.length > ITERATE_LIST_KEEP ? `…另 ${a.length - ITERATE_LIST_KEEP} 条(见 report 全文)` : undefined,
2354
+ }
2355
+ }
2356
+
2357
+ /**
2358
+ * 通用 JSON 报告折叠器:给「不是 iterate」但仍会输出大报告的脚本用
2359
+ * (lint_card / check_fidelity / readiness / preflight / describe_card / diff_bands …)。
2360
+ *
2361
+ * 为什么需要(2026-10-09 实测):`briefSkillReport` 此前只对 iterate.mjs 生效,而其余脚本仍是
2362
+ * `console.log(JSON.stringify(x, null, 2))` 全量输出。宿主对 skill_exec 的档位是 3000 字符,
2363
+ * 外层 `clipToolResult` 是**纯 slice 且不留任何截断提示** ⇒ 超限时模型拿到的是从中间切断的
2364
+ * 半截 JSON,连 lint 的 error 码 / readiness 的 blockers 都读不到。spec_audit 自己有摘要
2365
+ * (阈值 9000),但那也超 3000 档,同样会被切。
2366
+ *
2367
+ * 为什么用「结构规则」而不是给每个脚本手写字段白名单:
2368
+ * 项目铁律是识别信号只能取格式 / 几何 / 结构这类通用特征,禁止写死业务字段名或卡名。
2369
+ * 手写白名单还有一个现实问题:脚本加个字段就得同步改宿主,两处漂移就静默失效。
2370
+ * 这里的规则是**枚举折叠、决策字段全留**:
2371
+ * - 标量(数字 / 布尔 / null)与所有对象的键一律保留 —— 决策字段(ok / errors / match /
2372
+ * stage / total…)本来就都是标量,一个都不会丢;
2373
+ * - 数组只在「长且占体积」时折成 {total, sample, more};判据是**序列化字节数**而不是名字,
2374
+ * 所以 `hotspot:[x,y,w,h]` 这类坐标数组(20 字符)不会被误折;
2375
+ * - 超长字符串截头留标记(附「全文见 report 落盘路径」的口子)。
2376
+ *
2377
+ * 幂等:折叠产物带 summary===true,宿主再过一次直接放行(与 iterate 摘要同一约定)。
2378
+ */
2379
+
2380
+ /** 数组长度超过它、且序列化后超过 FOLD_MIN_CHARS 才折 —— 两个条件缺一不可 */
2381
+ const FOLD_LIST_KEEP = 3
2382
+ /** 小于这个字节数的数组视为「坐标 / 短枚举」,原样留(`[10,20,50,60]` 只有 13 字符) */
2383
+ const FOLD_MIN_CHARS = 240
2384
+ /**
2385
+ * 单条平均小于这个字节数的数组也**不折**。
2386
+ * 为什么加这一条(实测打出来的):spec_audit.mjs 自己的摘要刻意把 bands 压成
2387
+ * `{y, h, itemCount}`(约 30 字符/条)后全部保留 —— 那正是它的决策内容(逐带几何)。
2388
+ * 只按总字节数判断会把这条 900 字符的精简清单折成 3 条 sample,**反而丢掉了脚本刻意保留的信息**
2389
+ * (而全文在磁盘上,模型却不知道该去拿)。所以判据必须是「这条值本身有多重」,
2390
+ * 不是「这一组总共多大」:轻量条目成组留着只有几百字符,折叠它纯亏。
2391
+ */
2392
+ const FOLD_MIN_AVG_CHARS = 120
2393
+ /**
2394
+ * force 档(降级重试的最后一档)把「轻量条目豁免」整个关掉:只留 minChars 这条底线,
2395
+ * 且从 240 降到 80 —— 坐标数组(`[10,20,50,60]` 约 13 字符)仍然免折,折它只会更啰嗦、
2396
+ * 几乎不省字符;而「300 条 × 每条 20 字符」这种会被外层切成半截的形状必须折。
2397
+ * 存在的理由是实测:那条形状 14505 字符、单条均值 21,按常规阈值一条都不折,
2398
+ * 而宿主档位只有 3000 ⇒ 不 force 就必然超限。**冲突时让位的是豁免,不是「必须落进档位」** ——
2399
+ * 模型仍能拿到 total 与前几条,且知道全文在磁盘上;被切成半截 JSON 则什么都读不到。
2400
+ */
2401
+ const FOLD_FORCE_MIN_CHARS = 80
2402
+ /** 单个字符串超过它就截头(附续读提示) */
2403
+ const FOLD_STR_MAX = 600
2404
+ /** 递归深度上限:畸形报告(自引用会很麻烦,但 JSON.parse 本身挡住自引用)只需要防深嵌套 */
2405
+ const FOLD_MAX_DEPTH = 8
2406
+
2407
+ /**
2408
+ * 折叠一个已解析的 JSON 值。
2409
+ * @param {any} val 任意 JSON 值
2410
+ * @param {number} depth 递归深度(内部用)
2411
+ * @param {{keep?:number, force?:boolean}} [opt]
2412
+ * - `keep`:每条清单留几条(briefSkillReport 的降级重试会逐档调小)
2413
+ * - `force`:无视「轻量条目不折」这条豁免,一律折。给降级重试的最后一档用 ——
2414
+ * 实测有「300 条 × 每条 48 字符」这种全轻量报告,14505 字符按常规阈值一条都不折,
2415
+ * 而宿主档位只有 3000 ⇒ 不 force 就必然超限被外层切成半截 JSON。此时轻量豁免必须让位。
2416
+ * @returns {any} 折叠后的值
2417
+ */
2418
+ function foldJsonValue(val, depth = 0, opt = {}) {
2419
+ const keep = Math.max(1, Number(opt.keep) || FOLD_LIST_KEEP)
2420
+ const force = !!opt.force
2421
+ if (val == null) return val
2422
+ if (typeof val === 'number' || typeof val === 'boolean') return val
2423
+ if (typeof val === 'string') {
2424
+ if (val.length <= FOLD_STR_MAX) return val
2425
+ return `${val.slice(0, FOLD_STR_MAX)}…(本字符串已截断,原长 ${val.length} 字符;全文见 report 落盘路径)`
2426
+ }
2427
+ if (Array.isArray(val)) {
2428
+ if (depth >= FOLD_MAX_DEPTH) return val
2429
+ // 短小枚举 / 坐标数组 / 轻量条目成组:原样留。
2430
+ // 判据全是序列化字节数(总长 + 单条均值),不看字段名 —— 见 FOLD_MIN_AVG_CHARS 注释。
2431
+ let size = 0
2432
+ try {
2433
+ size = JSON.stringify(val).length
2434
+ } catch {
2435
+ return val
2436
+ }
2437
+ const avg = val.length > 0 ? size / val.length : 0
2438
+ const minChars = force ? FOLD_FORCE_MIN_CHARS : FOLD_MIN_CHARS
2439
+ // force 档不看单条均值(豁免整体关闭);常规档要求单条确实够重 —— 见 FOLD_MIN_AVG_CHARS 注释
2440
+ const foldable = force ? true : avg >= FOLD_MIN_AVG_CHARS
2441
+ if (val.length <= keep || size <= minChars || !foldable) {
2442
+ return val.map((x) => foldJsonValue(x, depth + 1, opt))
2443
+ }
2444
+ return {
2445
+ total: val.length,
2446
+ sample: val.slice(0, keep).map((x) => foldJsonValue(x, depth + 1, opt)),
2447
+ more: val.length > keep ? `…另 ${val.length - keep} 条(全文见 report 落盘路径 / 重跑该脚本加 --json)` : undefined,
2448
+ }
2449
+ }
2450
+ if (typeof val === 'object') {
2451
+ // 已折叠过的形态:原样放过,避免二次折叠把 {total,sample} 当普通对象再走一遍
2452
+ if (val.summary === true) return val
2453
+ if (depth >= FOLD_MAX_DEPTH) return val
2454
+ const out = {}
2455
+ for (const [k, v] of Object.entries(val)) out[k] = foldJsonValue(v, depth + 1, opt)
2456
+ return out
2457
+ }
2458
+ return val
2459
+ }
2460
+
2461
+ /**
2462
+ * 对 skill_exec 的 stdout 做「已是摘要就放行、未封装就裁一刀」。
2463
+ * iterate.mjs 走专用的字段级摘要(它要保住 S5 决策字段);其余 JSON 报告走通用结构折叠。
2464
+ * 非 JSON 输出 / 解析失败一律原样返回(裁剪是附加优化,不能影响主流程)。
2465
+ * @param {string} scriptRel skills 下的脚本相对路径
2466
+ * @param {string} text 脚本 stdout(已带 exit= 标注)
2467
+ * @param {string} root 项目根(仅用于日志留痕,当前无需读盘)
2468
+ * @returns {string} 裁剪后的 stdout;不适用时返回入参本身
2469
+ */
2470
+ function briefSkillReport(scriptRel, text, root = '') {
2471
+ const isIterate = /iterate\.mjs$/.test(String(scriptRel || ''))
2472
+ const s = String(text ?? '')
2473
+ const rep = parseSkillJsonReport(s)
2474
+ // summary===true:脚本已自己摘要过(当前 iterate.mjs 的默认行为),一个字节都不动
2475
+ if (!rep || rep.summary === true) return text
2476
+ if (!isIterate) {
2477
+ // 通用折叠。**唯一目标是落进档位内** —— 不落进档位,外层 clipToolResult 会从中间切断,
2478
+ // 那比「报告长一点」严重得多(模型拿到半截 JSON,连 error 码都读不到)。
2479
+ // 一次折叠可能不够(报告里全是轻量条目时,一条都不折):所以按 keep 逐档降级重试。
2480
+ const limit = toolResultLimit('skill_exec')
2481
+ if (s.length <= limit) return text
2482
+ // exit= 标注在首行,折进 JSON 会破坏服务端记账 —— 先摘出来,折叠完再接回去
2483
+ const exitLine = s.match(/^exit=\d+[^\n]*\n?/)
2484
+ const prefix = exitLine ? exitLine[0] : ''
2485
+ const body = prefix ? s.slice(prefix.length) : s
2486
+ const suffix = body.endsWith('\n') ? '\n' : ''
2487
+ let best = null
2488
+ // 降级阶梯:先按常规阈值逐步少留;仍超限则 force(放弃「轻量条目豁免」,见 FOLD_FORCE_* 注释)。
2489
+ // 顺序刻意是 keep 由多到少、force 放最后 —— 优先用「少留几条」这种损失最小的手段。
2490
+ for (const opt of [{ keep: FOLD_LIST_KEEP }, { keep: 2 }, { keep: 1 }, { keep: 1, force: true }]) {
2491
+ const folded = foldJsonValue(rep, 0, opt)
2492
+ folded.summary = true
2493
+ folded.summaryNote =
2494
+ '本报告为折叠版:所有标量与键名原样保留(决策字段一个不少),只是把长清单折成 {total, sample, more}、' +
2495
+ '超长字符串截了头;需要全文用 fs_read 读 report 落盘路径(长报告会带下一段 offset,照抄续读)。'
2496
+ const compact = prefix + JSON.stringify(folded) + suffix
2497
+ if (!best || compact.length < best.length) best = compact
2498
+ if (compact.length <= limit) return compact
2499
+ }
2500
+ // 逐档降完仍超限:给最终形态(它至少是合法 JSON + 有清单总数),
2501
+ // 不能原样放行 —— 那必然被外层切成半截。剩下的缺口交 fs_read 续读。
2502
+ return best
2503
+ }
2504
+ // 短报告(门禁模式 --gate 等)不值得裁,避免为省几百字符改写历史
2505
+ if (s.length <= ITERATE_REPORT_BRIEF_MAX) return text
2506
+ const pd = rep.pixelDiff
2507
+ const brief = {
2508
+ ok: rep.ok,
2509
+ cardId: rep.cardId,
2510
+ report: rep.report,
2511
+ conclusion: rep.conclusion,
2512
+ rounds: rep.rounds,
2513
+ best: rep.best,
2514
+ stalled: rep.stalled,
2515
+ gate: rep.gate,
2516
+ iterationReset: rep.iterationReset,
2517
+ appliedSets: rep.appliedSets,
2518
+ validate: rep.validate ? { errors: rep.validate.errors, warnings: rep.validate.warnings, envError: rep.validate.envError, details: rep.validate.details } : undefined,
2519
+ lint: rep.lint,
2520
+ preflight: rep.preflight
2521
+ ? { errors: rep.preflight.errors, details: briefListField(rep.preflight.details, (d) => (typeof d === 'string' ? d : d?.message || JSON.stringify(d))) }
2522
+ : undefined,
2523
+ readiness: rep.readiness,
2524
+ widthMismatch: rep.widthMismatch,
2525
+ contentOffset1x: rep.contentOffset1x,
2526
+ contentOffsetHint: rep.contentOffsetHint,
2527
+ pixelDiff: pd
2528
+ ? {
2529
+ match: pd.match,
2530
+ diffRatio: pd.diffRatio,
2531
+ size: pd.size,
2532
+ contentOffset: pd.contentOffset,
2533
+ structuralGaps: pd.structuralGaps,
2534
+ hotspots: Array.isArray(pd.hotspots) ? pd.hotspots.slice(0, ITERATE_LIST_KEEP) : pd.hotspots,
2535
+ hotspotsTotal: Array.isArray(pd.hotspots) ? pd.hotspots.length : pd.hotspotsTotal,
2536
+ }
2537
+ : undefined,
2538
+ structuralGaps: rep.structuralGaps,
2539
+ contributors: Array.isArray(rep.contributors) ? rep.contributors.slice(0, 1) : rep.contributors,
2540
+ contributorsTotal: Array.isArray(rep.contributors) ? rep.contributors.length : rep.contributorsTotal,
2541
+ contributorsNote: rep.contributorsNote,
2542
+ geometry: rep.geometry
2543
+ ? {
2544
+ counts: rep.geometry.counts,
2545
+ collapsed: briefListField(rep.geometry.collapsed),
2546
+ outOfCard: briefListField(rep.geometry.outOfCard),
2547
+ overWrapped: rep.geometry.counts?.overWrapped != null ? undefined : briefListField(rep.geometry.overWrapped),
2548
+ }
2549
+ : undefined,
2550
+ geometryNote: rep.geometryNote,
2551
+ fidelity: rep.fidelity
2552
+ ? {
2553
+ total: rep.fidelity.total,
2554
+ matched: rep.fidelity.matched,
2555
+ missing: briefListField(rep.fidelity.missing),
2556
+ invented: briefListField(rep.fidelity.invented),
2557
+ slices: rep.fidelity.slices,
2558
+ symbols: rep.fidelity.symbols,
2559
+ shapes: rep.fidelity.shapes,
2560
+ }
2561
+ : undefined,
2562
+ render: rep.render,
2563
+ designImage: rep.designImage,
2564
+ diffImage: rep.diffImage,
2565
+ renderImage: rep.renderImage,
2566
+ diffImageNote: rep.diffImageNote,
2567
+ elapsedMs: rep.elapsedMs,
2568
+ }
2569
+ if (rep.error || rep.message) {
2570
+ brief.error = rep.error
2571
+ brief.message = rep.message
2572
+ }
2573
+ brief.summaryNote =
2574
+ '本报告为摘要(contributors 只留首条热区、要素/几何清单只留前 3 条 + 总数、逐元素度量已省略);' +
2575
+ '需要全量明细用 fs_read 读 report 路径,或给 iterate.mjs 加 --detail 重跑。'
2576
+ brief.summary = true
2577
+ // 首行 exit=N 标注必须保留:它是服务端记账与用户侧判读的依据,不能被 JSON 吞掉
2578
+ const exitLine = s.match(/^exit=\d+[^\n]*\n?/)
2579
+ const prefix = exitLine ? exitLine[0] : ''
2580
+ const body = prefix ? s.slice(prefix.length) : s
2581
+ return prefix + JSON.stringify(brief) + (body.endsWith('\n') ? '\n' : '')
2582
+ }
2583
+
2020
2584
  async function runSkillExec(root, args, res, approved) {
2021
2585
  const script = resolveSkillScript(root, args.script)
2022
2586
  const argv = normalizeSkillArgs(args.args)
@@ -2041,7 +2605,7 @@ async function runSkillExec(root, args, res, approved) {
2041
2605
  PYTHONUNBUFFERED: '1',
2042
2606
  }),
2043
2607
  )
2044
- return annotateSkillExit(out, script.rel)
2608
+ return briefSkillReport(script.rel, annotateSkillExit(out, script.rel), root)
2045
2609
  }
2046
2610
 
2047
2611
  // ---- 对话内审批:未命中免确认策略的高危执行(默认是 shell_exec)每次调用都要用户点确认 ----
@@ -2141,17 +2705,213 @@ function skillRead(root, args) {
2141
2705
  const text = fs.readFileSync(full, 'utf8')
2142
2706
  // 补全提示也算进同一预算:否则「自动补全说明 + 正文 + 续读锚点」会比口径多出说明那几十字,
2143
2707
  // 外层再切一刀正好把续读锚点削掉,模型又回到「不知道还有后半段」的状态。
2144
- return resolvedNote + clipSkillDoc(text, args?.offset, hintRel, SKILL_READ_MAX - resolvedNote.length)
2708
+ const budget = SKILL_READ_MAX - resolvedNote.length
2709
+ // 按小节取:先切出目标节,再交给同一个 clipSkillDoc 收口(小节本身超一屏时口径完全一致)
2710
+ if (args?.section) {
2711
+ const sec = extractSection(text, args.section)
2712
+ if (!sec.ok) throw new Error(`${sec.reason}${sec.toc ? `\n${sec.toc}` : ''}`)
2713
+ const clipped = clipSkillDoc(sec.text, 0, hintRel, budget)
2714
+ if (clipped === sec.text) return resolvedNote + clipped
2715
+ // 这一节本身就超一屏:clipSkillDoc 的锚点里给的是**全文**目录,模型在这节内部无法定位。
2716
+ // 追加「该节内部子节清单」—— 它是继续下钻的唯一依据;缺了它模型只能顺序 offset 读完这节。
2717
+ const base = docSections(text).find((x) => sec.text.startsWith(x.title))
2718
+ const subs = docSections(sec.text, 2, 6).filter((h) => h.level > (base?.level ?? 2))
2719
+ if (!subs.length) return resolvedNote + clipped
2720
+ const subNote =
2721
+ `\n…(本节「${sec.title}」内部还有 ${subs.length} 个子节;` +
2722
+ `skill_read path=${rel} section=${args.section}.<子序号> 可直取一条):\n` +
2723
+ subs
2724
+ .map((s, i) => `${args.section}.${i + 1} ${s.title.slice(0, 34)}${s.title.length > 34 ? '…' : ''}(${s.size}字符)`)
2725
+ .join('\n')
2726
+ // 截断锚点以 `)` 收尾,把子节清单插进它前面,保持「…(…)→ 补充 → 」的读感
2727
+ const tailStart = clipped.lastIndexOf(')')
2728
+ return resolvedNote + (tailStart > 0 ? clipped.slice(0, tailStart) + subNote + clipped.slice(tailStart) : clipped + subNote)
2729
+ }
2730
+ return resolvedNote + clipSkillDoc(text, args?.offset, hintRel, budget)
2731
+ }
2732
+
2733
+ /**
2734
+ * 章节目录 + 按章节取正文:让 skill_read 支持 `section`,避免为读一份长文档顺序 offset 读十几次。
2735
+ *
2736
+ * 为什么需要(实测 2026-10-09):references 里多份文档远超一屏 —— generation_pitfalls.md 90212 字符
2737
+ * (`##` 小节 48 个,45 个一屏能读完,平均 1956 字符);顺序 offset 读要为 20040 字符的 ir-format.md 付 3 次
2738
+ * 10000 字符回喂,为 90212 字符的 pitfalls 付 9 次。**按小节读平均每次 ~2k 字符 ⇒ 省 90%+**,
2739
+ * 且模型不必自己心算 offset(算错就是「读到半截内容猜结论」,这是它历史上最常见的浪费步数)。
2740
+ *
2741
+ * 口径纪律(这三条都是被实测打出来的):
2742
+ * - **目录只列 `##` 及更深,跳过 `#`**:`#` 是文档标题(实测「# T」只有 4 字符),把它算成第 1 节会让
2743
+ * `section="1"` 返回标题本身而不是首个真小节,序号整体错位一位。
2744
+ * - **目录默认只列 `##`**:`###` 是条目级细则,列进去会让146 节的文档目录膨胀(实测 generation_pitfalls
2745
+ * 的 `##`+`###` 合起来 146 节 / 3049 字符 = 30% 预算),而且序号被噪音淹没——模型数到第 3 行拿到的是
2746
+ * 152 字符的「现象」而不是真规则。命中 `##` 时连带返回其下全部 `###`,所以粒度够用。
2747
+ * - **序号 / 关键词命中多个一律不猜**:把候选列出来让模型改关键词。歧义时猜错 = 静默读到别的节。
2748
+ */
2749
+ function docSections(text, minLevel = 2, maxLevel = 2) {
2750
+ const heads = []
2751
+ for (const m of text.matchAll(/^(#{1,6})[ \t]+(.+?)[ \t]*$/gm)) {
2752
+ const lv = m[1].length
2753
+ if (lv < minLevel || lv > maxLevel) continue
2754
+ heads.push({ at: m.index, title: m[2].trim(), level: lv })
2755
+ }
2756
+ return heads.map((h, i) => ({
2757
+ ...h,
2758
+ end: i + 1 < heads.length ? heads[i + 1].at : text.length,
2759
+ size: (i + 1 < heads.length ? heads[i + 1].at : text.length) - h.at,
2760
+ }))
2761
+ }
2762
+
2763
+ /**
2764
+ * 目录文本:序号. 标题(字符数)。
2765
+ *
2766
+ * 标题截到 34 字:实测 generation_pitfalls.md 的小节标题是整句话(「…缺陷(2026-09-26 转账计划确认卡
2767
+ * 训练固化)」),48 行不截要3049 字符 = 30% 预算。截断后 1788 字符,序号与关键词仍可检索 ——
2768
+ * 目录的作用是让模型定位,不是让它读完。
2769
+ *
2770
+ * 硬上限 60 行:节数失控的畸形文档(实测有人把拼接后的整段 `.repeat(200)` 写进文件,## 出现 600 次)
2771
+ * 目录会挤爆预算、连正文都挤掉。超出部分不列,并如实标注总数——不给「看起来完整」的假象。
2772
+ */
2773
+ function docToc(sections, { max = 60, titleMax = 34 } = {}) {
2774
+ const body = sections
2775
+ .slice(0, max)
2776
+ .map((s, i) => {
2777
+ const t = s.title.length > titleMax ? `${s.title.slice(0, titleMax)}…` : s.title
2778
+ return `${i + 1}. ${t}(${s.size}字符)`
2779
+ })
2780
+ .join('\n')
2781
+ return sections.length > max ? `${body}\n…(该文件共 ${sections.length} 节,已列前 ${max} 节)` : body
2782
+ }
2783
+
2784
+ /**
2785
+ * 「我要目录」的结构性意图词(整词匹配,锚定避免误伤真标题)。
2786
+ *
2787
+ * 为什么要它:模型看到一份带小节目录的长文档,最自然的下一步是「先把目录列出来」,
2788
+ * 但它手里只有 `section` 这一个入口,没有「只给目录」的显式参数 —— 于是它会拿
2789
+ * 「列表」「目录」这类**意图词**去当标题关键词用,必然命中不到、吃一个
2790
+ * `没有小节标题含「列表」` 的报错。实测(greetingRecommend / 2026-10-09)为此白烧了两步。
2791
+ *
2792
+ * 边界(都很重要):
2793
+ * - 只认**整词**:文档里真有一个叫「事件列表」的小节,传「列表」时若按子串匹配就会
2794
+ * 错误地只给目录、拿不到那一节。锚定 + 「词本身不是常用标题后缀」两点把它限死在纯意图词上。
2795
+ * - 不含任何业务实体名:这是「我要目录」的通用说法,与卡名/字段名/组件名无关,
2796
+ * 符合「识别信号只取格式/几何/结构」的铁律。
2797
+ */
2798
+ const TOC_INTENT_WORDS = /^(?:目录|小节目录|章节|列表|目录列表|toc|table[ _-]?of[ _-]?contents|contents|index)$/i
2799
+
2800
+ /**
2801
+ * 切出某标题节点的完整内容:含其下所有更深层级的子节点,直到遇到下一个同级或更高级标题。
2802
+ * @param {string} text @param {number} at @param {number} level @param {{level:number}[]} all
2803
+ */
2804
+ function sectionBody(text, at, level, all) {
2805
+ let end = text.length
2806
+ for (const h of all) {
2807
+ if (h.at > at && h.level <= level) {
2808
+ end = h.at
2809
+ break
2810
+ }
2811
+ }
2812
+ return text.slice(at, end)
2813
+ }
2814
+
2815
+ /**
2816
+ * 按序号或标题关键词取一节(**目录口径 = `docSections(text)` 默认只看 `##`**)。
2817
+ *
2818
+ * 三种写法:
2819
+ * - `12` → 目录里的第 12 节(`##` 层),**含它下面全部 `###` 子节**;
2820
+ * - `12.3` → 第 12 节里的第 3 个 `###` 子节(大节再钻用,实测 generation_pitfalls 的
2821
+ * 「9. 视觉还原硬规则」有 46683 字符 / 67 个子节,不下钻就得顺序读 5 次);
2822
+ * - `视觉还原` → 标题关键词(子串包含、大小写无关),命中唯一才返回,命中多个列候选不猜。
2823
+ * - `目录`/`列表` → 只给目录(见 TOC_INTENT_WORDS:这类词不是标题,是「我要目录」的意图)
2824
+ *
2825
+ * @returns {{ok:true, text:string, title:string} | {ok:false, reason:string, toc:string}}
2826
+ */
2827
+ function extractSection(text, section) {
2828
+ // 目录层:只看 ##(与上面口径一致)。匹配时另外建全层表用于定位边界。
2829
+ const tocSections = docSections(text)
2830
+ const all = docSections(text, 2, 6)
2831
+ if (!tocSections.length) {
2832
+ return { ok: false, reason: '这份文档没有可按小节切分的小标题(无 ## 及更深段落)', toc: '' }
2833
+ }
2834
+ const toc = docToc(tocSections)
2835
+ const q = String(section ?? '').trim()
2836
+ if (!q) return { ok: false, reason: '', toc }
2837
+ // 「我要目录」是模型的高频意图,但它不是文档里的标题 ⇒ 原来落到下面的关键词匹配里必然报错。
2838
+ // 实测(greetingRecommend,2026-10-09):模型连发两次 section="列表" / "目录" 想拿目录,
2839
+ // 两次都吃 `没有小节标题含「列表」`,白烧两步(第三步才改用 section="3" 拿到想要的内容)。
2840
+ // 这里把这类**纯结构性意图词**识别成「给目录」,而不是拿它去标题里找。
2841
+ // 判据是「整词就是一个通用目录说法」,不含任何业务实体名 —— 与「识别信号只取格式/几何/结构」同源。
2842
+ if (TOC_INTENT_WORDS.test(q)) return { ok: false, reason: '', toc }
2843
+ const pick = (head) => ({ ok: true, text: sectionBody(text, head.at, head.level, all), title: head.title })
2844
+
2845
+ // 序号(1 起;容错 0 起写法与全角数字)
2846
+ const half = q.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
2847
+ const num = Number(half)
2848
+ const isPlainNum = Number.isInteger(num) && String(num) === half
2849
+ if (isPlainNum) {
2850
+ const hit = tocSections[num >= 1 ? num - 1 : num]
2851
+ if (hit) return pick(hit)
2852
+ return { ok: false, reason: `没有第 ${num} 个小节(共 ${tocSections.length} 个)`, toc }
2853
+ }
2854
+ // 两级序号「12.3」:第 12 节里的第 3 个子节点(### 或更深,取该节点下更深层级的内容)
2855
+ const dotted = half.match(/^(\d+)[..](\d+)$/)
2856
+ if (dotted) {
2857
+ const outer = tocSections[Number(dotted[1]) - 1]
2858
+ if (!outer) return { ok: false, reason: `没有第 ${dotted[1]} 个小节(共 ${tocSections.length} 个)`, toc }
2859
+ // 该节内部的更深层标题,按文档顺序编号(与「3」取到的目录序号一致)
2860
+ const subs = all.filter((h) => h.at > outer.at && h.at < outer.at + outer.size && h.level > outer.level)
2861
+ const sub = subs[Number(dotted[2]) - 1]
2862
+ if (!sub) {
2863
+ // 子节清单要限流:实测 generation_pitfalls 第 12 节下有 67 个子节,全列是 1650 字符
2864
+ // —— 报错比答案还长。列前 12 个并给总数,够模型改对序号了。
2865
+ const show = subs.slice(0, 12)
2866
+ return {
2867
+ ok: false,
2868
+ reason:
2869
+ `第 ${dotted[1]} 节(${outer.title})下没有第 ${dotted[2]} 个子节` +
2870
+ (subs.length
2871
+ ? `(它有 ${subs.length} 个,前 12 个:${show.map((s, i) => `${i + 1}=${s.title.slice(0, 18)}`).join(' / ')}${subs.length > 12 ? ' …' : ''})`
2872
+ : '(这节没有更深层的小标题)'),
2873
+ toc,
2874
+ }
2875
+ }
2876
+ return pick(sub)
2877
+ }
2878
+ // 标题关键词(大小写无关子串包含)——只在目录层(##)匹配,避免命中 ### 噪音
2879
+ const kw = q.toLowerCase()
2880
+ const hits = tocSections.filter((s) => s.title.toLowerCase().includes(kw))
2881
+ if (hits.length === 1) return pick(hits[0])
2882
+ // 候选清单限流 12 条:实测「规则」这种宽关键词能命中 7+ 节,全列会把报错写成小节目录。
2883
+ // 带字符数是刻意的:模型据此自己判断该读哪条(歧义时报错是对的 —— 猜错就是静默读到别的节)。
2884
+ const brief = (arr) =>
2885
+ arr
2886
+ .slice(0, 12)
2887
+ .map((s) => `${tocSections.indexOf(s) + 1}=${s.title.slice(0, 20)}(${s.size}字符)`)
2888
+ .join(' / ') + (arr.length > 12 ? ` …(共 ${arr.length} 个)` : '')
2889
+ if (!hits.length) {
2890
+ // 放宽:关键词各字都出现在标题里(中文小节标题常不含完整词),并给「哪一节最接近」的提示
2891
+ const near = tocSections.filter((s) => {
2892
+ const t = s.title.toLowerCase()
2893
+ return [...new Set(kw.split(''))].every((ch) => ch === ' ' || t.includes(ch))
2894
+ })
2895
+ return {
2896
+ ok: false,
2897
+ reason: `没有小节标题含「${q}」${near.length ? `;标题里含这些字的是:${brief(near)}` : ''}`,
2898
+ toc,
2899
+ }
2900
+ }
2901
+ return {
2902
+ ok: false,
2903
+ reason: `「${q}」命中 ${hits.length} 个小节,别猜,改用序号:${brief(hits)}`,
2904
+ toc,
2905
+ }
2145
2906
  }
2146
2907
 
2147
2908
  /**
2148
- * 技能文档超长时的返回:一屏正文 + 尾巴上的续读锚点(读了哪段 / 下一段 offset / 下文小节)。
2909
+ * 技能文档超长时的返回:一屏正文 + 尾巴上的续读锚点(读了哪段 / 下一段 offset / **完整小节目录**)。
2149
2910
  *
2150
2911
  * 为什么不是「简单截断 + 省略 N 字符」:截断提示必须在**预算之内**。旧实现(skill_read 自截
2151
2912
  * 30000)与统一口径(12000)不一致,外层再切一刀连提示一起削掉,模型读到的就是 12000 字正文、
2152
- * 且完全不知道后面还有 5000 字契约(实测 ir-format.md 17285 字符每次静默丢 31%,模型拿着
2153
- * 前半段字段契约猜后半段)。现在超长文档由本函数自己收口,尾巴给出 `offset`,
2154
- * 模型想读后半段只需带上同一个 offset 再调一次。
2913
+ * 且完全不知道后面还有 5000 字契约(实测 ir-format.md 每次静默丢 31%,模型拿着前半段猜后半段)。
2914
+ * 现在超长文档由本函数自己收口,尾巴给出 `offset` 与完整目录,模型照目录里的序号续读更稳。
2155
2915
  */
2156
2916
  function clipSkillDoc(text, offset = 0, rel = '', budget = SKILL_READ_MAX) {
2157
2917
  const total = text.length
@@ -2162,18 +2922,23 @@ function clipSkillDoc(text, offset = 0, rel = '', budget = SKILL_READ_MAX) {
2162
2922
  if (start >= total) start = Math.max(0, total - Math.max(1, Math.floor(budget / 2)))
2163
2923
  const head = start > 0 ? `…(续读:自第 ${start} 字符起)\n` : ''
2164
2924
  const trailer = (end) => {
2165
- const heads = []
2166
- for (const m of text.matchAll(/^#{1,4}[ \t]+(.+?)[ \t]*$/gm)) {
2167
- if (m.index < end) continue
2168
- heads.push(`${m[1].slice(0, 18)}@${m.index}`)
2169
- if (heads.length >= 6) break
2170
- }
2171
- return (
2172
- `\n…(已截断:全文 ${total} 字符,本次 ${start}..${end},余 ${total - end} 字符。` +
2173
- `续读:skill_read path=${rel || '<同一个 path>'} offset=${end}` +
2174
- (heads.length ? `;下文小节:${heads.join(' · ')}` : ';余下部分无小节标题') +
2175
- ')'
2176
- )
2925
+ const sections = docSections(text)
2926
+ // 全文目录(序号. 标题(字符数)):模型照序号 section= 取节,不必心算 offset。
2927
+ // 只有一份就够了 —— 原实现只列「本次之后的 6 个后续小标题」,模型看不到前面有什么、也拿不到序号。
2928
+ const toc = docToc(sections)
2929
+ // 超大节(自身就要好几屏)点名:否则「取一节」到 46683 字符的节里仍要顺序读 5 次。
2930
+ // 给出「节.子序号」的下钻写法(不是空喊「用 offset」——大节里 offset 定位全靠猜)。
2931
+ const big = sections.filter((s) => s.size > budget * 1.5)
2932
+ const bigNote = big.length
2933
+ ? `\n其中第 ${big.map((s) => sections.indexOf(s) + 1).join(' / ')} 节本身很大(${big
2934
+ .map((s) => `${sections.indexOf(s) + 1}·${s.title.slice(0, 24)}… ${s.size}字符`)
2935
+ .join(';')}),**别顺序读它**:先 section=该节序号拿到首屏,再按它返回的子节清单用 section=<节序号>.<子序号> 直取需要的条目。`
2936
+ : ''
2937
+ const how =
2938
+ sections.length > 1
2939
+ ? `**本文件小节目录(共 ${sections.length} 节;skill_read path=${rel || '<同一个 path>'} section=<序号或标题关键词> 直接取一节,省 90%+ 回喂)**:\n${toc}${bigNote}\n`
2940
+ : `续读:skill_read path=${rel || '<同一个 path>'} offset=${end}(本文件无 ## 小节可按节取,只能按 offset 续读)`
2941
+ return `\n…(已截断:全文 ${total} 字符,本次 ${start}..${end},余 ${total - end} 字符。${how})`
2177
2942
  }
2178
2943
  let end = Math.min(total, start + Math.max(1, budget - head.length - SKILL_READ_TRAILER_RESERVE))
2179
2944
  // 一屏就够读到尾:不加锚点(否则模型会以为还有下文,白跑一次)
@@ -4617,8 +5382,9 @@ function toOpenAiMessages(messages) {
4617
5382
  for (const tc of calls) {
4618
5383
  const result = tc.error ? `Error: ${tc.error}` : typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result ?? '')
4619
5384
  // 截断口径必须与轮内 live 上下文(下方 currentMessages.push)逐字一致:
4620
- // 前端回传的历史是按同一上限截过的,两边不同就会在轮边界改写历史 ⇒ 前缀缓存整段失效
4621
- out.push({ role: 'tool', tool_call_id: tc.toolCallId || tc.id || `call_${calls.indexOf(tc)}`, content: result.slice(0, TOOL_RESULT_MAX_CHARS) })
5385
+ // 前端回传的历史是按同一上限截过的,两边不同就会在轮边界改写历史 ⇒ 前缀缓存整段失效。
5386
+ // 档位按 toolName 取(clipToolResult 是唯一出口),所以两侧天然同源。
5387
+ out.push({ role: 'tool', tool_call_id: tc.toolCallId || tc.id || `call_${calls.indexOf(tc)}`, content: clipToolResult(tc.toolName || tc.name, result) })
4622
5388
  }
4623
5389
  continue
4624
5390
  }
@@ -5101,28 +5867,12 @@ export function createAiChatMiddleware(opts = {}) {
5101
5867
  if (!safeCardId(id)) return sendJson(req, res, 400, { error: 'missing-card-id' })
5102
5868
  try {
5103
5869
  // 服务端权威历史(P2-13):浏览器这份是 live 视图副本,回合收尾时服务端已按 turnId 写过。
5104
- // 这里做「合并校对」而不是整表覆盖:
5105
- // ① 同 turnId 的 assistant 消息以服务端那份(serverAuthored)为准,客户端副本丢掉;
5106
- // ② 其余按 id 去重(后到的覆盖先到的,客户端补的编辑不丢),无 id 的原样保留;
5870
+ // 这里做「合并校对」而不是整表覆盖(口径与取舍见 chatHistory.mergeIncomingMessages):
5871
+ // ① 已收尾的回合一律以服务端那份为准,客户端同 turnId 的副本无条件丢弃;
5872
+ // ② 其余按 id 去重(后到的覆盖先到的,浏览器补的编辑不丢),无 id 的原样保留;
5107
5873
  // ③ 合并后按 ts 稳定排序,用户消息与回答的先后不乱。
5108
5874
  const stored = readHistory(chatDir, id)
5109
- const storedMsgs = Array.isArray(stored.messages) ? stored.messages : []
5110
- const srvTurns = new Set(storedMsgs.filter((m) => m && m.serverAuthored && m.turnId).map((m) => m.turnId))
5111
- const byId = new Map()
5112
- const anon = []
5113
- const put = (m) => {
5114
- if (!m || typeof m !== 'object') return
5115
- if (m.id) byId.set(m.id, m)
5116
- else anon.push(m)
5117
- }
5118
- for (const m of storedMsgs) put(m)
5119
- for (const m of Array.isArray(body.messages) ? body.messages : []) {
5120
- if (!m || typeof m !== 'object') continue
5121
- // 客户端的回合中副本:同 turnId 且非服务端权威 → 日志 reduce 出来的那份才是完整轨迹
5122
- if (m.role === 'assistant' && m.turnId && srvTurns.has(m.turnId) && !m.serverAuthored) continue
5123
- put(m)
5124
- }
5125
- const merged = [...byId.values(), ...anon].sort((x, y) => (Number(x.ts) || 0) - (Number(y.ts) || 0))
5875
+ const merged = mergeIncomingMessages(stored.messages, body.messages)
5126
5876
  return sendJson(req, res, 200, writeHistory(chatDir, id, merged))
5127
5877
  } catch (e) {
5128
5878
  return sendJson(req, res, 500, { error: 'write-failed', message: String((e && e.message) || e) })
@@ -5373,13 +6123,32 @@ export function createAiChatMiddleware(opts = {}) {
5373
6123
  const visionOn = visionProbe.ok
5374
6124
 
5375
6125
  let tools, dispatch, systemPrompt
6126
+ // 附件摘要与挂载白名单:在拼系统提示词**之前**产出(提示词分层要靠 designFlow 决定注入哪几段),
6127
+ // 后面组装 contextPrompt 时直接复用,不再重跑一次describeAttachments
6128
+ let prebuiltAttachment = { designFlow: false, text: '' }
6129
+ let attachedRoots = []
5376
6130
  try {
5377
6131
  ;({ tools, dispatch } = await loadTools())
6132
+
6133
+ // 附件摘要(含建卡输入路由判定)必须**先**跑出来:系统提示词按阶段分层要靠 designFlow 决定注入哪几段,
6134
+ // 而 designFlow 正是 describeAttachments 的产物。原顺序是「先拼提示词、后判路由」,分层后必须调换。
6135
+ attachedRoots = attachments
6136
+ .map((a) => (a.isDir ? String(a.path || '') : path.dirname(String(a.path || ''))))
6137
+ .filter(Boolean)
6138
+ if (attachments.length) {
6139
+ // 目录浅层清单 + 建卡输入路由(Workflow 0)在服务端先跑掉再进上下文:
6140
+ // 实测让模型自己摸索时,12 步工具额度里 4 步浪费在「fs_read 一个目录」上,最后超限零产出
6141
+ prebuiltAttachment = await describeAttachments(root, attachments, dispatch, attachedRoots)
6142
+ }
6143
+ const buildIntent = classifyBuildIntent(attachments, prebuiltAttachment.designFlow, {
6144
+ empty: isCardEmptyForBuildIntent(root, cardContext?.id),
6145
+ })
5378
6146
  systemPrompt = buildSystemPrompt({
5379
6147
  shellAllowed: Boolean(defaults.shellAllowed),
5380
6148
  vision: visionOn,
5381
6149
  autoApprove: defaults.autoApprove,
5382
6150
  root,
6151
+ ...buildIntent,
5383
6152
  })
5384
6153
  } catch (e) {
5385
6154
  releaseTurnLock()
@@ -5423,18 +6192,13 @@ export function createAiChatMiddleware(opts = {}) {
5423
6192
  if (cardContext?.id) {
5424
6193
  contextPrompt += `\n\n## 当前卡片上下文\n\n用户正在编辑卡片 ID: ${cardContext.id};现状以 card_read / card_node_models 的结果为准,不要凭记忆猜测。`
5425
6194
  }
6195
+ // 用户挂载的目录(或文件所在目录)纳入 fs_read / fs_list 白名单:挂载即授权只读。
6196
+ // 附件摘要与建卡输入路由已在拼系统提示词之前跑过(分层要靠它的 designFlow),这里只做复用与注入。
6197
+ const mountedRoots = attachedRoots
5426
6198
  /** 本轮是否「设计稿建卡」流程(由附件路由判定得出,用于思考熔断催促时附加 IR 路线指引) */
5427
- let designFlow = false
5428
- // 用户挂载的目录(或文件所在目录)纳入 fs_read / fs_list 白名单:挂载即授权只读
5429
- const mountedRoots = attachments
5430
- .map((a) => (a.isDir ? String(a.path || '') : path.dirname(String(a.path || ''))))
5431
- .filter(Boolean)
6199
+ const designFlow = prebuiltAttachment.designFlow
5432
6200
  if (attachments.length) {
5433
- // 目录浅层清单 + 建卡输入路由(Workflow 0)在服务端先跑掉再进上下文:
5434
- // 实测让模型自己摸索时,12 步工具额度里 4 步浪费在「fs_read 一个目录」上,最后超限零产出
5435
- const brief = await describeAttachments(root, attachments, dispatch, mountedRoots)
5436
- designFlow = brief.designFlow
5437
- contextPrompt += `\n\n## 用户挂载的本地文件(仅本机路径,未上传;需要内容时用工具自行读取)\n\n${brief.text}`
6201
+ contextPrompt += `\n\n## 用户挂载的本地文件(仅本机路径,未上传;需要内容时用工具自行读取)\n\n${prebuiltAttachment.text}`
5438
6202
  }
5439
6203
 
5440
6204
  res.statusCode = 200
@@ -6275,16 +7039,18 @@ export function createAiChatMiddleware(opts = {}) {
6275
7039
  )
6276
7040
  }
6277
7041
  // 推给前端的 result 与下面的模型上下文**同一截断口径**:前端会把它原样存进对话记录、
6278
- // 下一回合再回传。给前端截得更短(旧值 4000)等于每轮都改写一次历史,前缀缓存从该条起全废
7042
+ // 下一回合再回传。给前端截得更短(旧值 4000)等于每轮都改写一次历史,前缀缓存从该条起全废。
7043
+ // 三处(这里 /下面 currentMessages.push / 回放 toOpenAiMessages)都走 clipToolResult,
7044
+ // 档位按 toolName 取,天然同源——不要在任何一处自己写 slice。
6279
7045
  sseWrite(res, {
6280
7046
  type: 'tool-result',
6281
7047
  toolName,
6282
7048
  toolCallId: tc.id,
6283
- result: text.slice(0, TOOL_RESULT_MAX_CHARS),
6284
- truncated: text.length > TOOL_RESULT_MAX_CHARS,
7049
+ result: clipToolResult(toolName, text),
7050
+ truncated: text.length > toolResultLimit(toolName),
6285
7051
  image,
6286
7052
  })
6287
- currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: text.slice(0, TOOL_RESULT_MAX_CHARS) })
7053
+ currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: clipToolResult(toolName, text) })
6288
7054
  // 视觉注入点②:工具产出的图像先入队,循环末尾打包注入(文本模型下 image 恒为 undefined,零开销)
6289
7055
  if (visionOn && image) {
6290
7056
  visionQueue.push({ caption: `${toolName} 产出的渲染截图`, part: dataUrlPart(Buffer.from(image.base64, 'base64'), image.mime) })
@@ -6299,10 +7065,10 @@ export function createAiChatMiddleware(opts = {}) {
6299
7065
  } catch (e) {
6300
7066
  const errMsg = String(e?.message || e)
6301
7067
  sseWrite(res, { type: 'tool-result', toolName, toolCallId: tc.id, error: errMsg })
6302
- // 报错文本同样按统一口径截断:回放侧(toOpenAiMessages)会 slice 到同一上限,
7068
+ // 报错文本同样走 clipToolResult(按 toolName 取档):回放侧(toOpenAiMessages)用同一函数,
6303
7069
  // 两处不一致时,一条超长报错(路径回显 / 网关长详情)就会在轮边界改写历史、
6304
7070
  // 把「这条之后」的前缀缓存整段打掉。Error: 前缀在两侧都算进同一个上限里
6305
- currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: `Error: ${errMsg}`.slice(0, TOOL_RESULT_MAX_CHARS) })
7071
+ currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: clipToolResult(toolName, `Error: ${errMsg}`) })
6306
7072
  }
6307
7073
  }
6308
7074
 
@@ -6474,7 +7240,21 @@ export {
6474
7240
  prefixDrift,
6475
7241
  locateDropped,
6476
7242
  collectLocateRoots,
6477
- TOOL_RESULT_MAX_CHARS,
7243
+ toolResultLimit,
7244
+ clipToolResult,
7245
+ TOOL_RESULT_MAX_BY_TOOL,
7246
+ TOOL_RESULT_MAX_DEFAULT,
7247
+ classifyBuildIntent,
7248
+ isCardEmptyForBuildIntent,
7249
+ buildSystemPrompt,
7250
+ BUILD_PROTOCOL_SECTION,
7251
+ docSections,
7252
+ docToc,
7253
+ extractSection,
7254
+ clipSkillDoc,
7255
+ clipPlainText,
7256
+ fsRead,
7257
+ FS_READ_MAX,
6478
7258
  // 以下为本轮新增能力的单测入口(草稿区写入闸门 / IR 最小写 / 上下文尺子校准 / 迭代进度播报 / 正文截断)
6479
7259
  fsWrite,
6480
7260
  irPatch,
@@ -6482,6 +7262,11 @@ export {
6482
7262
  ctxScale,
6483
7263
  observeCtxScale,
6484
7264
  parseSkillJsonReport,
7265
+ briefSkillReport,
7266
+ foldJsonValue,
7267
+ FOLD_LIST_KEEP,
7268
+ FOLD_MIN_CHARS,
7269
+ FOLD_MIN_AVG_CHARS,
6485
7270
  notifyIterateProgress,
6486
7271
  irResolve,
6487
7272
  irNodeHint,