cardbuddy 0.14.3 → 0.16.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.
Files changed (108) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
  3. package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/card-editor/runtime/adapter.js +2 -2
  7. package/dist/vendored/mcp/client.mjs +9 -2
  8. package/dist/vendored/server/core/ai-chat.js +1702 -134
  9. package/dist/vendored/server/core/browser.js +12 -3
  10. package/dist/vendored/server/core/cardFiles.js +3 -0
  11. package/dist/vendored/server/core/cardHistory.js +1 -0
  12. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  13. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  14. package/dist/vendored/server/core/cardSave.js +1 -0
  15. package/dist/vendored/server/core/cardSync.js +3 -0
  16. package/dist/vendored/server/core/cardValidate.js +16 -5
  17. package/dist/vendored/server/core/codeBridge.js +5 -1
  18. package/dist/vendored/server/core/logStream.js +1 -0
  19. package/dist/vendored/server/core/sliceStatic.js +24 -4
  20. package/dist/vendored/server/core/sliceUpload.js +1 -0
  21. package/dist/vendored/server/core/turnJournal.js +6 -0
  22. package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
  23. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
  24. package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
  26. package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
  27. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
  28. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
  29. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
  30. package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
  31. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
  32. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
  33. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
  34. package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
  35. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  36. package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
  37. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
  38. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
  39. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
  40. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
  41. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
  42. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  43. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
  44. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  45. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
  46. package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
  47. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
  48. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  49. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
  50. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
  51. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  52. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
  53. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
  54. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
  55. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  56. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
  57. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  58. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
  59. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
  60. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  61. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  62. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  63. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
  64. package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
  65. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  66. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
  67. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  68. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
  69. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
  70. package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
  71. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
  72. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
  73. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
  74. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
  75. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
  76. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
  77. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
  78. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
  79. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  80. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  81. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
  82. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
  83. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
  84. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
  85. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
  86. package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
  87. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
  88. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  89. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
  90. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
  91. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  92. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
  93. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
  94. package/dist/vendored/skills/shared/exit-codes.md +79 -0
  95. package/dist/vendored/skills/shared/spec-contract.md +118 -0
  96. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
  97. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
  98. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
  99. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
  100. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
  101. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
  102. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
  103. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
  104. package/package.json +1 -1
  105. package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
  106. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
  107. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
  108. package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gate.mjs — 卡片校验门禁一条命令(2026-09-21 优化方案 1.3)。
