sillyspec 3.33.0 → 3.33.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.
Files changed (62) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +11 -3
  3. package/AGENTS.md +1 -1
  4. package/assets/command-cards/flow.md +1 -1
  5. package/package.json +1 -1
  6. package/src/archive-delta.js +4 -3
  7. package/src/change-delete.js +6 -5
  8. package/src/contract-matrix.js +2 -1
  9. package/src/cross-placement.js +5 -1
  10. package/src/db.js +8 -2
  11. package/src/deleted-ledger.js +2 -1
  12. package/src/doctor-diagnostics.js +8 -3
  13. package/src/flow-draft.js +2 -1
  14. package/src/flow.js +111 -62
  15. package/src/friction-ledger.js +2 -1
  16. package/src/friction-tally.js +8 -4
  17. package/src/handoff.js +2 -1
  18. package/src/hooks/worktree-guard.js +5 -5
  19. package/src/index.js +41 -7
  20. package/src/knowledge-hits.js +2 -1
  21. package/src/knowledge-vector.js +2 -1
  22. package/src/progress/consistency-doctor.js +5 -2
  23. package/src/progress/stage-machine.js +6 -2
  24. package/src/progress.js +19 -8
  25. package/src/quicklog.js +194 -9
  26. package/src/review-dispatch.js +4 -3
  27. package/src/review-tier.js +2 -1
  28. package/src/route-hindsight.js +4 -3
  29. package/src/run/bg-sync.js +10 -3
  30. package/src/run/command.js +13 -11
  31. package/src/run/complete-handlers.js +40 -37
  32. package/src/run/complete.js +12 -8
  33. package/src/run/gate-snapshot-ledger.js +2 -1
  34. package/src/run/gate-snapshot.js +57 -11
  35. package/src/run/gates.js +31 -7
  36. package/src/run/prompt.js +6 -5
  37. package/src/run/scan-profile.js +2 -1
  38. package/src/run/shared.js +109 -5
  39. package/src/run/stage.js +5 -4
  40. package/src/run/test-ledger.js +2 -1
  41. package/src/run/verify-quality-scan.js +11 -5
  42. package/src/runtime-paths.js +189 -0
  43. package/src/scan-refresh.js +4 -3
  44. package/src/scope-audit.js +7 -6
  45. package/src/sentinel-assertions.js +37 -0
  46. package/src/spec-sync.js +38 -22
  47. package/src/stage-contract.js +24 -2
  48. package/src/stage-review.js +3 -2
  49. package/src/stages/brainstorm.js +1 -1
  50. package/src/sync-noise.js +3 -2
  51. package/src/sync.js +71 -15
  52. package/src/task-done.js +2 -1
  53. package/src/task-review.js +7 -5
  54. package/src/task-tick.js +23 -1
  55. package/src/validate-artifacts.js +2 -1
  56. package/src/verify-draft.js +2 -1
  57. package/src/verify-postcheck.js +11 -10
  58. package/src/verify-probes.js +157 -52
  59. package/src/watcher.js +15 -9
  60. package/src/worktree-apply.js +2 -1
  61. package/src/write-audit.js +2 -1
  62. package/templates/agents-instruction.md +1 -1
@@ -25,6 +25,7 @@
25
25
  * 无引用论断检不出。refresh 只声称「核对至 HEAD 的检出项已处理」,不声称「一致」。
26
26
  */
27
27
  import { existsSync, readFileSync, readdirSync, mkdirSync } from 'node:fs'
28
+ import { runtimeStatePath } from './runtime-paths.js'
28
29
  import { join } from 'node:path'
29
30
  import { createHash } from 'node:crypto'
30
31
  import { safeGit } from './git-helper.js'
