sillyspec 3.33.4 → 3.33.6

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.
@@ -436,6 +436,105 @@ export async function runReviewDispatch(opts = {}) {
436
436
  }
437
437
  }
438
438
 
439
+ // ════════ 薄道评审平台单调用派发(2026-10-23-review-inline-dispatch)════════
440
+ /**
441
+ * 薄道(thin flow)review 子步的平台单调用通道:flow done 评审缺席分支经 local.yaml
442
+ * review_inline_wait 键门控调用——薄道任务书(renderReviewerTaskbook 产物+平台产出方式
443
+ * 尾注,任务书生产权仍在 CLI)派发平台 readOnly worker,有界轮询至完成/超时,三通道
444
+ * 提取复用厚道 extractReviewFromArtifacts(JSON 对象提取与 schema 无关)+ 薄道
445
+ * validateReviewJson 同一源校验,通过才落 changeDir/review.json(reviewer=platform-inline
446
+ * 如实标注)——成功后调用方同调用继续登记子步,missing→PASS 往返消灭。
447
+ * 任何失败返回 {ok:false, reason}——调用方回退现状任务书面(零治理损失)。
448
+ * 不写 dispatch record(那是厚道跨命令异步形态;单调用在途态只随返回值与输出留痕)。
449
+ */
450
+ export async function runThinReviewInline(opts = {}) {
451
+ const {
452
+ cwd = process.cwd(), specBase, change, changeDir, taskbookText,
453
+ waitMs = 60_000, pollIntervalMs = 5_000, budgetUsd = 1.0,
454
+ client = null, probe = null, log = null,
455
+ } = opts
456
+ const say = typeof log === 'function' ? log : () => {}
457
+ if (!specBase || !change || !changeDir || typeof taskbookText !== 'string' || !taskbookText.trim()) {
458
+ return { ok: false, reason: '缺少 specBase/change/changeDir/taskbookText' }
459
+ }
460
+ let cl = client
461
+ if (!cl) {
462
+ try {
463
+ const { SillyHubMcpClient } = await import('./sillyhub-mcp/client.js')
464
+ cl = new SillyHubMcpClient({ cwd })
465
+ } catch (e) {
466
+ return { ok: false, reason: `client 构建失败(${(e && e.message) || e})` }
467
+ }
468
+ }
469
+ if (!cl || typeof cl.createMission !== 'function' || typeof cl.dispatchWorker !== 'function'
470
+ || typeof cl.listWorkers !== 'function' || typeof cl.getWorkerResult !== 'function') {
471
+ return { ok: false, reason: 'client 不具备平台派发接口(create/dispatch/list/result)' }
472
+ }
473
+ try {
474
+ const probeFn = probe || (await import('./dispatch/probe.js')).probeSillyHub
475
+ const pr = await probeFn({ client: cl, worktreePath: null, cwd })
476
+ if (!pr || pr.available !== true) {
477
+ return { ok: false, reason: `平台通道不可用(${pr && pr.reason ? pr.reason : 'probe 异常'})` }
478
+ }
479
+ const objective = `轻量变更独立评审(${change})——按任务书产出 review.json 结论`
480
+ const workerPrompt = taskbookText + '\n' + [
481
+ '',
482
+ '【平台通道产出方式(read_only 约束——覆盖上方「写到文件」指引)】',
483
+ '- 平台 worker 禁止写任何文件、禁止 git 操作:把完整 review.json 文本(上方 schema)作为产出返回。',
484
+ '- 优先放 artifacts(kind 含 review,content 字段放纯 JSON 文本);宿主不支持 artifacts 时放回答正文首个 ```json 代码块(CLI 回收链三通道兜底)。',
485
+ ].join('\n')
486
+ const mission = await cl.createMission({ objective, orchestrationMode: 'external', budgetUsd })
487
+ const missionId = mission && mission.missionId
488
+ if (!missionId) return { ok: false, reason: 'create_mission 未返回 missionId' }
489
+ say(`🛰️ 平台评审已派发(mission ${String(missionId).slice(0, 8)})——有界等待 ${Math.round(waitMs / 1000)}s(review_inline_wait)`)
490
+ let worker = null
491
+ try {
492
+ worker = await cl.dispatchWorker({ missionId, objective, readOnly: true, worktreePath: cwd, workerPrompt })
493
+ } catch (e) {
494
+ return { ok: false, reason: `dispatch_worker 异常(${(e && e.message) || e})`, missionId }
495
+ }
496
+ const workerId = worker && worker.workerId
497
+ if (!workerId) return { ok: false, reason: 'dispatch_worker 未返回 workerId', missionId }
498
+ const deadline = Date.now() + Math.max(1000, waitMs)
499
+ let lastState = ''
500
+ while (Date.now() < deadline) {
501
+ await new Promise((r) => setTimeout(r, Math.max(200, Math.min(pollIntervalMs, deadline - Date.now()))))
502
+ let w = null
503
+ try {
504
+ const workers = await cl.listWorkers(missionId)
505
+ w = Array.isArray(workers) ? workers.find((x) => x && (x.id === workerId || x.worker_id === workerId)) : null
506
+ } catch { /* 单次轮询抖动按在途继续(下轮重试) */ }
507
+ const st = w ? String(w.status || '') : 'unknown'
508
+ if (st !== lastState) {
509
+ say(`🛰️ 平台评审状态:${st}(剩 ${Math.max(0, Math.ceil((deadline - Date.now()) / 1000))}s)`)
510
+ lastState = st
511
+ }
512
+ if (st === 'failed' || st === 'killed') return { ok: false, reason: `worker 终态 ${st}`, missionId, workerId }
513
+ if (st === 'completed') {
514
+ const gr = await cl.getWorkerResult({ missionId, workerId })
515
+ const extracted = extractReviewFromArtifacts(gr && gr.artifacts, gr && gr.answer)
516
+ if (!extracted || typeof extracted !== 'object') {
517
+ return { ok: false, reason: 'worker completed 但三通道提取不到 review JSON(artifacts kind/文本、answer 正文)——平台 UI 可回看 mission', missionId, workerId }
518
+ }
519
+ // reviewer 如实标注(覆盖 worker 任意填法);change 对齐本变更;其余字段产物原样不改写
520
+ const candidate = { ...extracted, change, reviewer: 'platform-inline' }
521
+ const reviewPath = join(changeDir, 'review.json')
522
+ writeAtomicSync(reviewPath, JSON.stringify(candidate, null, 2) + '\n')
523
+ const { validateReviewJson } = await import('./flow-review.js')
524
+ const v = validateReviewJson(reviewPath)
525
+ if (!v.ok) {
526
+ try { rmSync(reviewPath, { force: true }) } catch { /* 删件失败由下轮覆盖兜底 */ }
527
+ return { ok: false, reason: `提取的 review 不符薄道 schema(${v.errors.join(';')})——原文可参考平台 mission UI`, missionId, workerId }
528
+ }
529
+ return { ok: true, missionId, workerId, reviewPath }
530
+ }
531
+ }
532
+ return { ok: false, reason: `等待 ${Math.round(waitMs / 1000)}s 未完成(在途 mission ${missionId} 可在平台 UI 回看;本轮回退任务书面)`, missionId, workerId }
533
+ } catch (e) {
534
+ return { ok: false, reason: `inline 派发异常(${(e && e.message) || String(e)})` }
535
+ }
536
+ }
537
+
439
538
  // ════════ 影子期派发(2026-09-18-ceremony-risk-pricing task-06 / FR-04 / D-007@v1 / R-04 / R-06)════════
