sillyspec 3.25.11 → 3.26.1
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.
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
- package/.sillyspec-platform-cleaned +1 -0
- package/.sillyspec-platform.json +7 -0
- package/README.md +3 -5
- package/package.json +2 -3
- package/src/db-engine.js +55 -0
- package/src/db.js +53 -35
- package/src/dispatch/probe.js +11 -9
- package/src/doctor-diagnostics.js +6 -6
- package/src/hooks/worktree-guard.js +18 -26
- package/src/index.js +151 -3
- package/src/local-detect.js +120 -110
- package/src/progress/change-registry.js +43 -2
- package/src/progress/shared.js +2 -2
- package/src/progress/stage-machine.js +6 -0
- package/src/progress/step-store.js +10 -0
- package/src/progress.js +302 -10
- package/src/quicklog.js +48 -9
- package/src/run/command.js +13 -2
- package/src/run/complete-handlers.js +37 -8
- package/src/run/complete.js +17 -4
- package/src/run/gates.js +13 -2
- package/src/run/shared.js +74 -0
- package/src/run/stage.js +6 -1
- package/src/sillyhub-mcp/client.js +12 -7
- package/src/sillyhub-mcp/config.js +62 -0
- package/src/stage-review.js +105 -1
- package/src/stages/doctor.js +1 -1
- package/src/stages/execute.js +6 -5
- package/src/stages/quick.js +24 -8
- package/src/stages/scan.js +44 -5
- package/src/stages/verify.js +2 -2
- package/src/sync.js +430 -13
- package/src/task-review.js +29 -0
- package/src/verify-postcheck.js +7 -2
- package/src/worktree-apply.js +135 -27
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.
|
|
198
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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 (!
|
|
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
|
/**
|
package/src/task-review.js
CHANGED
|
@@ -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 {}
|
package/src/verify-postcheck.js
CHANGED
|
@@ -431,6 +431,9 @@ function gitChangedFiles(cwd) {
|
|
|
431
431
|
* 判定(与 checkExecuteCodeEvidence / task-review 同源,meta.json 为权威):
|
|
432
432
|
* 1. change 有 worktree meta 且 baseHash 存在 → 在 worktree(或 in-place 的 cwd)
|
|
433
433
|
* 跑 `git diff --name-only <baseHash>..HEAD` 取真实代码改动集
|
|
434
|
+
* ⚠️ baseline checkpoint 修复:优先用 baselineCommit/actualBaseHash(baseline overlay
|
|
435
|
+
* 之后),否则 baseHash(pre-baseline)会把 baseline 同步的跨模块文件算进 verify diff
|
|
436
|
+
* → 命中无关模块(如 ppm 变更误测 daemon/frontend)。与 task-review.js 同源。
|
|
434
437
|
* 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
|
|
435
438
|
*
|
|
436
439
|
* @param {string} cwd - 项目根目录(主仓)
|
|
@@ -443,11 +446,13 @@ export function resolveVerifyChangedFiles(cwd, changeName) {
|
|
|
443
446
|
if (existsSync(metaPath)) {
|
|
444
447
|
let meta = null
|
|
445
448
|
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
|
|
446
|
-
|
|
449
|
+
// 优先 baselineCommit/actualBaseHash(baseline checkpoint 之后),回退 baseHash
|
|
450
|
+
const diffBase = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash
|
|
451
|
+
if (diffBase) {
|
|
447
452
|
const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
|
|
448
453
|
? meta.worktreePath
|
|
449
454
|
: cwd
|
|
450
|
-
const files = runGitDiffNameOnly(gitDir, `${
|
|
455
|
+
const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
|
|
451
456
|
if (files !== null) return files
|
|
452
457
|
// worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
|
|
453
458
|
}
|