sillyspec 3.29.3 → 3.29.4

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 (32) hide show
  1. package/asset-audit-2026-09-20/README.md +42 -0
  2. package/asset-audit-2026-09-20/asset-audit.cjs +253 -0
  3. package/asset-audit-2026-09-20/extract-candidates.cjs +37 -0
  4. package/asset-audit-2026-09-20/platform-archive.csv +294 -0
  5. package/asset-audit-2026-09-20/platform-fr.csv +12 -0
  6. package/asset-audit-2026-09-20/platform-gotchas.csv +6 -0
  7. package/asset-audit-2026-09-20/platform-knownissues.csv +23 -0
  8. package/asset-audit-2026-09-20/platform-modules.csv +222 -0
  9. package/asset-audit-2026-09-20/platform-quicklog.csv +1076 -0
  10. package/asset-audit-2026-09-20/platform-troubleshooting-candidates.csv +88 -0
  11. package/asset-audit-2026-09-20/platform-troubleshooting.csv +1 -0
  12. package/asset-audit-2026-09-20/sillyspec-archive.csv +108 -0
  13. package/asset-audit-2026-09-20/sillyspec-fr.csv +51 -0
  14. package/asset-audit-2026-09-20/sillyspec-gotchas.csv +4 -0
  15. package/asset-audit-2026-09-20/sillyspec-knownissues.csv +34 -0
  16. package/asset-audit-2026-09-20/sillyspec-modules.csv +30 -0
  17. package/asset-audit-2026-09-20/sillyspec-quicklog.csv +460 -0
  18. package/asset-audit-2026-09-20/sillyspec-troubleshooting.csv +69 -0
  19. package/asset-audit-2026-09-20/summary.json +241 -0
  20. package/package.json +1 -1
  21. package/src/cross-repo-reconcile.js +232 -46
  22. package/src/fr-index.js +58 -2
  23. package/src/index.js +2 -2
  24. package/src/run/bg-sync.js +231 -0
  25. package/src/run/complete-handlers.js +208 -5
  26. package/src/run/gates.js +10 -5
  27. package/src/run/prompt.js +1 -1
  28. package/src/run/shared.js +32 -0
  29. package/src/scope-audit.js +303 -20
  30. package/src/stages/quick.js +2 -0
  31. package/src/sync.js +12 -0
  32. package/src/verify-postcheck.js +9 -5