440
539
  // 依据:design.md「总体方案 Phase 3」+「风险登记 R-04/R-06」+ plan.md「Wave 4」+ 全局硬约束 2。
441
540
  // 定位:S0/S1 轻档变更在明面轻仪主线外,后台静默派发重仪式独立评审**只记账不阻断**——
@@ -855,17 +855,26 @@ export async function bakeArchiveTimeline({ cwd, specBase, changeName, destDir,
855
855
  }
856
856
 
857
857
  /**
858
- * runArchiveChain —— 归档执行链(R7 切片二 task-02 从 archiveChangeDirectory 抽出,纯搬运)。
859
- *
860
- * 链面:未 apply 交付面检查(--skip-apply 留痕旁路)→ plan.md 硬校验(skipPlanCheck 旁路:
861
- * flow thin 轻量工件面无 plan.md,task-03 flow done 归档子步传 true;既有 archive 流程缺省
862
- * false 行为零变化)→ module-impact pending 死信校验 → 目标目录检查 → 目录搬移(rename 重试)
863
- * → 时间线烤制(fail-open,2026-09-28-archive-timeline-bake)→ unregisterChange 终态一致化
864
- * → archiveNarrowedGitAdd 窄化暂存 → 他者半归档残留探测。
865
- * 消费方:archiveChangeDirectory(既有 archive 流程)与 flow done 归档子步(R7)。
866
- *
867
- * @returns {Promise<void>} 失败面沿链内既有 process.exit 语义(纯搬运不改)。
858
+ * 归档落库判定(2026-10-23-flow-done-shared-repo-multi-session-frictions 缺陷③):HEAD 树中
859
+ * 是否已含本变更的归档目录条目(.sillyspec/changes/archive/<changeName>/ 下一层首段)。
860
+ * 目录名口径:archiveDestDirName 现行=裸变更名;`-<changeName>` 后缀兼容历史日期前缀形态。
861
+ * 纯函数(headTreeFiles 由调用方 git ls-tree 取好传入,测试零 git 依赖)。
862
+ * @param {{headTreeFiles:string, changeName:string}} p
863
+ * @returns {boolean}
868
864
  */
865
+ export function archiveLandedInHeadTree({ headTreeFiles, changeName }) {
866
+ const prefix = '.sillyspec/changes/archive/'
867
+ const name = String(changeName || '')
868
+ if (!name) return false
869
+ for (const line of String(headTreeFiles || '').split('\n')) {
870
+ const p = line.trim().replace(/\\/g, '/')
871
+ if (!p.startsWith(prefix)) continue
872
+ const first = p.slice(prefix.length).split('/')[0]
873
+ if (first === name || first.endsWith('-' + name)) return true
874
+ }
875
+ return false
876
+ }
877
+
869
878
  /**
870
879
  * 归档提交 pathspec 构成(2026-10-06-archive-cmd-race-and-brief:共享暂存区竞态加固)。
871
880
  *
@@ -920,6 +929,18 @@ export function resolveArchiveCommitPathspecs({ changeName, stagedNameStatus, he
920
929
  return { pathspecs, dropped }
921
930
  }
922
931
 
932
+ /**
933
+ * runArchiveChain —— 归档执行链(R7 切片二 task-02 从 archiveChangeDirectory 抽出,纯搬运)。
934
+ *
935
+ * 链面:未 apply 交付面检查(--skip-apply 留痕旁路)→ plan.md 硬校验(skipPlanCheck 旁路:
936
+ * flow thin 轻量工件面无 plan.md,task-03 flow done 归档子步传 true;既有 archive 流程缺省
937
+ * false 行为零变化)→ module-impact pending 死信校验 → 目标目录检查 → 目录搬移(rename 重试)
938
+ * → 时间线烤制(fail-open,2026-09-28-archive-timeline-bake)→ unregisterChange 终态一致化
939
+ * → archiveNarrowedGitAdd 窄化暂存 → 他者半归档残留探测。
940
+ * 消费方:archiveChangeDirectory(既有 archive 流程)与 flow done 归档子步(R7)。
941
+ *
942
+ * @returns {Promise<void>} 失败面沿链内既有 process.exit 语义(纯搬运不改)。
943
+ */
923
944
  export async function runArchiveChain({ pm, cwd, specBase, changeName, srcDir, destDir, skipApply = false, skipPlanCheck = false, deliverableFiles = null, docsIncrement = null, extraDocs = null } = {}) {
924
945
  const archiveDir = dirname(destDir) // 抽取补:原闭包变量(changes/archive),从 destDir 推导等价
925
946
  // ── 模块文档认领 advisory(资产三小件③,2026-09-23)──
@@ -1152,12 +1173,26 @@ try {
1152
1173
  existsFn: (p) => { try { return existsSync(join(cwd, p)) } catch { return false } },
1153
1174
  })
1154
1175
  if (pathspecs.length > 0) {
1155
- console.log(`🧾 归档提交一笔到位(src 锚 HEAD 树/归档侧目录 pathspec/共享面按在场——共享暂存区竞态加固):`)
1156
- console.log(` git commit -m "chore(archive): ${changeName} 归档留档" -- ${pathspecs.join(' ')}`)
1176
+ // 落库断言(2026-10-23-flow-done-shared-repo-multi-session-frictions 缺陷③):打印命令≠落库
1177
+ // ——共享暂存区竞态下命令没人执行/被并行会话抢跑,都呈现「归档目录全 staged(A/R)+ 源侧
1178
+ // 删除停 D + flow done 报成功」的半态(实测 pull/清理丢归档)。终态不得只以打印当成功:
1179
+ // HEAD 树含本变更归档路径=已落库(ℹ️ 语义不变);未落库=🚨 升级 stderr + 可复制命令 + 后果。
1180
+ const landed = archiveLandedInHeadTree({ headTreeFiles: String(headTreeRaw || ''), changeName })
1181
+ const out = landed ? console.log : console.error
1182
+ if (!landed) {
1183
+ console.error(`🚨 归档提交未落库:归档目录已进暂存区(A/R)但 HEAD 树中无 .sillyspec/changes/archive/ 下本变更路径——只打印不执行 ≠ 归档成功(共享仓竞态:命令未被本会话执行或被并行会话抢跑)`)
1184
+ console.error(` 立即执行(幂等;pathspec 限本变更面):`)
1185
+ } else {
1186
+ console.log(`🧾 归档提交一笔到位(归档路径已在 HEAD 树——余下 staged 面一笔带走;src 锚 HEAD 树/归档侧目录 pathspec/共享面按在场——共享暂存区竞态加固):`)
1187
+ }
1188
+ out(` git commit -m "chore(archive): ${changeName} 归档留档" -- ${pathspecs.join(' ')}`)
1157
1189
  if (dropped.length > 0) {
1158
- console.log(` ⚠️ 丢弃 ${dropped.length} 个瞬时暂存条目(HEAD 不在册且工作区不在场——并行会话动了共享暂存区,未入命令):${dropped.join('、')}`)
1190
+ out(` ⚠️ 丢弃 ${dropped.length} 个瞬时暂存条目(HEAD 不在册且工作区不在场——并行会话动了共享暂存区,未入命令):${dropped.join('、')}`)
1191
+ }
1192
+ out(` (若执行报 pathspec 不匹配:共享仓暂存区在打印→执行间又被并行会话改动——git diff --cached --name-only 全量重核,以实际面替换 pathspec 后再提交)`)
1193
+ if (!landed) {
1194
+ console.error(` 不执行的后果:归档件只存在于暂存区——下一次 pull/清理/工作区重置会丢归档。执行后核对:git log --oneline -1 应见「chore(archive): ${changeName} 归档留档」。`)
1159
1195
  }
1160
- console.log(` (若执行报 pathspec 不匹配:共享仓暂存区在打印→执行间又被并行会话改动——git diff --cached --name-only 全量重核,以实际面替换 pathspec 后再提交)`)
1161
1196
  }
1162
1197
  } catch { /* 提示失败不阻断归档(advisory) */ }