@@ -227,7 +228,7 @@ export function computeRefreshPlan({ projectRoot, specBase, projectName, force =
227
228
  try {
228
229
  const guardDir = join(specBase, '.runtime')
229
230
  mkdirSync(guardDir, { recursive: true })
230
- writeAtomicSync(join(guardDir, 'scan-guard.json'), JSON.stringify({
231
+ writeAtomicSync(runtimeStatePath(guardDir, 'scan-guard.json'), JSON.stringify({
231
232
  name_zh: '增量刷新守卫',
232
233
  mode: 'scan-refresh',
233
234
  refreshDocs,
@@ -381,7 +382,7 @@ export async function finalizeRefresh(opts = {}) {
381
382
  const scanDir = join(specBase, 'docs', projectName, 'scan')
382
383
  // ①拍 guard 会话态必须存在(握手文件=工单受影响集与内容哈希的唯一来源)
383
384
  let guard = null
384
- const guardPath = join(specBase, '.runtime', 'scan-guard.json')
385
+ const guardPath = runtimeStatePath(join(specBase, '.runtime'), 'scan-guard.json')
385
386
  try { guard = JSON.parse(readFileSync(guardPath, 'utf8')) } catch { /* 缺失/坏 JSON → 下方拒绝 */ }
386
387
  if (!guard || guard.mode !== 'scan-refresh') {
387
388
  console.error('scan refresh --done:未找到增量刷新会话态(.runtime/scan-guard.json 无 mode=scan-refresh)')
@@ -453,7 +454,7 @@ export async function finalizeRefresh(opts = {}) {
453
454
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
454
455
  mkdirSync(runtimeRoot, { recursive: true })
455
456
  const ts = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14)
456
- auditPath = join(runtimeRoot, `scan-refresh-${ts}.json`)
457
+ auditPath = runtimeStatePath(runtimeRoot, `scan-refresh-${ts}.json`)
457
458
  writeAtomicSync(auditPath, JSON.stringify({
458
459
  changeType: 'scan-refresh',
459
460
  head: headShort,
@@ -32,6 +32,7 @@
32
32
  * degraded 仓退 v1 ⊘ 形态不炸主仓表;预执行形态不调内核(跨仓行保持清单形态)。
33
33
  */
34
34
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
35
+ import { runtimeStatePath } from './runtime-paths.js'
35
36
  import { createHash } from 'node:crypto'
36
37
  import { join, dirname } from 'node:path'
37
38
  import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs, isQuickMetadata } from './run/shared.js'
@@ -263,7 +264,7 @@ function readScopeSnapshot(changeDir, runtimeRoot, changeName) {
263
264
  } catch { /* 缺失/损坏 → 下一源 */ }
264
265
  }
265
266
  try {
266
- const snap = JSON.parse(readFileSync(join(runtimeRoot, `scope-audit-${changeName}.json`), 'utf8'))
267
+ const snap = JSON.parse(readFileSync(runtimeStatePath(runtimeRoot, `scope-audit-${changeName}.json`), 'utf8'))
267
268
  if (snap && typeof snap === 'object' && Array.isArray(snap.rows)) return snap
268
269
  } catch { /* 缺失/损坏 → null */ }
269
270
  return null
@@ -859,10 +860,10 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
859
860
  })() : false
860
861
  const hasSnapshotFile = hasMergedScopeAudit
861
862
  || (changeDirInfo
862
- ? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
863
- : existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`)))
864
- const hasApplyPathspec = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
865
- const hasCleanupReceipt = existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
863
+ ? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(runtimeStatePath(runtimeRoot, `scope-audit-${changeName}.json`))
864
+ : existsSync(runtimeStatePath(runtimeRoot, `scope-audit-${changeName}.json`)))
865
+ const hasApplyPathspec = existsSync(runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`))
866
+ const hasCleanupReceipt = existsSync(runtimeStatePath(runtimeRoot, `execute-cleanup-${changeName}.json`))
866
867
  let hasExecuteRunStamp = false
867
868
  if (!hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt) {
868
869
  try {
@@ -1016,7 +1017,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1016
1017
  let narrowNote = null
1017
1018
  if (freshActual && collectPatch) {
1018
1019
  try {
1019
- const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
1020
+ const pathspecFile = runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`)
1020
1021
  const owned = readFileSync(pathspecFile, 'utf8').split('\n').map(l => toPosix(l.trim())).filter(Boolean)
1021
1022
  if (owned.length > 0) {
1022
1023
  const ownedSet = new Set(owned)
@@ -207,6 +207,43 @@ export function detectTickBurst(tickLog, opts = {}) {
207
207
  };
208
208
  }
209
209
 
210
+ /**
211
+ * 勾选时点判定·换源版(2026-10-17-tick-timing-source,纯函数):task-ticks.jsonl 侧车的逐格
212
+ * 墙钟时戳 → 勾选是「边干边勾」还是「末尾集中」。旧判据(tasks.md git 首次提交 == 收口最后
213
+ * 提交)已退役——git 历史形态被官方允许的 reset --soft 压扁无声摧毁(tick-timing-squash-
214
+ * blindspot 坑:边干边勾的渐进提交被并行会话压扁后警告照出,结构性误报;且 tasks.md 不进
215
+ * 交付 pathspec 的纪律下「untracked 直至归档」恒复现)。墙钟时戳是侧车文件事实,压扁/
216
+ * rebase 均不可改写——与「审计真相锚 change.patch sha256、不依赖 git 形态」口径对齐。
217
+ * 判定序(豁免优先):
218
+ * 'no-ticks' 侧车无记录(agent 全程直接 Edit)——不判(调用方给一行 task tick 教学);
219
+ * 'single' 仅 1 格——单格无节奏可言;
220
+ * 'spread' 时戳跨度 > clusterWindowMs(缺省 120s)——天然分散,边干边勾确认
221
+ * (超窗即铁证,无需存活锚);
222
+ * 'no-anchor' lifespanMs 非有限或 ≤0(baseline 缺失)——fail-open 不判(防误报);
223
+ * 'concentrated' 跨度 ≤ clusterWindowMs 且 lifespanMs ≥ ratio×max(跨度,1ms)(缺省 10)
224
+ * ——格子全挤在收口前短窗且变更活得够久,末尾集中勾选形态;
225
+ * 其余(挤短窗但存活比不足)→ 'spread'——快变更快勾是合法形态,不告。
226
+ * 输入 ticks 为 [{ts, task}](parseTickLog 产物或同形);行序不保证,内部按 ts 排序。
227
+ * 坏行(ts 非有限/≤0)静默剔除。watcher 采样事件刻意不作判据(部分观测会把分散勾选采样
228
+ * 成尾窗聚集,与采样合并跳系列坑同源——缺观测宁可教学不判)。
229
+ * @param {{ticks?:Array<{ts:number,task:string}>, lifespanMs?:number, clusterWindowMs?:number, ratio?:number}} args
230
+ * @returns {{status:'spread'|'concentrated'|'single'|'no-ticks'|'no-anchor', count:number, spanMs:number}}
231
+ */
232
+ export function detectTickTiming({ ticks, lifespanMs, clusterWindowMs = 120_000, ratio = 10 } = {}) {
233
+ const list = (Array.isArray(ticks) ? ticks : [])
234
+ .map((r) => Number(r && r.ts))
235
+ .filter((ts) => Number.isFinite(ts) && ts > 0)
236
+ .sort((a, b) => a - b);
237
+ if (list.length === 0) return { status: 'no-ticks', count: 0, spanMs: 0 };
238
+ if (list.length === 1) return { status: 'single', count: 1, spanMs: 0 };
239
+ const spanMs = list[list.length - 1] - list[0];
240
+ if (spanMs > clusterWindowMs) return { status: 'spread', count: list.length, spanMs };
241
+ const life = Number(lifespanMs);
242
+ if (!Number.isFinite(life) || life <= 0) return { status: 'no-anchor', count: list.length, spanMs };
243
+ if (life >= ratio * Math.max(spanMs, 1)) return { status: 'concentrated', count: list.length, spanMs };
244
+ return { status: 'spread', count: list.length, spanMs };
245
+ }
246
+
210
247
  /**
211
248
  * 单拍勾选门决策(2026-09-29-batch-tick-gate,纯函数):检出单拍跳(batchTick 非 null)
212
249
  * 时的收口动作裁决。镜像豁免分支退役(2026-10-07-thin-tasks-v3——任务面统一为 agent
package/src/spec-sync.js CHANGED
@@ -19,6 +19,7 @@ import { createHash } from 'crypto';
19
19
  import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
20
20
  import { join, relative, sep } from 'path';
21
21
  import { bindSyncNoiseRoot, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
22
+ import { runtimeStatePath } from './runtime-paths.js';
22
23
  import { writeAtomicSync } from './fs-atomic.js';
23
24
 
24
25
  // 与 sillyhub-daemon/src/spec-sync.ts 共用排除口径(task-07 / FR-06 / D-008@v2):
@@ -69,7 +70,7 @@ export function describeSyncError(err) {
69
70
  // 才拿到),且失败走「下次自动重试」无限静默循环——归档半态(本地已归档、平台仍「进行中」)
70
71
  // 挂数小时无人知晓。两道护栏:①非 2xx 响应体解析进告警行;②按 label 连续失败计数持久化,
71
72
  // 连续 ≥3 次升级 🚨 显式告警,任一次成功清零。
72
- const FAIL_STREAK_FILE = join('.runtime', 'spec-sync-fail-streak.json');
73
+ const FAIL_STREAK_FILE = join('.runtime', 'state', 'spec-sync-fail-streak.json');
73
74
  const FAIL_STREAK_ESCALATE_AT = 3;
74
75
 
75
76
  export function describeErrorBody(status, rawBody) {
@@ -387,7 +388,7 @@ const LAST_SUCCESS_MARKER = 'spec-sync-last-success.json';
387
388
 
388
389
  function readLastSyncTs(specRoot) {
389
390
  try {
390
- const raw = readFileSync(join(specRoot, '.runtime', LAST_SUCCESS_MARKER), 'utf8');
391
+ const raw = readFileSync(runtimeStatePath(join(specRoot, '.runtime'), LAST_SUCCESS_MARKER), 'utf8');
391
392
  const ts = JSON.parse(raw).ts;
392
393
  return Number.isFinite(ts) ? ts : null;
393
394
  } catch {
@@ -398,7 +399,7 @@ function readLastSyncTs(specRoot) {
398
399
  function writeLastSyncTs(specRoot) {
399
400
  try {
400
401
  mkdirSync(join(specRoot, '.runtime'), { recursive: true });
401
- writeFileSync(join(specRoot, '.runtime', LAST_SUCCESS_MARKER), JSON.stringify({ ts: Date.now() }) + '\n', 'utf8');
402
+ writeFileSync(runtimeStatePath(join(specRoot, '.runtime'), LAST_SUCCESS_MARKER), JSON.stringify({ ts: Date.now() }) + '\n', 'utf8');
402
403
  } catch { /* 标记失败只损失下次的防回推过滤,不影响本次同步结果 */ }
403
404
  }
404
405
 
@@ -415,7 +416,7 @@ const BASE_SNAPSHOT = 'spec-sync-base.json';
415
416
 
416
417
  function readBaseSnapshot(specRoot) {
417
418
  try {
418
- const raw = readFileSync(join(specRoot, '.runtime', BASE_SNAPSHOT), 'utf8');
419
+ const raw = readFileSync(runtimeStatePath(join(specRoot, '.runtime'), BASE_SNAPSHOT), 'utf8');
419
420
  const hashes = JSON.parse(raw).hashes;
420
421
  return hashes && typeof hashes === 'object' ? hashes : null;
421
422
  } catch {
@@ -428,7 +429,7 @@ function writeBaseSnapshot(specRoot, localFiles) {
428
429
  const hashes = {};
429
430
  for (const f of localFiles) hashes[f.path] = f.hash;
430
431
  mkdirSync(join(specRoot, '.runtime'), { recursive: true });
431
- writeFileSync(join(specRoot, '.runtime', BASE_SNAPSHOT), JSON.stringify({ ts: Date.now(), hashes }, null, 2) + '\n', 'utf8');
432
+ writeFileSync(runtimeStatePath(join(specRoot, '.runtime'), BASE_SNAPSHOT), JSON.stringify({ ts: Date.now(), hashes }, null, 2) + '\n', 'utf8');
432
433
  } catch { /* 快照失败只损失下次的内容级防回推,不影响本次同步结果 */ }
433
434
  }
434
435
 
@@ -497,7 +498,7 @@ export function partitionConflictPaths(serverVersions, localFiles, baseHashes, l
497
498
  */
498
499
  function isSamePendingSpecConflict(specRoot, changeName, realPaths) {
499
500
  try {
500
- const raw = readFileSync(join(specRoot, '.runtime', `spec-sync-conflict-${changeName}.json`), 'utf8');
501
+ const raw = readFileSync(runtimeStatePath(join(specRoot, '.runtime'), `spec-sync-conflict-${changeName}.json`), 'utf8');
501
502
  const prev = JSON.parse(raw).conflicting_paths;
502
503
  if (!Array.isArray(prev) || prev.length !== realPaths.length) return false;
503
504
  const prevSet = new Set(prev);
@@ -514,7 +515,7 @@ function isSamePendingSpecConflict(specRoot, changeName, realPaths) {
514
515
  */
515
516
  function clearSpecConflictMarker(specRoot, changeName) {
516
517
  try {
517
- const p = join(specRoot, '.runtime', `spec-sync-conflict-${changeName}.json`);
518
+ const p = runtimeStatePath(join(specRoot, '.runtime'), `spec-sync-conflict-${changeName}.json`);
518
519
  if (existsSync(p)) unlinkSync(p);
519
520
  } catch { /* 清理失败不影响同步结果 */ }
520
521
  }
@@ -553,7 +554,7 @@ export function writeTombstoneAttribution(specRoot, platformDeleted) {
553
554
  const byChange = extractTombstonedChanges(platformDeleted);
554
555
  const written = [];
555
556
  for (const [name, paths] of byChange) {
556
- const filePath = join(specRoot, '.runtime', `spec-sync-conflict-${name}.json`);
557
+ const filePath = runtimeStatePath(join(specRoot, '.runtime'), `spec-sync-conflict-${name}.json`);
557
558
  let prev = null;
558
559
  try { prev = JSON.parse(readFileSync(filePath, 'utf8')); } catch { /* 首见 */ }
559
560
  const record = {
@@ -584,13 +585,17 @@ export function writeTombstoneAttribution(specRoot, platformDeleted) {
584
585
  * 清理条数。挂在两个全绿点:POST 成功无冲突分支与无差异跳过分支。
585
586
  */
586
587
  export function clearStaleTombstoneMarkers(specRoot) {
588
+ // 2026-10-18 布局分层:冲突文件落 state/;根目录扫描保留旧布局兜底
587
589
  const runtimeDir = join(specRoot, '.runtime');
588
590
  let files = [];
589
- try { files = readdirSync(runtimeDir); } catch { return 0; }
591
+ const conflictDirs = [join(runtimeDir, 'state'), runtimeDir]
590
592
  let cleared = 0;
591
- for (const f of files) {
593
+ for (const dir of conflictDirs) {
594
+ let dirFiles = [];
595
+ try { dirFiles = readdirSync(dir); } catch { continue; }
596
+ for (const f of dirFiles) {
592
597
  if (!f.startsWith('spec-sync-conflict-') || !f.endsWith('.json')) continue;
593
- const p = join(runtimeDir, f);
598
+ const p = join(dir, f);
594
599
  try {
595
600
  const j = JSON.parse(readFileSync(p, 'utf8'));
596
601
  const cp = Array.isArray(j.conflicting_paths) ? j.conflicting_paths : null;
@@ -601,6 +606,7 @@ export function clearStaleTombstoneMarkers(specRoot) {
601
606
  }
602
607
  } catch { /* 损坏文件不动 */ }
603
608
  }
609
+ }
604
610
  if (cleared > 0) {
605
611
  console.log(`[spec-sync] 全绿同步清理 ${cleared} 条陈旧墓碑冲突记录(被删变更已收敛)`);
606
612
  }
@@ -624,7 +630,7 @@ const FOLLOW_REPORT_MARKER = 'spec-sync-follow-reported.json';
624
630
  */
625
631
  export function isFollowerSetChanged(specRoot, key, list) {
626
632
  const sorted = [...(list || [])].sort();
627
- const p = join(specRoot, '.runtime', FOLLOW_REPORT_MARKER);
633
+ const p = runtimeStatePath(join(specRoot, '.runtime'), FOLLOW_REPORT_MARKER);
628
634
  let cur = {};
629
635
  try { cur = JSON.parse(readFileSync(p, 'utf8')) || {}; } catch { /* 无 marker = 首报 */ }
630
636
  const prev = Array.isArray(cur[key]) ? cur[key] : [];
@@ -665,10 +671,20 @@ function attributeChangeLabel(paths) {
665
671
  return names.size === 1 ? [...names][0] : 'full-tree'
666
672
  }
667
673
 
674
+ /**
675
+ * spec 树增量同步(以服务器清单为锚做全树 diff → POST ops)。
676
+ *
677
+ * 返回契约(2026-10-17-archive-half-state-location-stuck-active):`ok` 字段表达
678
+ * 「本轮文件树通道是否确认收敛」——ok:true = 确认(无差异 no-op / 推送成功 / 冲突自愈
679
+ * / 未连接本地合法态);ok:false = 确认失败(清单/推送 HTTP 失败、异常);**无 ok 字段
680
+ * = 未确认**(未决冲突待人裁等,fail-closed)。终态强校验消费方
681
+ * (run/shared.js triggerSyncTerminalStrict)按 `ok === true` 判收敛——历史调用方
682
+ * (syncSpecTreeOnly / resolve forcePush 等)不消费该字段,向后兼容。
683
+ */
668
684
  export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
669
685
  if (!platform || !platform.url || !platform.token) {
670
686
  debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
671
- return { synced: 0 };
687
+ return { synced: 0, ok: true };
672
688
  }
673
689
  // 全树入口(platform sync 无 --change,2026-10-11-sync-conflict-tri-fix FR-03)
674
690
  // changeName=null:日志用 full-tree 标签,冲突归因见下方 conflictLabel。
@@ -705,7 +721,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
705
721
  if (failCount >= FAIL_STREAK_ESCALATE_AT) {
706
722
  console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
707
723
  }
708
- return { synced: 0 };
724
+ return { synced: 0, ok: false };
709
725
  }
710
726
  const body = await res.json().catch(() => ({}));
711
727
  serverManifest = body.files || {};
@@ -716,7 +732,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
716
732
  if (failCount >= FAIL_STREAK_ESCALATE_AT) {
717
733
  console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
718
734
  }
719
- return { synced: 0 };
735
+ return { synced: 0, ok: false };
720
736
  }
721
737
 
722
738
  // 2. walk/hash/diff
@@ -758,7 +774,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
758
774
  debugLog(`[spec-sync] 无差异,跳过同步: ${logLabel}`);
759
775
  clearStaleTombstoneMarkers(specRoot);
760
776
  clearFailStreak(specRoot, logLabel);
761
- return { synced: 0 };
777
+ return { synced: 0, ok: true };
762
778
  }
763
779
 
764
780
  // 3. POST ops
@@ -782,7 +798,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
782
798
  if (failCount >= FAIL_STREAK_ESCALATE_AT) {
783
799
  console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
784
800
  }
785
- return { synced: 0 };
801
+ return { synced: 0, ok: false };
786
802
  }
787
803
  const body = await res.json().catch(() => ({}));
788
804
  if (body.conflict) {
@@ -843,7 +859,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
843
859
  debugLog(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器: ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
844
860
  }
845
861
  console.warn(`⚠️ [spec-sync] 变更 ${conflictLabel} 存在未决 spec 树冲突(${realPaths.length} 个文件待裁决,本轮非冲突改动已照常同步)。处理:sillyspec platform resolve ${conflictLabel} --keep-local | --take-platform | --abort`);
846
- return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${conflictLabel}.json`), suppressed: true };
862
+ return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: runtimeStatePath(join(specRoot, '.runtime'), `spec-sync-conflict-${conflictLabel}.json`), suppressed: true };
847
863
  }
848
864
  if (followers.length > 0) {
849
865
  console.warn(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器(正常多端前进,无需人工裁决): ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
@@ -855,13 +871,13 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
855
871
  noteSyncSuccess();
856
872
  clearFailStreak(specRoot, logLabel);
857
873
  console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${conflictLabel}`);
858
- return { synced: Math.max(ops.length - followers.length, 0), conflict: false, autoResolved: followers.length };
874
+ return { synced: Math.max(ops.length - followers.length, 0), ok: true, conflict: false, autoResolved: followers.length };
859
875
  }
860
876
  let conflictPath = null;
861
877
  try {
862
878
  const runtimeDir = join(specRoot, '.runtime');
863
879
  mkdirSync(runtimeDir, { recursive: true });
864
- conflictPath = join(runtimeDir, `spec-sync-conflict-${conflictLabel}.json`);
880
+ conflictPath = runtimeStatePath(runtimeDir, `spec-sync-conflict-${conflictLabel}.json`);
865
881
  writeFileSync(conflictPath, JSON.stringify({
866
882
  change: conflictLabel,
867
883
  kind: 'spec-tree',
@@ -901,7 +917,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
901
917
  clearStaleTombstoneMarkers(specRoot);
902
918
  writeLastSyncTs(specRoot);
903
919
  writeBaseSnapshot(specRoot, localFiles);
904
- return { synced: ops.length };
920
+ return { synced: ops.length, ok: true };
905
921
  } catch (err) {
906
922
  const failCount = bumpFailStreak(specRoot, logLabel, describeSyncError(err));
907
923
  const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
@@ -909,7 +925,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
909
925
  if (failCount >= FAIL_STREAK_ESCALATE_AT) {
910
926
  console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
911
927
  }
912
- return { synced: 0 };
928
+ return { synced: 0, ok: false };
913
929
  }
914
930
  }
915
931
 
@@ -897,7 +897,7 @@ function matrixEvidenceMissing(verdict, evidence) {
897
897
  */
898
898
  export function extractAcceptanceMatrixSlots(verifyMd) {
899
899
  const md = String(verifyMd || '')
900
- const out = { present: false, rows: [], unfilled: 0, missingEvidence: 0 }
900
+ const out = { present: false, rows: [], unfilled: 0, missingEvidence: 0, strayRows: [] }
901
901
  const hm = md.match(/^#### 探针 7[::]/m)
902
902
  if (!hm) return out
903
903
  out.present = true
@@ -905,15 +905,27 @@ export function extractAcceptanceMatrixSlots(verifyMd) {
905
905
  const endMatch = rest.match(/^#{1,4}(?:[ \t]|$)/m)
906
906
  const sectionText = endMatch ? rest.slice(0, endMatch.index) : rest
907
907
 
908
+ // 表头武装(坑 carry-orphan-rows,2026-10-16 下游实证):五列表行只在探针 7 段已被表头行
909
+ // 武装时才计数;refresh 携载注释块解除武装——其后残行(旧模板占位行/键漂移携载行)是无
910
+ // 表头归属的孤行,不栽给最近 **task-NN** 锚误报「判定未填」,进独立 strayRows 桶由
911
+ // validateAcceptanceMatrix 报错要求裁决。武装是段级粘性(首表头武装整段,锚行不重置——
912
+ // 锚下不重复表头的既有手写/历史形态照旧按锚归属);只有携载注释块与其后无新表头重新
913
+ // 武装的窗口内,行才进孤行桶。正常渲染文法(锚行→表头→分隔→数据行)行为与既有口径一致。
908
914
  let currentTask = ''
915
+ let tableArmed = false
909
916
  for (const rawLine of sectionText.split(/\r?\n/)) {
910
917
  const anchor = rawLine.match(/^\*\*\s*(task-[A-Za-z0-9._-]+)\s*\*\*/)
911
918
  if (anchor) { currentTask = anchor[1]; continue }
919
+ if (/<!--\s*refresh 携载/.test(rawLine)) { tableArmed = false; continue } // 携载注释块:其后表行是孤行(直至新表头重新武装)
912
920
  const cells = splitMatrixRowCells(rawLine)
913
921
  if (!cells || cells.length !== 5) continue // 列表防御行/不适用行/非五列表行不计槽
914
922
  const c = cells.map(x => x.trim())
915
923
  if (c.every(x => /^:?-{3,}:?$/.test(x))) continue // 分隔行 |---|---|…|
916
- if (c[3] === '判定' && c[4] === '证据') continue // 表头行
924
+ if (c[3] === '判定' && c[4] === '证据') { tableArmed = true; continue } // 表头行(武装本段)
925
+ if (!tableArmed) {
926
+ out.strayRows.push({ task: currentTask, acceptance: c[0], verdict: c[3], evidence: c[4] })
927
+ continue
928
+ }
917
929
  const verdict = c[3]
918
930
  const evidence = c[4]
919
931
  const row = { task: currentTask, acceptance: c[0], verdict, evidence }
@@ -985,6 +997,16 @@ function validateAcceptanceMatrix(cwd, changeName, context = {}) {
985
997
  `修复:在证据列补测试锚点(如 \`test/foo.test.mjs\` 或 \`src/x.js:42\`)或 non-testable 理由(运行验证移交场景适用:构建回执+移交去向一句话)。`
986
998
  )
987
999
  }
1000
+ // 孤行报错(坑 carry-orphan-rows,2026-10-16 下游实证):无表头归属的五列残行(refresh
1001
+ // 携载块后的旧模板占位行/键漂移携载行/手误行)不归属任何 task 锚——不栽给最近锚计
1002
+ // unfilled(旧路径误报「文档同步任务判定未填」),独立报错要求裁决:删或补归属。
1003
+ if ((matrix.strayRows || []).length > 0) {
1004
+ const list = matrix.strayRows.map((r) => `${r.task || '(无 task 锚)'}|${clip(r.acceptance)}`).join(';')
1005
+ errors.push(
1006
+ `探针 7 验收×测试覆盖矩阵存在 ${matrix.strayRows.length} 行无表头归属的孤行(携载残行/旧模板占位行/手误——不属于任何 task 锚下的有效表,不计入任务矩阵):${list}。` +
1007
+ `修复:删除这些残行,或补全 **task-NN** 锚行与表头后按五列格式填判定与证据。`
1008
+ )
1009
+ }
988
1010
 
989
1011
  // ── partial/uncovered × facts.handover 联动(D-003/FR-04,2026-09-17-pass-cap-semantics task-03)──
990
1012
  // 部分实现必须有移交去向:矩阵含 partial/uncovered 行且 facts.handover 零有效行(任意
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs'
17
+ import { runtimeStatePath } from './runtime-paths.js'
17
18
  import { writeAtomicSync } from './fs-atomic.js'
18
19
  import { join, dirname } from 'path'
19
20
  import { createHash } from 'crypto'
@@ -364,7 +365,7 @@ export function generateStageReviewRunId() {
364
365
  */
365
366
  export function stageReviewMarkerPath(runtimeRoot, stage, changeName) {
366
367
  const suffix = changeName ? `${stage}-${changeName}` : stage
367
- return join(runtimeRoot, `current-stage-review-run-id-${suffix}`)
368
+ return runtimeStatePath(runtimeRoot, `current-stage-review-run-id-${suffix}`)
368
369
  }
369
370
 
370
371
  /**
@@ -853,7 +854,7 @@ export function printStageReviewResult(result, context = {}) {
853
854
  // fail-open:无 changeName / 无记录 / 读失败 → 零输出(旧口径)。
854
855
  if (changeName && runtimeRoot) {
855
856
  try {
856
- const recPath = join(runtimeRoot, `review-dispatch-${changeName}.json`)
857
+ const recPath = runtimeStatePath(runtimeRoot, `review-dispatch-${changeName}.json`)
857
858
  if (existsSync(recPath)) {
858
859
  const rec = JSON.parse(readFileSync(recPath, 'utf8'))
859
860
  if (rec && typeof rec === 'object' && (rec.state === 'in-flight' || rec.state === 'dispatching')) {
@@ -58,7 +58,7 @@ export const definition = {
58
58
  - **明显 small**(满足:单上下文可吞吐的纯执行类——改文案/修 bug/样式调整/配置微调,预计无 Wave 编排/上下文分片需求)→ 输出:「此变更规模较小,建议直接走轻量变更」+ 一句依据,给出可照抄建议命令(--input 引号内换行合法):
59
59
 
60
60
  \`\`\`
61
- sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景
61
+ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景
62
62
 
63
63
  成功标准:
64
64
  - <可验证标准>"
package/src/sync-noise.js CHANGED
@@ -24,6 +24,7 @@
24
24
  * 调用方传 noMute 绕过。
25
25
  */
26
26
  import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
27
+ import { runtimeStatePath } from './runtime-paths.js'
27
28
  import { join } from 'path'
28
29
 
29
30
  const NOISE_MARKER = 'sync-noise-mute.json'
@@ -254,7 +255,7 @@ let _openedSelfHealByThisProcess = new Set()
254
255
  const _seenSelfHealInProcess = new Set()
255
256
 
256
257
  function _readSelfHealWindows() {
257
- const p = _runtimeDir ? join(_runtimeDir, SELF_HEAL_MARKER) : null
258
+ const p = _runtimeDir ? runtimeStatePath(_runtimeDir, SELF_HEAL_MARKER) : null
258
259
  if (!p) return null
259
260
  try {
260
261
  const s = JSON.parse(readFileSync(p, 'utf8'))
@@ -279,7 +280,7 @@ function _selfHealLineVisible(changeName, kind, noMute) {
279
280
  next[key] = now + MUTE_WINDOW_MS
280
281
  try {
281
282
  mkdirSync(_runtimeDir, { recursive: true })
282
- writeFileSync(join(_runtimeDir, SELF_HEAL_MARKER), JSON.stringify(next) + '\n', 'utf8')
283
+ writeFileSync(runtimeStatePath(_runtimeDir, SELF_HEAL_MARKER), JSON.stringify(next) + '\n', 'utf8')
283
284
  } catch { /* marker 写失败 = 闸门失效退化为直通,仅损失降噪 */ }
284
285
  _openedSelfHealByThisProcess.add(key)
285
286
  return true