sillyspec 3.27.11 → 3.27.12

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/src/run/shared.js CHANGED
@@ -616,6 +616,38 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
616
616
  }
617
617
  }
618
618
 
619
+ /**
620
+ * 触发步骤开始上报(X3 / design §8.2,变更 2026-08-29-change-delete-closure-and-spec-pull task-13)。
621
+ *
622
+ * 步骤启动时同端点同结构补推一次 progress:CLI 状态机的步骤行 DB 侧种子是 pending,
623
+ * 本推送在载荷层把 current_stage 的第一个未完成步投影为 in-progress(sync.js
624
+ * _projectStepStart,不写 DB,--done 推送路径不受影响)。真实效果 = 平台
625
+ * last_pushed_at 以步骤起点刷新(投影 current_step_status 对下一个 pending 步本就
626
+ * 推导为 active,X3 不改变其值,只让「最后信号」时间可信,停滞判定不再失真)。
627
+ * 与 triggerSync 同款契约:quick 会话降级跳过(无 progress 实体)、未连接平台在
628
+ * sync() 内部静默跳过、8s 总熔断(raceWithAbort + abort 在飞请求)、失败只 warn 不抛。
629
+ *
630
+ * 接线说明(本变更 allowed_paths 约束):步骤 prompt 渲染点(run/stage.js outputStep
631
+ * 前段 / run/prompt.js)不在本变更可改文件集内——本导出即「步骤开始钩子」本体,
632
+ * 渲染侧一行 `triggerStepStartSync(cwd, changeName, platformOpts)` 的接线由后续
633
+ * 变更补上;execute 阶段的任务粒度信号已由 X4(stages/execute.js Wave prompt
634
+ * 每任务上报指引)覆盖。
635
+ */
636
+ export async function triggerStepStartSync(cwd, changeName, platformOpts = {}, opts = {}) {
637
+ try {
638
+ const tsSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
639
+ if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(tsSpecBase, 'changes', changeName))) {
640
+ return // quick 会话无 progress 实体(与 triggerSync 同判),无步骤可报
641
+ }
642
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层
643
+ const syncMod = await import('../sync.js')
644
+ await raceWithAbort((sig) => syncMod.sync(changeName, cwd, { ...sig, stepStart: true }), opts.timeoutMs)
645
+ } catch (e) {
646
+ // Best Effort:步骤开始上报失败不影响主流程(下次 --done 的 triggerSync 兜底)
647
+ console.warn('⚠️ 步骤开始上报失败:', e.message)
648
+ }
649
+ }
650
+
619
651
  /**
620
652
  * 自动 pull 节流(体检 PERF-02):triggerPull/triggerPullActiveChange 在每条 stage 命令
621
653
  * 启动时注入,agent 命令往往是秒级连发——每次都发 HTTP GET(daemon 挂/慢时单命令最多
@@ -177,6 +177,11 @@ created_at: <now-datetime>
177
177
  ### 输出
178
178
  归档完成 + archive 目录路径`,
179
179
  outputHint: '归档确认',
180
+ // 坑 archive-batch-31-tool-notes ②:完成本步必须带 --confirm(complete-handlers 门控),
181
+ // 但 outputStep 机器生成的「完成后执行」提示原是通用 --done 模板不带 --confirm——agent
182
+ // 照抄执行就撞「请添加 --confirm」。requiresConfirm 让提示行带上该 flag(数据驱动,其他
183
+ // 阶段未来有确认步同样声明即可)。
184
+ requiresConfirm: true,
180
185
  optional: false
181
186
  },
182
187
  {
@@ -1214,6 +1214,7 @@ ${taskList}
1214
1214
  4. 每个任务完成后:
1215
1215
  - **在 worktree 内 git add -A && git commit -m "<task-NN 摘要>"**(坑 subagent-uncommitted-newfile-apply3way,2026-08-22 实证:纯新增文件不 commit 时 apply 的 git apply --3way 报 "does not exist in index" 直接炸——未 commit 的新文件不在 base commit 也不在 index,patch 生成取不到。commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点)
1216
1216
  - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
1217
+ - **任务边界上报(每任务一次,主仓根目录)**:勾选 checkbox 后跑一次 \`sillyspec platform sync --change <change-name>\`——以任务粒度把「最后信号」(last_pushed_at)与 tasks.md 勾选状态推上平台(变更中心「进行中」可见性);未连接平台时该命令静默跳过,无需先检查连接状态
1217
1218
  - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
1218
1219
  - 记录改动文件和测试结果
1219
1220
  5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
package/src/sync.js CHANGED
@@ -8,13 +8,13 @@
8
8
  * HTTP 请求:Node.js 原生 fetch(Node 22+)
9
9
  */
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync, rmSync } from 'fs';
12
12
  import { writeAtomicSync } from './fs-atomic.js';
13
- import { join } from 'path';
13
+ import { join, dirname, basename, isAbsolute, relative } from 'path';
14
14
  import { resolvePlatformSpecDir } from './progress.js';
15
15
  import { safeGit } from './git-helper.js';
16
16
  import { openDatabase } from './db-engine.js';
17
- import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
17
+ import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
18
18
  import { syncSpecTree } from './spec-sync.js';
19
19
 
20
20
  // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