@@ -1,5 +1,5 @@
1
1
  /**
2
- * cross-repo-reconcile.js — 跨仓 task 卡 target_files 的 per-repo 对账(零环新模块)。
2
+ * cross-repo-reconcile.js — 跨仓 per-repo 对账 + 共享采集内核 collectRepoActual。
3
3
  *
4
4
  * 坑 cross-repo-reconcile-blindness(2026-09-15 复盘实证:三端变更的对账表只有主仓机器可见,
5
5
  * 22 个跨仓文件在归档表全标「计划未动」、探针5 报「0 frontend calls」,跨仓全靠人工到对应仓
@@ -7,22 +7,33 @@
7
7
  * 声明,剔除本身正确;但「留待后续分期」一直没有兑现,跨仓侧零机器可见性。
8
8
  *
9
9
  * 本模块兑现该分期:对每张跨仓卡声明的 repo key,读 local.yaml repos 注册表解析仓根,在**该仓**
10
- * 取 actual(diff HEAD~1..HEAD ∪ status porcelain untracked,与 resolveVerifyChangedFiles 跨仓
11
- * 分支同源口径),按主仓同款三类差集对账(matched / missing / undeclared + 脚手架软桶)。
10
+ * 取 actual,按主仓同款三类差集对账(matched / missing / undeclared + 脚手架软桶)。
11
+ *
12
+ * collectRepoActual(2026-09-20 scope-audit-cross-repo,D-001@v1):per-repo actual 采集**共享
13
+ * 内核**(scope-audit 与 verify 侧 reconcile 双侧消费、单一真相源,防两份口径漂移)——职责 =
14
+ * 仓注册解析 → 仓根 → 锚点四级(A reviews-range[execute task 锡点区间并集 ∪ status 未提交尾巴]
15
+ * > B head~1-window[HEAD~1..HEAD ∪ status,与 resolveVerifyChangedFiles 跨仓分支同源口径]
16
+ * > C head-uncommitted-window[仅 status] > degraded[未注册/路径不可达/git 双源失败合并判定])
17
+ * → actual 文件集。**行数采集不进内核**(评审 G3):内核绝不 import scope-audit(否则与本模块
18
+ * 消费方 scope-audit 成环),stats 由调用方对内核产物自行跑 collectNumstatByPath。
12
19
  *
13
20
  * 定位与边界(与主仓对账的差异,显式声明防误读):
14
- * - **advisory 不阻断**:跨仓 diff 锚点是 HEAD~1..HEAD 最近提交窗口(多 task 同仓时只反映最近
15
- * 一笔,精确范围要 task 卡 base/head 锡点——verify-postcheck:1119 先例同款限制),锚点脆弱期
16
- * (已 commit 多笔 / 全在 working-tree)会产②类假信号,故只报告不翻转 verify 状态。
21
+ * - **advisory 不阻断**:B/C 档锚点是最近提交/未提交窗口(多 task 同仓时 B 档只反映最近一笔,
22
+ * 精确范围要 task 卡 base/head 锡点——A 档即该锡点档,verify-postcheck:1119 先例同款限制),
23
+ * 锚点脆弱期(已 commit 多笔 / 全在 working-tree)会产②类假信号,故只报告不翻转 verify 状态。
17
24
  * - 仓未注册 / 路径不可达 / 非 git 仓 → 该 repo degraded 一行说明,不炸整体。
18
- * - 不 import verify-postcheck(后者 import 本模块,反向成环);路径归一/大小写折叠与主仓
19
- * normalizeReconcilePath/pathKey 同口径本地实现,注释锚定不漂移。
25
+ * - 不 import verify-postcheck(后者 import 本模块,反向成环);import task-review 的
26
+ * resolveLatestExecuteRunIdWithTasks/readReview 不新增环边:task-review 本身不 import 本模块,
27
+ * 「本模块→worktree-apply→task-review→verify-postcheck→本模块」的环今日已存在(worktree-apply
28
+ * 静态 import 同批函数先例),直连只是抄近路,且全为调用期求值的函数声明(ESM live binding)。
29
+ * - 路径归一/大小写折叠与主仓 normalizeReconcilePath/pathKey 同口径本地实现,注释锚定不漂移。
20
30
  */
21
- import { existsSync, readFileSync } from 'fs'
31
+ import { existsSync, readFileSync, readdirSync } from 'fs'
22
32
  import { join, isAbsolute, resolve } from 'path'
23
33
  import { gitQuiet } from './git-helper.js'
24
34
  import { parseRepoRegistry } from './stages/plan-postcheck.js'
25
35
  import { filterDeliverableFiles, classifyToolScaffold } from './worktree-apply.js'
36
+ import { resolveLatestExecuteRunIdWithTasks, readReview } from './task-review.js'
26
37
 
27
38
  // 与 verify-postcheck normalizeReconcilePath 同口径(本地实现防环):剥 ./ 前缀、反斜杠归一