1163
1198
  if (othersResidual.length > 0) {
@@ -358,8 +358,10 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
358
358
  // 无 /g 标志无状态,提级零行为差):
359
359
  // ① 路径段匹配:任意层出现 src / test / tests / __tests__ 段(src-guide 等长名不误蹭——按段全等)
360
360
  // ② 代码扩展名兜底:src/test 目录约定外的代码(backend/app/**.py、scripts/*.go 等)
361
- const CODE_PATH_SEGMENT_RE = /(^|\/)(src|tests?|__tests__)(\/|$)/
362
- const CODE_EXTENSION_RE = /\.(?:js|mjs|cjs|ts|tsx|jsx|py|pyw|go|rs|java|kt|kts|rb|php|cs|c|h|cpp|cc|hpp|swift|scala|groovy|vue|svelte)$/
361
+ // export(2026-10-23-turbo-docs-auto):flow.js docs-auto 档纯文档面判定同源消费——
362
+ // 分类器单一源(宁可多跑不可漏跑口径),本体零变化
363
+ export const CODE_PATH_SEGMENT_RE = /(^|\/)(src|tests?|__tests__)(\/|$)/
364
+ export const CODE_EXTENSION_RE = /\.(?:js|mjs|cjs|ts|tsx|jsx|py|pyw|go|rs|java|kt|kts|rb|php|cs|c|h|cpp|cc|hpp|swift|scala|groovy|vue|svelte)$/
363
365
 
364
366
  // 测试文件判定:目录段(test/tests/__tests__——比 quick-gate-profile 的 TEST_DIR_RE 多认裸
365
367
  // test/ 段,本仓 test/ 下即有无 .test. 后缀的 .mjs)+ basename test_ 前缀 + .test./_test./.spec.
package/src/run/shared.js CHANGED
@@ -390,7 +390,7 @@ export function detectCwdInsideWorktree(dir) {
390
390
  export const UNINIT_CWD_GATE_EXEMPT = new Set([
391
391
  'init', 'scan', 'doctor', 'status', 'progress', 'next', 'workspace',
392
392
  'setup', 'knowledge', 'local', 'config', 'mcp', 'dashboard', 'platform',
393
- 'wt-commit', 'agent-log',
393
+ 'wt-commit', 'push', 'agent-log',
394
394
  ])
395
395
 
396
396
  /**
@@ -627,6 +627,7 @@ export async function cmdKnowledge(args, dir, opts = {}) {
627
627
  ' inspect <文件> 查看单个知识文件条目结构',
628
628
  ' validate 校验知识库完整性(INDEX 锚点/注册/幽灵条目——纯读)',
629
629
  ' digest 治理信号卡(rot/伪域/坏绑定——纯读)',
630
+ ' scope 知识注入作用域现状与标记语法(always/repo/module/关键词;纯读)',
630
631
  ' stats 覆盖率/死条目统计(纯读)',
631
632
  ' refresh 重扫 scan 文档重写 generated/ 区(**写盘**——先 validate 再用)',
632
633
  ' propose / classify / inbox / graph / stats / renumber',
@@ -636,7 +637,7 @@ export async function cmdKnowledge(args, dir, opts = {}) {
636
637
  '写盘面子命令(refresh/propose/classify)先跑同名只读命令确认现状;探索用法先看本帮助。',
637
638
  ]
638
639
  for (const l of lines) console.log(l)
639
- output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'classify', 'inbox', 'graph', 'stats', 'redomain-unmapped'] })
640
+ output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'classify', 'inbox', 'graph', 'stats', 'scope', 'redomain-unmapped'] })
640
641
  return
641
642
  }
642
643
  const subCommand = args[0] || ''
@@ -706,6 +707,48 @@ export async function cmdKnowledge(args, dir, opts = {}) {
706
707
  }
707
708
  return mod.cmdKnowledgeStats(dir, args.slice(1), opts)
708
709
  }
710
+ case 'scope': {
711
+ // 知识注入作用域发现面(2026-10-23-knowledge-scope-discoverability):机制 2026-10-23-
712
+ // knowledge-scope-inject 落地但零发现面(用户追问「在哪引导配置」)——本命令列四档现状+
713
+ // 标记语法教学(示例与 collectScopedKnowledge 解析同源),空态明示仅关键词档在用。
714
+ // 纯读零写盘;--json 单对象供程序化消费。
715
+ try {
716
+ const { collectScopedKnowledge } = await import('../knowledge-scope.js')
717
+ const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
718
+ const scoped = collectScopedKnowledge(knowledgeDir)
719
+ const moduleEntries = Object.entries(scoped.modules)
720
+ const total = new Set([...scoped.always, ...scoped.repo, ...moduleEntries.flatMap(([, v]) => v)].map((e) => e.id)).size
721
+ const guide = [
722
+ '作用域标记(HTML 注释,写在 top-level knowledge/<文件>.md 的 section heading 之下;无标记=关键词命中现状):',
723
+ ' <!-- scope: always --> 每次变更恒注入(跨仓通用规约)',
724
+ ' <!-- scope: repo --> 本仓每次变更恒注入(仓级约束)',
725
+ ' <!-- scope: module:<域>[,<域>] --> 仅当触达该知识域时注入(域 id 与 FR 域同源,逗号多域)',
726
+ '查看现状:sillyspec knowledge scope;flow start 注入块中以 📌 行呈现;机制变更:2026-10-23-knowledge-scope-inject',
727
+ ]
728
+ if (opts.json || args.slice(1).includes('--json')) {
729
+ output(true, { subcommand: 'scope', ...scoped, total, guide })
730
+ } else {
731
+ console.log(`🧭 知识注入作用域现状(${total} 条 scoped;关键词档始终生效)`)
732
+ if (scoped.always.length > 0) {
733
+ console.log(' always(每次变更恒注入):')
734
+ for (const e of scoped.always) console.log(` - ${e.id}:${e.summary}`)
735
+ }
736
+ if (scoped.repo.length > 0) {
737
+ console.log(' repo(本仓恒注入):')
738
+ for (const e of scoped.repo) console.log(` - ${e.id}:${e.summary}`)
739
+ }
740
+ for (const [d, list] of moduleEntries) {
741
+ console.log(` module:${d}(触达该域时注入):`)
742
+ for (const e of list) console.log(` - ${e.id}:${e.summary}`)
743
+ }
744
+ if (total === 0) console.log(' 当前仅关键词档在用(无任何 scoped 标记)——要恒注入的规约/仓级约束按下述标记声明:')
745
+ for (const l of guide) console.log(` ${l}`)
746
+ }
747
+ } catch (e) {
748
+ output(false, {}, { code: 'scope_failed', message: String((e && e.message) || e) })
749
+ }
750
+ return
751
+ }
709
752
  case 'digest': {
710
753
  // 治理信号摘要(2026-09-27-knowledge-digest):四类沉睡信号(rot 待复核/收件箱积压/
711
754
  // 伪域落库/绑定解析失败)超阈才见人——安静即健康态。--json 供平台 RPC 消费(信号卡)。
@@ -2162,6 +2162,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
2162
2162
  // ① 本变更测试 ∪ ③ import 依赖(discoverModuleDependentTests);② FR 关联回归(fail-open)。
2163
2163
  let depsAutoFiles = []
2164
2164
  let frPre = null
2165
+ let frCapApplied = null
2165
2166
  if (strategy === 'dynamic' && !gitUnavailable) {
2166
2167
  try {
2167
2168
  depsAutoFiles = discoverModuleDependentTests({ cwd, changedFiles: lastChangedFiles, coveredCommands: [] })
@@ -2169,6 +2170,17 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
2169
2170
  try {
2170
2171
  frPre = collectFrLinkedTests({ specBase, changeName, changedFiles: lastChangedFiles, projectRoot: cwd })
2171
2172
  } catch { frPre = null }
2173
+ // 帽在 frPre 产生点一次成型(2026-10-23-fr-regress-default-off):决策空判/展示计数/执行
2174
+ // 组面全用帽后面——单一时点单一口径(v2 缺省 0=off:FR 层不进面即不触发 dynamic-subset
2175
+ // 空跑);capApplied 随行传执行层出披露行(total=真实绑定面,防二次施帽口径失真)。
2176
+ try {
2177
+ const capVal = readFrRegressCap(specBase)
2178
+ if (frPre && capVal !== null) {
2179
+ const capped = applyFrRegressCap(frPre, capVal)
2180
+ frPre = capped.fr
2181
+ frCapApplied = capped.capApplied
2182
+ }
2183
+ } catch { /* 键读取 fail-soft=缺省 off */ }
2172
2184
  }