@@ -29,6 +29,9 @@ function safePlatformSpecDir(cwd) {
29
29
 
30
30
  const LOCAL_YAML = '.sillyspec/local.yaml';
31
31
  const REQUEST_TIMEOUT_MS = 10_000;
32
+ // X2 spec bundle 整树下载(tar 流式响应体,审计 B2):大树/慢网远超常规 JSON 轮询口径,
33
+ // 独立大超时(daemon 同端点 30s;CLI 面向交互式大仓更宽)。
34
+ const PULL_BUNDLE_TIMEOUT_MS = 120_000;
32
35
 
33
36
  // 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
34
37
  // 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
@@ -205,8 +208,10 @@ function resolvePlatformUser(cwd, explicitUser) {
205
208
  // HUB-09:单请求超时与外部 signal 合并——自动同步熔断(run/shared.js trigger* 的 race)
206
209
  // 触发 abort 时在飞 fetch 被真实取消,而不是熔断后任由请求自行完成(平台可能已接受,
207
210
  // 客户端却当作超时放弃;spec 树推送无 base_ts 自愈兜底)。AbortSignal.any 需 Node ≥20.3。
208
- function combineSignals(external) {
209
- const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
211
+ // timeoutMs 可选:pullSpecBundle 整树下载走 PULL_BUNDLE_TIMEOUT_MS 大口径(审计 B2),
212
+ // 其余调用缺省 REQUEST_TIMEOUT_MS。
213
+ function combineSignals(external, timeoutMs = REQUEST_TIMEOUT_MS) {
214
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
210
215
  return external ? AbortSignal.any([timeoutSignal, external]) : timeoutSignal;
211
216
  }
212
217
 
@@ -262,6 +267,129 @@ async function fetchJsonWithStatus(url, options = {}) {
262
267
  }
263
268
  }
264
269
 
270
+ // ── X2 spec bundle tar 解析(task-14 / design §7.1)──
271
+
272
+ // 平台 build_bundle(spec_workspace/service.py,Python tarfile mode "w")输出未压缩 tar,
273
+ // Python 3.8+ 默认 PAX 格式:路径 ≤100 字符是纯 ustar 头,超限走 typeflag 'x' 的 PAX
274
+ // 扩展头(path 记录)+ 截断名条目;GNU tar 长名走 'L'。仓内无三方 tar 库(package.json
275
+ // deps 无 tar/zlib 需求),按 512 字节头极简自实现——tar-slip 双重校验照搬 daemon
276
+ // extractTar(sillyhub-daemon/src/spec-sync.ts:982)同款范式,两仓行为对齐。
277
+
278
+ /** tar 头字符串字段读:截到首个 NUL,去首尾空白。 */
279
+ function _readTarString(buf) {
280
+ const end = buf.indexOf(0);
281
+ const s = (end === -1 ? buf : buf.subarray(0, end)).toString('utf8');
282
+ return s.replace(/\0/g, '').trim();
283
+ }
284
+
285
+ /** tar 头 size 字段(12 字节八进制)→ 字节数。 */
286
+ function _readTarSize(field) {
287
+ const s = _readTarString(field);
288
+ if (!s) return 0;
289
+ return parseInt(s, 8) || 0;
290
+ }
291
+
292
+ /**
293
+ * PAX 扩展头 data 解析为 {key: value}:记录形态 "<len> <key>=<value>\n" 重复,
294
+ * len 十进制**字节数**且含自身位数、空格与尾部 \n。
295
+ * 必须在 Buffer 上按字节偏移推进/切片:value 含非 ASCII(中文文件名)时 len(字节)
296
+ * > JS 字符串长度(UTF-16 码元),按码元校验会首记录即 break、path 丢失 → 文件落
297
+ * 实体头 name 的 ascii/replace 混淆路径并互相覆盖(与 daemon spec-sync.ts 同款修复)。
298
+ */
299
+ function _parsePaxRecords(data) {
300
+ const out = {};
301
+ let pos = 0;
302
+ while (pos < data.length) {
303
+ const sp = data.indexOf(0x20, pos);
304
+ if (sp === -1) break;
305
+ const len = parseInt(data.toString('utf8', pos, sp), 10);
306
+ if (!Number.isInteger(len) || len <= 0 || pos + len > data.length) break;
307
+ const record = data.toString('utf8', sp + 1, pos + len - 1); // 尾部 \n 不属于 value
308
+ const eq = record.indexOf('=');
309
+ if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
310
+ pos += len;
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /**
316
+ * tar-slip 双重校验(daemon extractTar 同款):显式穿越模式 + join 后 relative 复核。
317
+ * 通过返回安全相对路径的原样 name;命中穿越抛 Error(调用方转为失败 reason,不落盘)。
318
+ */
319
+ function _assertSafeTarName(name, targetDir) {
320
+ if (name.includes('..') || isAbsolute(name) || /^[A-Za-z]:[\\/]/.test(name)) {
321
+ throw new Error(`tar 路径越界已拦截: ${name}`);
322
+ }
323
+ const fullPath = join(targetDir, name);
324
+ const rel = relative(targetDir, fullPath);
325
+ if (rel.startsWith('..') || isAbsolute(rel)) {
326
+ throw new Error(`tar 路径逃逸目标目录已拦截: ${name} -> ${fullPath}`);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 解析整只未压缩 tar 为条目列表 [{ name, isDir, data }](先全量解析校验,后落盘——
332
+ * 恶意/损坏 tar 在任何本地删除动作之前暴露,见 pullSpecBundle 覆盖顺序)。
333
+ * 容忍并落地 tar 顶层 PLATFORM-BUNDLE.json(task-08 快照元数据,design §7.3);
334
+ * symlink/fifo/dev 等非常规条目跳过(spec 树不应含,daemon 同款)。
335
+ * @param {Buffer} tarBuf
336
+ * @returns {Array<{name: string, isDir: boolean, data: Buffer}>}
337
+ */
338
+ function _parseSpecTar(tarBuf) {
339
+ const entries = [];
340
+ let offset = 0;
341
+ let pendingPath = null; // PAX 'x' path 记录 / GNU 'L' 长名,覆盖下一实条目的 name
342
+ while (offset + 512 <= tarBuf.length) {
343
+ const header = tarBuf.subarray(offset, offset + 512);
344
+ if (header.every((b) => b === 0)) break; // 结尾 zero block → 结束
345
+ const typeflag = String.fromCharCode(header[156] ?? 0);
346
+ const size = _readTarSize(header.subarray(124, 136));
347
+ offset += 512;
348
+ const data = tarBuf.subarray(offset, offset + size);
349
+ offset += Math.ceil(size / 512) * 512;
350
+
351
+ let name = _readTarString(header.subarray(0, 100));
352
+ // POSIX ustar prefix 字段(magic 'ustar\0' + version '00' 时有效);GNU 长名走 'L' 条目
353
+ const magic = header.subarray(257, 263).toString('latin1');
354
+ const version = header.subarray(263, 265).toString('latin1');
355
+ if (magic.startsWith('ustar') && version === '00') {
356
+ const prefix = _readTarString(header.subarray(345, 500));
357
+ if (prefix) name = `${prefix}/${name}`;
358
+ }
359
+ if (pendingPath) { name = pendingPath; pendingPath = null; }
360
+
361
+ if (typeflag === 'x' || typeflag === 'X') {
362
+ const recs = _parsePaxRecords(data);
363
+ if (recs.path) pendingPath = recs.path;
364
+ continue;
365
+ }
366
+ if (typeflag === 'L') { // GNU LongLink(长文件名)
367
+ pendingPath = _readTarString(data);
368
+ continue;
369
+ }
370
+ if (typeflag === 'K') continue; // GNU 长链接目标:spec 树无符号链接,跳过
371
+ if (!name) continue;
372
+
373
+ if (typeflag === '5' || name.endsWith('/')) {
374
+ _assertSafeTarName(name.replace(/\/+$/, ''), '.'); // 目录条目同样过穿越校验(相对假根)
375
+ entries.push({ name, isDir: true, data: Buffer.alloc(0) });
376
+ continue;
377
+ }
378
+ if (typeflag === '0' || typeflag === '\0') {
379
+ _assertSafeTarName(name, '.');
380
+ entries.push({ name, isDir: false, data });
381
+ continue;
382
+ }
383
+ debugLog(`[sync] spec bundle tar 跳过非常规条目: ${name} (typeflag ${typeflag})`);
384
+ }
385
+ return entries;
386
+ }
387
+
388
+ /** 目录有内容判定(daemon dirHasContent 同语义):不存在/读失败/空均视为无内容。 */
389
+ function _dirHasContent(dir) {
390
+ try { return readdirSync(dir).length > 0; } catch { return false }
391
+ }
392
+
265
393
  // ── SyncManager ──
266
394
 
267
395
  export class SyncManager {
@@ -438,18 +566,29 @@ export class SyncManager {
438
566
  const changeDir = join(specBase, 'changes', changeName);
439
567
  let archivedQuietly = false;
440
568
  if (!existsSync(changeDir)) {
441
- archivedQuietly = existsSync(join(specBase, 'changes', 'archive', changeName))
442
- || this._isChangeArchivedInDb(changeName);
443
- if (archivedQuietly) {
444
- console.log(`[sync] 变更已归档(目录在 archive/),继续从 DB 推送最终状态: ${changeName}`);
569
+ // change-delete(2026-08-30):DB status='deleted' 的行目录已随删除移除,属正常时序,
570
+ // 单行 info 提示后继续推删除终态/墓碑(不落入「目录不存在」warn)
571
+ if (this._changeDbStatus(changeName) === 'deleted') {
572
+ console.log(`[sync] 变更已删除(DB status=deleted),继续推送删除终态/墓碑: ${changeName}`);
445
573
  } else {
446
- console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
574
+ archivedQuietly = existsSync(join(specBase, 'changes', 'archive', changeName))
575
+ || this._changeDbStatus(changeName) === 'archived';
576
+ if (archivedQuietly) {
577
+ console.log(`[sync] 变更已归档(目录在 archive/),继续从 DB 推送最终状态: ${changeName}`);
578
+ } else {
579
+ console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
580
+ }
447
581
  }
448
582
  }
449
583
  // 透传给本次 sync 链内的 syncDocuments(best-effort 四件套直推:归档后目录已移走,
450
584
  // 其「变更不存在」warn 属正常时序,静默化)
451
585
  this._suppressDocsMissingWarn = archivedQuietly;
452
586
 
587
+ // X1 墓碑(design §5.5,变更 2026-08-29-change-delete-closure-and-spec-pull task-13):
588
+ // 触发判据见循环内 tombstoneDue(DB status='archived')。
589
+ // 常规终态推送保持原语义(archived/active 原值照推,platform-sync-archive-final-state 钉死),
590
+ // 墓碑作为推送链成功后的追加 POST(见 _pushTombstone)。
591
+
453
592
  const MAX_PUSH_ATTEMPTS = 2;
454
593
  for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
455
594
  // 读取 progress 数据(serializeForSync 六表裸 JSON,task-02 / D-005@v2,替代 read() 聚合视图)。
@@ -468,6 +607,25 @@ export class SyncManager {
468
607
  return { synced: 0, errors: [`变更无进度数据: ${changeName}`] };
469
608
  }
470
609
 
610
+ // X1 墓碑判定(design §5.5 / task-13;审计 B1 收窄):仅 DB status='archived'
611
+ // (unregisterChange 链——归档收尾/自愈/quick 收尾均置该值)或 status='deleted'
612
+ // (change-delete 命令,2026-08-30——载荷本身携带 deleted,平台 _apply_cli_tombstone
613
+ // 见该值即置 location='deleted';追加墓碑 POST 幂等兜底)触发,常规推送成功后追加
614
+ // 一次 changes[].status='deleted' 墓碑(平台 task-04 写路径置 location='deleted' 收敛镜像)。
615
+ // 不再把「实体目录双失」当裸删发墓碑:platform pull(进度同步)只写 DB 不建目录 →
616
+ // DB active + 目录双失是合法态,误发墓碑会让平台把活跃变更软删(全体成员生效)。
617
+ // 真实裸删(用户手动 rm -rf 目录)由平台镜像收敛兜底(spec-sync delete ops → scoped
618
+ // 定向删除,computeSpecOps 护栏只拦整树/changes 整删,不拦单变更 scoped 删除),
619
+ // CLI 不发墓碑不损失收敛。quick 会话豁免:triggerSync 对 quick 已降级 syncSpecTreeOnly,
620
+ // 直调也不给孤儿 key 造墓碑。
621
+ const tombstoneDue = !QUICK_SID_RE.test(changeName)
622
+ && (progressData.changes?.[0]?.status === 'archived'
623
+ || progressData.changes?.[0]?.status === 'deleted');
624
+
625
+ // X3 步骤开始上报(design §8.2 / task-13):opts.stepStart 时把 current_stage 的第一个
626
+ // 未完成步投影为 in-progress(仅载荷不写 DB;triggerStepStartSync 是唯一传该 flag 的入口)
627
+ if (opts.stepStart) this._projectStepStart(progressData, changeName);
628
+
471
629
  // 元字段走 HTTP header(D-015 / task-09):body 保持裸六表 JSON,sillyhub 老版忽略 header 零回归
472
630
  const headers = {
473
631
  'Content-Type': 'application/json',
@@ -493,6 +651,14 @@ export class SyncManager {
493
651
  signal: opts.signal, // HUB-09:熔断 abort 传到底层请求
494
652
  });
495
653
 
654
+ // X1(task-04 契约):409 + code='change_deleted' = 平台已删 key 拒收——不是 base_ts 冲突。
655
+ // 单行提示即可,不落冲突文件、不打全幅横幅:墓碑已收敛后的重复上行(归档收尾多步各推一次)、
656
+ // 多用户下他端删除后本地仍在推进,都是可预期态,按冲突卡死人工 resolve 反而是误报。
657
+ if (res.status === 409 && res.body && res.body.code === 'change_deleted') {
658
+ console.warn(`⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`);
659
+ return { synced: 0, errors: [], platformDeleted: true, reason: '平台已删除(change_deleted 拒收)' };
660
+ }
661
+
496
662
  if (res.ok) {
497
663
  // 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
498
664
  // 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
@@ -541,6 +707,18 @@ export class SyncManager {
541
707
  debugLog(`[sync] spec 树增量同步失败(不影响进度): ${changeName}: ${err.message}`);
542
708
  }
543
709
 
710
+ // X1 墓碑上行(收敛加速器,best-effort):常规终态 + 文档/spec 树都推完后追加一次
711
+ // changes[].status='deleted'(顺序在后——常规推送保持 archived/active 原语义是既有
712
+ // 契约,platform-sync-archive-final-state 钉死)。失败静默:平台闭环走方案 A 镜像驱动
713
+ // 收敛(design §5.5),墓碑只是让收敛即时。
714
+ if (tombstoneDue) {
715
+ try {
716
+ await this._pushTombstone(changeName, { signal: opts.signal });
717
+ } catch (err) {
718
+ debugLog(`[sync] 墓碑上行失败(不影响主推送): ${changeName}: ${err.message}`);
719
+ }
720
+ }
721
+
544
722
  return { synced: 1, errors: [] };
545
723
  }
546
724
 
@@ -620,6 +798,92 @@ export class SyncManager {
620
798
  return { synced: 0, errors: [`同步重试耗尽(仍冲突): ${changeName}`] };
621
799
  }
622
800
 
801
+ /**
802
+ * X3 步骤开始上报的载荷投影(design §8.2,变更 2026-08-29-change-delete-closure-and-spec-pull
803
+ * task-13):把 current_stage 下第一个未完成步(completed/skipped 之外,含 waiting——等待中
804
+ * 的步同样「在跑」)的 status 改为 'in-progress'。
805
+ *
806
+ * 仅改本次上行载荷,不写 DB——步骤行 DB 种子是 pending,completeStep 的
807
+ * findIndex(pending||in-progress) 两者等价,但保持 DB 不被推送侧污染(--done 推送路径
808
+ * 行为不变,回归约束)。六表 steps[].status 状态机本就含 in-progress 值(step-store
809
+ * VALID_STATUSES),平台侧裸 JSON 透传(design §8.2 已核实后端零改动)。
810
+ * @param {object} progressData serializeForSync 输出(原地修改)
811
+ * @param {string} changeName
812
+ */
813
+ _projectStepStart(progressData, changeName) {
814
+ try {
815
+ const row = progressData && progressData.changes && progressData.changes[0];
816
+ if (!row || !row.current_stage || !Array.isArray(progressData.steps)) return;
817
+ const idx = progressData.steps.findIndex(s =>
818
+ s.change_name === changeName && s.stage === row.current_stage
819
+ && s.status !== 'completed' && s.status !== 'skipped');
820
+ if (idx !== -1) progressData.steps[idx].status = 'in-progress';
821
+ } catch { /* 投影失败按原载荷推送(best-effort) */ }
822
+ }
823
+
824
+ /**
825
+ * X1:把墓碑状态写进载荷——changes[0].status='deleted'(对齐既有 'archived' 状态语义,
826
+ * design §5.5)。平台写路径(服务端 task-04 `_apply_cli_tombstone`)见到该值即置
827
+ * Change.location='deleted' 并触发镜像软删收敛。
828
+ * @param {object} progressData serializeForSync 输出(原地修改,返回同一引用)
829
+ */
830
+ _applyTombstoneStatus(progressData) {
831
+ if (progressData && Array.isArray(progressData.changes) && progressData.changes[0]) {
832
+ progressData.changes[0].status = 'deleted';
833
+ }
834
+ return progressData;
835
+ }
836
+
837
+ /**
838
+ * X1 墓碑上行(design §5.5 / task-13):同端点同结构的一次追加 POST(changes[].status='deleted')。
839
+ *
840
+ * 由 sync() 成功路径在常规推送 + 文档/spec 树链之后触发(CLI 单进程顺序推送,base_ts 进程内
841
+ * 单调,无乐观锁冲突)。fresh 重读 DB 序列化——常规推送成功的 base_ts 回填已落库,本推送带上
842
+ * 该 base_ts;重复墓碑(平台已删)收到 409 code='change_deleted' 属预期,静默返回。
843
+ * Best-effort:任何失败只 debugLog,不影响 sync() 主结果(平台闭环走方案 A 兜底)。
844
+ * @param {string} changeName
845
+ * @param {{signal?: object}} [opts]
846
+ * @returns {Promise<{synced: number}>}
847
+ */
848
+ async _pushTombstone(changeName, opts = {}) {
849
+ const platform = this._getPlatform();
850
+ if (!platform) return { synced: 0 };
851
+ const { ProgressManager } = await import('./progress.js');
852
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
853
+ const progressData = pm.serializeForSync(this.cwd, changeName);
854
+ if (progressData === null) return { synced: 0 }; // DB 行已不在(彻底删除),无墓碑可推,方案 A 兜底
855
+ this._applyTombstoneStatus(progressData);
856
+
857
+ const headers = {
858
+ 'Content-Type': 'application/json',
859
+ Authorization: `Bearer ${platform.token}`,
860
+ };
861
+ const pushedAt = new Date().toISOString();
862
+ const pushUser = platform.user || resolvePlatformUser(this.cwd) || null;
863
+ if (pushUser) headers['X-SillySpec-User'] = pushUser;
864
+ const baseTs = progressData.changes?.[0]?.last_synced_platform_ts;
865
+ if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs;
866
+ headers['X-SillySpec-Pushed-At'] = pushedAt;
867
+
868
+ const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
869
+ const res = await fetchJsonWithStatus(syncUrl, {
870
+ method: 'POST',
871
+ headers,
872
+ body: JSON.stringify(progressData),
873
+ signal: opts.signal,
874
+ });
875
+ if (res.ok) {
876
+ console.log(`[sync] 已上行墓碑(status=deleted): ${changeName}`);
877
+ try {
878
+ const ackTs = res.body && typeof res.body.last_pushed_at === 'string' ? res.body.last_pushed_at : pushedAt;
879
+ pm._updatePlatformLastSync(this.cwd, changeName, ackTs);
880
+ } catch { /* 回填失败下次 push 409 由自竞态自愈收敛 */ }
881
+ return { synced: 1 };
882
+ }
883
+ debugLog(`[sync] 墓碑上行未成功: ${changeName} (status=${res.status},平台方案 A 兜底)`);
884
+ return { synced: 0 };
885
+ }
886
+
623
887
  /**
624
888
  * 自竞态判定辅助:fresh 读本地 DB 的 last_synced_platform_ts(base_ts),判断它是否已覆盖
625
889
  * 平台 409 回执的 last_pushed_at(字符串字典序比较,契约 §4.2——ISO 8601 字典序 == 时间序)。
@@ -806,22 +1070,23 @@ export class SyncManager {
806
1070
  }
807
1071
 
808
1072
  /**
809
- * DB 侧归档态探测(坑 post-archive-sync-noise):changes 表 status='archived' 即真。
1073
+ * DB 侧变更状态探测(坑 post-archive-sync-noise;2026-08-30 泛化):只读直查 changes.status,
1074
+ * 供「目录缺失」降噪分支区分 archived(归档终态照推)与 deleted(change-delete 删除终态)。
810
1075
  * 只读直查(node:sqlite,不经 ProgressManager——sync 是短进程,惰性 import 无收益且
811
- * 本调用点在同步代码段);DB 不存在/无行/读失败 → false(保守退回普通 warn 措辞)。
1076
+ * 本调用点在同步代码段);DB 不存在/无行/读失败 → null(保守退回普通 warn 措辞)。
812
1077
  * @param {string} changeName
813
- * @returns {boolean}
1078
+ * @returns {string|null}
814
1079
  */
815
- _isChangeArchivedInDb(changeName) {
1080
+ _changeDbStatus(changeName) {
816
1081
  try {
817
1082
  const dbPath = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), '.runtime', 'sillyspec.db');
818
- if (!existsSync(dbPath)) return false;
1083
+ if (!existsSync(dbPath)) return null;
819
1084
  const db = openDatabase(dbPath, { readOnly: true });
820
1085
  try {
821
1086
  const row = db.prepare("SELECT status FROM changes WHERE name = ?").get(changeName);
822
- return row?.status === 'archived';
1087
+ return row?.status || null;
823
1088
  } finally { try { db.close() } catch {} }
824
- } catch { return false }
1089
+ } catch { return null }
825
1090
  }
826
1091
 
827
1092
  /**
@@ -1115,6 +1380,171 @@ export class SyncManager {
1115
1380
  }
1116
1381
  }
1117
1382
 
1383
+ /**
1384
+ * X2(task-14 / design §7.1 / FR-07 / FR-08):拉平台 spec 整树快照并解压到 specDir。
1385
+ *
1386
+ * 与既有 pull()(进度六表 DB import)正交:本方法操作的是 .sillyspec **文件树**——
1387
+ * GET {platform.url}/api/changes/-/spec-bundle(task-08 端点,Bearer shpsync token,
1388
+ * application/x-tar 流 + X-Spec-Version 响应头)下载未压缩 tar 后解包。
1389
+ *
1390
+ * 覆盖语义对齐 daemon pullSpecBundle(sillyhub-daemon/src/spec-sync.ts):
1391
+ * - specDir 为空(或不存在)→ 直接解压;
1392
+ * - 非空且无 force → 拒绝并明确提示(fail-fast,不发网络请求);
1393
+ * - force → rm 整树 + 解包(先下载并全量解析校验 tar,成功后才 rm——恶意/损坏
1394
+ * bundle 不先删本地)。local.yaml 是唯一保留例外:本机连接凭据,服务端 bundle
1395
+ * 恒排除它(spec-sync.js UPLOAD_EXCLUDE_FILENAMES),rm 前读出、解包落定后
1396
+ * 原样恢复——删它零收益纯断连。崩溃安全(审计 B3):rm 前凭据先原子备份到树外
1397
+ * `<specDir 根名>.local.yaml.bak`,成功后自动清理;恢复失败时从 .bak 兜底复制,
1398
+ * 兜底也失败则 .bak 留在现场供手工恢复(凭据不因覆盖中断永久丢失)。
1399
+ *
1400
+ * 快照语义(design §7.4):主动拉取服务器**打包时刻**快照,无自动同步、无会话中
1401
+ * 刷新;daemon 模式任务/会话开始时按 latest_spec_version 自动取新(本方法不涉)。
1402
+ *
1403
+ * Best Effort:未连接平台静默跳过(本地合法状态);网络/解包失败返回 ok:false 不抛。
1404
+ * @param {{force?: boolean, specDir?: string|null, signal?: AbortSignal}} [opts]
1405
+ * force 整树覆盖;specDir 显式覆盖树根(--spec-dir 全局选项通道;缺省与 sync()
1406
+ * 同源:平台模式锚 specRoot,本地模式 cwd/.sillyspec);signal 外部熔断
1407
+ * @returns {Promise<{ok: boolean, pulled: boolean, specDir?: string, specVersion?: string|null, reason?: string}>}
1408
+ */
1409
+ async pullSpecBundle(opts = {}) {
1410
+ const { force = false } = opts;
1411
+ const platform = this._getPlatform();
1412
+ if (!platform) {
1413
+ debugLog('[sync] 未连接平台(本地合法状态);pullSpecBundle 跳过');
1414
+ return { ok: false, pulled: false, reason: '未连接平台' };
1415
+ }
1416
+ // 树根与 sync()/spec 树推送同源(BUG-01:平台模式必须锚 specRoot,勿硬编码 cwd/.sillyspec)
1417
+ const specDir = opts.specDir || safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec');
1418
+
1419
+ // 覆盖守卫(daemon 同款):非空且无 --force 拒绝——先于网络请求,本地不可逆动作前 fail-fast
1420
+ if (_dirHasContent(specDir) && !force) {
1421
+ return {
1422
+ ok: false, pulled: false, specDir,
1423
+ reason: `specDir 非空(${specDir}),拒绝整树覆盖;确认用平台快照替换本地请加 --force`,
1424
+ };
1425
+ }
1426
+
1427
+ // 下载流式 tar(spec 树小,整只缓冲与 daemon getSpecBundle 同口径)。
1428
+ // 审计 B2:下载(含响应体读取)走 PULL_BUNDLE_TIMEOUT_MS 独立大口径——大树/慢网下
1429
+ // 10s 常规轮询口径必超时;仍尊重外部熔断 signal(两者 combine)。
1430
+ const bundleUrl = `${platform.url}/api/changes/-/spec-bundle`;
1431
+ let res;
1432
+ try {
1433
+ res = await fetch(bundleUrl, {
1434
+ headers: { Authorization: `Bearer ${platform.token}` },
1435
+ signal: combineSignals(opts.signal, PULL_BUNDLE_TIMEOUT_MS),
1436
+ });
1437
+ } catch (err) {
1438
+ // AbortSignal.timeout 到期抛 TimeoutError,外部 signal abort 抛 AbortError,两者都是超时/中断口径
1439
+ const why = (err.name === 'AbortError' || err.name === 'TimeoutError')
1440
+ ? `下载超时/中断 (${PULL_BUNDLE_TIMEOUT_MS / 1000}s 上限或外部熔断)`
1441
+ : err.message;
1442
+ console.warn(`[sync] GET ${bundleUrl} 请求失败: ${why}`);
1443
+ return { ok: false, pulled: false, specDir, reason: `拉取 spec bundle 失败: ${why}` };
1444
+ }
1445
+ if (!res.ok) {
1446
+ const why = res.status === 404
1447
+ ? '平台工作区尚无 spec 内容(HTTP 404,空 bundle)'
1448
+ : `HTTP ${res.status}`;
1449
+ console.warn(`[sync] GET ${bundleUrl} → ${res.status}`);
1450
+ return { ok: false, pulled: false, specDir, reason: `拉取 spec bundle 失败: ${why}` };
1451
+ }
1452
+ const specVersion = res.headers.get('x-spec-version');
1453
+ let tarBuf;
1454
+ try {
1455
+ tarBuf = Buffer.from(await res.arrayBuffer());
1456
+ } catch (err) {
1457
+ console.warn(`[sync] 读取 spec bundle 响应体失败: ${err.message}`);
1458
+ return { ok: false, pulled: false, specDir, reason: `读取 spec bundle 失败: ${err.message}` };
1459
+ }
1460
+
1461
+ // 全量解析 + 穿越校验在 rm 之前(tar-slip 命中时本地原样保留)
1462
+ let entries;
1463
+ try {
1464
+ entries = _parseSpecTar(tarBuf);
1465
+ } catch (err) {
1466
+ console.warn(`[sync] spec bundle 无效: ${err.message}`);
1467
+ return { ok: false, pulled: false, specDir, reason: `spec bundle 无效: ${err.message}` };
1468
+ }
1469
+
1470
+ // 覆盖语义:rm 整树(容忍不存在)→ 逐条目落盘(join 后 relative 双重校验再走一道)
1471
+ // local.yaml 唯一豁免(rm 前校验存在性并读出,解包落定后原样恢复 + 校验回存在):
1472
+ // 本机连接凭据(platform/mcp 段含 shpsync token),服务端 bundle 恒排除它
1473
+ // (spec-sync.js UPLOAD_EXCLUDE_FILENAMES)——rm 整树删它零收益纯断连。恢复放在
1474
+ // 条目循环之后,即便异常 bundle 携带同名条目也以本地为准(凭据永不取自服务端)。
1475
+ //
1476
+ // 审计 B3 崩溃安全(三层):rm 整树后凭据只剩内存 Buffer,任一步失败(条目写失败/
1477
+ // 恢复写失败——Windows EPERM·AV 锁·磁盘满)或进程崩溃即凭据永久丢失。三层防护:
1478
+ // ①rm 前先把内容原子写到树外 `<specDir 根名>.local.yaml.bak`(树内会被 rm 一并删掉,
1479
+ // 故放父目录);②恢复主路径不变,失败时从 .bak 再兜底复制一次;③成功路径末尾
1480
+ // 清理 .bak(明文 token 不留第二份;顺带清历史失败残留)。
1481
+ const localYamlPath = join(specDir, 'local.yaml');
1482
+ const localYamlBakPath = join(dirname(specDir), `${basename(specDir)}.local.yaml.bak`);
1483
+ let localYamlSaved = null; // null = 本无此文件,无需恢复
1484
+ if (existsSync(localYamlPath)) {
1485
+ try {
1486
+ localYamlSaved = readFileSync(localYamlPath); // Buffer 字节级:注释/CRLF 原样
1487
+ } catch (err) {
1488
+ // fail-closed:读不出就无从恢复,继续 rm = 确定性丢凭据断连,宁可整单拒绝
1489
+ console.warn(`[sync] local.yaml 存在但读取失败,拒绝 --force 整树覆盖: ${err.message}`);
1490
+ return { ok: false, pulled: false, specDir, reason: `local.yaml 读取失败,已拒绝整树覆盖(防丢连接凭据): ${err.message}` };
1491
+ }
1492
+ try {
1493
+ writeAtomicSync(localYamlBakPath, localYamlSaved);
1494
+ try { chmodSync(localYamlBakPath, 0o600); } catch { /* best-effort,同 local.yaml SEC-04 */ }
1495
+ } catch (err) {
1496
+ // 兜底都写不进(磁盘满等):继续 rm = 凭据只剩内存一份,恢复再失败即永久丢失,拒绝整单
1497
+ console.warn(`[sync] local.yaml 备份写入失败,拒绝 --force 整树覆盖: ${err.message}`);
1498
+ return { ok: false, pulled: false, specDir, reason: `local.yaml 备份失败,已拒绝整树覆盖(防丢连接凭据): ${err.message}` };
1499
+ }
1500
+ }
1501
+ try {
1502
+ rmSync(specDir, { recursive: true, force: true });
1503
+ } catch (err) {
1504
+ // Windows EPERM/EBUSY:整树内 .runtime/sillyspec.db 被运行中 daemon/CLI 持有时 rm 必失败
1505
+ //(本地树未被删除,可安全整单退出;.bak 留待下次成功覆盖时清理)
1506
+ const holdHint = err.code === 'EBUSY' || err.code === 'EPERM'
1507
+ ? '(可能被运行中的 daemon/CLI 持有 .runtime/sillyspec.db,请先停止相关进程再 --force)' : '';
1508
+ console.warn(`[sync] 删除本地 specDir 失败${holdHint}: ${err.message}`);
1509
+ return { ok: false, pulled: false, specDir, reason: `删除本地 specDir 失败${holdHint}: ${err.message}` };
1510
+ }
1511
+ try {
1512
+ for (const e of entries) {
1513
+ _assertSafeTarName(e.name.replace(/\/+$/, ''), specDir);
1514
+ const fullPath = join(specDir, e.name);
1515
+ if (e.isDir) {
1516
+ mkdirSync(fullPath, { recursive: true });
1517
+ } else {
1518
+ mkdirSync(dirname(fullPath), { recursive: true });
1519
+ writeFileSync(fullPath, e.data);
1520
+ }
1521
+ }
1522
+ if (localYamlSaved !== null) {
1523
+ mkdirSync(specDir, { recursive: true }); // 空 bundle(零条目)时树根未被条目重建
1524
+ writeAtomicSync(localYamlPath, localYamlSaved); // fs-atomic 契约:hook/probe 并发读 local.yaml
1525
+ try { chmodSync(localYamlPath, 0o600); } catch { /* SEC-04 best-effort,同 writeLocalYamlRaw */ }
1526
+ if (!existsSync(localYamlPath)) throw new Error('local.yaml 恢复后校验缺失');
1527
+ }
1528
+ // ③ 成功路径:兜底使命完成,清理 .bak(force:true 容忍本就无 .bak 的路径)
1529
+ rmSync(localYamlBakPath, { force: true });
1530
+ } catch (err) {
1531
+ // ② 恢复兜底:主路径失败(条目写失败中断恢复 / 恢复自身失败)时从 .bak 再试一次,
1532
+ // 仍失败不掩盖——.bak 留在树外原处,用户可手工复制为 local.yaml(凭据不因本次失败丢失)
1533
+ if (localYamlSaved !== null && existsSync(localYamlBakPath)) {
1534
+ try {
1535
+ mkdirSync(specDir, { recursive: true });
1536
+ writeAtomicSync(localYamlPath, readFileSync(localYamlBakPath));
1537
+ try { chmodSync(localYamlPath, 0o600); } catch { /* best-effort */ }
1538
+ } catch { /* .bak 兜底也失败:保留 .bak 现场供手工恢复 */ }
1539
+ console.warn(`[sync] 连接凭据已备份于 ${localYamlBakPath},恢复失败可手工复制为 local.yaml`);
1540
+ }
1541
+ console.warn(`[sync] 解压 spec bundle 失败: ${err.message}`);
1542
+ return { ok: false, pulled: false, specDir, reason: `解压 spec bundle 失败: ${err.message}` };
1543
+ }
1544
+ console.log(`[sync] 已拉取 spec 快照并解压到 ${specDir}${specVersion ? `(平台 spec_version: ${specVersion},打包时刻快照非实时)` : ''}`);
1545
+ return { ok: true, pulled: true, specDir, specVersion };
1546
+ }
1547
+
1118
1548
  /**
1119
1549
  * 冲突解决三选一(task-13 / D-002 / D-010 / D-013 / FR-05)。
1120
1550
  * 读 task-12 写的 sync-conflict-<change>.json,按 mode 处理后必清冲突文件防累积(R-04 / constraints)。
@@ -1369,6 +1799,12 @@ export async function pullList(cwd) {
1369
1799
  return new SyncManager(cwd).pullList();
1370
1800
  }
1371
1801
 
1802
+ // X2(task-14 / FR-07 / FR-08):spec 整树快照拉取便捷导出(index.js 顶层 pull --spec 命令用,
1803
+ // 与 SyncManager.pullSpecBundle 实例方法共用实现)。快照语义见实例方法 docstring(design §7.4)。
1804
+ export async function pullSpecBundle(cwd, opts) {
1805
+ return new SyncManager(cwd).pullSpecBundle(opts);
1806
+ }
1807
+
1372
1808
  // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(index.js platform resolve 子命令用)
1373
1809
  export async function resolve(changeName, mode, cwd) {
1374
1810
  return new SyncManager(cwd).resolve(changeName, mode);
package/src/workflow.js CHANGED
@@ -583,6 +583,10 @@ export function saveWorkflowRun(result, options = {}) {
583
583
  workflow: result.workflow,
584
584
  project: result.project,
585
585
  status: result.status,
586
+ // 坑 archive-batch-31-tool-notes ①:status 按步骤口径而非 workflow 整体口径时
587
+ // 标注来源(如 step:extract-module-impact——整体 status 因下一步角色恒 fail,见
588
+ // complete-handlers.js archive 分支注释),排查者据此区分「本步失败」与「口径说明」。
589
+ ...(result.status_scope ? { status_scope: result.status_scope } : {}),
586
590
  spec_version: result.spec_version,
587
591
  roles: result.roles,
588
592
  workflow_checks: result.workflow_checks,