sillyspec 3.26.1 → 3.26.2

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.
@@ -3,5 +3,5 @@
3
3
  "runtimeRoot": null,
4
4
  "workspaceId": null,
5
5
  "scanRunId": null,
6
- "savedAt": "2026-08-11T04:01:27.160Z"
6
+ "savedAt": "2026-08-11T08:03:50.313Z"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.26.1",
3
+ "version": "3.26.2",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -5,6 +5,10 @@
5
5
  * 供 auto 模式决定内部流程深度。
6
6
  */
7
7
 
8
+ import { existsSync, readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import jsYaml from 'js-yaml'
11
+
8
12
  /**
9
13
  * 用户显式指定的关键词 → 强制模式
10
14
  */
@@ -24,12 +28,36 @@ const FORCE_QUICK_PATTERNS = [
24
28
  /修复.*\s*\bbug\b.*\bfix\b/i,
25
29
  ]
26
30
 
31
+ /**
32
+ * 读 local.yaml 的 auto_mode 段(best-effort,绝不抛)。
33
+ * 供 runCommand 接线:传给 classifyChange 的 localConfig,让 force_*_patterns 真生效。
34
+ * @param {string} cwd - 主仓库根(local.yaml 在 <cwd>/.sillyspec/local.yaml)
35
+ * @returns {{ force_full_patterns?: string[], force_quick_patterns?: string[] } | null}
36
+ * auto_mode 段含至少一个 string pattern → 归一化数组;文件缺/解析失败/无段/空 → null(降级默认关键词)
37
+ */
38
+ export function readAutoModeFromLocalYaml(cwd) {
39
+ try {
40
+ const p = join(cwd, '.sillyspec', 'local.yaml')
41
+ if (!existsSync(p)) return null
42
+ const doc = jsYaml.load(readFileSync(p, 'utf8'))
43
+ if (!doc || typeof doc !== 'object') return null
44
+ const am = doc.auto_mode
45
+ if (!am || typeof am !== 'object') return null
46
+ const ffp = Array.isArray(am.force_full_patterns) ? am.force_full_patterns.filter(s => typeof s === 'string') : []
47
+ const fqp = Array.isArray(am.force_quick_patterns) ? am.force_quick_patterns.filter(s => typeof s === 'string') : []
48
+ if (ffp.length === 0 && fqp.length === 0) return null
49
+ return { force_full_patterns: ffp, force_quick_patterns: fqp }
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
27
55
  /**
28
56
  * 分类变更规模
29
57
  * @param {object} opts
30
58
  * @param {string} opts.description - 用户需求描述
31
59
  * @param {string} [opts.explicitMode] - 用户显式指定的模式(auto/quick/full)
32
- * @param {object} [opts.localConfig] - local.yaml 中的 auto_mode 配置
60
+ * @param {object} [opts.localConfig] - local.yaml auto_mode 段(由 readAutoModeFromLocalYaml 读出)
33
61
  * @returns {{ mode: 'quick'|'auto'|'full', reason: string }}
34
62
  */
35
63
  export function classifyChange({ description = '', explicitMode, localConfig } = {}) {
@@ -44,13 +72,21 @@ export function classifyChange({ description = '', explicitMode, localConfig } =
44
72
  const forceQuickPatterns = localConfig.force_quick_patterns || []
45
73
 
46
74
  for (const pattern of forceFullPatterns) {
47
- if (new RegExp(pattern, 'i').test(description)) {
48
- return { mode: 'full', reason: `local.yaml force_full_pattern 匹配: ${pattern}` }
75
+ try {
76
+ if (new RegExp(pattern, 'i').test(description)) {
77
+ return { mode: 'full', reason: `local.yaml force_full_pattern 匹配: ${pattern}` }
78
+ }
79
+ } catch {
80
+ // 非法正则跳过,不崩 auto 分类(review-2026-08-09 #30)
49
81
  }
50
82
  }
51
83
  for (const pattern of forceQuickPatterns) {
52
- if (new RegExp(pattern, 'i').test(description)) {
53
- return { mode: 'quick', reason: `local.yaml force_quick_pattern 匹配: ${pattern}` }
84
+ try {
85
+ if (new RegExp(pattern, 'i').test(description)) {
86
+ return { mode: 'quick', reason: `local.yaml force_quick_pattern 匹配: ${pattern}` }
87
+ }
88
+ } catch {
89
+ // 非法正则跳过
54
90
  }
55
91
  }
56
92
  }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * config-schema.js — local.yaml 配置键的单一数据源。
3
+ *
4
+ * 定位:`.sillyspec/local.yaml` 是 gitignored 的机器+人混写配置,键散落在 ~10 个 reader
5
+ * 里(detect / verify-postcheck / sync / dispatch / worktree-guard / classify-change …),
6
+ * 外部项目的 agent 无从得知有哪些键、哪些生效。本模块把「全部已知键 + 生效状态 + 读取点」
7
+ * 集中成一张表,供:
8
+ * - `sillyspec config schema`(人类可读树 / --json 机读)打印;
9
+ * - `sillyspec init` 调 renderExample() 落盘脱敏 local.yaml.example 给人/外部 agent 看。
10
+ *
11
+ * 单一真相 = LOCAL_YAML_SCHEMA。renderExample() 是策展模板(YAML 形态各异:嵌套 / inline
12
+ * flow / 顶层标量,自动生成不如策展可读),由测试「每个 live 键路径必出现于 example 文本」
13
+ * 耦合保证不漂移——加键忘 example 则测试红。
14
+ *
15
+ * reader 引用一律用符号名(readMcpConfig / extractModules),**不用行号**——行号随重构漂移,
16
+ * 符号名稳定可 grep。
17
+ *
18
+ * status 语义:
19
+ * - live 真有 reader 从 local.yaml 读并起效(配了即生效)。
20
+ * - declared 代码/JSDoc/prompt 声明过,但无 reader 真读 local.yaml(或调用方未传),配了不生效。
21
+ * 诚实标出,避免 agent 白配 + 暴露待接线债。
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} SchemaKey
26
+ * @property {string} path 点分路径(mcp.url / worktree-hook.readonlyCommands)
27
+ * @property {string} type string|enum|integer|object|array|scalar
28
+ * @property {string[]} [values] enum 取值
29
+ * @property {boolean} [required] 凭据类必填(两键齐全才视为有效源)
30
+ * @property {boolean} [secret] 敏感凭据,example 用占位符
31
+ * @property {boolean} [optional] 可选键
32
+ * @property {string} status live|declared
33
+ * @property {string[]} readers 读取点符号名(稳定可 grep),declared 键留空数组
34
+ * @property {string} desc 一句话说明
35
+ * @property {string} example 脱敏示例值(secret 键用占位符)
36
+ *
37
+ * @typedef {Object} SchemaSection
38
+ * @property {string} id
39
+ * @property {string} title
40
+ * @property {string} note 段说明(写入来源 / 注意事项)
41
+ * @property {SchemaKey[]} keys
42
+ */
43
+
44
+ /** @type {{ file: string, note: string, sections: SchemaSection[] }} */
45
+ export const LOCAL_YAML_SCHEMA = {
46
+ file: '.sillyspec/local.yaml',
47
+ note: 'gitignored(含敏感凭据勿提交);脱敏示例 local.yaml.example 由 sillyspec init 生成(可提交)。',
48
+ sections: [
49
+ {
50
+ id: 'project',
51
+ title: '项目类型',
52
+ note: '由 sillyspec local detect 纯 fs 嗅探写入,一般无需手填。',
53
+ keys: [
54
+ {
55
+ path: 'project.type',
56
+ type: 'enum',
57
+ values: ['nodejs', 'maven', 'gradle', 'make', 'generic'],
58
+ status: 'live',
59
+ readers: ['detectLocalYaml (src/local-detect.js)', 'extractProjectType (src/worktree-deps.js)'],
60
+ desc: '项目类型,决定默认 commands 嗅探与 worktree 依赖策略。',
61
+ example: 'nodejs',
62
+ },
63
+ ],
64
+ },
65
+ {
66
+ id: 'commands',
67
+ title: '构建/测试/lint 命令',
68
+ note: 'detect 核验 package.json(或对应构建文件) scripts 存在性后才写对应键;缺失则不写。agent 可改。',
69
+ keys: [
70
+ { path: 'commands.build', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '构建命令。', example: 'npm run build' },
71
+ { path: 'commands.test', type: 'string', optional: true, status: 'live', readers: ['extractTestCommand (src/verify-postcheck.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '测试命令——verify 阶段 CLI 亲自执行此命令与 verify-result.md 对账,实测失败即阻断。', example: 'npm test' },
72
+ { path: 'commands.lint', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'validateCommands (src/scan-postcheck.js)'], desc: 'lint 命令。', example: 'npm run lint' },
73
+ { path: 'commands.install', type: 'string', optional: true, status: 'live', readers: ['extractInstallCommand (src/worktree-deps.js)'], desc: '依赖安装命令(worktree 依赖 provisioning 用)。', example: 'npm install' },
74
+ ],
75
+ },
76
+ {
77
+ id: 'mcp',
78
+ title: 'SillyHub MCP 客户端凭据(派发用)',
79
+ note: 'agent 手填,或 sillyspec platform connect 在 mcp 段缺失时同源自动填(§7.4)。两键齐全才生效,否则回退 env SILLYHUB_MCP_URL/TOKEN。',
80
+ keys: [
81
+ { path: 'mcp.url', type: 'string', required: true, secret: true, status: 'live', readers: ['readMcpConfig (src/sillyhub-mcp/config.js)'], desc: 'SillyHub MCP 服务地址(尾斜杠自动归一)。', example: 'https://your-sillyhub.example.com' },
82
+ { path: 'mcp.token', type: 'string', required: true, secret: true, status: 'live', readers: ['readMcpConfig (src/sillyhub-mcp/config.js)'], desc: 'SillyHub MCP 认证 token。', example: '<your-mcp-token>' },
83
+ ],
84
+ },
85
+ {
86
+ id: 'platform',
87
+ title: 'SillyHub 平台同步',
88
+ note: 'sillyspec platform connect 写入(含 ping 验活);手填亦可。user/last_connected 可空或自动写。',
89
+ keys: [
90
+ { path: 'platform.url', type: 'string', required: true, secret: true, status: 'live', readers: ['SyncManager._getPlatform (src/sync.js)'], desc: 'SillyHub 平台地址。', example: 'https://your-sillyhub.example.com' },
91
+ { path: 'platform.token', type: 'string', required: true, secret: true, status: 'live', readers: ['SyncManager._getPlatform (src/sync.js)'], desc: '平台认证 token(Bearer)。', example: '<your-platform-token>' },
92
+ { path: 'platform.user', type: 'string', optional: true, status: 'live', readers: ['resolvePlatformUser / SyncManager.connect (src/sync.js)'], desc: '推送者身份;缺则回退 git user.name / env。推送时进 X-SillySpec-User header。', example: 'alice' },
93
+ { path: 'platform.last_connected', type: 'string', optional: true, status: 'live', readers: ['SyncManager.connect (src/sync.js)'], desc: 'connect 成功时间戳(ISO),自动写,勿手填。', example: '2026-08-11T00:00:00.000Z' },
94
+ ],
95
+ },
96
+ {
97
+ id: 'dispatch',
98
+ title: '派发调参',
99
+ note: '仅在确知调优值时填,否则留默认。',
100
+ keys: [
101
+ { path: 'dispatch.probe_ttl_ms', type: 'integer', optional: true, status: 'live', readers: ['readProbeTtlFromLocalYaml (src/dispatch/probe.js)'], desc: '派发探测负面缓存 TTL(毫秒,默认 60000)。daemon 抖动期避免反复探测。', example: '60000' },
102
+ { path: 'dispatch.poll_interval_ms', type: 'integer', optional: true, status: 'declared', readers: [], desc: '【路径A 预留·未落地】renderSillyHubInstruction 注入的轮询文本提及,但 isPathASupported()=false 该指令当前不注入;路径A 落地后接线,配了暂不生效。', example: '15000' },
103
+ { path: 'dispatch.worker_timeout_ms', type: 'integer', optional: true, status: 'declared', readers: [], desc: '【路径A 预留·未落地】同 poll_interval_ms,路径A 落地后接线,配了暂不生效。', example: '60000' },
104
+ ],
105
+ },
106
+ {
107
+ id: 'modules',
108
+ title: 'monorepo 子模块映射',
109
+ note: 'test_strategy: module 时,按 git diff 命中的模块子集收窄测试。只支持 inline flow 形态(extractModules 不解析嵌套展开式)。',
110
+ keys: [
111
+ { path: 'modules.<name>.path', type: 'string', optional: true, status: 'live', readers: ['extractModules (src/verify-postcheck.js)', 'parseLocalYamlModules (src/plan-postcheck.js)', 'extractModulePaths (src/worktree-deps.js)'], desc: '子模块目录路径(相对仓库根)。', example: 'frontend/' },
112
+ { path: 'modules.<name>.test', type: 'string', optional: true, status: 'live', readers: ['extractModules (src/verify-postcheck.js)'], desc: '该子模块的测试命令。', example: 'cd frontend && pnpm test' },
113
+ ],
114
+ },
115
+ {
116
+ id: 'test_strategy',
117
+ title: '测试策略',
118
+ note: 'verify 阶段 CLI 对账的收窄策略。',
119
+ keys: [
120
+ { path: 'test_strategy', type: 'enum', values: ['full', 'module'], optional: true, status: 'live', readers: ['extractTestStrategy (src/verify-postcheck.js)'], desc: 'full=全量 commands.test;module=按 git diff 命中 modules 子集收窄(需配 modules)。缺省=全量。', example: 'full' },
121
+ ],
122
+ },
123
+ {
124
+ id: 'known_failures',
125
+ title: '预存失败豁免清单',
126
+ note: '变更前就失败的测试行,豁免后不计入 verify 阻断。支持块列表或 inline 数组。务必定期复核,避免误豁免本变更引入的真实失败。',
127
+ keys: [
128
+ { path: 'known_failures', type: 'array', optional: true, status: 'live', readers: ['extractKnownFailures (src/verify-postcheck.js)'], desc: '失败行模式列表(匹配测试输出失败行)。', example: 'tests/some-old-test' },
129
+ ],
130
+ },
131
+ {
132
+ id: 'worktree-hook',
133
+ title: 'worktree-guard 扩展',
134
+ note: 'execute worktree 隔离期内放行的额外只读命令。键名用 camelCase(parseSimpleYaml 原样保留)。',
135
+ keys: [
136
+ { path: 'worktree-hook.readonlyCommands', type: 'array', optional: true, status: 'live', readers: ['loadLocalConfig (src/hooks/worktree-guard.js)'], desc: '额外放行的只读命令名列表(如 rg/fd),绕过 worktree-guard 写操作拦截。', example: 'rg' },
137
+ ],
138
+ },
139
+ {
140
+ id: 'auto_mode',
141
+ title: '变更规模自动分类',
142
+ note: 'sillyspec run auto 时,readAutoModeFromLocalYaml 读本段传 classifyChange 的 localConfig,force_*_patterns 匹配需求描述则强制对应模式。',
143
+ keys: [
144
+ { path: 'auto_mode.force_full_patterns', type: 'array', optional: true, status: 'live', readers: ['readAutoModeFromLocalYaml + classifyChange (src/classify-change.js)', 'runCommand (src/run/command.js)'], desc: '需求描述匹配任一正则(i 大小写无关)→ 强制 full 流程。非法正则跳过不崩。', example: '数据库|migration' },
145
+ { path: 'auto_mode.force_quick_patterns', type: 'array', optional: true, status: 'live', readers: ['readAutoModeFromLocalYaml + classifyChange (src/classify-change.js)', 'runCommand (src/run/command.js)'], desc: '需求描述匹配任一正则 → 强制 quick。非法正则跳过不崩。', example: 'fix typo' },
146
+ ],
147
+ },
148
+ ],
149
+ };
150
+
151
+ /** 全部键(拍平),便于 JSON 输出与 example 耦合测试。 */
152
+ export function flatKeys() {
153
+ return LOCAL_YAML_SCHEMA.sections.flatMap((s) => s.keys.map((k) => ({ section: s.id, ...k })));
154
+ }
155
+
156
+ /** 人类可读树形 schema 文本。live 在前、declared 在后,段内逐键列 path/type/约束/读取点/说明。 */
157
+ export function renderSchemaHuman() {
158
+ const L = [];
159
+ L.push('SillySpec local.yaml 配置清单');
160
+ L.push(`文件:${LOCAL_YAML_SCHEMA.file} — ${LOCAL_YAML_SCHEMA.note}`);
161
+ L.push('数据源:src/config-schema.js(本表唯一真相;reader 见各键「读取点」,符号名可 grep)');
162
+ L.push('');
163
+
164
+ const all = flatKeys();
165
+ const live = all.filter((k) => k.status === 'live');
166
+ const declared = all.filter((k) => k.status === 'declared');
167
+
168
+ L.push(`━━━ 生效(${live.length} 键,配了即生效) ━━━`);
169
+ L.push('');
170
+ for (const s of LOCAL_YAML_SCHEMA.sections) {
171
+ const keys = s.keys.filter((k) => k.status === 'live');
172
+ if (keys.length === 0) continue;
173
+ L.push(`[${s.id}] ${s.title}`);
174
+ L.push(` ${s.note}`);
175
+ for (const k of keys) {
176
+ L.push(` ${k.path}${formatConstraint(k)} ${k.desc}`);
177
+ if (k.readers.length) L.push(` 读取点:${k.readers.join(';')}`);
178
+ }
179
+ L.push('');
180
+ }
181
+
182
+ L.push(`━━━ 声明但未接线(${declared.length} 键,配了不生效 / 待接线债) ━━━`);
183
+ L.push('');
184
+ for (const s of LOCAL_YAML_SCHEMA.sections) {
185
+ const keys = s.keys.filter((k) => k.status === 'declared');
186
+ if (keys.length === 0) continue;
187
+ L.push(`[${s.id}] ${s.title}`);
188
+ L.push(` ${s.note}`);
189
+ for (const k of keys) {
190
+ L.push(` ${k.path}${formatConstraint(k)} ${k.desc}`);
191
+ }
192
+ L.push('');
193
+ }
194
+ return L.join('\n');
195
+ }
196
+
197
+ function formatConstraint(k) {
198
+ const tags = [];
199
+ if (k.required) tags.push('必填');
200
+ if (k.optional) tags.push('可选');
201
+ if (k.secret) tags.push('凭据');
202
+ if (k.type === 'enum' && k.values) tags.push(k.values.join('|'));
203
+ else tags.push(k.type);
204
+ return tags.length ? ` [${tags.join(' · ')}]` : '';
205
+ }
206
+
207
+ /** 机读 JSON(程序化消费)。结构 = LOCAL_YAML_SCHEMA 本体 + flatKeys 视图。 */
208
+ export function renderSchemaJson() {
209
+ return JSON.stringify({
210
+ file: LOCAL_YAML_SCHEMA.file,
211
+ note: LOCAL_YAML_SCHEMA.note,
212
+ sections: LOCAL_YAML_SCHEMA.sections,
213
+ keys: flatKeys().map(({ section, path, type, values, required, optional, secret, status, readers, desc }) => ({
214
+ section, path, type, values, required, optional, secret, status, readers, desc,
215
+ })),
216
+ }, null, 2);
217
+ }
218
+
219
+ /**
220
+ * 脱敏 local.yaml 示例文本(sillyspec init 落盘用)。
221
+ * 策展模板(非自动生成):YAML 形态各异(嵌套/inline flow/顶层标量),策展可读性远胜自动拼。
222
+ * 与 SCHEMA 的耦合由测试保证:每个 live 键路径必出现于本文本。
223
+ */
224
+ export function renderExample() {
225
+ return `# SillySpec local.yaml — 脱敏示例(由 sillyspec init 生成,可提交)
226
+ # 真实配置文件 .sillyspec/local.yaml 是 gitignored(含敏感凭据)。
227
+ # 用法:复制本文件为 .sillyspec/local.yaml,填真实值;或直接 sillyspec platform connect 自动写凭据段。
228
+ # 全部键与生效状态:sillyspec config schema
229
+
230
+ # ── 项目类型(sillyspec local detect 自动写入,一般无需手填)──
231
+ project:
232
+ type: nodejs # nodejs | maven | gradle | make | generic
233
+
234
+ # ── 构建/测试/lint 命令(detect 核验 scripts 存在性后才写;缺失键省略)──
235
+ commands:
236
+ build: npm run build
237
+ test: npm test
238
+ lint: npm run lint
239
+ # install: npm install # worktree 依赖安装命令
240
+
241
+ # ── SillyHub MCP 客户端凭据(派发到 worker 用)──
242
+ # agent 手填,或 sillyspec platform connect 在 mcp 段缺失时同源自动填。
243
+ # 两键齐全才生效;缺则回退环境变量 SILLYHUB_MCP_URL / SILLYHUB_MCP_TOKEN。
244
+ mcp:
245
+ url: https://your-sillyhub.example.com
246
+ token: <your-mcp-token>
247
+
248
+ # ── SillyHub 平台同步(sillyspec platform connect 写入,含 ping 验活)──
249
+ platform:
250
+ url: https://your-sillyhub.example.com
251
+ token: <your-platform-token>
252
+ user: alice # 推送者身份(可空,回退 git user.name / env)
253
+ # last_connected: 2026-08-11T00:00:00.000Z # connect 自动写,勿手填
254
+
255
+ # ── 派发调参(仅确知调优值时填,否则留默认)──
256
+ dispatch:
257
+ probe_ttl_ms: 60000 # 派发探测负面缓存 TTL(默认 60000)
258
+ # poll_interval_ms: 15000 # ⚠ 路径A 预留·未落地,配了暂不生效
259
+ # worker_timeout_ms: 60000 # ⚠ 路径A 预留·未落地,配了暂不生效
260
+
261
+ # ── monorepo 子模块映射(test_strategy: module 时按 git diff 命中模块收窄测试)──
262
+ # 只支持 inline flow 形态(嵌套展开式解析不出):
263
+ modules:
264
+ frontend: { path: "frontend/", test: "cd frontend && pnpm test" }
265
+ backend: { path: "backend/", test: "cd backend && npm test" }
266
+
267
+ # ── 测试策略:full=全量 commands.test | module=按命中模块收窄(需配 modules)──
268
+ test_strategy: full
269
+
270
+ # ── 预存失败豁免清单(变更前就失败的测试行;务必定期复核,防误豁免真实失败)──
271
+ known_failures:
272
+ - tests/some-old-stable-failure
273
+
274
+ # ── worktree-guard 只读命令扩展(execute worktree 隔离期内放行额外只读命令)──
275
+ worktree-hook:
276
+ readonlyCommands:
277
+ - rg
278
+ - fd
279
+
280
+ # ── 变更规模自动分类(sillyspec run auto 时按需求描述强制 quick/full;正则数组,i 大小写无关)──
281
+ auto_mode:
282
+ force_full_patterns:
283
+ - 数据库|migration
284
+ force_quick_patterns:
285
+ - fix typo
286
+ `;
287
+ }
package/src/db.js CHANGED
@@ -202,7 +202,7 @@ export class DB {
202
202
  CREATE TABLE IF NOT EXISTS project (
203
203
  id INTEGER PRIMARY KEY DEFAULT 1,
204
204
  name TEXT NOT NULL,
205
- schema_version INTEGER DEFAULT 4,
205
+ schema_version INTEGER DEFAULT 5,
206
206
  created_at TEXT NOT NULL,
207
207
  updated_at TEXT NOT NULL
208
208
  )
@@ -356,9 +356,10 @@ function matchFileWhitelist(filePath) {
356
356
  }
357
357
 
358
358
  /**
359
- * 读取 local.yaml 中的扩展白名单配置(如果存在)
359
+ * 读取并解析 local.yaml(轻量手写解析,返回顶层对象)。
360
+ * 消费方读嵌套段 worktree-hook.readonlyCommands(见 isSingleCommandReadonly 的 extraReadonlyCommands)。
360
361
  * @param {string} cwd
361
- * @returns {{ fileWhitelist?: string[], readonlyCommands?: string[] }}
362
+ * @returns {Record<string, any>} 解析后的 local.yaml 顶层对象(文件缺/解析失败 → {})
362
363
  */
363
364
  function loadLocalConfig(cwd) {
364
365
  const candidates = [
package/src/index.js CHANGED
@@ -75,6 +75,7 @@ SillySpec CLI — 规范驱动开发工具包
75
75
  doctor [--fix] [--stale-hours N] 健康检查 + 修复
76
76
 
77
77
  sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
78
+ sillyspec config [schema] [--json] 打印 local.yaml 全部已知键 + 生效状态 + 读取点(堵外部 agent 配置发现缺口)
78
79
  sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
79
80
  sillyspec dispatch <probe | hint> SillyHub 派发能力探测 + 策略生成(agent 调用桥,仅渲染不执行 tool)
80
81
 
@@ -1543,10 +1544,10 @@ SillySpec modules — 模块文档管理
1543
1544
  lines.push('# 测试策略:full=全量测试, module=只测变更模块, skip=跳过测试');
1544
1545
  lines.push('test_strategy: module');
1545
1546
  lines.push('');
1546
- lines.push('# 模块测试路径映射(可选)');
1547
- lines.push('# module_paths:');
1548
- lines.push('# user-service: "user/"');
1549
- lines.push('# order-service: "order/"');
1547
+ lines.push('# monorepo 子模块映射(test_strategy: module 时按 git diff 命中模块收窄测试;inline flow 形态)');
1548
+ lines.push('# modules:');
1549
+ lines.push('# user-service: { path: "user/", test: "cd user && npm test" }');
1550
+ lines.push('# order-service: { path: "order/", test: "cd order && npm test" }');
1550
1551
  lines.push('# 已知预存失败(可选):verify 实测时这些失败被豁免、不阻断归档;fail-safe 仅豁免能检测到的失败行');
1551
1552
  lines.push('# known_failures:');
1552
1553
  lines.push('# - "tests/test_legacy.py::test_old"');
@@ -1560,6 +1561,42 @@ SillySpec modules — 模块文档管理
1560
1561
  console.log(` 路径: ${localYamlPath}`);
1561
1562
  break;
1562
1563
  }
1564
+ case 'config': {
1565
+ // local.yaml 键发现缺口(2026-08-11):键散落 ~10 个 reader,外部项目 agent 无从得知。
1566
+ // config schema 把全部已知键 + 生效状态 + 读取点打印出来,堵住发现缺口。
1567
+ // 数据源唯一 = src/config-schema.js(与 init 落盘的 local.yaml.example 同源)。
1568
+ const configSub = filteredArgs[1];
1569
+ const wantHelp = configSub === 'help' || configSub === '--help' || configSub === '-h';
1570
+ const isSchema = !configSub || configSub === 'schema';
1571
+ if (wantHelp) {
1572
+ console.log(`
1573
+ SillySpec config — local.yaml 配置键速查
1574
+
1575
+ 用法:
1576
+ sillyspec config 打印 local.yaml 全部已知键(人类可读树,默认)
1577
+ sillyspec config schema 同上(显式子命令)
1578
+ sillyspec config --json 机读 JSON(程序化消费)
1579
+ sillyspec config schema --json 同上
1580
+
1581
+ 说明:
1582
+ - 数据源:src/config-schema.js(唯一真相;reader 见各键「读取点」)
1583
+ - 键分两类:【生效】配了即生效;【声明但未接线】代码/JSDoc 提及但无 reader,配了不生效
1584
+ - 脱敏示例文件:sillyspec init 生成 local.yaml.example(可提交;真实 local.yaml 是 gitignored)
1585
+ `);
1586
+ break;
1587
+ }
1588
+ if (!isSchema) {
1589
+ const sug = didYouMean(configSub, ['schema']);
1590
+ console.error(`❌ 未知子命令: config ${configSub}`);
1591
+ if (sug) console.error(` 你是想输入「config ${sug}」吗?`);
1592
+ console.error('用法: sillyspec config [schema] [--json] 打印 local.yaml 配置键');
1593
+ process.exit(2);
1594
+ }
1595
+ const { renderSchemaHuman, renderSchemaJson } = await import('./config-schema.js');
1596
+ if (json) console.log(renderSchemaJson());
1597
+ else console.log(renderSchemaHuman());
1598
+ break;
1599
+ }
1563
1600
  case 'runtime': {
1564
1601
  // D2:.runtime/ 产物索引。CLI 往 .runtime/ 写各种证据/状态文件(db、doctor-diagnosis、
1565
1602
  // workflow-runs、user-inputs…),却没有命令让 agent 一眼看到「我手上有哪些产物」。
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ import { checkbox, confirm, input } from '@inquirer/prompts';
5
5
  import { ProgressManager } from './progress.js';
6
6
  import chalk from 'chalk';
7
7
  import { getVersion } from './version.js';
8
+ import { renderExample } from './config-schema.js';
8
9
  // 向后兼容:getVersion 已抽到轻量 version.js(避免 index.js 为 --version 静态加载 init.js 的 inquirer 税),
9
10
  // 此处 re-export 保持 init.js 既有导出 API 不破坏(如 test/init-claude-injection.test.mjs)。
10
11
  export { getVersion };
@@ -313,6 +314,18 @@ function doInstall(projectDir, tools, subprojects = [], specDir = null) {
313
314
  const pm = new ProgressManager({ specDir: spec });
314
315
  pm.init(projectDir);
315
316
 
317
+ // 落盘脱敏 local.yaml.example(config-schema.js 单一数据源;不存在才写,仿 local detect skip-if-exists)
318
+ // 真实 local.yaml 是 gitignored 含凭据;example 可提交,是给人/外部 agent 看的配置发现物。
319
+ const examplePath = join(spec, 'local.yaml.example');
320
+ if (!existsSync(examplePath)) {
321
+ try {
322
+ writeFileSync(examplePath, renderExample());
323
+ console.log(chalk.green(' ✓ local.yaml.example 已生成(脱敏配置示例,可提交;真实 local.yaml 由 sillyspec local detect / platform connect 写入)'));
324
+ } catch (e) {
325
+ console.warn(chalk.yellow(` ⚠ 生成 local.yaml.example 失败: ${e.message}`));
326
+ }
327
+ }
328
+
316
329
  // .gitignore 只在 specDir 在项目内时才修改
317
330
  const isExternalSpec = specDir && resolve(spec) !== resolve(projectDir, '.sillyspec');
318
331
  if (!isExternalSpec) {
@@ -1004,8 +1004,10 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1004
1004
 
1005
1005
  // ── Classify change on first entry ──
1006
1006
  if (!progress.stages?.brainstorm?.status && !progress.stages?.plan?.status) {
1007
- const { classifyChange } = await import('../classify-change.js')
1008
- const classification = classifyChange({ description: inputText || '', explicitMode })
1007
+ const { classifyChange, readAutoModeFromLocalYaml } = await import('../classify-change.js')
1008
+ // auto_mode 接线(2026-08-11):从 local.yaml 读 auto_mode 段传 localConfig,让 force_*_patterns 真生效。
1009
+ const localConfig = readAutoModeFromLocalYaml(cwd)
1010
+ const classification = classifyChange({ description: inputText || '', explicitMode, localConfig })
1009
1011
  if (classification.mode === 'quick') {
1010
1012
  console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
1011
1013
  console.log(` 此变更建议使用 quick 模式,运行:sillyspec run quick "${inputText || '需求'}"`)
package/src/run/prompt.js CHANGED
@@ -41,9 +41,9 @@ function loadModuleContextIndex(specBase, projectName) {
41
41
  // 提示 v1/v2 格式混用风险(旧格式字段解析会静默错位)。正常生成的文件不触发。
42
42
  const sv = content.match(/^schema_version:\s*(\d+)/m)
43
43
  if (!sv) {
44
- console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}`)
44
+ console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
45
45
  } else if (sv[1] !== '2') {
46
- console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}`)
46
+ console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
47
47
  }
48
48
  return parseModuleMapSimple(content)
49
49
  } catch {
@@ -452,7 +452,13 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
452
452
  } catch {}
453
453
  if (!reviewRunId) {
454
454
  reviewRunId = generateStageReviewRunId()
455
- try { mkdirSync(tierRuntimeRoot, { recursive: true }); writeFileSync(reviewRunIdMarker, reviewRunId + '\n') } catch {}
455
+ try {
456
+ mkdirSync(tierRuntimeRoot, { recursive: true })
457
+ writeFileSync(reviewRunIdMarker, reviewRunId + '\n')
458
+ // plan-c: echo 完整 review 目录路径,避免 agent 拿裸 runId 给子代理时漏连字符拼错路径
459
+ const reviewDir = `${tierRuntimeRoot}/stage-reviews/${stageName}-${reviewRunId}`
460
+ console.log(` 📁 Stage Review 写入目录(直接复制给 review 子代理,勿手拼 runId):${reviewDir}/`)
461
+ } catch {}
456
462
  }
457
463
  // review.json 产物契约(schema + 示例 + docHash 算法 + 重算提示)事前注入,与 validateStageReviewSchema 同源
458
464
  const reviewContractMd = renderReviewJsonContract({ stage: stageName, changeDir: tierChangeDir, reviewRunId, tier: tier.tier })
@@ -24,9 +24,18 @@ export function printQuickAuditReview(review) {
24
24
  console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
25
25
  // 解锁咒语须在 BLOCKED 分支也给出 —— 原只在 confirm/review 分支打印,
26
26
  // 普通 --done 走这里只看到「恢复/拆分」反方向指引,正确解法(带 flag 重跑)根本不出现。
27
- console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
28
- console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
29
- console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
27
+ // 但删除是例外:deletedFiles.length>0 是裸判定(auditQuickCompletion 判定行不受任何 flag
28
+ // 门控),--force-baseline / --allow-new 都救不回。此时甩 flag 建议会误导调用者照加后继续
29
+ // 被拦——改提示走完整流程 execute(有意设计:删除是高危操作,须 review 把关)。
30
+ if (review.deletedFiles && review.deletedFiles.length > 0) {
31
+ console.error(`\n ⛔ 本次含删除文件 ${review.deletedFiles.length} 个:${review.deletedFiles.join(', ')}`)
32
+ console.error(` quick 不支持删除——--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
33
+ console.error(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
34
+ } else {
35
+ console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
36
+ console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
37
+ console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
38
+ }
30
39
  } else if (review.status === 'warning') {
31
40
  console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
32
41
  for (const r of review.reasons) {
package/src/run/shared.js CHANGED
@@ -655,10 +655,15 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
655
655
  console.log(` - ${r}`)
656
656
  }
657
657
  }
658
- console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
659
- console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
660
- console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
661
- console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
658
+ if (result.deletedFiles.length > 0) {
659
+ console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——quick 不支持删除,--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
660
+ console.log(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
661
+ } else {
662
+ console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
663
+ console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
664
+ console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
665
+ console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
666
+ }
662
667
  }
663
668
  } catch (e) {
664
669
  result.reasons.push(`审计失败: ${e.message}`)
@@ -425,6 +425,11 @@ related_tests: # 可选。当本 task 改动会导致
425
425
 
426
426
  const prompt = `为 plan.md 中的每个任务生成紧凑 TaskCard。
427
427
 
428
+ ⚠️ 生成卡片前先确认 plan.md 已满足(否则下一步 postcheck 会硬拦,导致返工重编号/重分 Wave):
429
+ - **共享文件须分 Wave**:若多个 task 的 allowed_path 含同一文件,plan.md 必须把它们分到不同「## Wave N」(同 Wave 共享文件会被 execute 强制并行,子代理互相覆盖;postcheck 拦同 Wave 共享)
430
+ - **task id 从 1 连续**:task-01、task-02、task-03… 不能跳号或重号(postcheck 校验 id 连续性,gap 会拦)
431
+ 这两条是跨 task 全局约束,子代理只写单卡看不到全局——你(主 agent)分派子代理前必须先在 plan.md 里确认 Wave 划分与编号正确。
432
+
428
433
  ## 任务清单
429
434
  ${taskList}
430
435
 
package/src/sync.js CHANGED
@@ -40,8 +40,13 @@ const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md
40
40
  // ── YAML 辅助 ──
41
41
 
42
42
  /**
43
- * 简易 YAML 读写,只处理 project 段的扁平结构。
43
+ * 简易 YAML 读取(解析为扁平 obj),供 _getPlatform / status 只读用。
44
44
  * 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
45
+ *
46
+ * 写入侧(connect/disconnect)**不再**走 parse→modify→write 有损往返——
47
+ * readLocalYaml/parseSimpleYaml 丢注释(:85 跳 # 行)、丢数组/深嵌套(只认一层 key:value),
48
+ * round-trip 会清空用户手填的注释与其他段。改为 readLocalYamlRaw + replaceTopLevelSection
49
+ * 文本级定向替换 platform 段,原文件注释/空行/其他段/数组/深嵌套/CRLF 字节级保留。
45
50
  */
46
51
  function readLocalYaml(cwd) {
47
52
  const p = join(cwd, LOCAL_YAML);
@@ -53,28 +58,81 @@ function readLocalYaml(cwd) {
53
58
  }
54
59
  }
55
60
 
56
- function writeLocalYaml(cwd, obj) {
57
- const dir = join(cwd, '.sillyspec');
58
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
59
- const lines = [];
60
- const rootKeys = Object.keys(obj);
61
- for (const key of rootKeys) {
62
- const val = obj[key];
63
- if (val === null || val === undefined) continue;
64
- if (typeof val === 'object' && !Array.isArray(val)) {
65
- lines.push(`${key}:`);
66
- for (const [k, v] of Object.entries(val)) {
67
- if (typeof v === 'string') {
68
- lines.push(` ${k}: ${v}`);
69
- } else {
70
- lines.push(` ${k}: ${JSON.stringify(v)}`);
71
- }
72
- }
73
- } else {
74
- lines.push(`${key}: ${typeof val === 'string' ? val : JSON.stringify(val)}`);
61
+ /** 读 local.yaml 原始文本(保留注释/换行/结构),不存在返回 ''。供 connect/disconnect 文本级改写。 */
62
+ function readLocalYamlRaw(cwd) {
63
+ const p = join(cwd, LOCAL_YAML);
64
+ if (!existsSync(p)) return '';
65
+ try {
66
+ return readFileSync(p, 'utf8');
67
+ } catch {
68
+ return '';
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 定位顶层 YAML 段(如 platform:/mcp:)的行范围 [start, end)。
74
+ * 段 = key 行(行首非空白 + 以 'name:' 开头)+ 后续连续缩进行(以空格/tab 开头)。
75
+ * 遇空行/注释/下一个顶层 key 即段结束——这些行不属于本段,保留不动。
76
+ * split('\n')/join('\n') 操作:CRLF 下 '\r' 留在行尾,重组原样还原(Windows 兼容,CLAUDE.md #13)。
77
+ * @param {string} text
78
+ * @param {string} name - 段名(不含冒号,如 'platform')
79
+ * @returns {{start: number, end: number} | null} 不存在返回 null
80
+ */
81
+ function findTopLevelSectionRange(text, name) {
82
+ const lines = text.split('\n');
83
+ const prefix = `${name}:`;
84
+ let start = -1;
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i];
87
+ // 顶层 key 行:行首非空白(排除缩进子段)+ 以 'name:' 开头
88
+ if (/^\S/.test(line) && line.startsWith(prefix)) {
89
+ start = i;
90
+ break;
75
91
  }
76
92
  }
77
- writeFileSync(join(cwd, LOCAL_YAML), lines.join('\n') + '\n', 'utf8');
93
+ if (start === -1) return null;
94
+ let end = start + 1;
95
+ while (end < lines.length && /^[ \t]/.test(lines[end])) {
96
+ end++;
97
+ }
98
+ return { start, end };
99
+ }
100
+
101
+ /**
102
+ * 文本级定向替换/删除/追加一个顶层 YAML 段,保留文件其余所有字节
103
+ * (注释/空行/其他段/数组/深嵌套/CRLF 全保留)。
104
+ * @param {string} text - 原始文件文本
105
+ * @param {string} name - 段名(不含冒号)
106
+ * @param {string|null} body - 段体(不含 key 行);null=删除该段;string=替换或追加
107
+ * @returns {string} 新文本
108
+ */
109
+ function replaceTopLevelSection(text, name, body) {
110
+ const lines = text.split('\n');
111
+ const range = findTopLevelSectionRange(text, name);
112
+ if (range) {
113
+ const before = lines.slice(0, range.start);
114
+ const after = lines.slice(range.end);
115
+ if (body === null) {
116
+ return [...before, ...after].join('\n');
117
+ }
118
+ const sectionLines = [`${name}:`, ...body.split('\n')];
119
+ return [...before, ...sectionLines, ...after].join('\n');
120
+ }
121
+ // 段不存在
122
+ if (body === null) return text; // 删不存在的段,原样返回
123
+ // 追加:去尾换行后加空行分隔 + 新段;空文件直接起段
124
+ const stripped = text.replace(/(\r?\n)+$/, '');
125
+ if (stripped === '') {
126
+ return `${name}:\n${body}\n`;
127
+ }
128
+ return `${stripped}\n\n${name}:\n${body}\n`;
129
+ }
130
+
131
+ /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。 */
132
+ function writeLocalYamlRaw(cwd, text) {
133
+ const dir = join(cwd, '.sillyspec');
134
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
135
+ writeFileSync(join(cwd, LOCAL_YAML), text, 'utf8');
78
136
  }
79
137
 
80
138
  function parseSimpleYaml(content) {
@@ -201,7 +259,8 @@ export class SyncManager {
201
259
  */
202
260
  async connect(url, token, user) {
203
261
  // 验证连接
204
- const healthUrl = `${url.replace(/\/+$/, '')}/api/health`;
262
+ const normalizedUrl = url.replace(/\/+$/, '');
263
+ const healthUrl = `${normalizedUrl}/api/health`;
205
264
  const result = await fetchJson(healthUrl);
206
265
  if (result === null) {
207
266
  console.warn(`[sync] 平台连接验证失败: ${url}`);
@@ -212,21 +271,23 @@ export class SyncManager {
212
271
  // 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
213
272
  const resolvedUser = resolvePlatformUser(this.cwd, user);
214
273
 
215
- // 写入 local.yaml
216
- const config = readLocalYaml(this.cwd);
217
- config.platform = {
218
- url: url.replace(/\/+$/, ''),
219
- token,
220
- last_connected: new Date().toISOString(),
221
- };
274
+ // 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
275
+ // (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
276
+ const platformEntries = [
277
+ ` url: ${normalizedUrl}`,
278
+ ` token: ${token}`,
279
+ ` last_connected: ${new Date().toISOString()}`,
280
+ ];
222
281
  if (resolvedUser) {
223
- config.platform.user = resolvedUser;
282
+ platformEntries.push(` user: ${resolvedUser}`);
224
283
  }
225
- // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09)
226
- if (!config.mcp) {
227
- config.mcp = { url: url.replace(/\/+$/, ''), token };
284
+ let text = readLocalYamlRaw(this.cwd);
285
+ text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
286
+ // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
287
+ if (findTopLevelSectionRange(text, 'mcp') === null) {
288
+ text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
228
289
  }
229
- writeLocalYaml(this.cwd, config);
290
+ writeLocalYamlRaw(this.cwd, text);
230
291
  }
231
292
 
232
293
  /**
@@ -239,17 +300,18 @@ export class SyncManager {
239
300
  console.log('[sync] 已断开连接(无配置文件)');
240
301
  return;
241
302
  }
242
- const config = readLocalYaml(this.cwd);
243
- if (!config.platform) {
303
+ const text = readFileSync(p, 'utf8');
304
+ if (findTopLevelSectionRange(text, 'platform') === null) {
244
305
  console.log('[sync] 已断开连接(未连接)');
245
306
  return;
246
307
  }
247
- delete config.platform;
248
- if (Object.keys(config).length === 0) {
249
- // 配置为空,删除整个文件
308
+ // 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
309
+ const newText = replaceTopLevelSection(text, 'platform', null);
310
+ if (newText.trim() === '') {
311
+ // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
250
312
  try { unlinkSync(p); } catch { /* best effort */ }
251
313
  } else {
252
- writeLocalYaml(this.cwd, config);
314
+ writeFileSync(p, newText, 'utf8');
253
315
  }
254
316
  console.log('[sync] 已断开连接');
255
317
  }