2173
2185
  const scopeFiles = frPre ? [...new Set([...depsAutoFiles, ...frPre.files])] : [...new Set(depsAutoFiles)]
2174
2186
  const action = decideVerifyTestAction({ strategy, scopeCount: scopeFiles.length, gitUnavailable })
@@ -2235,11 +2247,13 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
2235
2247
  incremental = computeIncrementalFace({ ledger, filesSince, fullFace: lastChangedFiles })
2236
2248
  }
2237
2249
  }
2250
+ // frCount 展示口径随帽(2026-10-23-fr-regress-default-off):frPre 已在产生点施帽——
2251
+ // 展示计数/空判/执行同源(见上方帽一次成型注释)。
2238
2252
  const frCount = frPre && frPre.files.length ? frPre.files.length : 0
2239
2253
  if (incremental) {
2240
2254
  subsetInput = incremental.files
2241
2255
  console.log(`⚡ ${incremental.note}`)
2242
- console.log(`ℹ️ 动态测试子集(增量重跑档):修复面三源重算(本变更测试 ∪ FR 关联回归 ∪ import 依赖),runner 自项目结构推断。恒全子集:local.yaml verify: test_rerun: full`)
2256
+ console.log(`ℹ️ 动态测试子集(增量重跑档):修复面三源重算(本变更测试 ∪ import 依赖;FR 关联回归层按 fr_regress_cap——缺省不跑归 pre-push/CI 门,2026-10-23-fr-regress-default-off),runner 自项目结构推断。恒全子集:local.yaml verify: test_rerun: full`)
2243
2257
  mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: subsetInput, frPre: null })