28
39
  function normalizeRepoPath(p) {
@@ -48,68 +59,243 @@ function parsePorcelain(raw) {
48
59
  .filter(Boolean)
49
60
  }
50
61
 
62
+ // ── collectRepoActual 共享采集内核(D-001@v1)────────────────────────────────
63
+
64
+ const GIT_TIMEOUT = 30 * 1000 // 与本模块既有 gitQuiet 调用一致
65
+
66
+ function degradedAnchor() {
67
+ return { source: 'degraded', base: null, head: null, label: 'degraded' }
68
+ }
69
+
70
+ // git rev-list --count a..b = b 可达集减 a 可达集的 commit 数;null = git 失败(commit 缺失等),
71
+ // 调用方按「无法判序」降级 B 档
72
+ function revListCount(repoPath, a, b) {
73
+ const out = gitQuiet(repoPath, ['rev-list', '--count', `${a}..${b}`], { timeout: GIT_TIMEOUT })
74
+ if (out === null) return null
75
+ const n = Number(String(out).trim())
76
+ return Number.isFinite(n) ? n : null
77
+ }
78
+
51
79
  /**
52
- * 跨仓声明对账。declarationsByRepo 由声明侧收集(collectDeclaredTargetFiles 跨仓卡分支):
53
- * `{ [repoKey]: Array<{task, path, isNew}> }`——path 相对该仓根(跨仓卡口径)。
54
- *
55
- * @param {{ specBase: string, cwd: string, declarationsByRepo: Record<string, Array<{task:string,path:string,isNew:boolean}>> }} args
56
- * @returns {Array<{repo:string, repoPath:string|null, declaredCount:number, actualCount:number,
57
- * matched:string[], missing:Array<{task:string,path:string,isNew?:boolean}>,
58
- * undeclared:string[], scaffoldCount:number, degradedReason:string|null}>}
59
- * 每 repo 一项;degradedReason 非空时 matched/missing/undeclared 为空数组(该仓无结论)。
80
+ * 多 task review 区间的最早 base / 最晚 head(A 档锚点核心)。
81
+ * 判序口径:rev-list --count x..y === 0 ⟺ y ⊆ x(y 是 x 的祖先或相等,即 y 更早或相等)。
82
+ * - base(要最早):count(base..b)===0 → b 更早或相等 → base=b;否则 b 含 base 外 commit(后代
83
+ * 或分叉)→ merge-base 统一兜底(后代时 merge-base===base 为 no-op;分叉时退公共祖先,保持
84
+ * base 是已见区间的共同起点);merge-base 失败保持现值(保守)。
85
+ * - head(要最晚):count(h..head)===0 → head ⊆ h(h 更晚或相等)→ head=h;否则保持——head 无
86
+ * merge-base 语义(公共祖先更早,取了反而丢区间),分叉宁保守。
87
+ * rev-list 失败(null)→ 整体 null(调用方降 B 档)。
60
88
  */
61
- export function reconcileCrossRepoDeclarations({ specBase, cwd, declarationsByRepo }) {
62
- const results = []
63
- const repos = declarationsByRepo && typeof declarationsByRepo === 'object' ? Object.keys(declarationsByRepo) : []
64
- if (repos.length === 0) return results
89
+ function computeReviewsRangeAnchor(repoPath, reviews) {
90
+ try {
91
+ let base = reviews[0].base
92
+ for (let i = 1; i < reviews.length; i++) {
93
+ const b = reviews[i].base
94
+ const cnt = revListCount(repoPath, base, b)
95
+ if (cnt === null) return null
96
+ if (cnt === 0) { base = b; continue }
97
+ const mb = gitQuiet(repoPath, ['merge-base', base, b], { timeout: GIT_TIMEOUT })
98
+ const mbTrim = mb === null ? '' : String(mb).trim()
99
+ if (mbTrim) base = mbTrim
100
+ }
101
+ let head = reviews[0].head
102
+ for (let i = 1; i < reviews.length; i++) {
103
+ const h = reviews[i].head
104
+ const cnt = revListCount(repoPath, h, head)
105
+ if (cnt === null) return null
106
+ if (cnt === 0) head = h
107
+ }
108
+ return { base, head }
109
+ } catch { return null }
110
+ }
65
111
 
66
- let registry = new Map()
112
+ /**
113
+ * A 档采集尝试:resolveLatestExecuteRunIdWithTasks 定位 run → 遍历 task-NN/review.json →
114
+ * review.repo===repoKey 且 base/head 均为非空 string 的条目 → 各自 base..head 区间 diff
115
+ * --name-only(有 diffPaths 按其收窄——统一 commit 模式语义,与 task-review 校验切片同款)
116
+ * 输出归一求并集。
117
+ * @returns {{anchor:{source:string,base:string,head:string,label:string}, files:string[]}|null}
118
+ * null = A 档不可用(无 run / 无匹配 review / 全部区间 diff 失败 / 锚点判序 git 失败)
119
+ * → 调用方落 B 档。
120
+ */
121
+ function tryCollectReviewsRange(repoPath, runtimeRoot, changeName, repoKey) {
122
+ let runId
123
+ try {
124
+ runId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName })
125
+ } catch { return null }
126
+ if (!runId) return null
127
+ const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks')
128
+ let taskDirs
67
129
  try {
68
- const yamlPath = join(specBase || '', 'local.yaml')
69
- if (specBase && existsSync(yamlPath)) {
70
- registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
130
+ if (!existsSync(tasksDir)) return null
131
+ taskDirs = readdirSync(tasksDir, { withFileTypes: true })
132
+ .filter(e => e.isDirectory()).map(e => e.name).sort()
133
+ } catch { return null }
134
+ const reviews = []
135
+ for (const name of taskDirs) {
136
+ let rr
137
+ try { rr = readReview(join(tasksDir, name, 'review.json')) } catch { continue }
138
+ if (!rr || !rr.ok || !rr.review) continue
139
+ const rv = rr.review
140
+ if (rv.repo !== repoKey) continue
141
+ if (typeof rv.base !== 'string' || !rv.base.trim()) continue
142
+ if (typeof rv.head !== 'string' || !rv.head.trim()) continue
143
+ const diffPaths = Array.isArray(rv.diffPaths)
144
+ ? rv.diffPaths.filter(p => typeof p === 'string' && p.trim()) : []
145
+ reviews.push({ base: rv.base.trim(), head: rv.head.trim(), diffPaths })
146
+ }
147
+ if (reviews.length === 0) return null
148
+ const union = new Set()
149
+ let anyDiff = false
150
+ for (const rv of reviews) {
151
+ const args = ['diff', '--name-only', `${rv.base}..${rv.head}`]
152
+ if (rv.diffPaths.length > 0) args.push('--', ...rv.diffPaths)
153
+ const out = gitQuiet(repoPath, args, { timeout: GIT_TIMEOUT })
154
+ if (out === null) continue // 单条区间失败跳过(其余区间仍算数);全失败由 anyDiff 兜底落 B 档
155
+ anyDiff = true
156
+ for (const f of String(out).split('\n')) {
157
+ const n = normalizeRepoPath(f)
158
+ if (n) union.add(n)
71
159
  }
72
- } catch { /* local.yaml 不可读 → 空注册表,各 repo 落「未注册」degraded */ }
160
+ }
161
+ if (!anyDiff) return null
162
+ const range = computeReviewsRangeAnchor(repoPath, reviews)
163
+ if (!range) return null
164
+ return {
165
+ anchor: { source: 'reviews-range', base: range.base, head: range.head,
166
+ label: `reviews base..head(execute task 锡点,${reviews.length} task 区间并集)` },
167
+ files: [...union],
168
+ }
169
+ }
73
170
 
