sillyspec 3.25.11 → 3.26.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.
@@ -191,24 +191,63 @@ export const definition = {
191
191
  2. 调用 \`sillyspec local detect\` 命令生成 local.yaml(该命令封装了纯 fs 项目类型嗅探:package.json→nodejs / pom.xml→maven / build.gradle→gradle / Makefile→make / 否则 generic,对应默认 commands)。**不要自行嗅探 package.json/pom.xml/build.gradle 等文件**——探测逻辑唯一归属 local-detect.js,本步骤只负责调用命令并报告结果。
192
192
  3. 该命令内部已处理:确保目录存在(mkdir -p)、已存在则跳过、原子写入(先写 tmp 再 rename)。
193
193
  4. 报告生成的 project.type 与 local.yaml 路径。
194
-
195
- ### 输出格式参考(由 \`sillyspec local detect\` 自动产出)
194
+ 5. **detect 只生成 \`project.type\` / \`commands.{build,test,lint}\`(存在性核验驱动)/ \`test_strategy\` 骨架**,以下策略字段由你(agent)按项目实际情况补充——能在 package.json / lockfile / 构建文件 / CI 配置 / README 中找到依据就填,不确定留空或注释:
195
+ - \`commands.install\`:依赖安装命令(如 \`npm install\` / \`pnpm install\` / \`yarn\`)
196
+ - \`env\`:构建/测试所需环境变量键值(仅从 README / CI 配置 / .env.example 能确证的)
197
+ - \`test_strategy\`:detect 未注入时按项目实际补(full=全量 / module=只测变更模块 / skip=跳过测试)
198
+ - \`modules\`:模块路径根(module_paths,如 \`src/\` / \`packages/\`)
199
+ - \`known_failures\`:已知失败/不稳定测试清单(仅从 CI 历史 / 测试注释能确证的)
200
+
201
+ ### platform / dispatch / mcp 段检查(FR-08,可选补充)
202
+ detect 不写这三段,由你按项目是否接入 sillyhub 平台决定是否补:
203
+ - **platform 段缺失**(要接入 sillyhub 平台)→ 运行 \`sillyspec platform connect <url> <token>\` 自动写入(推荐)
204
+ - **dispatch 段调参缺失**(启用 sillyhub 派发且需调优)→ 手填示例 \`probe_ttl_ms\` / \`poll_interval_ms\` / \`worker_timeout_ms\`(仅在你确知调优值时填,否则留默认)
205
+ - **mcp 段缺失**(dispatch worker 走 MCP 协议连 sillyhub)→ 优先 \`sillyspec platform connect\`(同源假设,platform+mcp 同写);不同源则手填 \`mcp.url\` / \`mcp.token\`,或设环境变量 \`SILLYHUB_MCP_URL\` / \`SILLYHUB_MCP_TOKEN\`(不入盘)
206
+
207
+ ### ⛔ 铁律(R-04,防编造)
208
+ 只写能从 package.json / lockfile / 构建文件 / CI 配置 / README 中确证的事实。不确定的字段一律留空或写注释占位,**绝不编造命令、路径或凭据**。commands 键的存在性已由 detect 核验,不要重复核验。
209
+
210
+ ### 输出格式参考(detect 自动产出骨架 + agent 补充策略字段)
196
211
  \`\`\`yaml
197
212
  # SillySpec 本地配置(自动生成,可手动修改)
198
213
  project:
199
214
  type: nodejs # nodejs/maven/gradle/make/generic
200
215
 
216
+ # commands 键存在性由 detect 核验 package.json scripts 驱动:
217
+ # 有 scripts.build 才写 build,有 scripts.test 才写 test,有 scripts.lint 才写 lint
218
+ # (仅 test/lint 无 build 的项目,build 键不写)
201
219
  commands:
202
- build: "npm run build"
220
+ build: "npm run build" # 仅 scripts.build 存在时由 detect 写入
203
221
  test: "npm test"
204
222
  lint: "npm run lint"
223
+ # install: "npm install" # ← agent 补:依赖安装命令
205
224
 
206
225
  # 测试策略:full=全量测试, module=只测变更模块, skip=跳过测试
207
226
  test_strategy: module
227
+
228
+ # ↓ 以下为 agent 按项目实际补充的策略字段(不确定留空或注释)
229
+ # env: # ← agent 补:构建/测试所需环境变量
230
+ # NODE_ENV: test
231
+ # modules: # ← agent 补:模块路径根(module_paths)
232
+ # - src/
233
+ # known_failures: # ← agent 补:已知失败/不稳定测试
234
+ # - "test/flaky.test.mjs"
235
+
236
+ # ↓ 外部连接段(接入 sillyhub 平台时补,detect 不写)
237
+ # platform: # ← sillyspec platform connect <url> <token> 写入
238
+ # url: ...
239
+ # token: ...
240
+ # mcp: # ← platform connect 同源写入 或 手填 或 env(SILLYHUB_MCP_URL/TOKEN)
241
+ # url: ...
242
+ # token: ...
243
+ # dispatch: # ← agent 按需调参(确知调优值才填)
244
+ # probe_ttl_ms: 30000
245
+ # poll_interval_ms: 2000
246
+ # worker_timeout_ms: 60000
208
247
  \`\`\`
209
248
 
210
249
  ### 输出
211
- local.yaml 生成结果(已存在/已生成 + project.type)`,
250
+ local.yaml 生成结果(已存在/已生成 + project.type + agent 补充的策略字段摘要)`,
212
251
  outputHint: 'local.yaml 生成状态',
213
252
  optional: false
214
253
  },
@@ -538,7 +577,7 @@ INDEX.md 维护索引,格式(每行:关键词1|关键词2 → [条目名](
538
577
  7. 确认所有文档都写入 \`{DOCS_ROOT}/\`(spec-root 下),**而非源码目录下的 .sillyspec/**
539
578
  8. 检查是否出现 tool_use_error 或 API Error 未恢复
540
579
  9. 检查 7 份文档 header 是否包含 author 和 created_at
541
- 10. 检查 local.yaml 中 commands 是否在 package.json scripts 中真实存在,不存在的必须标记 unavailable
580
+ 10. 复查 detect 核验结果——commands 键已由 detect 核验 scripts 存在性,agent 无需重复核验或标 unavailable,仅确认 local.yaml 与 detect 产出一致
542
581
 
543
582
  ### ⛔ API 错误处理
544
583
  - 遇到 API Error 529(服务过载)或 rate_limit 时,**停止当前操作并报告**,不要自动重试
@@ -66,7 +66,7 @@ export const definition = {
66
66
  - 如果存在 P0/P1 unresolved/blocking 决策,验证结论不能为 PASS
67
67
  - 如果发现 superseded 决策被下游引用,标记为 ⚠️ stale decision reference
68
68
  3. 加载项目信息:\`cat .sillyspec/projects/*.yaml 2>/dev/null\`
69
- 4. 加载本地配置:\`cat .sillyspec/local.yaml 2>/dev/null\`(构建命令、测试命令、lint 命令等)
69
+ 4. 加载本地配置:\`cat .sillyspec/local.yaml 2>/dev/null\`(构建命令、测试命令、lint 命令等)若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
70
70
  5. 加载代码规范:\`cat .sillyspec/docs/<project>/scan/CONVENTIONS.md 2>/dev/null\`
71
71
  - 测试现状:\`cat .sillyspec/docs/<project>/scan/TESTING.md 2>/dev/null\`(了解既有测试约定与覆盖范围,验收时对照)
72
72
  - 技术债清单:\`cat .sillyspec/docs/<project>/scan/CONCERNS.md 2>/dev/null\`(🔴/🟡 区域;本次变更若触碰须在 verify-result.md 标注)
@@ -164,7 +164,7 @@ export const definition = {
164
164
  prompt: `运行代码质量扫描(测试实测统一由 CLI 对账执行,本步不重复手动跑全量)。
165
165
 
166
166
  ### 操作
167
- 1. 读取 \`.sillyspec/local.yaml\` 获取构建、测试和 lint 命令
167
+ 1. 读取 \`.sillyspec/local.yaml\` 获取构建、测试和 lint 命令若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
168
168
  2. **不要手动重复跑 commands.test**——CLI 会在最终 --done 时统一执行一次(按变更命中模块子集),本步再跑 = 与 CLI 对账重复耗时(实测 198s×2)。如为提前发现实现问题,可对变更模块做**针对性快速冒烟**(可选,非必需):
169
169
  - Maven:\`mvn test -pl <变更模块> -am\`(仅编译变更模块及其依赖)
170
170
  - Gradle:\`./gradlew :<模块>:test\`
package/src/sync.js CHANGED
@@ -8,9 +8,10 @@
8
8
  * HTTP 请求:Node.js 原生 fetch(Node 22+)
9
9
  */
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { resolvePlatformSpecDir } from './progress.js';
14
+ import { safeGit } from './git-helper.js';
14
15
 
15
16
  // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
16
17
  function safePlatformSpecDir(cwd) {
@@ -107,6 +108,23 @@ function parseSimpleYaml(content) {
107
108
  return result;
108
109
  }
109
110
 
111
+ /**
112
+ * 解析推送者身份(user):区分多用户,供 push 时 X-SillySpec-User 标识(design D-004)。
113
+ * 优先级:显式参数 > git user.name(与 quicklog / prompt <git-user> 同口径)> process.env.USER
114
+ * (Unix)/ USERNAME(Windows)。Best effort:全失败返回 null,调用方据此决定是否写 user 字段
115
+ * (缺字段等价于「未知推送者」,平台侧自行兜底,不报错)。
116
+ */
117
+ function resolvePlatformUser(cwd, explicitUser) {
118
+ if (typeof explicitUser === 'string' && explicitUser.trim()) {
119
+ return explicitUser.trim();
120
+ }
121
+ // 回退 1:git user.name(safeGit 失败返回 null,不抛)
122
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value;
123
+ if (gitUser) return gitUser;
124
+ // 回退 2:环境变量(USER=Unix / USERNAME=Windows;跨平台兼容 CLAUDE.md #13)
125
+ return process.env.USER || process.env.USERNAME || null;
126
+ }
127
+
110
128
  // ── HTTP 辅助 ──
111
129
 
112
130
  async function fetchJson(url, options = {}) {
@@ -136,6 +154,37 @@ async function fetchJson(url, options = {}) {
136
154
  }
137
155
  }
138
156
 
157
+ /**
158
+ * 带状态码的 fetch:不吞非 2xx(fetchJson 在 !res.ok 时返回 null 丢状态,无法区分 409 冲突)。
159
+ * 返回 { ok, status, body }:body 尽力 JSON.parse(平台冲突响应带 progress JSON,读回给调用方)。
160
+ * 仅 sync() 的 progress POST 使用(识别 base_ts 乐观锁冲突,D-015 / task-09);其余调用仍走 fetchJson。
161
+ */
162
+ async function fetchJsonWithStatus(url, options = {}) {
163
+ const controller = new AbortController();
164
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
165
+ try {
166
+ const res = await fetch(url, { ...options, signal: controller.signal });
167
+ const text = await res.text().catch(() => '');
168
+ let body = null;
169
+ if (text) {
170
+ try { body = JSON.parse(text); } catch { body = null; }
171
+ }
172
+ if (!res.ok) {
173
+ console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
174
+ }
175
+ return { ok: res.ok, status: res.status, body };
176
+ } catch (err) {
177
+ if (err.name === 'AbortError') {
178
+ console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
179
+ } else {
180
+ console.warn(`[sync] ${url} 请求失败: ${err.message}`);
181
+ }
182
+ return { ok: false, status: 0, body: null };
183
+ } finally {
184
+ clearTimeout(timer);
185
+ }
186
+ }
187
+
139
188
  // ── SyncManager ──
140
189
 
141
190
  export class SyncManager {
@@ -146,8 +195,11 @@ export class SyncManager {
146
195
  /**
147
196
  * 连接 SillyHub 平台。
148
197
  * 保存配置到 .sillyspec/local.yaml,发送 ping 验证连接。
198
+ * @param {string} url - 平台地址
199
+ * @param {string} token - 认证 token
200
+ * @param {string} [user] - 推送者身份(可空,回退 git user.name / env,见 resolvePlatformUser)
149
201
  */
150
- async connect(url, token) {
202
+ async connect(url, token, user) {
151
203
  // 验证连接
152
204
  const healthUrl = `${url.replace(/\/+$/, '')}/api/health`;
153
205
  const result = await fetchJson(healthUrl);
@@ -157,6 +209,9 @@ export class SyncManager {
157
209
  }
158
210
  console.log(`[sync] 平台连接成功: ${url}`);
159
211
 
212
+ // 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
213
+ const resolvedUser = resolvePlatformUser(this.cwd, user);
214
+
160
215
  // 写入 local.yaml
161
216
  const config = readLocalYaml(this.cwd);
162
217
  config.platform = {
@@ -164,6 +219,13 @@ export class SyncManager {
164
219
  token,
165
220
  last_connected: new Date().toISOString(),
166
221
  };
222
+ if (resolvedUser) {
223
+ config.platform.user = resolvedUser;
224
+ }
225
+ // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09)
226
+ if (!config.mcp) {
227
+ config.mcp = { url: url.replace(/\/+$/, ''), token };
228
+ }
167
229
  writeLocalYaml(this.cwd, config);
168
230
  }
169
231
 
@@ -194,8 +256,9 @@ export class SyncManager {
194
256
 
195
257
  /**
196
258
  * 增量同步变更的 progress 状态到平台。
197
- * 读取 ProgressManager.read() 的数据,POST 到平台。
198
- * 同步完成后更新 changes 表的 platform_last_sync 字段。
259
+ * 读取 ProgressManager.serializeForSync() 的六表 JSON,POST 到平台。
260
+ * 元字段(user/base_ts/pushed_at)走 HTTP header,body 保持裸 JSON(D-015,sillyhub 老版零回归)。
261
+ * 同步完成后更新 changes 表的 platform_last_sync 字段;409 冲突读回平台最新 JSON(task-12 完整冲突处理)。
199
262
  */
200
263
  async sync(changeName) {
201
264
  const platform = this._getPlatform();
@@ -216,29 +279,58 @@ export class SyncManager {
216
279
  return { synced: 0, errors: [`变更不存在: ${changeName}`] };
217
280
  }
218
281
 
219
- // 读取 progress 数据(通过导入 ProgressManager 动态调用)
282
+ // 读取 progress 数据(serializeForSync 六表裸 JSON,task-02 / D-005@v2,替代 read() 聚合视图)
220
283
  let progressData;
221
284
  try {
222
285
  const { ProgressManager } = await import('./progress.js');
223
286
  const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
224
- progressData = pm.read(this.cwd, changeName);
287
+ progressData = pm.serializeForSync(this.cwd, changeName);
225
288
  } catch (err) {
226
289
  console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
227
290
  return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
228
291
  }
292
+ if (progressData === null) {
293
+ console.warn(`[sync] 变更无进度数据 (无活跃进度): ${changeName}`);
294
+ return { synced: 0, errors: [`变更无进度数据: ${changeName}`] };
295
+ }
296
+
297
+ // 元字段走 HTTP header(D-015 / task-09):body 保持裸六表 JSON,sillyhub 老版忽略 header 零回归
298
+ const headers = {
299
+ 'Content-Type': 'application/json',
300
+ Authorization: `Bearer ${platform.token}`,
301
+ };
302
+ const pushedAt = new Date().toISOString();
303
+ if (platform.user) headers['X-SillySpec-User'] = platform.user; // 推送者身份(task-08 local.yaml platform.user)
304
+ const baseTs = progressData.changes && progressData.changes[0] && progressData.changes[0].last_synced_platform_ts;
305
+ if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
306
+ headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
229
307
 
230
- // POST 到平台
308
+ // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)
231
309
  const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
232
- const result = await fetchJson(syncUrl, {
310
+ const res = await fetchJsonWithStatus(syncUrl, {
233
311
  method: 'POST',
234
- headers: {
235
- 'Content-Type': 'application/json',
236
- Authorization: `Bearer ${platform.token}`,
237
- },
312
+ headers,
238
313
  body: JSON.stringify(progressData),
239
314
  });
240
315
 
241
- if (!result) {
316
+ if (!res.ok) {
317
+ // 409 = base_ts 乐观锁冲突(平台已有更新);读回平台最新 JSON 供 task-12 完整冲突处理
318
+ if (res.status === 409) {
319
+ console.warn(`[sync] 冲突: ${changeName} 平台已有更新(base_ts 过期),请 platform status / resolve 处理`);
320
+ // 平台 409 响应 { conflict:true, platform_progress, last_pushed_at },platform_progress 即平台最新 progress JSON
321
+ const platformProgress = res.body && res.body.platform_progress ? res.body.platform_progress : res.body;
322
+ const platformLastPushedAt = (res.body && res.body.last_pushed_at) || null;
323
+ // 本地脏度(progressData 是 serializeForSync 输出,changes[0] 含 last_local_modified_ts)
324
+ const localModified = (progressData.changes && progressData.changes[0] && progressData.changes[0].last_local_modified_ts) || null;
325
+ // 写冲突文件(task-12 / D-002):base_ts=本次 push 的 base,强制提示走 resolve
326
+ const conflictPath = this._writeConflictFile(changeName, {
327
+ base_ts: baseTs || null,
328
+ local_modified_ts: localModified,
329
+ platform_last_pushed_at: platformLastPushedAt,
330
+ platform_progress: platformProgress,
331
+ });
332
+ return { synced: 0, errors: [`冲突: ${changeName}`], conflict: true, platform_progress: platformProgress, conflictPath };
333
+ }
242
334
  return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
243
335
  }
244
336
 
@@ -381,6 +473,310 @@ export class SyncManager {
381
473
  const config = readLocalYaml(this.cwd);
382
474
  return config.platform || null;
383
475
  }
476
+
477
+ /**
478
+ * 写冲突文件 .runtime/sync-conflict-<change>.json(task-12 / D-002 / D-008 / D-010 / FR-05)。
479
+ * push 409 与 pull 本地脏度双向冲突命中时调用,强制提示用户走 platform resolve 三选一。
480
+ * 绝不字段级 auto-merge;文件在 .runtime 下不入版本控制(gitignore)。
481
+ * @param {string} changeName
482
+ * @param {{base_ts?: string|null, local_modified_ts?: string|null, platform_last_pushed_at?: string|null, platform_progress?: object|null}} info
483
+ * @returns {string|null} 冲突文件路径(specDir 不可达返回 null)
484
+ */
485
+ _writeConflictFile(changeName, info = {}) {
486
+ const specDir = safePlatformSpecDir(this.cwd);
487
+ if (!specDir) return null;
488
+ const runtimeDir = join(specDir, '.runtime');
489
+ if (!existsSync(runtimeDir)) mkdirSync(runtimeDir, { recursive: true });
490
+ const conflictPath = join(runtimeDir, `sync-conflict-${changeName}.json`);
491
+ const payload = {
492
+ change: changeName,
493
+ base_ts: info.base_ts ?? null,
494
+ local_modified_ts: info.local_modified_ts ?? null,
495
+ platform_last_pushed_at: info.platform_last_pushed_at ?? null,
496
+ created_at: new Date().toISOString(),
497
+ // 额外存平台最新 progress JSON,供 resolve --take-platform 直接 import(无需再 pull)
498
+ platform_progress: info.platform_progress ?? null,
499
+ };
500
+ writeFileSync(conflictPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
501
+ return conflictPath;
502
+ }
503
+
504
+ /**
505
+ * 读取冲突文件(task-13 resolve / task-14 status 用)。
506
+ * @returns {object|null} 冲突文件内容;无文件返回 null
507
+ */
508
+ readConflictFile(changeName) {
509
+ const specDir = safePlatformSpecDir(this.cwd);
510
+ if (!specDir) return null;
511
+ const conflictPath = join(specDir, '.runtime', `sync-conflict-${changeName}.json`);
512
+ if (!existsSync(conflictPath)) return null;
513
+ try {
514
+ return JSON.parse(readFileSync(conflictPath, 'utf8'));
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * 删除冲突文件(resolve 完成后清理,task-13 用)。
522
+ * @returns {boolean} 是否删除(文件不存在返回 false)
523
+ */
524
+ clearConflictFile(changeName) {
525
+ const specDir = safePlatformSpecDir(this.cwd);
526
+ if (!specDir) return false;
527
+ const conflictPath = join(specDir, '.runtime', `sync-conflict-${changeName}.json`);
528
+ if (!existsSync(conflictPath)) return false;
529
+ try { unlinkSync(conflictPath); return true; }
530
+ catch { return false; }
531
+ }
532
+
533
+ /**
534
+ * 两级 pull 第一级:轻量 change 列表(design §7 / D-001 / D-006 / task-06 / FR-01 / FR-03)。
535
+ * GET {url}/api/changes → [{ name, current_stage, last_pushed_at, last_pusher }](兼容 { changes: [...] } 包裹)。
536
+ * CLI 比对本地决定哪些 change 需更新,控制 pull 性能(不每次全量拉单 change JSON)。
537
+ * Best Effort:未连接 / 网络失败 console.warn 不抛,返回 PullListResult { ok, changes, reason? }。
538
+ * @returns {Promise<{ok: boolean, changes: Array<object>, reason?: string}>}
539
+ */
540
+ async pullList() {
541
+ const platform = this._getPlatform();
542
+ if (!platform) {
543
+ debugLog('[sync] 未连接平台(本地合法状态);pullList 跳过');
544
+ return { ok: false, changes: [], reason: '未连接平台' };
545
+ }
546
+
547
+ const listUrl = `${platform.url}/api/changes`;
548
+ const result = await fetchJson(listUrl, {
549
+ headers: { Authorization: `Bearer ${platform.token}` },
550
+ });
551
+
552
+ if (result === null) {
553
+ console.warn(`[sync] 拉取变更列表失败: ${listUrl}`);
554
+ return { ok: false, changes: [], reason: '拉取变更列表失败' };
555
+ }
556
+
557
+ // 兼容两种响应形态:裸数组 / { changes: [...] } 包裹(sillyhub 后端实际 API 而定)
558
+ const changes = Array.isArray(result) ? result : (Array.isArray(result.changes) ? result.changes : []);
559
+ return { ok: true, changes, reason: undefined };
560
+ }
561
+
562
+ /**
563
+ * 两级 pull 第二级:按需拉平台单 change 完整 JSON 并 import 重建本地 DB 行(design §7 / D-001 / D-006 / D-014 / task-07 / FR-01 / FR-03 / FR-09)。
564
+ * GET {url}/api/changes/{name}/progress → 平台权威 JSON(serializeForSync 六表 + 顶层 last_pushed_at)。
565
+ * 本地脏度比对:last_local_modified_ts > last_synced_platform_ts(本地有未同步改动)且 平台 last_pushed_at 更新 → 冲突,不 import 返回 conflict:true(task-12 负责写 sync-conflict 文件)。
566
+ * 无冲突 → import() 重建 DB 行;import 后本地 base_ts/脏度重置为 pushed_at(D-013)。
567
+ * Best Effort:未连接 / 网络失败 / 404 console.warn 不抛,返回 PullResult { ok, imported, conflict, reason? }。
568
+ * @param {string} changeName
569
+ * @param {{force?: boolean}} [opts] - force 跳过本地脏度冲突检测直接 import(task-12 resolve --take-platform 用)
570
+ * @returns {Promise<{ok: boolean, imported: boolean, conflict: boolean, reason?: string}>}
571
+ */
572
+ async pull(changeName, opts = {}) {
573
+ const { force = false } = opts;
574
+ const platform = this._getPlatform();
575
+ if (!platform) {
576
+ debugLog('[sync] 未连接平台(本地合法状态);pull 跳过');
577
+ return { ok: false, imported: false, conflict: false, reason: '未连接平台' };
578
+ }
579
+ if (!changeName) {
580
+ console.warn('[sync] pull 需要指定变更名称 (changeName)');
581
+ return { ok: false, imported: false, conflict: false, reason: '未指定变更名称' };
582
+ }
583
+
584
+ // 1. GET 平台完整 progress JSON
585
+ const progressUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
586
+ const result = await fetchJson(progressUrl, {
587
+ headers: { Authorization: `Bearer ${platform.token}` },
588
+ });
589
+ if (result === null) {
590
+ // sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort 降级
591
+ console.warn(`[sync] 拉取变更进度失败: ${changeName}(sillyhub 未就绪或变更不存在)`);
592
+ return { ok: false, imported: false, conflict: false, reason: '拉取变更进度失败' };
593
+ }
594
+
595
+ // 平台响应:serializeForSync 六表 + 顶层 last_pushed_at(兼容 { progress: {...} } 包裹)
596
+ const platformProgress = (result && result.project && result.changes) ? result : (result.progress || result);
597
+ const platformPushedAt = (result && result.last_pushed_at) || null;
598
+
599
+ // 2. 本地脏度比对(非 force 时):本地脏 AND 平台更新 → 冲突
600
+ if (!force) {
601
+ let localLastModified = null;
602
+ let localLastSynced = null;
603
+ try {
604
+ const { ProgressManager } = await import('./progress.js');
605
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
606
+ const row = pm._ensureDB(this.cwd).getDb().prepare(
607
+ 'SELECT last_local_modified_ts, last_synced_platform_ts FROM changes WHERE name = ?'
608
+ ).get(changeName);
609
+ if (row) {
610
+ localLastModified = row.last_local_modified_ts;
611
+ localLastSynced = row.last_synced_platform_ts;
612
+ }
613
+ } catch (err) {
614
+ console.warn(`[sync] 读取本地脏度失败 (${changeName}): ${err.message}`);
615
+ }
616
+ const localDirty = localLastModified && localLastSynced && localLastModified > localLastSynced;
617
+ const platformNewer = platformPushedAt && (!localLastSynced || platformPushedAt > localLastSynced);
618
+ if (localDirty && platformNewer) {
619
+ console.warn(`[sync] pull 冲突: ${changeName} 本地有未同步改动且平台已更新(base_ts 过期),请 platform resolve 处理`);
620
+ // 写冲突文件(task-12 / D-002):强制提示,绝不 auto-merge
621
+ const conflictPath = this._writeConflictFile(changeName, {
622
+ base_ts: localLastSynced,
623
+ local_modified_ts: localLastModified,
624
+ platform_last_pushed_at: platformPushedAt,
625
+ platform_progress: platformProgress,
626
+ });
627
+ return { ok: false, imported: false, conflict: true, reason: `冲突: ${changeName} 本地脏且平台更新`, conflictPath };
628
+ }
629
+ }
630
+
631
+ // 3. 无冲突(或 force)→ import 平台 JSON 重建本地 DB 行
632
+ try {
633
+ const { ProgressManager } = await import('./progress.js');
634
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
635
+ const importInput = { ...platformProgress, pushed_at: platformPushedAt || new Date().toISOString() };
636
+ pm.import(this.cwd, importInput, changeName);
637
+ console.log(`[sync] 已拉取变更进度: ${changeName}`);
638
+ return { ok: true, imported: true, conflict: false, reason: undefined };
639
+ } catch (err) {
640
+ console.warn(`[sync] import 失败 (${changeName}): ${err.message}`);
641
+ return { ok: false, imported: false, conflict: false, reason: `import 失败: ${err.message}` };
642
+ }
643
+ }
644
+
645
+ /**
646
+ * 冲突解决三选一(task-13 / D-002 / D-010 / D-013 / FR-05)。
647
+ * 读 task-12 写的 sync-conflict-<change>.json,按 mode 处理后必清冲突文件防累积(R-04 / constraints)。
648
+ *
649
+ * 三种语义(design 生命周期契约表):
650
+ * - keep-local:本地 DB 不变,仅把 last_synced_platform_ts(base_ts)推进到平台 last_pushed_at
651
+ * (表示「已知平台最新,本地为准」),用户后续手动 platform sync push 本地。
652
+ * - take-platform:用冲突文件存的 platform_progress 调 import() 覆盖本地(保隔离状态,D-013 重置脏度)。
653
+ * - abort:本地 DB 与 base_ts 均不变,仅清冲突文件(放弃本次同步)。
654
+ *
655
+ * 无冲突文件 → ok:false resolved:false(index.js 据此提示「无可解决冲突」)。
656
+ * 绝不字段级 auto-merge(D-002)。
657
+ * @param {string} changeName
658
+ * @param {'keep-local'|'take-platform'|'abort'} mode
659
+ * @returns {Promise<{ok: boolean, resolved: boolean, mode?: string, reason: string}>}
660
+ */
661
+ async resolve(changeName, mode) {
662
+ const cf = this.readConflictFile(changeName);
663
+ if (!cf) {
664
+ return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict 文件)` };
665
+ }
666
+ const platformPushedAt = cf.platform_last_pushed_at || null;
667
+
668
+ if (mode === 'keep-local') {
669
+ // base_ts 推进到平台最新 last_pushed_at;本地 DB 不 import(用户本地为准,后续手动 push)
670
+ try {
671
+ const { ProgressManager } = await import('./progress.js');
672
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
673
+ const db = pm._ensureDB(this.cwd).getDb();
674
+ db.prepare('UPDATE changes SET last_synced_platform_ts = ? WHERE name = ?')
675
+ .run(platformPushedAt, changeName);
676
+ } catch (err) {
677
+ return { ok: false, resolved: false, reason: `keep-local 更新 base_ts 失败: ${err.message}` };
678
+ }
679
+ this.clearConflictFile(changeName);
680
+ return { ok: true, resolved: true, mode: 'keep-local', reason: '保留本地,base_ts 已推进到平台最新(后续请 platform sync push 本地)' };
681
+ }
682
+
683
+ if (mode === 'take-platform') {
684
+ // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)
685
+ if (!cf.platform_progress) {
686
+ return { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
687
+ }
688
+ try {
689
+ const { ProgressManager } = await import('./progress.js');
690
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
691
+ pm.import(this.cwd, { ...cf.platform_progress, pushed_at: platformPushedAt || new Date().toISOString() }, changeName);
692
+ } catch (err) {
693
+ return { ok: false, resolved: false, reason: `take-platform import 失败: ${err.message}` };
694
+ }
695
+ this.clearConflictFile(changeName);
696
+ return { ok: true, resolved: true, mode: 'take-platform', reason: '已用平台进度覆盖本地' };
697
+ }
698
+
699
+ if (mode === 'abort') {
700
+ // 本地 DB 与 base_ts 均不变,仅清冲突文件(放弃本次同步,下次 push/pull 会重新检测)
701
+ this.clearConflictFile(changeName);
702
+ return { ok: true, resolved: true, mode: 'abort', reason: '放弃本次同步,本地不变' };
703
+ }
704
+
705
+ return { ok: false, resolved: false, reason: `未知 resolve 模式: ${mode}(--keep-local / --take-platform / --abort)` };
706
+ }
707
+
708
+ /**
709
+ * 扫描 .runtime/sync-conflict-*.json 列出未决冲突(task-14 / D-002 / D-010 / FR-05)。
710
+ * 只读 + 容错:损坏文件跳过不崩(constraints)。供 collectStatus / platform status 展示。
711
+ * @returns {Array<{change: string, created_at: string|null, path: string}>}
712
+ */
713
+ listConflictFiles() {
714
+ const specDir = safePlatformSpecDir(this.cwd);
715
+ if (!specDir) return [];
716
+ const runtimeDir = join(specDir, '.runtime');
717
+ if (!existsSync(runtimeDir)) return [];
718
+ let files = [];
719
+ try { files = readdirSync(runtimeDir); }
720
+ catch { return []; }
721
+ const conflicts = [];
722
+ for (const f of files) {
723
+ if (!f.startsWith('sync-conflict-') || !f.endsWith('.json')) continue;
724
+ const filePath = join(runtimeDir, f);
725
+ try {
726
+ const cf = JSON.parse(readFileSync(filePath, 'utf8'));
727
+ conflicts.push({
728
+ change: cf.change || f.replace(/^sync-conflict-/, '').replace(/\.json$/, ''),
729
+ created_at: cf.created_at || null,
730
+ path: filePath,
731
+ });
732
+ } catch {
733
+ // 损坏文件跳过不崩(constraints:容错)
734
+ }
735
+ }
736
+ return conflicts;
737
+ }
738
+
739
+ /**
740
+ * 扩展 status(task-14 / FR-05):连接信息 + 落后标记 + 未决冲突列表。
741
+ * 只读展示,不修改任何进度(constraints)。未连接返回 base.status(connected:false,behind/conflicts 空)。
742
+ * 连接时调 pullList(轻量 GET)拿平台各 change last_pushed_at,比对本地 last_synced_platform_ts
743
+ * 标记本地可能落后;扫描 sync-conflict-*.json 列未决冲突。网络失败 listFailed=true 不崩。
744
+ * @returns {Promise<{connected: boolean, url?: string, lastSync?: string|null, behind: Array<object>, conflicts: Array<object>, listFailed: boolean}>}
745
+ */
746
+ async collectStatus() {
747
+ const base = this.status();
748
+ if (!base.connected) {
749
+ return { ...base, behind: [], conflicts: [], listFailed: false };
750
+ }
751
+ const behind = [];
752
+ let listFailed = false;
753
+ const list = await this.pullList();
754
+ if (list.ok) {
755
+ for (const ch of list.changes) {
756
+ const name = typeof ch === 'string' ? ch : ch.name;
757
+ if (!name) continue;
758
+ const platformPushedAt = (typeof ch === 'object' && (ch.last_pushed_at || ch.last_active)) || null;
759
+ if (!platformPushedAt) continue;
760
+ let localSynced = null;
761
+ try {
762
+ const { ProgressManager } = await import('./progress.js');
763
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
764
+ const row = pm._ensureDB(this.cwd).getDb().prepare('SELECT last_synced_platform_ts FROM changes WHERE name = ?').get(name);
765
+ if (row) localSynced = row.last_synced_platform_ts;
766
+ } catch {
767
+ // 本地无该 change 记录 / DB 不可达 → 不算落后,跳过
768
+ }
769
+ // 仅本地有同步基准(last_synced_platform_ts)且平台更新晚于本地 → 落后
770
+ if (localSynced && platformPushedAt > localSynced) {
771
+ behind.push({ name, local_synced: localSynced, platform_pushed_at: platformPushedAt });
772
+ }
773
+ }
774
+ } else {
775
+ listFailed = true;
776
+ }
777
+ const conflicts = this.listConflictFiles();
778
+ return { ...base, behind, conflicts, listFailed };
779
+ }
384
780
  }