2244
2258
  if (mainResult && typeof mainResult === 'object') {
2245
2259
  mainResult.mode = 'incremental-rerun'
@@ -2248,8 +2262,8 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
2248
2262
  : `增量重跑通过:${incremental.note}`
2249
2263
  }
2250
2264
  } else {
2251
- console.log(`ℹ️ 动态测试子集(缺省):本变更测试 ∪ FR 关联回归 ∪ import 依赖 = ${scopeFiles.length} 个(并集去重后的总数;分量 deps ${depsAutoFiles.length}${frCount ? ` + FR 绑定 ${frCount}` : ''},重叠只计一次);runner 自项目结构推断。显式 test_strategy: full 恢复全量`)
2252
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles, frPre, observability })
2265
+ console.log(`ℹ️ 动态测试子集(缺省):本变更测试 ∪ import 依赖${frCount ? ' ∪ FR 关联回归' : '(FR 关联回归层缺省不跑归 pre-push/CI 门——fr_regress_cap: N|all 可开启)'} = ${scopeFiles.length} 个(并集去重后的总数;分量 deps ${depsAutoFiles.length}${frCount ? ` + FR 绑定 ${frCount}` : ''},重叠只计一次);runner 自项目结构推断。显式 test_strategy: full 恢复全量`)
2266
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles, frPre, frCapApplied, observability })
2253
2267
  }
2254
2268
  // ledger 记账(无论增/全):head=当前 HEAD(git 失败 null → 下轮自动回全子集);失败批文件
2255
2269
  // 落账供下轮增量;passed 后 failedFiles 清空 → 下轮自然回全子集基线
@@ -2266,15 +2280,23 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
2266
2280
  } catch { /* 记账失败不阻断(下轮退化全子集) */ }
2267
2281
  } else {
2268
2282
  // —— dynamic-empty:变更与测试面零关系——
2269
- // 无自身测试、无 import 依赖测试、无 FR 关联绑定 → 不硬跑全量(防超时/预存失败面,
2270
- // 同旧 module 0 命中防误伤初衷);全量覆盖请显式 test_strategy: full。
2283
+ // 无自身测试、无 import 依赖测试(+FR 关联回归层:v2 缺省跳过或绑定面为空)→ 不硬跑
2284
+ // 全量(防超时/预存失败面,同旧 module 0 命中防误伤初衷);全量覆盖请显式 test_strategy: full。
2285
+ // FR 层被帽跳过(frCapApplied 在场 total>0)≠「无绑定」——reason 如实陈述并打披露行
2286
+ // (2026-10-23-fr-regress-default-off 评审 P1 修复:缺省跳过恒披露不静默)。
2287
+ if (frCapApplied && frCapApplied.total > 0) {
2288
+ console.log(`ℹ️ fr_regress_cap:FR 关联回归面 ${frCapApplied.kept}/${frCapApplied.total} 进实测面,其余 ${frCapApplied.total - frCapApplied.kept} 个归 pre-push/CI 门(可开启:local.yaml fr_regress_cap: <N|all>——N=前 N 个 / all=全跑旧行为;缺省不跑)`)
2289
+ }
2290
+ const frEmptyNote = frCapApplied && frCapApplied.total > 0
2291
+ ? `FR 关联回归层被 fr_regress_cap 跳过(绑定 ${frCapApplied.total} 个未跑,归 pre-push/CI 门)`
2292
+ : '无 FR 关联绑定'
2271
2293
  mainResult = {
2272
2294
  status: 'skipped',
2273
2295
  command: null,
2274
2296
  exitCode: null,
2275
2297
  durationMs: null,
2276
2298
  outputTail: null,
2277
- reason: `变更与测试面零关系(${lastChangedFiles.length} 个变更文件无自身测试、无 import 依赖测试、无 FR 关联绑定)——动态子集空,不硬跑全量(防超时/预存失败面)。全量覆盖请显式 test_strategy: full。` + eaNote,
2299
+ reason: `变更与测试面零关系(${lastChangedFiles.length} 个变更文件无自身测试、无 import 依赖测试、${frEmptyNote})——动态子集空,不硬跑全量(防超时/预存失败面)。全量覆盖请显式 test_strategy: full。` + eaNote,
2278
2300
  resultPath: null,
2279
2301
  mode: 'dynamic-empty',
2280
2302
  fallbackReason: null,
@@ -2989,6 +3011,16 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2989
3011
  }
2990
3012
  pyGroups.get(key).files.push(f)
2991
3013
  }