74
- for (const repoKey of repos) {
75
- const declarations = declarationsByRepo[repoKey] || []
76
- const base = { repo: repoKey, repoPath: null, declaredCount: declarations.length, actualCount: 0,
77
- matched: [], missing: [], undeclared: [], scaffoldCount: 0, degradedReason: null }
171
+ // status porcelain 未提交尾巴并入集合;返回 git 是否成功(C 档判据)
172
+ function collectStatusInto(repoPath, union) {
173
+ const statusOut = gitQuiet(repoPath, ['status', '--porcelain', '--untracked-files=all'],
174
+ { timeout: GIT_TIMEOUT, trim: false })
175
+ if (statusOut === null) return false
176
+ for (const f of parsePorcelain(statusOut)) union.add(f)
177
+ return true
178
+ }
78
179
 
180
+ /**
181
+ * per-repo actual 采集共享内核(scope-audit 与 verify 侧 reconcile 双侧消费,D-001@v1 单一
182
+ * 真相源):仓注册解析(local.yaml repos)→ 仓根 → 锚点四级 → actual 文件集(相对该仓根、
183
+ * 正斜杠、去重排序)。
184
+ *
185
+ * @param {{ repoKey: string, specBase: string, cwd: string, runtimeRoot?: string|null,
186
+ * changeName?: string|null }} args
187
+ * runtimeRoot:平台模式与 specBase 分离时传 execute-runs 读取根;changeName:A 档 reviews
188
+ * 解析入参。任一缺省 → 跳 A 档走 B 档(向后兼容现行调用)。
189
+ * 行数采集不进内核(防循环 import,评审 G3)——stats 由调用方(scope-audit 集成层)对返回
190
+ * files 跑 collectNumstatByPath(repoPath, files, { baseRef: anchor.base })。
191
+ * @returns {{ repo: string, repoPath: string|null,
192
+ * anchor: { source: 'reviews-range'|'head~1-window'|'head-uncommitted-window'|'degraded',
193
+ * base: string|null, head: string|null, label: string },
194
+ * files: string[], degradedReason: string|null }}
195
+ * degradedReason 非空时 anchor.source='degraded'、files=[];files 为原始集(**不做**
196
+ * filterDeliverableFiles 过滤——过滤归调用方,verify 侧现行过滤保持在 reconcile 内)。
197
+ * 纯读 fail-soft:任何异常 → degraded 形态返回(degradedReason=`内核采集异常: <msg首行>`),
198
+ * 绝不 throw。
199
+ */
200
+ export function collectRepoActual({ repoKey, specBase, cwd, runtimeRoot = null, changeName = null }) {
201
+ try {
202
+ // 1) 仓注册解析(与 reconcile 旧口径同源:local.yaml repos 段,读失败 → 空注册表落「未注册」)
203
+ let registry = new Map()
204
+ try {
205
+ const yamlPath = join(specBase || '', 'local.yaml')
206
+ if (specBase && existsSync(yamlPath)) registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
207
+ } catch { /* local.yaml 不可读 → 空注册表 → 落「未注册」degraded */ }
79
208
  const rawPath = registry.get(repoKey)
80
209
  if (!rawPath) {
81
- results.push({ ...base, degradedReason: `repo key「${repoKey}」未在 local.yaml repos 注册——跨仓对账不可达,请人工到对应仓核对` })
82
- continue
210
+ return { repo: repoKey, repoPath: null, anchor: degradedAnchor(), files: [],
211
+ degradedReason: `repo key「${repoKey}」未在 local.yaml repos 注册——跨仓对账不可达,请人工到对应仓核对` }
83
212
  }
213
+
214
+ // 2) 仓根(isAbsolute/resolve 兼容 Windows 盘符绝对注册与相对 cwd 注册)
84
215
  const repoPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath)
85
- base.repoPath = repoPath
86
216
  if (!existsSync(repoPath)) {
87
- results.push({ ...base, degradedReason: `注册路径不可达:${repoPath}` })
88
- continue
217
+ return { repo: repoKey, repoPath, anchor: degradedAnchor(), files: [],
218
+ degradedReason: `注册路径不可达:${repoPath}` }
89
219
  }
90
220
 
91
- // actual 双源(resolveVerifyChangedFiles 跨仓分支同源):已提交窗口 + working-tree 未提交
221
+ // 3) A 档 reviews-range:runtimeRoot 与 changeName 双提供才尝试(缺省跳过 = 向后兼容)
222
+ if (runtimeRoot && changeName) {
223
+ const range = tryCollectReviewsRange(repoPath, runtimeRoot, changeName, repoKey)
224
+ if (range) {
225
+ const union = new Set(range.files)
226
+ collectStatusInto(repoPath, union) // 未提交尾巴并入;status 失败不降级(A 档由锡点区间确立)
227
+ return { repo: repoKey, repoPath, anchor: range.anchor, files: [...union].sort(), degradedReason: null }
228
+ }
229
+ }
230
+
231
+ // 4) B/C 档:diff HEAD~1..HEAD(现行 reconcile 口径)∪ status;diff 失败仅 status → C 档
92
232
  const union = new Set()
93
- let anySource = false
94
- const diffOut = gitQuiet(repoPath, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: 30 * 1000 })
233
+ const diffOut = gitQuiet(repoPath, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: GIT_TIMEOUT })
95
234
  if (diffOut !== null) {
96
- anySource = true
97
235
  for (const f of String(diffOut).split('\n')) {
98
236
  const n = normalizeRepoPath(f)
99
237
  if (n) union.add(n)
100
238
  }
239
+ collectStatusInto(repoPath, union)
240
+ return { repo: repoKey, repoPath,
241
+ anchor: { source: 'head~1-window', base: null, head: null, label: 'HEAD~1..HEAD 最近提交窗口(降级——无可用 reviews)' },
242
+ files: [...union].sort(), degradedReason: null }
101
243
  }