4
+ *
5
+ * 把 Workflow A 步骤 4 的 5 条门禁命令合并成一次调用,消除 4 次 LLM 工具
6
+ * 调用往返。依次执行(任一步 FAIL 立即短路后续非修复步骤):
7
+ *
8
+ * 1. autofix python autofix_schema.py <schema> 机械修复(写回)
9
+ * 2. validate python validate_schema.py <schema> 结构合法性(0 ERROR 0 WARN)
10
+ * 3. data python derive_data_json.py <schema> --check data.json 一致性
11
+ * 4. lint node lint_card.mjs --id <id> 行为规则 0 ERROR
12
+ * 5. preflight node preflight.mjs --spec <spec> --id <id> 14 项机械检查(给了 --spec 才跑)
13
+ *
14
+ * 用法:
15
+ * node scripts/gate.mjs --id <卡id> [--spec UI/<画板>/spec.json] [--json] [--project-root <dir>]
16
+ *
17
+ * 退出码:0 = 全部通过;1 = 任一门禁 FAIL;2 = 用法/路径错误。
18
+ * --json 输出机器可读汇总:{ ok, steps: { autofix: {ok, output}, validate: {...}, ... }, summary }
19
+ *
20
+ * 自包含:只依赖 Node 内置模块 + Python3(validate/autofix/derive 是 .py)。
21
+ */
22
+ import { spawnSync } from 'node:child_process'
23
+ import path from 'node:path'
24
+ import { cardSchemaPath, cardsDir, skillRoot } from './lib/paths.mjs'
25
+ import { resolvePython } from './lib/python.mjs'
26
+
27
+ // ---------- CLI ----------
28
+ const argv = process.argv.slice(2)
29
+ function argOf(name) {
30
+ const i = argv.indexOf(name)
31
+ return i >= 0 ? argv[i + 1] : undefined
32
+ }
33
+ const hasFlag = (name) => argv.includes(name)
34
+
35
+ const id = argOf('--id')
36
+ const specPath = argOf('--spec')
37
+ const asJson = hasFlag('--json')
38
+ const projectRoot = argOf('--project-root')
39
+
40
+ if (!id || argv.includes('--help') || argv.includes('-h')) {
41
+ console.error('Usage: node scripts/gate.mjs --id <卡id> [--spec UI/<画板>/spec.json] [--json] [--project-root <dir>]')
42
+ process.exit(2)
43
+ }
44
+
45
+ const schema = cardSchemaPath(id)
46
+ let fs
47
+ try {
48
+ fs = await import('node:fs')
49
+ if (!fs.existsSync(schema)) {
50
+ console.error(`[gate] schema not found: ${schema}`)
51
+ process.exit(2)
52
+ }
53
+ } catch {
54
+ // fs import always succeeds; placeholder for symmetry
55
+ }
56
+
57
+ if (projectRoot) process.env.DSL_CARD_PROJECT_ROOT = projectRoot
58
+
59
+ const scriptsDir = path.join(skillRoot, 'scripts')
60
+ // 此前是 `process.env.PYTHON || 'python3'`:既不认 DSL_CARD_PYTHON,也不做探测,
61
+ // 于是在 Windows 上会钉住 Store 假别名 python3,autofix/validate 静默不执行。
62
+ const py = resolvePython()
63
+
64
+ /** 跑一步门禁。返回 { ok, code, output };output 只保留尾部的关键行(错误/摘要)。 */
65
+ function runStep(name, cmd, args) {
66
+ const res = spawnSync(cmd, args, {
67
+ cwd: projectRoot || process.cwd(),
68
+ encoding: 'utf8',
69
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
70
+ timeout: 120_000,
71
+ })
72
+ const out = `${res.stdout || ''}${res.stderr || ''}`.trim()
73
+ const ok = res.status === 0
74
+ // 只保留含 ERROR/WARN/✗/failed/关键字样的行 + 最后 8 行,控制输出体积
75
+ const lines = out.split('\n')
76
+ const key = lines.filter((l) =>
77
+ /ERROR|WARN|✗|✘|FAIL|failed|not found|missing|Invalid|invalid|error/i.test(l)
78
+ )
79
+ const brief = [...new Set([...key, ...lines.slice(-8)])].slice(-24).join('\n')
80
+ return { step: name, ok, code: res.status, output: brief || out.slice(0, 2000), full: out }
81
+ }
82
+
83
+ // ---------- 门禁序列 ----------
84
+ const steps = []
85
+
86
+ steps.push(
87
+ runStep('autofix', py, [path.join(scriptsDir, 'autofix_schema.py'), schema])
88
+ )
89
+ steps.push(
90
+ runStep('validate', py, [path.join(scriptsDir, 'validate_schema.py'), schema])
91
+ )
92
+ steps.push(
93
+ runStep('data', py, [path.join(scriptsDir, 'derive_data_json.py'), schema, '--check'])
94
+ )
95
+ steps.push(
96
+ runStep('lint', process.execPath, [path.join(scriptsDir, 'lint_card.mjs'), '--id', id])
97
+ )
98
+ if (specPath) {
99
+ steps.push(
100
+ runStep('preflight', process.execPath, [
101
+ path.join(scriptsDir, 'preflight.mjs'),
102
+ '--spec',
103
+ specPath,
104
+ '--id',
105
+ id,
106
+ ])
107
+ )
108
+ }
109
+
110
+ const failed = steps.filter((s) => !s.ok)
111
+ const ok = failed.length === 0
112
+
113
+ // ---------- 输出 ----------
114
+ if (asJson) {
115
+ const summary = {
116
+ id,
117
+ schema,
118
+ ok,
119
+ failedSteps: failed.map((s) => s.step),
120
+ stepCount: steps.length,
121
+ }
122
+ console.log(
123
+ JSON.stringify(
124
+ { ok, summary, steps: Object.fromEntries(steps.map((s) => [s.step, { ok: s.ok, code: s.code, output: s.output }])) },
125
+ null,
126
+ 2
127
+ )
128
+ )
129
+ } else {
130
+ console.log(`\n=== gate: ${id} ===`)
131
+ for (const s of steps) {
132
+ const mark = s.ok ? '✓' : '✗'
133
+ console.log(`${mark} ${s.step}${s.ok ? '' : ` (exit ${s.code})`}`)
134
+ }
135
+ if (ok) {
136
+ console.log(`\n[PASS] 全部 ${steps.length} 项门禁通过 → 可进入渲染/视觉闭环`)
137
+ } else {
138
+ for (const f of failed) {
139
+ console.log(`\n--- ${f.step} 输出(关键行)---`)
140
+ console.log(f.output)
141
+ }
142
+ console.log(`\n[FAIL] ${failed.length}/${steps.length} 项未过:${failed.map((f) => f.step).join(', ')}`)
143
+ console.log('按 lint/validate 输出的规则号逐条修复后重跑本命令;修复手法见规则号对应文档(rules.md / generation_pitfalls.md / component_rules.md)。')
144
+ }
145
+ }
146
+
147
+ process.exit(ok ? 0 : 1)
@@ -26,7 +26,7 @@ import { spawnSync } from 'node:child_process'
26
26
  import { generate as generateV3 } from './spec_to_schema_v3.mjs'
27
27
  import { copyCardDesignImage, findCardDesignImage } from './lib/design_image.mjs'
28
28
  import { cardsDir, projectRoot, slicesDir } from './lib/paths.mjs'