3014
+ // runner_flags(2026-10-23-runner-flags-generic,语言中立):推断路径按批类别注入声明
3015
+ // flags——批类别=CLI 自有 runner 分类(py|js|jsx|…,未知类别前向兼容,不枚举语言)。
3016
+ // hits 路径的模块命令自带 flags(extractRunnerTrailingFlags),此处不双加;flags 置于
3017
+ // 文件参数之前(node/go 类参数解析在首个位置参数处截断);缺省空=零回归。
3018
+ let runnerFlags = {}
3019
+ try { runnerFlags = readRunnerFlags(specBase) } catch { /* fail-soft 空映射 */ }
3020
+ const flagsSuffix = (kind) => (runnerFlags[kind] ? `${runnerFlags[kind]} ` : '')
3021
+ if (!pyRunner && pyGroups.size > 0 && runnerFlags.py) {
3022
+ for (const grp of pyGroups.values()) grp.command = `${grp.command} ${runnerFlags.py}`
3023
+ }
2992
3024
  if (!pyRunner && pyGroups.size > 0) pyRunner = pyGroups.values().next().value.command
2993
3025
  // JSX 运行器推断(2026-09-26-residual-runner-parity 起 + 结构推断源):.tsx/.jsx 是 node
2994
3026
  // 原生 type stripping 跑不了的(node --test 直跑恒败——R18 verify 段 ~11 次尝试的坑),
@@ -3055,7 +3087,7 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
3055
3087
  }
3056
3088
  }