102
- const statusOut = gitQuiet(repoPath, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30 * 1000, trim: false })
103
- if (statusOut !== null) {
104
- anySource = true
105
- for (const f of parsePorcelain(statusOut)) union.add(f)
244
+ if (collectStatusInto(repoPath, union)) {
245
+ return { repo: repoKey, repoPath,
246
+ anchor: { source: 'head-uncommitted-window', base: null, head: null, label: 'HEAD 未提交窗口(降级——无可用 diff 锚)' },
247
+ files: [...union].sort(), degradedReason: null }
106
248
  }
107
- if (!anySource) {
108
- results.push({ ...base, degradedReason: '该仓 git 不可用/非仓库(diff 与 status 双失败)' })
249
+
250
+ // 5) degraded:diff 与 status 双源失败合并判定(不拆「非 git 仓」——形态不可区分,G4)
251
+ return { repo: repoKey, repoPath, anchor: degradedAnchor(), files: [],
252
+ degradedReason: '该仓 git 不可用/非仓库(diff 与 status 双失败)' }
253
+ } catch (e) {
254
+ return { repo: repoKey, repoPath: null, anchor: degradedAnchor(), files: [],
255
+ degradedReason: `内核采集异常: ${String((e && e.message) || e).split('\n')[0]}` }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * 跨仓声明对账(collectRepoActual 内核的 verify 侧消费入口)。declarationsByRepo 由声明侧
261
+ * 收集(collectDeclaredTargetFiles 跨仓卡分支):`{ [repoKey]: Array<{task, path, isNew}> }`——
262
+ * path 相对该仓根(跨仓卡口径)。
263
+ *
264
+ * @param {{ specBase: string, cwd: string,
265
+ * declarationsByRepo: Record<string, Array<{task:string,path:string,isNew:boolean}>>,
266
+ * runtimeRoot?: string|null, changeName?: string|null }} args
267
+ * runtimeRoot/changeName 为可选增量(缺省 null → 内核跳 A 档走 B 档,向后兼容现行调用);
268
+ * 双提供时锚点升级 reviews-range 锡点档(②类假信号面收窄,advisory 定位不变)。
269
+ * @returns {Array<{repo:string, repoPath:string|null, declaredCount:number, actualCount:number,
270
+ * matched:string[], missing:Array<{task:string,path:string,isNew?:boolean}>,
271
+ * undeclared:string[], scaffoldCount:number, degradedReason:string|null,
272
+ * anchor:{source:string,base:string|null,head:string|null,label:string}}>}
273
+ * 每 repo 一项;degradedReason 非空时 matched/missing/undeclared 为空数组(该仓无结论)。
274
+ * anchor 为增量字段(内核透传的锚点档)——既有字段形状零变化,verify-postcheck 消费点无感。
275
+ */
276
+ export function reconcileCrossRepoDeclarations({ specBase, cwd, declarationsByRepo, runtimeRoot = null, changeName = null }) {
277
+ const results = []
278
+ const repos = declarationsByRepo && typeof declarationsByRepo === 'object' ? Object.keys(declarationsByRepo) : []
279
+ if (repos.length === 0) return results
280
+
281
+ for (const repoKey of repos) {
282
+ const declarations = declarationsByRepo[repoKey] || []
283
+ const base = { repo: repoKey, repoPath: null, declaredCount: declarations.length, actualCount: 0,
284
+ matched: [], missing: [], undeclared: [], scaffoldCount: 0, degradedReason: null, anchor: null }
285
+
286
+ // 采集半边全权委托内核(仓注册解析→仓根→锚点四级→actual 文件集);纯读 fail-soft 不 throw
287
+ const actual = collectRepoActual({ repoKey, specBase, cwd, runtimeRoot, changeName })
288
+ base.repoPath = actual.repoPath
289
+ base.anchor = actual.anchor
290
+ if (actual.degradedReason) {
291
+ base.degradedReason = actual.degradedReason
292
+ results.push(base)
109
293
  continue
110
294
  }
111
295
 
112
- const actualFiles = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
296
+ // 声明差集半边保持不动(主仓 reconcileTargetFiles 同款三类差集 + 脚手架软桶);
297
+ // filterDeliverableFiles 过滤归本消费方(内核产原始集,职责边界见 collectRepoActual JSDoc)
298
+ const actualFiles = [...new Set(filterDeliverableFiles(actual.files).filter(Boolean))].sort()
113
299
  base.actualCount = actualFiles.length
114
300
  const actualKeySet = new Set(actualFiles.map(pathKey))
115
301
  const declaredPaths = [...new Set(declarations.map(d => d.path))].sort()
package/src/fr-index.js CHANGED
@@ -320,9 +320,11 @@ export function indexRequirements({ changeDir, knowledgeRoot, headHash = '' }) {
320
320
  const target = st2.sections.find((s) => s.number === refId && !s.lines.some((l) => l.startsWith('superseded_by:')));
321
321
  if (!target) continue;
322
322
  // 就地补丁:状态翻 superseded + 插 superseded_by + 追取代链注记 + 刷新最近确认(摘要/标题保留)
323
+ // 待复核行随承接清除(2026-09-20-quick-asset-tail D-003:quick 触达留下的 needs_review 信号
324
+ // 在条目被正式承接取代时一并清——取代即复核完成,语义自然闭环)
323
325
  target.lines = target.lines
324
326
  .map((l) => (l.startsWith('状态:') ? `状态:superseded` : l.startsWith('最近确认:') ? `最近确认:${headHash || ''}` : l))
325
- .filter((l) => !l.startsWith('superseded_by:') && !l.startsWith('取代链:') && !l.startsWith('退役理由:'));
327
+ .filter((l) => !l.startsWith('superseded_by:') && !l.startsWith('取代链:') && !l.startsWith('退役理由:') && !l.startsWith('待复核:'));
326
328
  const stateIdx = target.lines.findIndex((l) => l.startsWith('状态:'));
327
329
  // 退役理由(追平刀②):承接令牌带(退役理由:…)时写进被取代条目——对标 OpenSpec REMOVED
328
330
  // 的 Migration 语义;无理由则省略行(不伪造)。
@@ -564,7 +566,11 @@ export function readActiveFrDigest(knowledgeRoot, domains) {
564
566
  const decisionLine = s.lines.find((l) => l.startsWith('依据决策:'));
565
567
  const decisions = decisionLine ? decisionLine.replace(/^依据决策:s*/, '').split('、').map((x) => x.trim()).filter(Boolean) : [];
566
568
  const scenarios = scenarioLine ? scenarioLine.replace(/^摘要:\s*/, '').split(';').map((x) => x.trim()).filter(Boolean) : [];
567
- out.push({ domain, id: s.number, title: s.title || '', change: s.change || '', scenarios, decisions });
569
+ // 待复核标记透传(2026-09-20-quick-asset-tail FR-02):quick 触达留下的信号,
570
+ // 注入面带 ⚠️ 提示后续变更核对——是信号非失效,承接翻链时清除。
571
+ const reviewLine = s.lines.find((l) => l.startsWith('待复核:'));
572
+ const needsReview = reviewLine ? reviewLine.replace(/^待复核:\s*/, '').trim() : null;
573
+ out.push({ domain, id: s.number, title: s.title || '', change: s.change || '', scenarios, decisions, needsReview });
568
574
  }
569
575
  }
570
576
  return out;
@@ -587,3 +593,53 @@ export function frTitleOverlap(a, b) {
587
593
  for (const g of A) if (B.has(g)) inter++;
588
594
  return inter / (A.size + B.size - inter);
589
595
  }
596
+
597
+ // ── needs_review 标记(2026-09-20-quick-asset-tail,D-003:信号非门禁)─────────────
598
+
599
+ /** 待复核行前缀(机械解析契约:digest 按此前缀读,条目可多轮标记仅保最新一条)。 */
600
+ const FR_NEEDS_REVIEW_PREFIX = '待复核:';
601
+
602
+ /**
603
+ * quick 触达域后给 active FR 条目打待复核标记(钩子#1 升级:遥测之外让地图本身诚实)。
604
+ * 幂等:条目已有含同 ref 的待复核行 → 跳过;superseded 条目不标(digest 注入面本就不含)。
605
+ * 只动文件不改语义——行是信号,承接翻链时被 indexRequirements 的 filter 清除。
606
+ * @param {string} knowledgeRoot
607
+ * @param {string[]} frIds - 全局 id 列表(钩子#1 的 readActiveFrDigest 命中集)
608
+ * @param {string} refNote - 标记来源(ql-id 或 change 名)
609
+ * @returns {{ marked: number, warnings: string[] }}
610
+ */
611
+ export function markFrNeedsReview(knowledgeRoot, frIds, refNote) {
612
+ const warnings = [];
613
+ let marked = 0;
614
+ if (!Array.isArray(frIds) || frIds.length === 0 || !refNote) return { marked, warnings };
615
+ const all = scanAllDomains(knowledgeRoot);
616
+ const idSet = new Set(frIds);
617
+ const dirty = new Set();
618
+ for (const [domain, st] of all.entries()) {
619
+ for (const s of st.sections) {
620
+ if (!idSet.has(s.number)) continue;
621
+ if (s.lines.some((l) => l.startsWith('superseded_by:'))) continue; // 已取代不标
622
+ const line = `${FR_NEEDS_REVIEW_PREFIX}${refNote}`;
623
+ if (s.lines.some((l) => l === line)) continue; // 幂等:同 ref 已标
624
+ // 移除旧 ref 的待复核行(多轮标记仅保最新),追加新行到摘要行后
625
+ s.lines = s.lines.filter((l) => !l.startsWith(FR_NEEDS_REVIEW_PREFIX));
626
+ const sumIdx = s.lines.findIndex((l) => l.startsWith('摘要:'));
627
+ s.lines.splice(sumIdx === -1 ? s.lines.length : sumIdx + 1, 0, line);
628
+ dirty.add(domain);
629
+ marked++;
630
+ }
631
+ }
632
+ if (dirty.size > 0) {
633
+ mkdirSync(frDirPath(knowledgeRoot), { recursive: true });
634
+ for (const d of dirty) {
635
+ const st = all.get(d);
636
+ writeFileSync(join(frDirPath(knowledgeRoot), `${d}.md`), joinKnowledgeFile(st.preamble, st.sections));
637
+ }
638
+ }
639
+ for (const id of idSet) {
640
+ let found = false;
641
+ for (const st of all.values()) if (st.sections.some((s) => s.number === id)) { found = true; break; }
642
+ if (!found) warnings.push(`markFrNeedsReview:${id} 不在索引中(跳过)`);
643
+ }
644
+ return { marked, warnings };
645
+ }
package/src/index.js CHANGED
@@ -116,7 +116,7 @@ SillySpec CLI — 规范驱动开发工具包
116
116
  sillyspec commit [--json] 智能提交建议:收集 QUICKLOG/已勾 task/阶段产出语义,生成建议 message(只建议不执行)
117
117
  sillyspec verify-probes --change <name> [--init] verify 机械探针(TODO 标记/测试覆盖/API 对账/删除对账);--init 生成 verify-result.md 骨架
118
118
  sillyspec module-impact --change <name> 生成 module-impact.md 骨架(文件×模块归属按 module-map 预填 + 未匹配清单)
119
- sillyspec scope-audit --change <name> [--json] 变更范围对账:计划×实际三态全表 + 行数(✓ 计划内/⚠️ 计划外/⚠️ 计划未动;quick 会话传 quick-<id> 出归属表;已归档变更可查——快照记录态;advisory 只读不设门禁)
119
+ sillyspec scope-audit --change <name> [--json] 变更范围对账:计划×实际三态全表 + 行数(✓ 计划内/⚠️ 计划外/⚠️ 计划未动;quick 会话传 quick-<id> 出归属表;跨仓条目按 local.yaml repos 分仓真实对账——--json 出 repos[] 仓库维度汇总;已归档变更可查——快照记录态;advisory 只读不设门禁)
120
120
  sillyspec module-docs-sync --change <name> [--note ...] diff 归属模块 → sidecar 追加变更索引行 + 卡 updated_at 戳(幂等)
121
121
  sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
122
122
  sillyspec endpoints baseline --change <name> [--spec-dir <path>] [--json] 拍变更前端点基线(幂等不覆盖;worktree 内跑自动锚主仓;归档 delta 端点增删 before 侧)
@@ -1419,7 +1419,7 @@ task 进行中状态标记:开工/完工落 .runtime/task-progress/,list 标
1419
1419
  const saChangeIdx = args.indexOf('--change');
1420
1420
  const saChange = saChangeIdx >= 0 && args[saChangeIdx + 1] && !String(args[saChangeIdx + 1]).startsWith("--") ? args[saChangeIdx + 1] : null;
1421
1421
  if (!saChange) {
1422
- console.error('用法: sillyspec scope-audit --change <name> [--file <path>] [--json] [--spec-dir <path>]\n 变更范围对账:计划×实际三态全表 + 行数(quick 会话传 quick-<8hex> 出归属表,附 [gate] 分级门禁画像——表格画像段与 --json gateProfile 字段;已归档变更可查——快照冻结记录态);--file <path> 看单文件变化内容(对账同源锚点的 git diff);advisory 只读展示,不构成门禁');
1422
+ console.error('用法: sillyspec scope-audit --change <name> [--file <path>] [--json] [--spec-dir <path>]\n 变更范围对账:计划×实际三态全表 + 行数(quick 会话传 quick-<8hex> 出归属表,附 [gate] 分级门禁画像——表格画像段与 --json gateProfile 字段;跨仓条目按 local.yaml repos 分仓真实对账——--json 出 repos[] 仓库维度汇总;已归档变更可查——快照冻结记录态);--file <path> 看单文件变化内容(对账同源锚点的 git diff,跨仓行路由该仓锚点区间);advisory 只读展示,不构成门禁');
1423
1423
  process.exit(2);
1424
1424
  }
1425
1425
  assertSafeChangeName(saChange, '--change 变更名');