385
781
 
386
782
  // ── CLI 入口函数 ──
@@ -422,6 +818,27 @@ export async function checkApproval(changeName, cwd) {
422
818
  return new SyncManager(cwd).checkApproval(changeName);
423
819
  }
424
820
 
821
+ // task-11 / D-006 / D-009 / FR-03:手动 pull 便捷导出(index.js platform pull 子命令用,
822
+ // 与自动 triggerPull 共用 SyncManager.pull 实例方法,行为一致)
823
+ export async function pull(changeName, opts, cwd) {
824
+ return new SyncManager(cwd).pull(changeName, opts);
825
+ }
826
+
827
+ // 两级 pull 第一级(轻量 change 列表),platform pull 无 --change 时先拉列表再按需 pull
828
+ export async function pullList(cwd) {
829
+ return new SyncManager(cwd).pullList();
830
+ }
831
+
832
+ // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(index.js platform resolve 子命令用)
833
+ export async function resolve(changeName, mode, cwd) {
834
+ return new SyncManager(cwd).resolve(changeName, mode);
835
+ }
836
+
837
+ // task-14 / FR-05:扩展 status(连接信息 + 落后标记 + 未决冲突列表),index.js platform status 用
838
+ export async function collectStatus(cwd) {
839
+ return new SyncManager(cwd).collectStatus();
840
+ }
841
+
425
842
  // TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