3057
3089
  if (jsNative.length > 0) {
3058
- const jsPrefix = 'node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout '
3090
+ const jsPrefix = 'node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ' + flagsSuffix('js')
3059
3091
  const jsChunks = chunkByCommandLength(jsPrefix, jsNative)
3060
3092
  jsChunks.forEach((chunk, i) => {
3061
3093
  const isMulti = jsChunks.length > 1
@@ -3093,7 +3125,7 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
3093
3125
  const multiGroup = jsxGroups.size > 1
3094
3126
  for (const [command, grp] of jsxGroups) {
3095
3127
  const isVitest = /vitest/.test(command)
3096
- const jsxPrefix = `${command} ${isVitest ? 'run ' : ''}`
3128
+ const jsxPrefix = `${command} ${isVitest ? 'run ' : ''}${jsxRunnerFromHits ? '' : flagsSuffix('jsx')}`
3097
3129
  const grpDir = grp.dirKey || /(?:^|\s)cd\s+(\S+)\s*&&/.exec(command)?.[1] || null
3098
3130
  const rebaseJsx = (f) => (grpDir && f.startsWith(grpDir + '/')) ? f.slice(grpDir.length + 1) : f
3099
3131
  const jsxArgs = grp.files.map(rebaseJsx).map(quoteWinPath)
@@ -3349,13 +3381,134 @@ export function applyTraceResidual({ mainResult, action, cwd, specBase, changeNa
3349
3381
  * export 供直测(buildDepsBatches 同款先例——fixture 需真跑测试文件,不宜经
3350
3382
  * runVerifyTestCheck 全链只为断言组卷接线)。
3351
3383
  */
3352
- export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [], frPre = null, observability = null }) {
3384
+ /**
3385
+ * runner_flags 读取(2026-10-23-runner-flags-generic,语言中立):local.yaml 的批类别 →
3386
+ * 尾随 flags 映射(批类别 = CLI 自有 runner 分类 py|js|jsx|…,未知类别保留、前向兼容——
3387
+ * 不枚举语言/框架,对齐 AGENTS 规约 00)。嵌套块行式解析(引号/注释/CRLF 容错,块结束于
3388
+ * 去缩进行);py_runner_flags 单键保留为兼容别名(映射到 py)。缺省/坏值/不可读 → 空映射
3389
+ * (零回归)。flags 注入置于文件参数之前(node/go 类参数解析在首个位置参数处截断)。
3390
+ */
3391
+ export function readRunnerFlags(specBase) {
3392
+ const out = {}
3393
+ try {
3394
+ const raw = normalizeLineEndings(readFileSync(join(specBase, 'local.yaml'), 'utf8'))
3395
+ const legacy = raw.match(/^[ ]*py_runner_flags[ ]*:[ ]*([^#\n]+?)[ ]*(?:#.*)?$/m)
3396
+ if (legacy) {
3397
+ const v = legacy[1].trim().replace(/^["']|["']$/g, '').trim()
3398
+ if (v) out.py = v
3399
+ }
3400
+ const lines = raw.split(String.fromCharCode(10))
3401
+ let inBlock = false
3402
+ let indent = -1
3403
+ for (const line of lines) {
3404
+ if (!inBlock) {
3405
+ if (/^[ ]*runner_flags[ ]*:[ ]*(?:#.*)?$/.test(line)) { inBlock = true; indent = -1 }
3406
+ continue
3407
+ }
3408
+ if (!line.trim() || /^[ ]*#/.test(line)) continue
3409
+ const m = /^([ ]+)([A-Za-z0-9_.:+/-]+)[ ]*:[ ]*([^#\n]+?)[ ]*(?:#.*)?$/.exec(line)
3410
+ if (!m) { if (!/^[ ]/.test(line)) break; continue }
3411
+ if (indent === -1) indent = m[1].length
3412
+ if (m[1].length < indent) { break }
3413
+ const v = m[3].trim().replace(/^["']|["']$/g, '').trim()
3414
+ if (v) out[m[2]] = v
3415
+ }
3416
+ } catch { /* fail-soft 空映射 */ }
3417
+ return out
3418
+ }
3419
+
3420
+ /** py_runner_flags 兼容别名(2026-10-23-py-runner-flags;泛化后保留读口——等价 readRunnerFlags().py)。 */
3421
+ export function readVerifyPyFlags(specBase) {
3422
+ return readRunnerFlags(specBase).py || ''
3423
+ }
3424
+
3425
+ /**
3426
+ * deps_regress_cap 读取(2026-10-23-runner-flags-generic,语言中立):import 依赖面档位——
3427
+ * N(1-2000)= 变更自身测试优先整跑、import 依赖只取前 N 个;off = 只跑变更自身测试(其余
3428
+ * 归 pre-push/CI 门);缺省/坏值/不可读 → null(全跑=现状零回归)。own 判定=变更触碰文件集
3429
+ * ∩ 依赖面(测试文件),与语言/框架无关。
3430
+ */
3431
+ export function readDepsRegressCap(specBase) {
3432
+ try {
3433
+ const raw = normalizeLineEndings(readFileSync(join(specBase, 'local.yaml'), 'utf8'))
3434
+ const m = raw.match(/^[ ]*deps_regress_cap[ ]*:[ ]*([^#\n]+?)[ ]*(?:#.*)?$/m)
3435
+ if (!m) return null
3436
+ const v = m[1].trim().replace(/^["']|["']$/g, '')
3437
+ if (/^off$/i.test(v)) return 0
3438
+ if (/^\d+$/.test(v)) {
3439
+ const n = Number(v)
3440
+ if (n >= 1 && n <= 2000) return n
3441
+ }
3442
+ return null
3443
+ } catch { return null }
3444
+ }
3445
+
3446
+ /**
3447
+ * fr_regress_cap 读取 v2(2026-10-23-fr-regress-default-off,缺省翻转:用户裁决「默认就不跑,
3448
+ * 配置可以开启」):缺省(无键)/坏值/不可读 → 0(off——FR 关联回归层本地不跑,归 pre-push/CI
3449
+ * 门);off → 0;N(1-2000 正整数)→ 前 N 个;all → null(无帽=旧行为,显式恢复全跑)。
3450
+ * normalizeLineEndings CRLF 归一保留;轻量正则不引 yaml 依赖(extractTestCommand 同风格)。
3451
+ */
3452
+ export function readFrRegressCap(specBase) {
3453
+ try {
3454
+ const raw = normalizeLineEndings(readFileSync(join(specBase, 'local.yaml'), 'utf8'))
3455
+ const m = raw.match(/^[ \t]*fr_regress_cap:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
3456
+ if (!m) return 0 // 缺省=off(v2 翻转)
3457
+ const v = m[1].trim().replace(/^["']|["']$/g, '')
3458
+ if (/^all$/i.test(v)) return null // 显式恢复旧行为(无帽整跑)
3459
+ if (/^off$/i.test(v)) return 0
3460
+ if (/^\d+$/.test(v)) {
3461
+ const n = Number(v)
3462
+ if (n >= 1 && n <= 2000) return n
3463
+ }
3464
+ return 0 // 坏值 fail-soft=新缺省 off
3465
+ } catch { return 0 } // 不可读 fail-soft=新缺省 off
3466
+ }
3467
+
3468
+ /**
3469
+ * FR 回归面帽纯函数(2026-10-23-fr-regress-cap):fr.files 超帽截前 N 个(现有稳定序);
3470
+ * cap=0(off)整层归零;null/undefined/帽内零截断 → 原对象零变化。返回 { fr, capApplied },
3471
+ * capApplied={kept,total}|null 供 loud 披露。下游(frLinked/frReport/优先面标记/批组卷)
3472
+ * 从帽后面对象自然推导,无第二套口径。
3473
+ */
3474
+ export function applyFrRegressCap(fr, cap) {
3475
+ if (cap === null || cap === undefined || !fr || !Array.isArray(fr.files) || fr.files.length === 0) {
3476
+ return { fr, capApplied: null }
3477
+ }
3478
+ if (cap === 0) return { fr: { ...fr, files: [] }, capApplied: { kept: 0, total: fr.files.length } }
3479
+ if (fr.files.length <= cap) return { fr, capApplied: null }
3480
+ return { fr: { ...fr, files: fr.files.slice(0, cap) }, capApplied: { kept: cap, total: fr.files.length } }
3481
+ }
3482
+
3483
+ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [], frPre = null, frCapApplied = null, observability = null }) {
3353
3484
  const subsetStartedAt = Date.now()
3354
3485
  const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
3355
3486
  // 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
3356
3487
  // 变更 src 文件的直接断言测试 → module 收窄漏全量断言。附加执行「import 变更 src 的测试 ∪
3357
3488
  // 变更的 test 文件」中未被命中模块命令覆盖的部分;无额外文件零行为(不新增失败面)。
3358
- const deps = discoverModuleDependentTests({ cwd, changedFiles, coveredCommands: hits.map(h => h.test) })
3489
+ let deps = discoverModuleDependentTests({ cwd, changedFiles, coveredCommands: hits.map(h => h.test) })
3490
+ // deps_regress_cap(2026-10-23-runner-flags-generic,语言中立):import 依赖面档位——
3491
+ // own(变更触碰文件集∩依赖面)优先整跑,其余按 N 截断/off 全弃(归 pre-push/CI 门)。
3492
+ // 缺省 null=全跑(现状零回归)。own 判定与语言/框架无关(纯路径集合运算)。
3493
+ let depsCapApplied = null
3494
+ try {
3495
+ const depCap = readDepsRegressCap(specBase)
3496
+ if (depCap !== null && deps.length > 0) {
3497
+ const ownSet = new Set((changedFiles || []).map((f) => String(f).split(String.fromCharCode(92)).join('/')))
3498
+ const own = deps.filter((f) => ownSet.has(f))
3499
+ const rest = deps.filter((f) => !ownSet.has(f))
3500
+ if (depCap === 0) {
3501
+ deps = own
3502
+ depsCapApplied = { kept: own.length, total: own.length + rest.length }
3503
+ } else if (rest.length > depCap) {
3504
+ deps = [...own, ...rest.slice(0, depCap)]
3505
+ depsCapApplied = { kept: deps.length, total: own.length + rest.length }
3506
+ }
3507
+ }
3508
+ } catch { /* 键读取 fail-soft=全跑 */ }
3509
+ if (depsCapApplied) {
3510
+ console.log(`ℹ️ deps_regress_cap:实测依赖面 ${depsCapApplied.kept}/${depsCapApplied.total} 进实测面,其余 ${depsCapApplied.total - depsCapApplied.kept} 个归 pre-push/CI 门(可开启:local.yaml deps_regress_cap: <N|off>——N=前 N 个依赖 / off=只跑变更自身测试;缺省全跑)`)
3511
+ }
3359
3512
  // FR 关联回归源(2026-09-26-dynamic-test-inference 三源之二):active FR 覆盖面(来源变更
3360
3513
  // patch ∪ 绑定)∩ 本次触碰文件 ≠ ∅ 者,其绑定 tests 解析仓根相对后并入实测面——「需求→
3361
3514
  // 用例」映射即测试需求。frPre 在场用调用方预计算(runVerifyTestCheck 决策已扫过,免二次
@@ -3365,6 +3518,17 @@ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures
3365
3518
  let fr = frPre
3366
3519
  if (!fr) {
3367
3520
  try { fr = collectFrLinkedTests({ specBase, changeName, changedFiles: changedFiles || [], projectRoot: cwd }) } catch { fr = null }
3521
+ // 本地收集路径同样施帽(2026-10-23-fr-regress-default-off 单一口径:直调方/增量重跑未带
3522
+ // frPre——v2 缺省 0=off,FR 层本地不跑归 pre-push/CI 门;all=null 无帽旧行为)。frPre 由
3523
+ // 调用方在产生点施帽并随行带 frCapApplied(防二次施帽口径失真),本块不再重复施帽。
3524
+ try {
3525
+ const capVal = readFrRegressCap(specBase)
3526
+ if (fr && capVal !== null) {
3527
+ const capped = applyFrRegressCap(fr, capVal)
3528
+ fr = capped.fr
3529
+ frCapApplied = capped.capApplied
3530
+ }
3531
+ } catch { /* 键读取 fail-soft=缺省 off */ }
3368
3532
  }
3369
3533
  // 优先面全量口径(2026-10-06-fr-priority-overlap):frLinked 只含与 deps 无重叠的「新增」
3370
3534
  // 绑定文件——重叠者此前拿不到优先权,仍按普通依赖受帽字母序弃置(resume-title 收口实测
@@ -3382,6 +3546,9 @@ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures
3382
3546
  console.warn(`⚠️ ${fr.unresolved.length} 个 FR 绑定测试路径无法自仓根解析(不入实测面):${fr.unresolved.slice(0, 3).join('、')}${fr.unresolved.length > 3 ? ' 等' : ''}——sillyspec tests repair-paths 可修`)
3383
3547
  }
3384
3548
  }
3549
+ if (frCapApplied) {
3550
+ console.log(`ℹ️ fr_regress_cap:FR 关联回归面 ${frCapApplied.kept}/${frCapApplied.total} 进实测面,其余 ${frCapApplied.total - frCapApplied.kept} 个归 pre-push/CI 门(可开启:local.yaml fr_regress_cap: <N|all>——N=前 N 个 / all=全跑旧行为;缺省不跑)`)
3551
+ }
3385
3552
  const depsAll = [...new Set([...deps, ...frLinked])].sort()
3386
3553
  let depsBatches = []
3387
3554
  if (depsAll.length > 0) {
@@ -26,7 +26,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文
26
26
  ## 恢复与查看
27
27
  - 轻量变更恢复:重跑 `sillyspec flow start --change <名>`(输出恢复简报)或 `flow status --change <名>`;完整流程恢复:`run <stage> --change <名>` 续跑。不直接 commit 半成品
28
28
  - 跨会话交接:`sillyspec handoff --change <名>`;变更列表:`sillyspec status`;自检修复:`sillyspec doctor`
29
- - 知识库:`sillyspec knowledge search "<关键词>"`(命中知识 CLI 会自动注入 prompt,勿自行重复检索)
29
+ - 知识库:`sillyspec knowledge search "<关键词>"`(命中知识 CLI 会自动注入 prompt,勿自行重复检索);知识条目可声明注入作用域(`<!-- scope: always|repo|module:<域> -->`,无标记=关键词档)——现状与语法:`sillyspec knowledge scope`
30
30
 
31
31
  ## 核心规则
32
32
  1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
@@ -42,3 +42,4 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文
42
42
  11. **禁止目录级 git add / git add -A**(多会话共享仓):目录级暂存会夹带并行会话的进行中文件甚至误删已提交文件。提交一律用显式 pathspec,且 **add 与 commit 用同一份清单**:`git commit -m "..." -- 文件1 文件2`——带 pathspec 的 commit 只提交这些路径,他侧已 staged 的条目既不被带走、也原样留给对方;裸 `git commit` 提交的是整个共享暂存区,他会话 add 过的文件会被一并扫入。核对暂存面固定用 `git diff --cached --name-only` 全量读取,禁用过滤式核对(过滤正是致盲原因);核对与提交分开两条命令执行,不得链在同一命令里(链行没有拦截点)。安装了 commit-guard hook 的仓会对超声明面的 staged 文件警告(只警告不阻断)。
43
43
  12. **不奉承用户**,禁止"你说得对"类话术,直接给结论、依据、方案。
44
44
  13. **边干边勾(任务进度唯一源=变更的 tasks.md)**:完成一个任务单元(实现到位 + 相关测试跑绿)当场勾对应格——`sillyspec task tick --change <名> --task task-NN`(即时回显进度与下一任务)或直接 Edit 翻格 `- [ ]`→`- [x]`,勿攒到收口一把勾。harness 自带的 TodoWrite 类工具是会话内便利面,不替代 tasks.md——平台进度投影、收口哨兵、断点恢复只读 tasks.md。
45
+ 14. **推送前压扁**:本地未推送提交统一走 `sillyspec push`(默认 by-change;`--mode one` 需明确归属),禁止直接 `git push`、`git push --force` 或手工 `git reset --soft` 压扁;已推送历史不改写,并行会话的 staged 文件/Dirty 文件不替人提交或清理。