sillyspec 3.29.5 → 3.30.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.
- package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +18 -116
- package/AGENTS.md +38 -20
- package/SKILL.md +9 -3
- package/assets/command-cards/flow.md +32 -0
- package/assets/command-cards/run-brainstorm.md +1 -1
- package/assets/command-cards/run-quick.md +8 -25
- package/package.json +2 -1
- package/packages/dashboard/server/executor.js +2 -1
- package/packages/dashboard/server/parser.js +7 -7
- package/r7-test-env/README-R7-TEST.md +93 -0
- package/r7-test-env/demo-project/lib/calc.js +6 -0
- package/r7-test-env/demo-project/package.json +1 -0
- package/r7-test-env/demo-project/test.js +10 -0
- package/r7-test-env/prompt-R7-SMOKE.md +41 -0
- package/r7-test-env/sillyspec-r7.cmd +3 -0
- package/round4/account.mjs +18 -5
- package/round4/b1-analyze-task08.mjs +40 -0
- package/round4/b1-b1-target-validation.md +32 -0
- package/round4/b3a-prompt-audit-result.md +66 -0
- package/round4/b3a-prompt-audit.mjs +89 -0
- package/round4/optimization-plan.md +165 -0
- package/round4/r4-final-report.html +347 -0
- package/round4/r5-scenario-matrix.md +46 -0
- package/src/change-list.js +9 -0
- package/src/classify-change.js +6 -0
- package/src/command-cards.js +2 -1
- package/src/commit-guard.js +2 -0
- package/src/config-schema.js +64 -12
- package/src/db.js +35 -4
- package/src/decision-distill.js +9 -3
- package/src/docs-check.js +1 -1
- package/src/doctor-diagnostics.js +44 -2
- package/src/flow-draft.js +748 -0
- package/src/flow-parity.js +236 -0
- package/src/flow-review.js +184 -0
- package/src/flow.js +1492 -0
- package/src/foreign-declared.js +17 -1
- package/src/fr-index.js +396 -22
- package/src/fs-atomic.js +5 -1
- package/src/git-helper.js +2 -2
- package/src/greenfield-bootstrap.js +98 -0
- package/src/handoff.js +120 -7
- package/src/hunk-attribution.js +191 -0
- package/src/index.js +570 -16
- package/src/knowledge-classify.js +47 -1
- package/src/knowledge-digest.js +162 -0
- package/src/knowledge-quicklog.js +246 -0
- package/src/machine-draft.js +128 -0
- package/src/machine-interface.js +107 -5
- package/src/mcp-server.js +1 -0
- package/src/module-resolve.js +3 -1
- package/src/preview-progress.js +160 -0
- package/src/progress/change-registry.js +11 -3
- package/src/progress/shared.js +3 -2
- package/src/progress.js +56 -13
- package/src/quicklog.js +44 -6
- package/src/redomain.js +147 -0
- package/src/review-material-pack.js +204 -1
- package/src/run/archive-distill.js +4 -2
- package/src/run/bg-sync.js +67 -0
- package/src/run/command.js +123 -10
- package/src/run/complete-handlers.js +327 -72
- package/src/run/complete.js +350 -71
- package/src/run/gate-snapshot-ledger.js +213 -0
- package/src/run/gate-snapshot.js +250 -34
- package/src/run/gates.js +370 -233
- package/src/run/green-cache.js +123 -0
- package/src/run/junction-rm.js +70 -0
- package/src/run/next.js +14 -0
- package/src/run/prompt.js +185 -55
- package/src/run/quick-audit.js +185 -12
- package/src/run/shared.js +120 -3
- package/src/run/stage.js +182 -216
- package/src/run/test-ledger.js +264 -0
- package/src/run/verify-quality-scan.js +84 -7
- package/src/scope-audit.js +6 -1
- package/src/sentinel-assertions.js +114 -0
- package/src/setup.js +3 -3
- package/src/spec-sync.js +41 -1
- package/src/stage-contract.js +26 -8
- package/src/stage-review-checklist.js +2 -0
- package/src/stage-review.js +7 -2
- package/src/stage-templates.js +157 -0
- package/src/stages/archive.js +2 -0
- package/src/stages/brainstorm.js +24 -106
- package/src/stages/execute.js +221 -84
- package/src/stages/knowledge.js +74 -1
- package/src/stages/plan-postcheck.js +352 -1
- package/src/stages/plan.js +23 -1
- package/src/stages/quick.js +10 -2
- package/src/stages/verify.js +4 -2
- package/src/sync.js +81 -23
- package/src/task-done.js +181 -0
- package/src/task-review.js +8 -55
- package/src/test-bindings.js +549 -0
- package/src/timeline.js +218 -0
- package/src/ui-visual.js +175 -0
- package/src/verify-draft.js +214 -0
- package/src/verify-postcheck.js +860 -158
- package/src/verify-probes.js +59 -3
- package/src/watcher.js +1031 -0
- package/src/worktree-apply.js +44 -4
- package/src/worktree-deps.js +22 -8
- package/src/worktree-policy.js +42 -0
- package/src/worktree.js +102 -15
- package/templates/agents-instruction.md +37 -18
package/src/quicklog.js
CHANGED
|
@@ -752,10 +752,12 @@ const GUARD_CLAIM_STALE_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
752
752
|
* 遍历 quick-sessions 目录,按同一僵尸窗口口径读各会话 guard.json(collectGuardReservedQuicklogIds
|
|
753
753
|
* 与 collectActiveQuickGuardFiles 共用,活跃判定勿分叉):guard.json 损坏/缺失 → guard=null 保留
|
|
754
754
|
* 条目(消费方各自决定跳过或视为空声明);startedAt 可解析且超 GUARD_CLAIM_STALE_MS → 僵尸剔除。
|
|
755
|
+
* 附 guardMtimeMs(guard.json 最后写时间——会话结构性更新【启动/--files 追加边界】都会重写该文件,
|
|
756
|
+
* 是比 startedAt 更新的活性信号;非周期心跳,长思考会话会漂老,消费方 TTL 须以此为前提选窗)。
|
|
755
757
|
*
|
|
756
758
|
* @param {string} dir quick-sessions 目录
|
|
757
759
|
* @param {number} [nowMs] 当前时间戳(可注入,测试用)
|
|
758
|
-
* @returns {Array<{sessionId: string, guard: object|null}>}
|
|
760
|
+
* @returns {Array<{sessionId: string, guard: object|null, guardMtimeMs: number|null}>}
|
|
759
761
|
*/
|
|
760
762
|
function listQuickSessionGuards(dir, nowMs = Date.now()) {
|
|
761
763
|
const out = []
|
|
@@ -765,12 +767,17 @@ function listQuickSessionGuards(dir, nowMs = Date.now()) {
|
|
|
765
767
|
} catch { return out } // 目录不存在/不可读 → 无会话
|
|
766
768
|
for (const sessionName of sessionDirs) {
|
|
767
769
|
let guard = null
|
|
768
|
-
|
|
770
|
+
let guardMtimeMs = null
|
|
771
|
+
const guardPath = join(dir, sessionName, 'guard.json')
|
|
772
|
+
try {
|
|
773
|
+
guard = JSON.parse(readFileSync(guardPath, 'utf8'))
|
|
774
|
+
guardMtimeMs = statSync(guardPath).mtimeMs
|
|
775
|
+
} catch { guard = null } // 损坏/缺失 → null(mtime 一并放弃)
|
|
769
776
|
if (guard && guard.startedAt) {
|
|
770
777
|
const startedAtMs = Date.parse(guard.startedAt)
|
|
771
778
|
if (Number.isFinite(startedAtMs) && nowMs - startedAtMs > GUARD_CLAIM_STALE_MS) continue // 僵尸不活跃
|
|
772
779
|
}
|
|
773
|
-
out.push({ sessionId: sessionName, guard })
|
|
780
|
+
out.push({ sessionId: sessionName, guard, guardMtimeMs })
|
|
774
781
|
}
|
|
775
782
|
return out
|
|
776
783
|
}
|
|
@@ -795,30 +802,61 @@ export function collectGuardReservedQuicklogIds(specBase, sessionsDirHint = null
|
|
|
795
802
|
* (quick --done 完成时清理 guard 目录,完成态天然退出),超 7 天僵尸窗口(GUARD_CLAIM_STALE_MS,
|
|
796
803
|
* 异常残留不钉死)剔除。勿用 changes.last_active 判活跃(非周期心跳,D-002)。
|
|
797
804
|
*
|
|
805
|
+
* **拦截面 TTL(ql-20260921-009 ①,2026-09-21 batch2 归档实证)**:在僵尸窗口之上,本函数(apply
|
|
806
|
+
* 拦截面专用消费口)再按 guard.json **mtime** 收紧——超过 staleAfterMs(缺省 4h)无结构性更新的
|
|
807
|
+
* 会话视为放弃,声明不再参与相交拦截。实证形态:quick 会话中断未 --done、guard 目录残留 36h,
|
|
808
|
+
* apply 被幽灵守卫拦住,人工查证 owner.json 才敢解锁。安全性:守卫只是预警层,apply 的内容级
|
|
809
|
+
* 脏文件三方合并检查(EXCLUDE-DIRTY)仍兜底真实冲突;4h 窗口对 quick(短命流程)足够宽,
|
|
810
|
+
* 超窗未动 CLI 的会话即使活着,其声明文件若真有未提交改动也走内容级检查。quicklog ID 预留
|
|
811
|
+
* (collectGuardReservedQuicklogIds)**不套用**此 TTL——ID 复用避开近期死会话仍取 7 天窗。
|
|
812
|
+
* 过期跳过时打一行 ℹ️(可观测,不静默);staleAfterMs=null 关闭(测试/回退口径)。
|
|
813
|
+
*
|
|
798
814
|
* @param {string} specBase .sillyspec 根目录
|
|
799
|
-
* @param {{ excludeChange?: string|null, sessionsDir?: string|null, nowMs?: number }} [opts]
|
|
815
|
+
* @param {{ excludeChange?: string|null, sessionsDir?: string|null, nowMs?: number, staleAfterMs?: number|null }} [opts]
|
|
800
816
|
* - excludeChange:排除自身 change 的 quick 会话——sessionId == changeName(quick 会话 id 即
|
|
801
817
|
* change 名)或 guard.linkedChanges 显式关联该 change 的协作会话(自己人,非拦截面)
|
|
802
818
|
* - sessionsDir:quick-sessions 目录(缺省 <specBase>/.runtime/quick-sessions)
|
|
803
819
|
* - nowMs:当前时间戳(可注入,测试用)
|
|
820
|
+
* - staleAfterMs:拦截面 TTL 毫秒数(缺省 GUARD_INTERCEPT_STALE_MS=4h;null 关闭)
|
|
804
821
|
* @returns {Map<string, string[]>} sessionId → guard.allowedFiles(无 guard/无声明 → 空数组;fail-open)
|
|
805
822
|
*/
|
|
806
|
-
|
|
823
|
+
const GUARD_INTERCEPT_STALE_MS = 4 * 60 * 60 * 1000
|
|
824
|
+
export function collectActiveQuickGuardFiles(specBase, { excludeChange = null, sessionsDir = null, nowMs = Date.now(), staleAfterMs = GUARD_INTERCEPT_STALE_MS } = {}) {
|
|
807
825
|
const out = new Map()
|
|
826
|
+
let staleSkipped = 0
|
|
808
827
|
try {
|
|
809
828
|
const dir = sessionsDir || join(specBase, '.runtime', 'quick-sessions')
|
|
810
|
-
for (const { sessionId, guard } of listQuickSessionGuards(dir, nowMs)) {
|
|
829
|
+
for (const { sessionId, guard, guardMtimeMs } of listQuickSessionGuards(dir, nowMs)) {
|
|
811
830
|
if (excludeChange && (sessionId === excludeChange
|
|
812
831
|
|| (guard && Array.isArray(guard.linkedChanges) && guard.linkedChanges.includes(excludeChange)))) continue
|
|
832
|
+
if (staleAfterMs != null && guardMtimeMs != null && Number.isFinite(guardMtimeMs)
|
|
833
|
+
&& nowMs - guardMtimeMs > staleAfterMs) { staleSkipped++; continue } // TTL 过期:拦截面视为放弃
|
|
813
834
|
const allowed = (guard && Array.isArray(guard.allowedFiles))
|
|
814
835
|
? guard.allowedFiles.filter(f => typeof f === 'string' && f.length > 0)
|
|
815
836
|
: []
|
|
816
837
|
out.set(sessionId, allowed)
|
|
817
838
|
}
|
|
818
839
|
} catch { /* fail-open:采集失败等同无活跃 guard(不放大拦截面) */ }
|
|
840
|
+
if (staleSkipped > 0) {
|
|
841
|
+
console.log(`ℹ️ 已忽略 ${staleSkipped} 个过期 quick 守卫(>${Math.round(staleAfterMs / 3600000)}h 无活动,TTL 自动失效)——内容级脏文件合并检查仍兜底真实冲突`)
|
|
842
|
+
}
|
|
819
843
|
return out
|
|
820
844
|
}
|
|
821
845
|
|
|
846
|
+
/**
|
|
847
|
+
* quick 启动并发错峰建议探测(ql-20260921-009 ③,2026-09-21 batch2 归档实证)。纯读:
|
|
848
|
+
* 他者活跃 quick 守卫(TTL 过滤后)≥1 且主仓已有 src/test 脏文件 → suggest=true——两者
|
|
849
|
+
* 同时在场时工作区互写/apply 相交拦截摩擦集中爆发。单会话常态 suggest=false 零输出。
|
|
850
|
+
* @returns {{ suggest: boolean, others: string[] }}
|
|
851
|
+
*/
|
|
852
|
+
export function detectQuickConcurrencyAdvice({ specBase, changeName, baselineFiles, sessionsDir = null, nowMs = Date.now() } = {}) {
|
|
853
|
+
const others = collectActiveQuickGuardFiles(specBase, { excludeChange: changeName, sessionsDir, nowMs })
|
|
854
|
+
const srcTestDirty = Array.isArray(baselineFiles) && baselineFiles.some(
|
|
855
|
+
(p) => { const n = String(p).split('\\').join('/'); return n.startsWith('src/') || n.startsWith('test/') }
|
|
856
|
+
)
|
|
857
|
+
return { suggest: others.size > 0 && !!srcTestDirty, others: [...others.keys()] }
|
|
858
|
+
}
|
|
859
|
+
|
|
822
860
|
/**
|
|
823
861
|
* 数某 qlId 在所有 QUICKLOG-*.md 里的条目头出现次数(--done 占用校验用)。
|
|
824
862
|
* ≥2 = 分配竞态残留已损坏记录(同 ID 双条目);容错空白形态与 scanExisting 的 reLoose 同口径。
|
package/src/redomain.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redomain.js — FR 条目跨域迁移(2026-09-27-redomain,三层治理 v2 ③)
|
|
3
|
+
*
|
|
4
|
+
* 伪域信号卡指出迁移建议(suggestDomainFromFiles),本模块给机器通道:
|
|
5
|
+
* 把 fr/<from>.md 的条目段切出、拼进 fr/<to>.md(缺席则按 loadDomainSections
|
|
6
|
+
* 同款头新建),目标域无 INDEX 路由行则补(syncIndexRoutingLines 单源)。
|
|
7
|
+
*
|
|
8
|
+
* 身份铁律(D-001 单一身份同源):迁域**不换号**——条目 ID(FR-<旧域>-NNN)
|
|
9
|
+
* 保持原样,绑定行/supersede 链/最近确认锚全靠 ID 寻址(upsertFrBindings 按
|
|
10
|
+
* `## ${frId}` 全目录扫描定位,与所在文件无关);前缀与域不符属历史痕迹。
|
|
11
|
+
*
|
|
12
|
+
* 空壳防线:全域迁移后源文件剩 0 条目则删除(防留空壳域文件污染域列表)。
|
|
13
|
+
* 纯文件手术——不动条目内容、不动绑定块(随条目段整体搬)。
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
|
|
16
|
+
import { join } from 'path'
|
|
17
|
+
import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines } from './decision-distill.js'
|
|
18
|
+
import { FR_SECTION_RE } from './fr-index.js'
|
|
19
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
20
|
+
|
|
21
|
+
/** 条目段头(## FR-xxx-NNN / ## FR-NNN)判定与 ID 提取 */
|
|
22
|
+
const FR_HEADER_RE = /^## (FR-[\w.-]+-\d+|FR-\d+)\s*(.*)$/
|
|
23
|
+
|
|
24
|
+
function frEntryId(headerLine) {
|
|
25
|
+
const m = FR_HEADER_RE.exec(headerLine)
|
|
26
|
+
return m ? m[1] : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 目标域文件头(与 fr-index loadDomainSections 新建头逐字同款) */
|
|
30
|
+
function newDomainPreamble(domain) {
|
|
31
|
+
// 与 fr-index loadDomainSections 新建头逐字同款(评审 P2-2 清偿:含 superseded 回溯行
|
|
32
|
+
// 与 伪域/模块卡 条件行,勿再分叉)
|
|
33
|
+
return [
|
|
34
|
+
'---',
|
|
35
|
+
`author: sillyspec-fr-index`,
|
|
36
|
+
`created_at: ${new Date().toISOString()}`,
|
|
37
|
+
'---',
|
|
38
|
+
'',
|
|
39
|
+
`# FR 索引 — ${domain}`,
|
|
40
|
+
'',
|
|
41
|
+
'> fr-index 从归档变更 requirements.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为机械解析契约,勿手改。',
|
|
42
|
+
'> superseded 条目保留供取代链回溯;brainstorm 注入默认只给 active。',
|
|
43
|
+
String(domain).startsWith('auto-')
|
|
44
|
+
? '> 伪域(auto- 前缀):由文件路径段投票派生,无模块卡——为该域补模块卡后,新变更将自动落回真域'
|
|
45
|
+
: `> 模块卡:modules/${domain}.md(域=模块 id 同构;行为条目↔模块契约互跳)`,
|
|
46
|
+
'',
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 执行迁移(纯函数式落盘)。
|
|
52
|
+
* @param {{knowledgeRoot: string, from: string, to: string, anchors?: string[]}} opts
|
|
53
|
+
* @returns {{moved: Array<{id,title}>, sourceDeleted: boolean, targetCreated: boolean, indexLineAdded: boolean}}
|
|
54
|
+
* @throws 源域缺席 / 无匹配条目 / anchor 未命中
|
|
55
|
+
*/
|
|
56
|
+
export function redomainFrEntries({ knowledgeRoot, from, to, anchors = null }) {
|
|
57
|
+
const frDir = join(knowledgeRoot, 'fr')
|
|
58
|
+
const fromPath = join(frDir, `${from}.md`)
|
|
59
|
+
const toPath = join(frDir, `${to}.md`)
|
|
60
|
+
if (!existsSync(fromPath)) throw new Error(`源域文件不存在:fr/${from}.md`)
|
|
61
|
+
if (from === to) throw new Error('源域与目标域相同')
|
|
62
|
+
|
|
63
|
+
const fromParsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
|
|
64
|
+
const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
|
|
65
|
+
|
|
66
|
+
// 目标域既有内容(缺席=新建头)
|
|
67
|
+
const targetCreated = !existsSync(toPath)
|
|
68
|
+
const toParsed = targetCreated
|
|
69
|
+
? { preamble: newDomainPreamble(to), sections: [] }
|
|
70
|
+
: splitKnowledgeSections(readFileSync(toPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
|
|
71
|
+
|
|
72
|
+
const moving = []
|
|
73
|
+
const staying = []
|
|
74
|
+
for (const s of fromParsed.sections) {
|
|
75
|
+
const id = frEntryId(s.lines[0] || '')
|
|
76
|
+
if (!id) { staying.push(s); continue } // 非条目段(说明/杂项)留守
|
|
77
|
+
if (anchorSet && !anchorSet.has(id)) { staying.push(s); continue }
|
|
78
|
+
moving.push({ id, title: (FR_HEADER_RE.exec(s.lines[0])?.[2] || '').trim(), section: s })
|
|
79
|
+
}
|
|
80
|
+
if (moving.length === 0) {
|
|
81
|
+
throw new Error(anchorSet ? `anchor 未命中(${[...anchorSet].join('、')} 不在 fr/${from}.md)` : `fr/${from}.md 无 FR 条目段`)
|
|
82
|
+
}
|
|
83
|
+
if (anchorSet && moving.length !== anchorSet.size) {
|
|
84
|
+
const got = new Set(moving.map((m) => m.id))
|
|
85
|
+
const miss = [...anchorSet].filter((a) => !got.has(a))
|
|
86
|
+
throw new Error(`anchor 未命中:${miss.join('、')}(不在 fr/${from}.md)`)
|
|
87
|
+
}
|
|
88
|
+
// 目标域 ID 冲突防线(同 ID 已在目标域=迁移语义不明)
|
|
89
|
+
const toIds = new Set(toParsed.sections.map((s) => frEntryId(s.lines[0] || '')).filter(Boolean))
|
|
90
|
+
const clash = moving.filter((m) => toIds.has(m.id))
|
|
91
|
+
if (clash.length > 0) throw new Error(`目标域已有同 ID 条目(${clash.map((c) => c.id).join('、')})——先解冲突再迁移`)
|
|
92
|
+
|
|
93
|
+
// 落盘:目标域追加;源域留余或删除空壳
|
|
94
|
+
toParsed.sections.push(...moving.map((m) => m.section))
|
|
95
|
+
writeAtomicSync(toPath, joinKnowledgeFile(toParsed.preamble, toParsed.sections))
|
|
96
|
+
const sourceDeleted = staying.length === 0
|
|
97
|
+
if (sourceDeleted) unlinkSync(fromPath)
|
|
98
|
+
else writeAtomicSync(fromPath, joinKnowledgeFile(fromParsed.preamble, staying))
|
|
99
|
+
|
|
100
|
+
// INDEX 路由行:syncIndexRoutingLines 扫全目录幂等同步(既有行 no-op、目标域缺席行补上)
|
|
101
|
+
const indexPath = join(knowledgeRoot, 'INDEX.md')
|
|
102
|
+
const before = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
|
|
103
|
+
try {
|
|
104
|
+
syncIndexRoutingLines(knowledgeRoot, {
|
|
105
|
+
section: 'FR 需求索引',
|
|
106
|
+
subdir: 'fr',
|
|
107
|
+
makeLine: (d) => d.startsWith('auto-')
|
|
108
|
+
? `- ${d}|${d.slice(5)}|FR|需求|承接 → [fr/${d}.md](fr/${d}.md)`
|
|
109
|
+
: `- ${d}|FR|需求|承接 → [fr/${d}.md](fr/${d}.md)`,
|
|
110
|
+
})
|
|
111
|
+
} catch { /* INDEX 缺席/同步失败不拦迁移(主手术已成) */ }
|
|
112
|
+
const after = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
moved: moving.map((m) => ({ id: m.id, title: m.title })),
|
|
116
|
+
sourceDeleted,
|
|
117
|
+
targetCreated,
|
|
118
|
+
indexLineAdded: after !== before,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 干跑预览:列出将迁移条目,不落盘 */
|
|
123
|
+
export function planRedomain({ knowledgeRoot, from, to, anchors = null }) {
|
|
124
|
+
const frDir = join(knowledgeRoot, 'fr')
|
|
125
|
+
const fromPath = join(frDir, `${from}.md`)
|
|
126
|
+
if (!existsSync(fromPath)) throw new Error(`源域文件不存在:fr/${from}.md`)
|
|
127
|
+
if (from === to) throw new Error('源域与目标域相同')
|
|
128
|
+
const fromParsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
|
|
129
|
+
const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
|
|
130
|
+
const candidates = []
|
|
131
|
+
for (const s of fromParsed.sections) {
|
|
132
|
+
const id = frEntryId(s.lines[0] || '')
|
|
133
|
+
if (!id) continue
|
|
134
|
+
if (anchorSet && !anchorSet.has(id)) continue
|
|
135
|
+
candidates.push({ id, title: (FR_HEADER_RE.exec(s.lines[0])?.[2] || '').trim() })
|
|
136
|
+
}
|
|
137
|
+
if (candidates.length === 0) {
|
|
138
|
+
throw new Error(anchorSet ? `anchor 未命中(${[...anchorSet].join('、')})` : `fr/${from}.md 无 FR 条目段`)
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
from,
|
|
142
|
+
to,
|
|
143
|
+
targetExists: existsSync(join(frDir, `${to}.md`)),
|
|
144
|
+
entries: candidates,
|
|
145
|
+
sourceWillDelete: candidates.length === fromParsed.sections.filter((s) => frEntryId(s.lines[0] || '')).length,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* 是 prompt.js tier 注入链的组包入口——机械抽素材半边(designDigest/fileList/硬约束/diff/热区/
|
|
24
24
|
* checklist),主代理点名半边(五交叉点/plan 差量)留位不预填(decisions.md D-002)。
|
|
25
25
|
*/
|
|
26
|
-
import { readFileSync } from 'fs'
|
|
26
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
27
27
|
import { join } from 'path'
|
|
28
28
|
import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
|
|
29
29
|
|
|
@@ -132,9 +132,15 @@ export function buildReviewMaterialPack(stage, inputs = {}) {
|
|
|
132
132
|
const delta = (inputs.planDelta || []).map(d => `- [${d.id}] ${d.status}${d.note ? '——' + d.note : ''}`).join('\n')
|
|
133
133
|
body = ['### design 硬约束', hc || '(无)', '### plan 差量(逐约束判定:一致/偏离/未覆盖)', delta || '(无——主代理未提供差量,评审者按 cannot_verify 列缺件)'].join('\n\n')
|
|
134
134
|
} else if (stage === 'execute-qa') {
|
|
135
|
+
// 测试清单(R16 减负批次 2026-09-24):diff 命中的测试文件点名——QA 对「组装行为」条目
|
|
136
|
+
// 作答时不必全仓找测试(R14 对撞失控勘察子代理 2.33M 的反面教材后,包内点名收窄查证面)。
|
|
137
|
+
const testFiles = (inputs.diffSummary && inputs.diffSummary.files || [])
|
|
138
|
+
.filter(f => /(^|[\\/])(tests?|__tests__)([\\/])|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|[\\/])test_[^\\/]+\.py$/i.test(f))
|
|
139
|
+
.slice(0, 30)
|
|
135
140
|
body = [
|
|
136
141
|
'### diff 摘要', (inputs.diffSummary && inputs.diffSummary.files || []).map(f => '- ' + f).join('\n') || '(无)',
|
|
137
142
|
inputs.diffSummary && inputs.diffSummary.stat ? '```diff\n' + inputs.diffSummary.stat + '\n```' : '',
|
|
143
|
+
'### 测试清单(diff 命中的测试文件)', testFiles.map(f => '- ' + f).join('\n') || '(diff 无测试文件——「组装行为」条目按包不足处理:cannot_verify 列缺件或定向查证并列明)',
|
|
138
144
|
'### design 热区(CLI 抽取)', extractDesignHotZone(inputs.designContent || '') || '(无命中节)',
|
|
139
145
|
'### 验收清单', (inputs.checklist || []).map(c => '- [ ] ' + c).join('\n') || '(无)',
|
|
140
146
|
].filter(Boolean).join('\n\n')
|
|
@@ -263,3 +269,200 @@ export async function assembleStageReviewMaterials({ stage, cwd, changeName, spe
|
|
|
263
269
|
} catch { /* 装配 best-effort:失败零注入(与占位符缺失同态) */ }
|
|
264
270
|
return ''
|
|
265
271
|
}
|
|
272
|
+
|
|
273
|
+
// ══ execute 任务材料包(2026-09-21-r5-efficiency-batch1 task-02,B-④ / D-003)════
|
|
274
|
+
// 两段式装配:稳定段(design.md「接口定义」「文件变更清单」固定节名机械选取原文 + 签名锚点)
|
|
275
|
+
// 在前、专属段(task 卡 title/goal/implementation/acceptance + allowed_paths)在后——稳定前缀
|
|
276
|
+
// 跨任务共享,兑现统一前缀缓存收益(压 task-08 型子代理轮均 148K vs 同侪 45-92K 差额)。
|
|
277
|
+
// 只摘不译(D-003 错键正典红线):一切内容为源文件原文逐字摘录/字段机械抽取,禁转写缩写。
|
|
278
|
+
|
|
279
|
+
/** 材料包字节上限(专属段尾部优先截;稳定段超限按节优先级截断——见 assembleExecuteTaskMaterials) */
|
|
280
|
+
const EXEC_MATERIALS_MAX_BYTES = 24576
|
|
281
|
+
/** 被截节保底形态里「首个代码块/表格」的块级上限(单块病态体量防穿顶) */
|
|
282
|
+
const EXEC_STUB_BLOCK_MAX_BYTES = 3600
|
|
283
|
+
|
|
284
|
+
function bytesOf(s) { return Buffer.byteLength(String(s || ''), 'utf8') }
|
|
285
|
+
|
|
286
|
+
/** 字节安全截断(按 UTF-8 字节预算切,不切断多字节字符) */
|
|
287
|
+
function byteSafeSlice(text, maxBytes) {
|
|
288
|
+
const s = String(text || '')
|
|
289
|
+
if (bytesOf(s) <= maxBytes) return s
|
|
290
|
+
let out = ''
|
|
291
|
+
let used = 0
|
|
292
|
+
for (const ch of s) {
|
|
293
|
+
const b = Buffer.byteLength(ch, 'utf8')
|
|
294
|
+
if (used + b > maxBytes) break
|
|
295
|
+
out += ch
|
|
296
|
+
used += b
|
|
297
|
+
}
|
|
298
|
+
return out
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 节内首个完整代码块(``` 围栏)或表格(连续 | 行)——被截节的保底保留形态 */
|
|
302
|
+
function firstBlockOrTable(body) {
|
|
303
|
+
const fence = body.match(/```[^\n]*\n[\s\S]*?```/)
|
|
304
|
+
if (fence) return fence[0]
|
|
305
|
+
const lines = body.split('\n')
|
|
306
|
+
const start = lines.findIndex(l => l.trim().startsWith('|'))
|
|
307
|
+
if (start >= 0) {
|
|
308
|
+
let end = start
|
|
309
|
+
while (end < lines.length && lines[end].trim().startsWith('|')) end++
|
|
310
|
+
return lines.slice(start, end).join('\n')
|
|
311
|
+
}
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** task 卡 frontmatter 块级解析(机械逐字):顶格 key 行归属,其后缩进行归当前 key */
|
|
316
|
+
function splitTaskCardFields(cardText) {
|
|
317
|
+
const m = String(cardText || '').match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
318
|
+
if (!m) return {}
|
|
319
|
+
const fields = {}
|
|
320
|
+
let cur = null
|
|
321
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
322
|
+
if (/^[A-Za-z_][\w-]*:/.test(line)) {
|
|
323
|
+
const at = line.indexOf(':')
|
|
324
|
+
cur = { key: line.slice(0, at), raw: line.slice(at + 1).trim(), lines: [] }
|
|
325
|
+
fields[cur.key] = cur
|
|
326
|
+
} else if (cur) {
|
|
327
|
+
cur.lines.push(line)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return fields
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function unquoteScalar(v) {
|
|
334
|
+
const s = String(v || '')
|
|
335
|
+
return s.replace(/^'(.*)'$/s, '$1').replace(/^"(.*)"$/s, '$1')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* task 卡字段取值(只摘不译——原文保持,无 YAML 折叠语义改写):
|
|
340
|
+
* 块标量(>/|)→ 原始行去公共缩进后逐行拼接;列表 → `- ` 后原文数组;标量 → 去包裹引号。
|
|
341
|
+
* @returns {string|string[]}
|
|
342
|
+
*/
|
|
343
|
+
function taskCardFieldValue(field) {
|
|
344
|
+
if (!field) return ''
|
|
345
|
+
if (/^[>|][+-]?$/.test(field.raw)) {
|
|
346
|
+
const indents = field.lines.filter(l => l.trim()).map(l => l.match(/^[ \t]*/)[0].length)
|
|
347
|
+
const cut = indents.length ? Math.min(...indents) : 0
|
|
348
|
+
return field.lines.map(l => (l.trim() ? l.slice(cut) : '')).join('\n').trim()
|
|
349
|
+
}
|
|
350
|
+
if (field.raw) return unquoteScalar(field.raw)
|
|
351
|
+
const items = []
|
|
352
|
+
for (const l of field.lines) {
|
|
353
|
+
const im = l.match(/^\s*-\s?(.*)$/)
|
|
354
|
+
if (im && im[1].trim()) items.push(im[1].trim())
|
|
355
|
+
}
|
|
356
|
+
return items
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 组装 execute 任务材料包(两段式,只摘不译——契约见 change design.md「接口定义」)。
|
|
361
|
+
* @param {object} opts
|
|
362
|
+
* @param {string} opts.changeDir - 变更目录(读 design.md / tasks/task-NN.md)
|
|
363
|
+
* @param {string} opts.taskId - 'task-NN'
|
|
364
|
+
* @param {string} [opts.materialsDir] - 落盘目录;缺省 → 不落盘不抛错返回 null(additive)
|
|
365
|
+
* @param {Array<{symbol: string, file: string, line: number}>} [opts.signatureAnchors]
|
|
366
|
+
* - 接口签名锚点(可选,调用方供给;缺省空)
|
|
367
|
+
* @returns {Promise<{path: string, bytes: number, truncated: boolean}|null>}
|
|
368
|
+
* 截尾序:①专属段尾部优先截(稳定段全量保留);②稳定段自身超限按节优先级(接口定义 >
|
|
369
|
+
* 文件变更清单——高优先节后截)逐节降为「节头 + 首个代码块/表格 + 回源指引行」;锚点永不丢。
|
|
370
|
+
*/
|
|
371
|
+
export async function assembleExecuteTaskMaterials({ changeDir, taskId, materialsDir, signatureAnchors }) {
|
|
372
|
+
if (!changeDir || !taskId || !materialsDir) return null
|
|
373
|
+
const designPath = join(changeDir, 'design.md')
|
|
374
|
+
const taskCardPath = join(changeDir, 'tasks', `${taskId}.md`)
|
|
375
|
+
let designContent = ''
|
|
376
|
+
let cardContent = ''
|
|
377
|
+
try { designContent = readFileSync(designPath, 'utf8') } catch { /* 缺 design → 稳定段只剩锚点 */ }
|
|
378
|
+
try { cardContent = readFileSync(taskCardPath, 'utf8') } catch { /* 缺卡 → 专属段仅回源指引 */ }
|
|
379
|
+
|
|
380
|
+
// ── 稳定段素材:固定节名机械选取(缺节跳过)+ 签名锚点行 ──
|
|
381
|
+
const anchors = (Array.isArray(signatureAnchors) ? signatureAnchors : [])
|
|
382
|
+
.filter(a => a && a.symbol)
|
|
383
|
+
.map(a => `- \`${String(a.symbol)}\` → ${a.file || '?'}:${a.line || '?'}`)
|
|
384
|
+
const anchorsBlock = anchors.length ? `### 接口签名锚点\n\n${anchors.join('\n')}` : ''
|
|
385
|
+
|
|
386
|
+
const SECTION_SPECS = [{ name: '接口定义' }, { name: '文件变更清单' }] // 序 = 保留优先级序
|
|
387
|
+
const secBlocks = SECTION_SPECS
|
|
388
|
+
.map(s => ({ ...s, body: sectionBody(designContent, s.name) }))
|
|
389
|
+
.filter(s => s.body)
|
|
390
|
+
.map(s => ({ ...s, full: `### ${s.name}(design.md 原文摘录)\n\n${s.body}` }))
|
|
391
|
+
|
|
392
|
+
/** 被截节残体:节头 + 首个代码块/表格(块级 clamp)+ 一行回源指引 */
|
|
393
|
+
const stubOf = s => {
|
|
394
|
+
const first = firstBlockOrTable(s.body)
|
|
395
|
+
const kept = first
|
|
396
|
+
? (bytesOf(first) > EXEC_STUB_BLOCK_MAX_BYTES ? byteSafeSlice(first, EXEC_STUB_BLOCK_MAX_BYTES) + '\n…(首块超限截断)' : first)
|
|
397
|
+
: byteSafeSlice(s.body, 600)
|
|
398
|
+
return `### ${s.name}(design.md 原文摘录)\n\n${kept}\n\n完整内容回源:${designPath}#${s.name}`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── 专属段素材:task 卡要点(机械摘录)+ allowed_paths + 回源指引 ──
|
|
402
|
+
const fields = splitTaskCardFields(cardContent)
|
|
403
|
+
const val = k => taskCardFieldValue(fields[k])
|
|
404
|
+
const bulletList = v => (Array.isArray(v) ? v.map(i => ` - ${i}`).join('\n') : ` - ${v}`)
|
|
405
|
+
const title = val('title')
|
|
406
|
+
const goal = val('goal')
|
|
407
|
+
const impl = val('implementation')
|
|
408
|
+
const acc = val('acceptance')
|
|
409
|
+
const paths = val('allowed_paths')
|
|
410
|
+
const cardLines = [
|
|
411
|
+
title ? `- title:${title}` : '',
|
|
412
|
+
goal ? `- goal:${goal}` : '',
|
|
413
|
+
impl ? `- implementation:\n${bulletList(impl)}` : '',
|
|
414
|
+
acc ? `- acceptance:\n${bulletList(acc)}` : '',
|
|
415
|
+
].filter(Boolean).join('\n')
|
|
416
|
+
const dedicatedFull = [
|
|
417
|
+
cardLines ? `### 任务卡要点(tasks/${taskId}.md 摘录)\n\n${cardLines}` : '',
|
|
418
|
+
Array.isArray(paths) && paths.length ? `### allowed_paths\n\n${paths.map(p => `- ${p}`).join('\n')}` : '',
|
|
419
|
+
`### 回源指引\n\n- design.md:${designPath}\n- 任务卡:${taskCardPath}`,
|
|
420
|
+
].filter(Boolean).join('\n\n')
|
|
421
|
+
|
|
422
|
+
// ── 装配(稳定段在前、专属段在后)+ 字节预算 ──
|
|
423
|
+
const packTitle = `# execute 任务材料包:${taskId}`
|
|
424
|
+
const packNote = '> 两段式:稳定段(design 契约节原文摘录)在前、专属段(task 卡摘录)在后。\n> 只摘不译——正文均为源文件原文逐字摘录;与源文件冲突时以源文件为准,按锚点回源核对。'
|
|
425
|
+
const stableTitle = '## 稳定段(design 契约节原文摘录——跨任务稳定)'
|
|
426
|
+
const dedicatedTitle = `## 专属段(${taskId} 专属)`
|
|
427
|
+
const tailMarker = `…(专属段超限截尾——完整内容回源:${taskCardPath})`
|
|
428
|
+
const joinParts = parts => parts.filter(Boolean).join('\n\n')
|
|
429
|
+
const assemble = (secs, dedicatedText) =>
|
|
430
|
+
joinParts([packTitle, packNote, stableTitle, ...secs, anchorsBlock, dedicatedTitle, dedicatedText])
|
|
431
|
+
|
|
432
|
+
let truncated = false
|
|
433
|
+
const sections = secBlocks.map(s => s.full)
|
|
434
|
+
let content = assemble(sections, dedicatedFull)
|
|
435
|
+
|
|
436
|
+
if (bytesOf(content) > EXEC_MATERIALS_MAX_BYTES) {
|
|
437
|
+
truncated = true
|
|
438
|
+
// ① 专属段尾部优先截:稳定段全量保留,专属段按剩余预算截尾
|
|
439
|
+
const clampDedicated = () => {
|
|
440
|
+
const head = assemble(sections, '')
|
|
441
|
+
const room = EXEC_MATERIALS_MAX_BYTES - bytesOf(head) - bytesOf('\n\n') - bytesOf(tailMarker) - 1
|
|
442
|
+
if (room < 0) return false
|
|
443
|
+
const sliced = byteSafeSlice(dedicatedFull, room)
|
|
444
|
+
content = head + '\n\n' + (sliced ? sliced + '\n' + tailMarker : tailMarker)
|
|
445
|
+
return true
|
|
446
|
+
}
|
|
447
|
+
if (!clampDedicated()) {
|
|
448
|
+
// ② 稳定段自身超限:按节优先级(接口定义 > 文件变更清单)低优先节先降为残节,逐节重试
|
|
449
|
+
for (let i = secBlocks.length - 1; i >= 0; i--) {
|
|
450
|
+
sections[i] = stubOf(secBlocks[i])
|
|
451
|
+
if (clampDedicated()) break
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// ③ 终极兜底(双节皆残仍超限——仅病态锚点面可达):字节安全截 + 锚点块强制保留
|
|
455
|
+
if (bytesOf(content) > EXEC_MATERIALS_MAX_BYTES && anchorsBlock) {
|
|
456
|
+
const cut = content.indexOf(anchorsBlock)
|
|
457
|
+
const before = (cut > 0 ? content.slice(0, cut) : content).replace(/\n+$/, '')
|
|
458
|
+
content = byteSafeSlice(before, EXEC_MATERIALS_MAX_BYTES - bytesOf('\n\n' + anchorsBlock)) + '\n\n' + anchorsBlock
|
|
459
|
+
} else if (bytesOf(content) > EXEC_MATERIALS_MAX_BYTES) {
|
|
460
|
+
content = byteSafeSlice(content, EXEC_MATERIALS_MAX_BYTES)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
mkdirSync(materialsDir, { recursive: true })
|
|
465
|
+
const outPath = join(materialsDir, `${taskId}.md`)
|
|
466
|
+
writeFileSync(outPath, content, 'utf8')
|
|
467
|
+
return { path: outPath, bytes: bytesOf(content), truncated }
|
|
468
|
+
}
|
|
@@ -48,8 +48,10 @@ export async function executeArchiveDistill({ cwd, specBase, changeName }) {
|
|
|
48
48
|
|
|
49
49
|
// ── FR 索引提炼(2026-09-18-fr-index-l1 L1:稳定 id 发号+承接翻链;同 best-effort 降级语义)──
|
|
50
50
|
try {
|
|
51
|
-
const { indexRequirements } = await import('../fr-index.js')
|
|
52
|
-
|
|
51
|
+
const { indexRequirements, archiveDeliverableFiles } = await import('../fr-index.js')
|
|
52
|
+
// greenfield-bootstrap:供 deliverableFiles(design 表 ∪ apply-manifest)——此前缺供致域路由
|
|
53
|
+
// 退化为 design 清单单源,全泛化段时落 unmapped(R17 臂3 实证),与轻量道口径对齐。
|
|
54
|
+
const fr = indexRequirements({ changeDir, knowledgeRoot, headHash, deliverableFiles: archiveDeliverableFiles(changeDir) })
|
|
53
55
|
const frWritten = Array.isArray(fr && fr.written) ? fr.written : []
|
|
54
56
|
if (fr && fr.skipped) {
|
|
55
57
|
console.log(`\nℹ️ FR 索引零输出:${fr.skipped}`)
|
package/src/run/bg-sync.js
CHANGED
|
@@ -52,6 +52,11 @@ const LOCK_STALE_MS = 15 * 60 * 1000;
|
|
|
52
52
|
const LOG_TRUNCATE_THRESHOLD = 1_000_000;
|
|
53
53
|
const LOG_TRUNCATE_KEEP = 512 * 1024;
|
|
54
54
|
|
|
55
|
+
// 终态补推扫描窗口与上限(2026-09-23 quick ql-20260923-007):候选取 last_active 倒序
|
|
56
|
+
// 前 50 行(老终态缺位也收敛,但不全表扫描);一轮最多补 3 条(防多缺位风暴挤爆预算)。
|
|
57
|
+
const TERMINAL_SWEEP_CANDIDATES = 50;
|
|
58
|
+
export const TERMINAL_SWEEP_MAX = 3;
|
|
59
|
+
|
|
55
60
|
/** pid 活性探测(跨平台):signal 0 探测;EPERM = 进程存在但属他户,保守按活。 */
|
|
56
61
|
function isPidAlive(pid) {
|
|
57
62
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -178,6 +183,48 @@ export async function spawnBackgroundSync(cwd, changeName, platformOpts = {}, op
|
|
|
178
183
|
return { status: 'spawned', logPath };
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
/**
|
|
187
|
+
* 归档终态补推扫描:DB 里 status=archived/deleted 且本地终态脏于平台镜像
|
|
188
|
+
* (last_local_modified_ts > last_synced_platform_ts,或从未同步)的变更名,last_active
|
|
189
|
+
* 倒序取前 TERMINAL_SWEEP_MAX 条。
|
|
190
|
+
*
|
|
191
|
+
* 背景(2026-09-22 session-fork-continuation 实证):终态推送依赖归档后还有一轮
|
|
192
|
+
* triggerSync——bg 子进程 best-effort,spawn 失败/单飞锁竞态/会话在归档后戛然而止都会
|
|
193
|
+
* 吞掉最后一轮,本地已归档而平台镜像停在旧阶段(verify),此后再无命令碰该变更即永久
|
|
194
|
+
* 滞后。归档/删除收尾均 _touchLocalModified(change-registry 语义:本地状态推进),
|
|
195
|
+
* 谓词即「终态戳晚于最后一次成功推送」。补推幂等(sync() 对 archived/deleted 推终态+
|
|
196
|
+
* 墓碑,POST 幂等),任意后续命令触发的 bg 轮顺带收敛。
|
|
197
|
+
* 时钟口径:last_synced_platform_ts 是服务端时间,本地钟超前服务端时至多多补一轮幂等
|
|
198
|
+
* 推送(无副作用),且 synced 随每次成功推进,自愈。
|
|
199
|
+
* 库路径与 sync() 内 ProgressManager 同源(resolvePlatformSpecDir(cwd):平台指针→向上
|
|
200
|
+
* 发现→cwd/.sillyspec 默认;platformOpts.specRoot 不进库路径——与 triggerSync 对齐),
|
|
201
|
+
* 保证扫的库就是主轮推的库;解析抛错(指针失效等)→ 空集跳过本轮(best-effort)。
|
|
202
|
+
*/
|
|
203
|
+
export async function collectTerminalSyncPending(cwd) {
|
|
204
|
+
const { resolvePlatformSpecDir, ProgressManager } = await import('../progress.js');
|
|
205
|
+
let specDir = null;
|
|
206
|
+
try { specDir = resolvePlatformSpecDir(cwd); } catch { return []; }
|
|
207
|
+
if (!specDir) return [];
|
|
208
|
+
if (!existsSync(join(specDir, '.runtime', 'sillyspec.db'))) return [];
|
|
209
|
+
const pm = new ProgressManager({ specDir });
|
|
210
|
+
const rows = pm._ensureDB(cwd).getDb().prepare(
|
|
211
|
+
`SELECT name, last_active, last_synced_platform_ts, last_local_modified_ts
|
|
212
|
+
FROM changes WHERE status IN ('archived','deleted')
|
|
213
|
+
ORDER BY last_active DESC LIMIT ${TERMINAL_SWEEP_CANDIDATES}`
|
|
214
|
+
).all();
|
|
215
|
+
const pending = [];
|
|
216
|
+
for (const r of rows) {
|
|
217
|
+
if (!r.name) continue;
|
|
218
|
+
const synced = r.last_synced_platform_ts || '';
|
|
219
|
+
const local = r.last_local_modified_ts || '';
|
|
220
|
+
// 脏度戳必须在场(fail-closed):D-013 之前的陈年行两戳皆空,无从判滞后——补推只会
|
|
221
|
+
// 给平台凭空造新行;synced 空而 local 在场 = 从未成功推送过但终态有据 → 补。
|
|
222
|
+
if (local && (!synced || local > synced)) pending.push(r.name);
|
|
223
|
+
if (pending.length >= TERMINAL_SWEEP_MAX) break;
|
|
224
|
+
}
|
|
225
|
+
return pending;
|
|
226
|
+
}
|
|
227
|
+
|
|
181
228
|
/**
|
|
182
229
|
* 子侧入口(bootstrap 经 env 传参调起;env 注入默认值供测试覆盖)。
|
|
183
230
|
*
|
|
@@ -224,6 +271,26 @@ export async function runBgSyncFromEnv(env = process.env) {
|
|
|
224
271
|
break;
|
|
225
272
|
}
|
|
226
273
|
}
|
|
274
|
+
// 终态补推(2026-09-23 quick ql-20260923-007):主轮收尾后扫 DB,补推「本地已达终态但
|
|
275
|
+
// 平台镜像滞后」的变更(session-fork 实证缺口)。幂等;主变更刚推过跳过;预算耗尽留下一轮。
|
|
276
|
+
try {
|
|
277
|
+
const pending = await collectTerminalSyncPending(cwd);
|
|
278
|
+
for (const name of pending) {
|
|
279
|
+
if (name === changeName) continue;
|
|
280
|
+
if (Date.now() + roundTimeoutMs > deadline) {
|
|
281
|
+
console.warn('[spec-sync-bg] 总预算用尽,终态补推剩余条目由下一轮 bg 接管');
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
console.log(`[spec-sync-bg] 终态补推(本地终态未被平台镜像): ${name}`);
|
|
285
|
+
try {
|
|
286
|
+
await triggerSync(cwd, name, platformOpts, { inline: true, timeoutMs: roundTimeoutMs });
|
|
287
|
+
} catch (e) {
|
|
288
|
+
console.warn(`[spec-sync-bg] 终态补推异常(best-effort,下轮重试): ${(e && e.message) || e}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.warn(`[spec-sync-bg] 终态补推扫描异常(best-effort): ${(e && e.message) || e}`);
|
|
293
|
+
}
|
|
227
294
|
console.log(`[spec-sync-bg] 后台同步完成 ${new Date().toISOString()}: ${changeName}`);
|
|
228
295
|
removeBgSyncLock(runtimeRoot);
|
|
229
296
|
}
|