426
843
  // 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
427
844
  /**
@@ -611,6 +611,35 @@ export function generateExecuteRunId() {
611
611
  * @param {{ runtimeRoot: string, changeName: string }} opts
612
612
  * @returns {string|null} runId(exec- 前缀),无则 null
613
613
  */
614
+ /**
615
+ * 扫描 execute-runs/,返回 mtime 最新且真正含 tasks/ 子目录的 runId。
616
+ *
617
+ * 专治 execute run marker 漂移(prompt-control-debt.md gate-atom-a):generateExecuteRunId 只写
618
+ * marker 字符串、run 目录由 ensureTaskReviewDir 在写 review.json 时才建,marker 漂到「尚未建目录 /
619
+ * 未写任何 task review」的新 run 后,旧 run 里齐备的 review.json 全部失联。resolveLatestExecuteRunId
620
+ * 见 marker 非空即原样返回(不校验目录),接不住这种漂移;本函数无视 marker、只看真实目录内容,
621
+ * 供 enforceReviewJsonGate 在 marker 指向的 run 缺 tasks/ 时兜底重定位,避免误报「review.json 不存在」。
622
+ * 全部 run 都无 tasks/(真没写 review)返回 null——调用方维持原阻断,不误放行。
623
+ *
624
+ * @param {{ runtimeRoot: string }} opts
625
+ * @returns {string|null} 含 tasks/ 的最新 runId,无则 null
626
+ */
627
+ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
628
+ try {
629
+ const runsDir = join(runtimeRoot, 'execute-runs')
630
+ if (!existsSync(runsDir)) return null
631
+ const entries = readdirSync(runsDir)
632
+ .map(e => ({ e, p: join(runsDir, e) }))
633
+ .filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
634
+ .filter(x => { try { return statSync(join(x.p, 'tasks')).isDirectory() } catch { return false } })
635
+ .map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
636
+ .sort((a, b) => b.mtime - a.mtime)
637
+ return entries.length > 0 ? entries[0].e : null
638
+ } catch {
639
+ return null
640
+ }
641
+ }
642
+
614
643
  export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
615
644
  const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
616
645
  try { if (existsSync(marker)) { const c = readFileSync(marker, 'utf8').trim(); if (c) return c } } catch {}