29
- import { buildContentIndex, rewriteSliceRefs, sha256File } from './lib/slice_dedupe.mjs'
29
+ import { buildContentIndex, rewriteSliceRefs, sha256File, sliceNameMatches } from './lib/slice_dedupe.mjs'
30
30
 
31
31
  const CHECK_FIDELITY = path.join(projectRoot, 'skills', 'dsl-card-generator', 'scripts', 'check_fidelity.mjs')
32
32
 
@@ -121,20 +121,24 @@ function collectSlicesToSrcSlices(artboardDir, schema, cardDir, data) {
121
121
  if (!/\.(png|jpe?g|webp|svg)$/i.test(ent.name)) continue
122
122
  const rel = path.relative(srcDir, full).split(path.sep).join('/')
123
123
  const flat = rel.replace(/\//g, '_')
124
- if (!referenced.has(flat)) continue
125
- const target = path.join(slicesDir, flat)
126
- referenced.delete(flat)
124
+ // 源文件可能仍是设计稿原名(`警示-#999.png`),而 schema 引用是消毒名(`警示-999.png`)
125
+ // → 归一后匹配。**落盘名一律取 schema 引用的名字**(wanted),保证「引用 = 磁盘名」这条等式成立,
126
+ // 否则引用与磁盘名各写各的,r31c 会报悬空、图空白。
127
+ const wanted = [...referenced].find((r) => sliceNameMatches(flat, r))
128
+ if (!wanted) continue
129
+ const target = path.join(slicesDir, wanted)
130
+ referenced.delete(wanted)
127
131
  if (fs.existsSync(target)) continue
128
132
  const sha = sha256File(full)
129
133
  const twin = (contentIndex.get(sha) || [])[0]
130
- if (twin && twin !== flat) {
131
- rewrites.push([`/slices/${flat}`, `/slices/${twin}`])
134
+ if (twin && twin !== wanted) {
135
+ rewrites.push([`/slices/${wanted}`, `/slices/${twin}`])
132
136
  continue
133
137
  }
134
138
  fs.copyFileSync(full, target)
135
139
  const list = contentIndex.get(sha)
136
- if (list) list.push(flat)
137
- else contentIndex.set(sha, [flat])
140
+ if (list) list.push(wanted)
141
+ else contentIndex.set(sha, [wanted])
138
142
  n++
139
143
  }
140
144
  }
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ir_merge.mjs — 把「分片 IR 片段」合并进主 IR(分片落盘的后半段)。
4
+ *
5
+ * 为什么需要它:IR 一直是一次 fs_write 写完整份。工具是在**流结束后**才执行的,所以那一刻之前
6
+ * 盘上什么都没有——实测一次建卡 fs_write 花了 88s 纯吐参数(8.9KB IR),这 88s 里任何一次
7
+ * 输出截断(finish_reason=length)/ 网关静默 / 墙钟到点,整份 IR 全废,连「写了一半」都没有。
8
+ * 拆成「骨架 + 逐区块片段」以后:每片一个步骤落盘(一小段输出),某片失败只丢那一片,
9
+ * 已落盘的片段继续有效,合并一次即可继续跑 expand_ir。
10
+ *
11
+ * 用法:
12
+ * node ir_merge.mjs --ir .cache/ai-scratch/<id>.ir.json --part .cache/ai-scratch/<id>.p1.json [--part <p2.json> ...]
13
+ * node ir_merge.mjs --ir <ir.json> --parts-dir .cache/ai-scratch/<id>.parts # 目录里 *.json 按文件名排序全部合并
14
+ *
15
+ * 片段格式(一个文件 = 一片,ops 可多条):
16
+ * {
17
+ * "ops": [
18
+ * { "path": "root.children", "append": [ {…IR 节点…}, {…} ] },
19
+ * { "path": "root.children.0.s", "merge": { "paddingType": "1", "paddingTop": "12" } }
20
+ * ]
21
+ * }
22
+ * 也接受单条 op 的简写(顶层直接 {path, append|merge})与 ops 数组。
23
+ *
24
+ * 捆绑包(2026-09-21 起,省 LLM 往返):一次 fs_write 写多片 + 一次 merge 全收——
25
+ * { "parts": [ { "ops": [...] }, { "ops": [...] } ] } 或直接 [ { "ops": [...] }, ... ]
26
+ * 幂等粒度 = 整个捆绑文件名(记入 _parts,重发整包跳过)。
27
+ *
28
+ * path 与 iterate.mjs --set 同一套点路径(数字段=数组下标):**中间段必须已存在**,
29
+ * 末段指向的对象必须已存在(append 要数组、merge 要对象)。刻意不做「自动建中间节点」——
30
+ * 拼错一个键就静默建出一条没人会发现的错分支,比报错贵得多。
31
+ *
32
+ * 幂等:合并过的片段名记在 IR 的 `_parts` 里,重复合并同一个文件直接跳过。
33
+ * 这一条是给「网关静默重试 / 模型重发同一次调用」兜底的——append 天然不幂等,
34
+ * 没有这层保护就会把同一段子树加两遍(实测同类事故:同一次 iterate 被原样重发)。
35
+ *
36
+ * 原子性:所有 op 先内存里全部应用成功,再 tmp+rename 写回;任何一条失败即整体不写。
37
+ */
38
+ import fs from 'node:fs'
39
+ import path from 'node:path'
40
+ import { fileURLToPath } from 'node:url'
41
+
42
+ /** 分片落盘的记账键:合并过的片段名(basename),用于幂等跳过 */
43
+ const PARTS_KEY = '_parts'
44
+
45
+ /* ── 点路径寻址(与 iterate.mjs --set 同口径)──────────────── */
46
+ function resolveParent(ir, dottedPath) {
47
+ const segs = String(dottedPath).split('.').filter(Boolean)
48
+ if (!segs.length) throw new Error(`空路径: "${dottedPath}"`)
49
+ let node = ir
50
+ for (let i = 0; i < segs.length - 1; i++) {
51
+ const k = segs[i]
52
+ const next = Array.isArray(node) ? node[Number(k)] : node?.[k]
53
+ if (next === undefined || next === null || typeof next !== 'object') {
54
+ throw new Error(
55
+ `路径中段不存在或不是对象: ${segs.slice(0, i + 1).join('.')}(完整路径 ${dottedPath})` +
56
+ `;这一级可用的键:${describeKeys(node)}`,
57
+ )
58
+ }
59
+ node = next
60
+ }
61
+ const lastKey = segs[segs.length - 1]
62
+ const parent = node
63
+ const target = Array.isArray(parent) ? parent[Number(lastKey)] : parent?.[lastKey]
64
+ return { parent, lastKey, target }
65
+ }
66
+
67
+ /** 报错时把「这一级实际有什么」列出来:模型据此一次改对,不用靠猜(实测拼错键名会连撞两次) */
68
+ function describeKeys(node) {
69
+ if (Array.isArray(node)) return `数组(len=${node.length})`
70
+ if (!node || typeof node !== 'object') return `(${node === null ? 'null' : typeof node})`
71
+ const keys = Object.keys(node)
72
+ return keys.length ? keys.slice(0, 12).join(' / ') + (keys.length > 12 ? ' …' : '') : '(空对象)'
73
+ }
74
+
75
+ /** 一条 op → 应用到 ir。返回 { kind, path, n, created? };不合法就抛错(信息里带上可用键) */
76
+ function applyOp(ir, op) {
77
+ if (!op || typeof op !== 'object') throw new Error(`op 必须是对象:${JSON.stringify(op)}`)
78
+ const p = String(op.path || '').trim()
79
+ if (!p) throw new Error(`op 缺 path:${JSON.stringify(op).slice(0, 120)}`)
80
+ const { parent, lastKey, target } = resolveParent(ir, p)
81
+ if ('append' in op) {
82
+ const items = Array.isArray(op.append) ? op.append : [op.append]
83
+ // append 要求数组已存在:`chidren.0` 这类拼错会静默建出一条没人会发现的空数组,比报错贵得多
84
+ if (target === undefined) {
85
+ throw new Error(`路径不存在:${p}(这一级可用的键:${describeKeys(parent)})—— append 不会自动创建容器,骨架里要先建好`)
86
+ }
87
+ if (!Array.isArray(target)) {
88
+ throw new Error(`append 的 path 必须指向已存在的数组:${p} 实际是 ${Array.isArray(target) ? 'array' : typeof target}(要改对象里的字段请用 merge)`)
89
+ }
90
+ target.push(...items)
91
+ return { kind: 'append', path: p, n: items.length }
92
+ }
93
+ if ('merge' in op) {
94
+ if (!op.merge || typeof op.merge !== 'object' || Array.isArray(op.merge)) {
95
+ throw new Error(`merge 需要对象(想追加数组请用 append):${p}`)
96
+ }
97
+ // merge 允许「末段键还不存在」(某节点在第一片里没写 s,后一片补样式):这是幂等的对象字段
98
+ // 合并,不会像 append 那样加出重复子树;但仍如实报成 created —— 拼错的键也长这样,
99
+ // 列出来模型一眼能看出是不是新建了奇怪的键。
100
+ if (target === undefined) {
101
+ if (Array.isArray(parent)) {
102
+ throw new Error(`merge 指到数组里还不存在的下标:${p}(该数组长度 ${parent.length})`)
103
+ }
104
+ parent[lastKey] = { ...op.merge }
105
+ return { kind: 'merge', path: p, n: Object.keys(op.merge).length, created: true }
106
+ }
107
+ if (typeof target !== 'object' || Array.isArray(target)) {
108
+ throw new Error(`merge 的 path 必须指向对象:${p} 实际是 ${Array.isArray(target) ? 'array' : typeof target}`)
109
+ }
110
+ Object.assign(target, op.merge)
111
+ return { kind: 'merge', path: p, n: Object.keys(op.merge).length }
112
+ }
113
+ throw new Error(`op 必须带 append 或 merge 之一:${JSON.stringify(op).slice(0, 120)}`)
114
+ }
115
+ /** 片段里的 ops(四种写法都认:{ops} / {parts} 捆绑 / ops 数组 / 单条 op。宁可宽容解析、不为格式再烧一轮) */
116
+ export function opsOf(part) {
117
+ if (Array.isArray(part)) {
118
+ // 捆绑包:[片段, 片段, …](每个元素是 {ops:[…]} 形状的片段)→ 拼接全部 ops
119
+ if (part.length && part.every((p) => p && typeof p === 'object' && Array.isArray(p.ops))) {
120
+ return part.flatMap((p) => opsOf(p))
121
+ }
122
+ return part
123
+ }
124
+ if (part && Array.isArray(part.parts)) return part.parts.flatMap((p) => opsOf(p)) // 捆绑包:{parts:[…]}
125
+ if (part && Array.isArray(part.ops)) return part.ops
126
+ if (part && typeof part === 'object' && ('path' in part)) return [part]
127
+ throw new Error('片段里没有 ops:应为 {ops:[…]}、{parts:[…]}(捆绑包)、单条 {path,append|merge} 或 ops 数组')
128
+ }
129
+
130
+ /** 形状体检(合并前,硬拦):IR 契约里 c(className)是必填,缺了必被 expand/validate 拦下来。
131
+ * 放在写盘之前拦:盘上永远只有「能进管线」的内容,模型也在这一步就知道是哪一片写错了。 */
132
+ function shapeProblems(partName, ops) {
133
+ const out = []
134
+ ops.forEach((op, oi) => {
135
+ if (!('append' in op)) return
136
+ const items = Array.isArray(op.append) ? op.append : [op.append]
137
+ items.forEach((it, ii) => {
138
+ const at = `${partName} ops[${oi}].append[${ii}]`
139
+ if (!it || typeof it !== 'object' || Array.isArray(it)) out.push(`${at} 不是对象`)
140
+ else if (!it.c) out.push(`${at} 缺 c(组件类型,IR 必填)`)
141
+ })
142
+ })
143
+ return out
144
+ }
145
+
146
+ export function countNodes(ir) {
147
+ let n = 0
148
+ const walk = (node) => {
149
+ if (!node || typeof node !== 'object') return
150
+ n++
151
+ for (const c of node.children || []) walk(c)
152
+ for (const list of Object.values(node.slots || {})) for (const c of list || []) walk(c)
153
+ }
154
+ for (const c of ir.children || []) walk(c)
155
+ return n
156
+ }
157
+
158
+ /**
159
+ * 把若干片段合并进 irPath(原地原子写回)。
160
+ *
161
+ * 三条不变量:
162
+ * ① 幂等:合并过的片段名(basename)记在 IR 的 _parts 里,重复合并直接跳过 —— append 天然不幂等,
163
+ * 而「原样重发同一次调用」在本项目是常态(网关静默重试 / 模型重发),没有这层保护会加两遍;
164
+ * ② 先校验后写:所有片段的形状体检、所有 op 的应用都在内存里做完,任何一步失败即整份不写,
165
+ * 盘上永远是上一次成功合并的合法状态;
166
+ * ③ 片段名只在整批成功后记账:失败的那批改完可以原样重跑(不会被误判成「已合并」而跳过)。
167
+ */
168
+ export function mergeParts(irPath, partPaths) {
169
+ const ir = JSON.parse(fs.readFileSync(irPath, 'utf8'))
170
+ const seen = new Set(Array.isArray(ir[PARTS_KEY]) ? ir[PARTS_KEY].map((x) => path.basename(String(x))) : [])
171
+ const applied = []
172
+ const skipped = []
173
+ const pending = []
174
+ for (const p of partPaths) {
175
+ const name = path.basename(p)
176
+ if (seen.has(name)) {
177
+ skipped.push(name)
178
+ continue
179
+ }
180
+ const part = JSON.parse(fs.readFileSync(p, 'utf8'))
181
+ const list = opsOf(part)
182
+ if (!list.length) throw new Error(`${name}: ops 为空,没有可合并的内容`)
183
+ const problems = shapeProblems(name, list)
184
+ if (problems.length) {
185
+ throw new Error(`片段形状不对(本次未写盘,改好可原样重跑):${problems.join(';')}`)
186
+ }
187
+ pending.push({ name, list })
188
+ }
189
+ const ops = []
190
+ for (const { name, list } of pending) {
191
+ for (const op of list) ops.push({ part: name, ...applyOp(ir, op) })
192
+ applied.push(name)
193
+ }
194
+ ir[PARTS_KEY] = [...(ir[PARTS_KEY] || []), ...applied]
195
+ const bytes = Buffer.byteLength(JSON.stringify(ir))
196
+ if (applied.length) {
197
+ const tmp = `${irPath}.tmp-${process.pid}`
198
+ fs.writeFileSync(tmp, JSON.stringify(ir, null, 2) + '\n')
199
+ fs.renameSync(tmp, irPath)
200
+ }
201
+ return { ir, applied, skipped, ops, nodes: countNodes(ir), bytes }
202
+ }
203
+ /* ── CLI ───────────────────────────────────────────────────── */
204
+ function parseArgs(argv) {
205
+ const out = { ir: '', parts: [], partsDir: '', quiet: false, ignored: [] }
206
+ for (let i = 0; i < argv.length; i++) {
207
+ const a = argv[i]
208
+ if (a === '--ir') out.ir = argv[++i] || ''
209
+ else if (a === '--part') out.parts.push(argv[++i] || '')
210
+ else if (a === '--parts-dir') out.partsDir = argv[++i] || ''
211
+ else if (a === '--quiet') out.quiet = true
212
+ else if (a === '-h' || a === '--help') {
213
+ console.log(`Usage: node ir_merge.mjs --ir <IR.json> --part <p1.json> [--part <p2.json> ...]
214
+ node ir_merge.mjs --ir <IR.json> --parts-dir <目录> # 目录内片段 *.json 按文件名排序合并(骨架自己与不像片段的 JSON 跳过,列在 ignored)
215
+
216
+ 片段格式:{ "ops": [ { "path": "root.children", "append": [ …IR 节点… ] },
217
+ { "path": "root.children.0.s", "merge": { "paddingType": "1" } } ] }
218
+ path 与 iterate.mjs --set 同口径(数字段=数组下标);中间段必须已存在。
219
+ 已合并过的片段名记在 IR 的 _parts 里,重复合并自动跳过(append 不幂等,防重发)。
220
+ 输出 JSON:{ ok, applied[], skipped[], ops[], warnings[], nodes, bytes };任何一条 op 失败即整体不写。`)
221
+ process.exit(0)
222
+ }
223
+ }
224
+ if (!out.ir) {
225
+ console.error('缺少 --ir')
226
+ process.exit(2)
227
+ }
228
+ if (out.partsDir) {
229
+ // 目录模式:骨架与片段常在同一目录(.cache/ai-scratch/),这里只排除 --ir 自己;
230
+ // 同目录里别的 JSON(别的卡的 .ir/.audit/.iterate)不是片段 —— 跳过并列进 ignored,不静默吞。
231
+ const irAbs = path.resolve(out.ir)
232
+ for (const f of fs.readdirSync(out.partsDir).filter((n) => n.endsWith('.json')).sort()) {
233
+ const full = path.join(out.partsDir, f)
234
+ if (path.resolve(full) === irAbs) continue
235
+ let looksLikePart = false
236
+ try {
237
+ const j = JSON.parse(fs.readFileSync(full, 'utf8'))
238
+ looksLikePart = Array.isArray(j) || Boolean(j && (Array.isArray(j.ops) || j.path))
239
+ } catch {
240
+ looksLikePart = false
241
+ }
242
+ if (looksLikePart) out.parts.push(full)
243
+ else out.ignored.push(f)
244
+ }
245
+ }
246
+ if (!out.parts.length) {
247
+ console.error('至少要给一个 --part 或 --parts-dir')
248
+ process.exit(2)
249
+ }
250
+ return out
251
+ }
252
+
253
+ function main() {
254
+ const args = parseArgs(process.argv.slice(2))
255
+ if (!fs.existsSync(args.ir)) {
256
+ console.log(JSON.stringify({ ok: false, error: `IR 不存在:${args.ir}(先 fs_write 骨架:{id,name,width,children:[]})` }))
257
+ process.exit(1)
258
+ }
259
+ const missing = args.parts.filter((p) => !fs.existsSync(p))
260
+ if (missing.length) {
261
+ console.log(JSON.stringify({ ok: false, error: `片段文件不存在:${missing.join(' / ')}` }))
262
+ process.exit(1)
263
+ }
264
+ try {
265
+ const r = mergeParts(args.ir, args.parts)
266
+ console.log(
267
+ JSON.stringify({
268
+ ok: true,
269
+ ir: args.ir,
270
+ applied: r.applied,
271
+ skipped: r.skipped,
272
+ ignored: args.ignored,
273
+ ops: r.ops.map((o) => `${o.part}:${o.kind} ${o.path} +${o.n}${o.created ? '(新建键)' : ''}`),
274
+ nodes: r.nodes,
275
+ bytes: r.bytes,
276
+ hint:
277
+ r.applied.length === 0
278
+ ? '这批片段都合并过了(幂等跳过),IR 未改动;还缺区块就写下一片再合并,结构完整就直接 expand_ir.mjs'
279
+ : '结构完整就跑 expand_ir.mjs 展开;还缺区块就继续写下一片再合并;合并后要改细节一律 iterate.mjs --set(改内容别重写已合并的片段名:那会被当成已合并而跳过,改结构就用新名字的片段 + merge 覆盖容器键)',
280
+ }),
281
+ )
282
+ } catch (e) {
283
+ // 形状不对 / 一条 op 失败 = 整份不写:盘上仍是上一次成功合并的合法状态,改片段可原样重跑
284
+ console.log(JSON.stringify({ ok: false, error: String(e?.message || e), ir: args.ir, applied: [] }))
285
+ process.exit(1)
286
+ }
287
+ }
288
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ir_restore.mjs — 把 IR 回退到某一轮(默认「历史最好成绩」那轮)的结构。
4
+ *
5
+ * 存在的理由(2026-09-21 建卡回合实测):iterate 的 round 历史里 match 一路震荡下滑
6
+ * (79.6 → 92.42 → 90.04),模型发现「越改越差」时**没有任何历史版本可退**,
7
+ * 唯一的办法是凭记忆全量重写整份 IR —— 16 次重写、12.4 万字符,约占该轮全部输出的一半。
8
+ * iterate 现在每轮落一份 <id>.ir.rNN.json、刷新最好成绩时另存 <id>.ir.best.json,
9
+ * 本脚本负责把其中一份原子写回工作 IR(回退前先把当前态备份,回退本身也可撤销)。
10
+ *
11
+ * 用法:
12
+ * node ir_restore.mjs --best .cache/ai-scratch/<id>.ir.best.json --ir .cache/ai-scratch/<id>.ir.json
13
+ * node ir_restore.mjs --round 10 --id <cardId> --ir .cache/ai-scratch/<id>.ir.json
14
+ * node ir_restore.mjs --list --id <cardId> # 只列可用快照与各轮 match,不改文件
15
+ *
16
+ * 退出码:0 成功;1 参数 / 文件错误;3 目标不是合法 JSON(拒绝写坏工作 IR)。
17
+ */
18
+ import fs from 'node:fs'
19
+ import path from 'node:path'
20
+ import { repoRoot } from './lib/paths.mjs'
21
+
22
+ const SCRATCH_DIR = path.join(repoRoot, '.cache', 'ai-scratch')
23
+
24
+ function parseArgs(argv) {
25
+ const out = { best: '', round: 0, id: '', ir: '', list: false }
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i]
28
+ if (a === '--best') out.best = argv[++i] || ''
29
+ else if (a === '--round') out.round = Number(argv[++i]) || 0
30
+ else if (a === '--id') out.id = argv[++i] || ''
31
+ else if (a === '--ir') out.ir = argv[++i] || ''
32
+ else if (a === '--list') out.list = true
33
+ else if (a === '-h' || a === '--help') {
34
+ console.log(`Usage:
35
+ node ir_restore.mjs --best <ir.best.json> --ir <工作IR.json>
36
+ node ir_restore.mjs --round <NN> --id <cardId> --ir <工作IR.json>
37
+ node ir_restore.mjs --list --id <cardId>
38
+
39
+ 快照来源:iterate.mjs 每轮落 <卡>.ir.rNN.json,刷新最好成绩时另存 <卡>.ir.best.json(均在 .cache/ai-scratch/)。`)
40
+ process.exit(0)
41
+ }
42
+ }
43
+ return out
44
+ }
45
+
46
+ /** 报告里的轮次历史:--list 与「按轮次回退」都靠它 */
47
+ function readReport(cardId) {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(path.join(SCRATCH_DIR, `${cardId}.iterate.json`), 'utf8'))
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+ function readHistory(cardId) {
55
+ const report = readReport(cardId)
56
+ return Array.isArray(report?.history) ? report.history : []
57
+ }
58
+
59
+ function listSnapshots(cardId) {
60
+ const report = readReport(cardId)
61
+ const history = Array.isArray(report?.history) ? report.history : []
62
+ const rows = history.map((h) => ({
63
+ round: h.round ?? null,
64
+ match: h.match ?? null,
65
+ heightDelta: h.heightDelta ?? null,
66
+ snapshot: h.irSnapshot || null,
67
+ exists: h.irSnapshot ? fs.existsSync(path.join(SCRATCH_DIR, h.irSnapshot)) : false,
68
+ }))
69
+ const bestIr = path.join(SCRATCH_DIR, `${cardId}.ir.best.json`)
70
+ // 最好成绩认报告里**单独持久化**的 best:history 只留最近几条,按它算会把更早的最好成绩算丢
71
+ const best = report?.best || null
72
+ return {
73
+ ok: rows.length > 0 || Boolean(best),
74
+ cardId,
75
+ bestIr: fs.existsSync(bestIr) ? bestIr : null,
76
+ bestRound: best?.round ?? null,
77
+ bestMatch: best?.match ?? null,
78
+ bestInWindow: best ? rows.some((r) => r.round === best.round) : false,
79
+ note: 'rounds 只列最近几轮;更早轮次的快照文件仍在 .cache/ai-scratch/<卡>.ir.rNN.json,可用 --round NN 直接回退',
80
+ rounds: rows,
81
+ }
82
+ }
83
+
84
+ function pickSnapshot(args) {
85
+ if (args.best) return { file: args.best, from: 'best' }
86
+ if (args.round > 0) {
87
+ const hit = readHistory(args.id).find((h) => Number(h.round) === Number(args.round))
88
+ // history 只留最近 9 条:窗口外的轮次按约定文件名直取(快照文件本身不会删)
89
+ const file = hit?.irSnapshot
90
+ ? path.join(SCRATCH_DIR, hit.irSnapshot)
91
+ : path.join(SCRATCH_DIR, `${args.id}.ir.r${String(args.round).padStart(2, '0')}.json`)
92
+ if (!fs.existsSync(file)) {
93
+ return { error: `没有第 ${args.round} 轮的 IR 快照(${file});先用 --list 看有哪些轮次可用` }
94
+ }
95
+ return { file, from: `r${args.round}` }
96
+ }
97
+ // 两者都没给:默认回退到 best
98
+ if (!args.id) return { error: '需要 --best <文件> 或 --round <NN> --id <cardId>' }
99
+ const bestIr = path.join(SCRATCH_DIR, `${args.id}.ir.best.json`)
100
+ if (!fs.existsSync(bestIr)) return { error: `没有最好成绩快照 ${bestIr}(可能是还没跑过 iterate,或从未刷新过成绩)` }
101
+ return { file: bestIr, from: 'best' }
102
+ }
103
+
104
+ function main() {
105
+ const args = parseArgs(process.argv.slice(2))
106
+ if (args.list) {
107
+ const info = listSnapshots(args.id)
108
+ console.log(JSON.stringify(info, null, 2))
109
+ process.exit(info.ok ? 0 : 1)
110
+ }
111
+ if (!args.ir) {
112
+ console.error('缺少 --ir <工作 IR 路径>:回退的目标文件必须显式给出,不做猜测')
113
+ process.exit(1)
114
+ }
115
+ const picked = pickSnapshot(args)
116
+ if (picked.error) {
117
+ console.error(picked.error)
118
+ process.exit(1)
119
+ }
120
+ if (!fs.existsSync(picked.file)) {
121
+ console.error(`快照不存在:${picked.file}`)
122
+ process.exit(1)
123
+ }
124
+ if (!fs.existsSync(args.ir)) {
125
+ console.error(`工作 IR 不存在:${args.ir}`)
126
+ process.exit(1)
127
+ }
128
+ let snapshot
129
+ try {
130
+ snapshot = JSON.parse(fs.readFileSync(picked.file, 'utf8'))
131
+ } catch (e) {
132
+ console.error(`快照不是合法 JSON,拒绝写回:${picked.file}(${e.message})`)
133
+ process.exit(3)
134
+ }
135
+ const cur = fs.readFileSync(args.ir, 'utf8')
136
+ const backup = `${args.ir}.pre-restore`
137
+ const cardId = args.id || path.basename(args.ir).replace(/\.ir.*$/, '')
138
+ const report = readReport(cardId)
139
+ const bestIrPath = path.join(SCRATCH_DIR, `${cardId}.ir.best.json`)
140
+ const fromBest = path.resolve(picked.file) === path.resolve(bestIrPath)
141
+ const hit =
142
+ readHistory(cardId).find((h) => h.irSnapshot && path.resolve(path.join(SCRATCH_DIR, h.irSnapshot)) === path.resolve(picked.file)) ||
143
+ (fromBest || Number(report?.best?.round) === Number(args.round) ? report?.best : null) ||
144
+ null
145
+ const tmp = `${args.ir}.tmp-${process.pid}`
146
+ fs.writeFileSync(tmp, JSON.stringify(snapshot, null, 2) + '\n')
147
+ fs.renameSync(tmp, args.ir)
148
+ try {
149
+ fs.writeFileSync(backup, cur)
150
+ } catch {
151
+ /* 备份失败不影响回退结果,下面如实说明 */
152
+ }
153
+ const nodes = (function count(list) {
154
+ return (list || []).reduce((n, k) => n + 1 + count(k.children) + Object.values(k.slots || {}).reduce((m, s) => m + count(s), 0), 0)
155
+ })(snapshot.children)
156
+ console.log(
157
+ JSON.stringify(
158
+ {
159
+ ok: true,
160
+ restoredFrom: path.relative(repoRoot, picked.file),
161
+ source: picked.from,
162
+ match: hit?.match ?? null,
163
+ round: args.round || hit?.round || null,
164
+ ir: path.relative(repoRoot, args.ir),
165
+ nodes,
166
+ backup: fs.existsSync(backup) ? path.relative(repoRoot, backup) : null,
167
+ nextStep: `回退后必须重跑一轮 iterate 拿新报告(旧报告的 schemaHash 已失效,--gate 会判 FAIL):skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs args=["--ir","${path.relative(repoRoot, args.ir)}","--out","src/cards/<卡>/", "--design","<设计稿>","--width","<卡宽>"]`,
168
+ },
169
+ null,
170
+ 2,
171
+ ),
172
+ )
173
+ }
174
+
175
+ main()