sillyspec 3.28.3 → 3.28.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 (56) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +24 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/src/archive-delta.js +82 -0
  15. package/src/change-delete.js +2 -1
  16. package/src/change-list.js +7 -2
  17. package/src/change-risk-profile.js +77 -6
  18. package/src/config-cat.js +12 -1
  19. package/src/constants.js +3 -1
  20. package/src/docs-check.js +34 -0
  21. package/src/doctor-diagnostics.js +441 -5
  22. package/src/foreign-declared.js +53 -2
  23. package/src/index.js +181 -6
  24. package/src/module-impact.js +77 -12
  25. package/src/plan-adopt-waves.js +13 -5
  26. package/src/progress/change-registry.js +16 -0
  27. package/src/progress.js +62 -1
  28. package/src/quick-recommend.js +6 -1
  29. package/src/quicklog.js +10 -2
  30. package/src/run/command.js +30 -9
  31. package/src/run/complete-handlers.js +152 -6
  32. package/src/run/complete.js +45 -0
  33. package/src/run/gates.js +94 -6
  34. package/src/run/prompt.js +96 -5
  35. package/src/run/shared.js +74 -6
  36. package/src/run/stage.js +35 -0
  37. package/src/runtime-hygiene.js +84 -0
  38. package/src/spec-sync.js +63 -8
  39. package/src/stage-contract.js +83 -3
  40. package/src/stage-review.js +3 -0
  41. package/src/stages/archive.js +4 -4
  42. package/src/stages/brainstorm.js +2 -8
  43. package/src/stages/doctor.js +41 -515
  44. package/src/stages/plan-postcheck.js +191 -7
  45. package/src/stages/plan.js +5 -26
  46. package/src/stages/quick.js +11 -11
  47. package/src/stages/verify.js +10 -6
  48. package/src/sync-noise.js +158 -0
  49. package/src/sync.js +42 -17
  50. package/src/task-review.js +25 -1
  51. package/src/verify-facts-schema.js +141 -0
  52. package/src/verify-postcheck.js +257 -19
  53. package/src/verify-probes.js +142 -13
  54. package/src/workflow.js +6 -0
  55. package/src/worktree-apply.js +51 -13
  56. package/src/worktree.js +5 -1
@@ -16,6 +16,8 @@
16
16
  * - 不删除/移动任何文件;orphan db 仅报告,处理交给后续 --dump-db / --confirm 流程。
17
17
  * - execute-progress-plan-mismatch 维度同样只读:仅读 plan.md checkbox + 只读查 stages 表,
18
18
  * 绝不调用 ProgressManager 写方法(写操作是 progress.alignExecuteToPlan 的职责,D-001@v2 诊断/写分离)。
19
+ * - 写动作是独立 opt-in 导出(--cleanup-ghosts / --gc-unstamped-runs / --cleanup-remnant),
20
+ * 与 --json 诊断路径分离;默认 dry-run,--confirm 才落盘。
19
21
  *
20
22
  * 风格对齐 scan-postcheck.js:checks 用 CHECK_SEVERITY,formatter 产出 schema_version JSON,
21
23
  * writer 落盘到 <authoritySpecDir>/.runtime/。
@@ -23,8 +25,10 @@
23
25
  import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
24
26
  import { safeGit } from './git-helper.js';
25
27
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
26
- import { join } from 'path';
28
+ import { join, relative, resolve, dirname } from 'path';
29
+ import { gitQuiet } from './git-helper.js'
27
30
  import jsYaml from 'js-yaml';
31
+ import { pruneTimestampedEntries } from './runtime-hygiene.js';
28
32
  import { CHECK_SEVERITY } from './constants.js';
29
33
  import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
30
34
 
@@ -239,15 +243,18 @@ function detectPointerHealth(pointer, cwd = null) {
239
243
  if (cwd) {
240
244
  const decl = checkPlatformManaged(cwd);
241
245
  if (decl) {
246
+ // 悬空声明检测(2026-09-09 §7-2,会话实证:.sillyspec-platform-managed 指向已删除的
247
+ // tmp 目录会挡住一切 CLI 命令):声明 specRoot 不存在 = 平台会话已死/测试残留——
248
+ // disconnect 是唯一正解,点名前置(防用户对着死路径反复重建指针)。
249
+ const declDead = !!decl.specRoot && !existsSync(decl.specRoot)
242
250
  return {
243
251
  name: 'pointer_health',
244
252
  label: '平台指针健康',
245
253
  pass: false,
246
254
  severity: CHECK_SEVERITY.WARNING,
247
255
  findings: [
248
- `pointer_missing_but_managed: 平台接管声明存在但恢复指针缺失(原 specRoot: ${decl.specRoot || '(未记录)'})——` +
249
- `裸调命令将 fail-closed 拒绝静默落本地。恢复:① 重跑平台 scan/init(带 --spec-root)重建指针;` +
250
- `② sillyspec platform disconnect(删除接管声明,彻底脱离平台);③ 显式 --spec-dir <路径> 临时指定。`,
256
+ `pointer_missing_but_managed: 平台接管声明存在但恢复指针缺失(原 specRoot: ${decl.specRoot || '(未记录)'}${declDead ? '——⚠️ 该路径已不存在(平台会话已死/测试残留),重建指针无意义' : ''})——` +
257
+ `裸调命令将 fail-closed 拒绝静默落本地。恢复:${declDead ? '建议直接 sillyspec platform disconnect(删除接管声明,脱离平台后走本地库);' : '① 重跑平台 scan/init(带 --spec-root)重建指针;② sillyspec platform disconnect(删除接管声明,彻底脱离平台);'}③ 显式 --spec-dir <路径> 临时指定。`,
251
258
  ],
252
259
  safe_actions: [{ dimension: 'pointer_health', action: 'recreate_pointer', risk: 'confirm_required', rationale: '接管声明存在但指针缺失,需重建指针或显式脱离平台', next_step: '重跑平台 scan(带 --spec-root)或 sillyspec platform disconnect' }],
253
260
  };
@@ -738,6 +745,191 @@ export function detectDocBloat(specRoot) {
738
745
  ? { ...dim, findings: ['模块卡与知识库体量在软限内'], pass: true, severity: CHECK_SEVERITY.PASSED }
739
746
  : { ...dim, pass: false, severity: CHECK_SEVERITY.WARNING };
740
747
  }
748
+ // ══ 2026-09-09-doctor-noai:净增三类探测器(只读 fail-soft,skipped 带内降级)══
749
+
750
+ /**
751
+ * worktree 健康(FR-02):复用 WorktreeManager.doctor()(薄适配不重复探测——设计审查附加
752
+ * gap 修正)+ sillyspec/* 残留分支对账(vs changes/ 活跃目录)。锚定主仓根
753
+ * .sillyspec/.runtime/worktrees(worktree.js:24——勿用 runtimeRoot,平台模式分离只影响
754
+ * execute-runs 族)。
755
+ */
756
+ export function detectWorktreeHealth(cwd) {
757
+ const base = { name: 'worktree_health', label: 'worktree 隔离健康', safe_actions: [] }
758
+ try {
759
+ const { WorktreeManager } = require_wtm()
760
+ const wm = new WorktreeManager(cwd)
761
+ const r = wm.doctor(cwd)
762
+ const findings = []
763
+ for (const iss of r.issues || []) findings.push(`worktree: ${typeof iss === 'string' ? iss : JSON.stringify(iss)}`)
764
+ // 残留分支对账(sillyspec/* vs 活跃变更目录)
765
+ try {
766
+ const branches = gitQuiet(cwd, ['branch', '--list', 'sillyspec/*']) || ''
767
+ const activeDirs = new Set()
768
+ // 活跃变更目录锚定主仓根(审查 B-01:worktree 内 cwd/.sillyspec/changes 只含 archive——
769
+ // 活跃变更不入 git;git common-dir 反推主仓根,失败回退 cwd)
770
+ let mainRoot = cwd
771
+ try {
772
+ const commonDir = gitQuiet(cwd, ['rev-parse', '--git-common-dir'])
773
+ if (commonDir) mainRoot = resolve(dirname(resolve(cwd, String(commonDir).trim())))
774
+ } catch { /* 回退 cwd */ }
775
+ const changesDir = join(mainRoot, '.sillyspec', 'changes')
776
+ if (existsSync(changesDir)) {
777
+ for (const d of readdirSync(changesDir)) {
778
+ if (!d.startsWith('archive')) activeDirs.add(d)
779
+ }
780
+ }
781
+ for (const line of branches.split('\n')) {
782
+ // 剥 */+ 两前缀(审查 B-01:+ 是 worktree checkout 中标记,未剥致分支名与删除命令双重语法坏)
783
+ const b = line.trim().replace(/^[*+][ ]*/, '').replace(/^sillyspec\//, '')
784
+ if (b && !activeDirs.has(b)) findings.push(`残留分支 sillyspec/${b}(变更已归档/删除)——git branch -d sillyspec/${b} 或 sillyspec worktree cleanup`)
785
+ }
786
+ } catch { /* 分支读失败跳过该子项 */ }
787
+ return findings.length === 0
788
+ ? { ...base, findings: ['worktree 与会话目录健康'], pass: true, severity: CHECK_SEVERITY.PASSED }
789
+ : { ...base, findings, pass: false, severity: CHECK_SEVERITY.WARNING }
790
+ } catch (e) {
791
+ return { ...base, findings: [`探测降级(git 不可用或非仓库:${e?.message || e})——skipped`], pass: true, severity: null, skipped: true }
792
+ }
793
+ }
794
+
795
+ /**
796
+ * 构建环境(FR-02):node 版本 vs package.json engines.node(^/>=/= 前缀简化比较,
797
+ * 复杂区间 unknown 不判红)+ 包管理器存在性。无 package.json → skipped 注记。
798
+ */
799
+ export function detectBuildEnv(cwd) {
800
+ const base = { name: 'build_env', label: '构建环境', safe_actions: [] }
801
+ try {
802
+ const pkgPath = join(cwd, 'package.json')
803
+ if (!existsSync(pkgPath)) return { ...base, findings: ['无 package.json(非 Node 项目)——skipped'], pass: true, severity: null, skipped: true }
804
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
805
+ const findings = []
806
+ const want = pkg.engines && pkg.engines.node
807
+ if (want) {
808
+ const cur = process.version.replace(/^v/, '')
809
+ const m = String(want).match(/^(\^|>=|=)?\s*(\d+)\.?(\d+)?\.?(\d+)?/)
810
+ if (m) {
811
+ const [_, op, a, b, c] = m
812
+ const w = [Number(a), Number(b || 0), Number(c || 0)]
813
+ const cu = cur.split('.').map(Number)
814
+ const ge = cu[0] > w[0] || (cu[0] === w[0] && (cu[1] > w[1] || (cu[1] === w[1] && cu[2] >= w[2])))
815
+ const ok = op === '^' ? (cu[0] === w[0] && ge) : ge
816
+ if (!ok) findings.push(`node ${process.version} 不满足 engines.node ${want}`)
817
+ } else {
818
+ findings.push(`engines.node 区间复杂(${want})——unknown 不判红`)
819
+ }
820
+ }
821
+ // 包管理器
822
+ const pm = (pkg.packageManager || '').split('@')[0]
823
+ || (existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(cwd, 'yarn.lock')) ? 'yarn' : 'npm')
824
+ findings.push(`node ${process.version} / 包管理器 ${pm}${pkg.packageManager ? `(packageManager 锁定)` : '(lockfile 推断)'}`)
825
+ return findings.some(f => f.includes('不满足'))
826
+ ? { ...base, findings, pass: false, severity: CHECK_SEVERITY.WARNING }
827
+ : { ...base, findings, pass: true, severity: CHECK_SEVERITY.PASSED }
828
+ } catch (e) {
829
+ return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true }
830
+ }
831
+ }
832
+
833
+ /**
834
+ * MCP 端点配置在场性(FR-02):项目级 .mcp.json / .cursor/mcp.json 查 Context7/grep.app
835
+ * 键——零网络请求。无配置 → skipped 注记(不判红——MCP 是可选增强)。
836
+ */
837
+ export function detectMcpEndpoints(cwd) {
838
+ const base = { name: 'mcp_endpoints', label: 'MCP 端点配置', safe_actions: [] }
839
+ try {
840
+ const candidates = [join(cwd, '.mcp.json'), join(cwd, '.cursor', 'mcp.json')]
841
+ const found = candidates.find(p => existsSync(p))
842
+ if (!found) return { ...base, findings: ['无项目级 MCP 配置(.mcp.json/.cursor/mcp.json)——可选增强,skipped'], pass: true, severity: null, skipped: true }
843
+ const text = readFileSync(found, 'utf8')
844
+ const findings = [`配置文件:${relative(cwd, found)}`]
845
+ for (const key of ['context7', 'grep.app', 'grepapp']) {
846
+ if (text.toLowerCase().includes(key)) findings.push(`${key} 端点已配置 ✅`)
847
+ }
848
+ return { ...base, findings, pass: true, severity: CHECK_SEVERITY.PASSED }
849
+ } catch (e) {
850
+ return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true }
851
+ }
852
+ }
853
+
854
+ /**
855
+ * 模块文档健康(2026-09-09-doctor-noai 审查 B-02 补维,FR-01 step1 承诺):
856
+ * _module-map.yaml 的 needs_review=true 清单(卡缺失/精确校验归 modules rebuild/status)。
857
+ * 只读 fail-soft。
858
+ */
859
+ function detectModuleDocHealth(cwd) {
860
+ const base = { name: 'module_doc_health', label: '模块文档健康', safe_actions: [] }
861
+ try {
862
+ let mapText = null
863
+ const docsDir = join(cwd, '.sillyspec', 'docs')
864
+ if (existsSync(docsDir)) {
865
+ for (const d of readdirSync(docsDir)) {
866
+ const cand = join(docsDir, d, 'modules', '_module-map.yaml')
867
+ if (existsSync(cand)) { mapText = readFileSync(cand, 'utf8'); break }
868
+ }
869
+ }
870
+ if (!mapText) return { ...base, findings: ['无 _module-map.yaml(未 scan)——skipped'], pass: true, severity: null, skipped: true }
871
+ const findings = []
872
+ // needs_review 清单(模块块内 needs_review: true)
873
+ const blocks = mapText.split(/\n(?= \\w)/)
874
+ for (const blk of blocks) {
875
+ const idM = blk.match(/^ ([\w.-]+):/)
876
+ if (idM && /needs_review:\s*true/.test(blk)) {
877
+ const reasons = (blk.match(/review_reasons:\s*\[([^\]]*)\]/) || [])[1] || ''
878
+ findings.push(`${idM[1]} needs_review=true${reasons ? `(${reasons.trim()})` : ''}——按卡指引复核`)
879
+ }
880
+ }
881
+ return findings.length === 0
882
+ ? { ...base, findings: ['模块卡 needs_review 清零'], pass: true, severity: CHECK_SEVERITY.PASSED }
883
+ : { ...base, findings, pass: false, severity: CHECK_SEVERITY.WARNING }
884
+ } catch (e) {
885
+ return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true }
886
+ }
887
+ }
888
+
889
+ /**
890
+ * 决策版本漂移(审查 B-02 补维):knowledge/decisions/ 条目计数 + 提示精确复核链归属
891
+ * (behind 精确计算归 verify evidence-auto / docs-check 决策规则族——此处只做在场概览)。
892
+ * 只读 fail-soft。
893
+ */
894
+ function detectDecisionDrift(cwd) {
895
+ const base = { name: 'decision_drift', label: '决策版本漂移', safe_actions: [] }
896
+ try {
897
+ const decDir = join(cwd, '.sillyspec', 'knowledge', 'decisions')
898
+ if (!existsSync(decDir)) return { ...base, findings: ['无 knowledge/decisions/——skipped'], pass: true, severity: null, skipped: true }
899
+ let count = 0
900
+ for (const f of readdirSync(decDir)) {
901
+ if (!f.endsWith('.md')) continue
902
+ count += (readFileSync(join(decDir, f), 'utf8').match(/^## D-\d+@\d+/gm) || []).length
903
+ }
904
+ return { ...base, findings: [`decisions 库 ${count} 条(behind 精确复核在 verify evidence-auto 与 docs-check 决策规则族)`], pass: true, severity: CHECK_SEVERITY.PASSED }
905
+ } catch (e) {
906
+ return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true }
907
+ }
908
+ }
909
+
910
+ /**
911
+ * renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
912
+ * ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
913
+ * _cliAction 步共用。
914
+ */
915
+ export function renderDoctorSummary(diagnostics) {
916
+ const L = ['🩺 SillySpec 项目自检诊断', '']
917
+ for (const d of (diagnostics.dimensions || [])) {
918
+ if (!d || !d.name) continue
919
+ const icon = d.pass === false && d.severity === CHECK_SEVERITY.FAILED ? '❌' : d.pass === false ? '⚠️' : '✅'
920
+ L.push(`${icon} ${d.label || d.name}`)
921
+ for (const f of (d.findings || []).slice(0, 3)) L.push(` · ${f}`)
922
+ if ((d.findings || []).length > 3) L.push(` · …还有 ${d.findings.length - 3} 条`)
923
+ for (const a of (d.safe_actions || []).slice(0, 2)) L.push(` 🔧 ${a.action}:${a.next_step || a.rationale || ''}`)
924
+ }
925
+ L.push('')
926
+ return L.join('\n')
927
+ }
928
+
929
+ // worktree.js 延迟引用(防环:worktree 不 import doctor-diagnostics)
930
+ import * as _wtm from './worktree.js'
931
+ function require_wtm() { return _wtm }
932
+
741
933
  export async function runDoctorDiagnostics({ cwd }) {
742
934
  const pointer = resolvePointer(cwd);
743
935
  const multiDb = detectMultiDb(cwd, pointer);
@@ -759,7 +951,10 @@ export async function runDoctorDiagnostics({ cwd }) {
759
951
  // P2-2-②:file-lifecycle 文档欠账(git behind 事实计算,治「改了生命周期代码忘同步文档」)
760
952
  const lifecycleDoc = detectLifecycleDocStaleness(cwd);
761
953
 
762
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc];
954
+ const worktreeHealth = detectWorktreeHealth(cwd)
955
+ const buildEnv = detectBuildEnv(cwd)
956
+ const mcpEndpoints = detectMcpEndpoints(cwd)
957
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints];
763
958
 
764
959
  return {
765
960
  dimensions,
@@ -1169,3 +1364,244 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
1169
1364
  if (db) { try { db.close(); } catch { /* 忽略关闭失败 */ } }
1170
1365
  }
1171
1366
  }
1367
+
1368
+ // ── 存量无戳 execute-runs 清扫(doctor --gc-unstamped-runs)────────────────
1369
+
1370
+ const TASK_ID_RE = /\btask-(\d+)\b/gi
1371
+ const CHANGE_PATH_RE = /(?:^|\/)changes\/([^/]+)\//
1372
+ const TASK_DIR_RE = /^task-(\d+)$/i
1373
+
1374
+ function normTaskId(digits) {
1375
+ return `task-${String(digits).padStart(2, '0')}`
1376
+ }
1377
+
1378
+ function parseTaskIdsFromText(text) {
1379
+ const ids = new Set()
1380
+ if (!text) return ids
1381
+ const re = new RegExp(TASK_ID_RE.source, TASK_ID_RE.flags)
1382
+ for (const m of String(text).matchAll(re)) ids.add(normTaskId(m[1]))
1383
+ return ids
1384
+ }
1385
+
1386
+ function setEq(a, b) {
1387
+ if (a.size !== b.size) return false
1388
+ for (const x of a) if (!b.has(x)) return false
1389
+ return true
1390
+ }
1391
+
1392
+ function isSubset(small, big) {
1393
+ for (const x of small) if (!big.has(x)) return false
1394
+ return true
1395
+ }
1396
+
1397
+ function listDirNames(dir) {
1398
+ if (!dir || !existsSync(dir)) return []
1399
+ try {
1400
+ return readdirSync(dir, { withFileTypes: true })
1401
+ .filter((e) => e.isDirectory())
1402
+ .map((e) => e.name)
1403
+ } catch {
1404
+ return []
1405
+ }
1406
+ }
1407
+
1408
+ function loadTasksMd(changeDir) {
1409
+ const p = join(changeDir, 'tasks.md')
1410
+ if (!existsSync(p)) return new Set()
1411
+ try {
1412
+ return parseTaskIdsFromText(readFileSync(p, 'utf8'))
1413
+ } catch {
1414
+ return new Set()
1415
+ }
1416
+ }
1417
+
1418
+ /**
1419
+ * 扫一个 execute-run 目录:戳 / reviewedFiles 路径首段 / task-NN 目录名。
1420
+ * reviewedFiles 取全部条目(不只 [0]),路径段精确相等——login 不命中 2026-08-01-login。
1421
+ */
1422
+ function inspectExecuteRun(runDir) {
1423
+ const stampFile = join(runDir, 'change')
1424
+ let stamp = ''
1425
+ if (existsSync(stampFile)) {
1426
+ try { stamp = readFileSync(stampFile, 'utf8').trim() } catch { /* 读失败当无戳 */ }
1427
+ }
1428
+ const pathOwners = new Set()
1429
+ const taskIds = new Set()
1430
+ const tasksDir = join(runDir, 'tasks')
1431
+ if (existsSync(tasksDir)) {
1432
+ let entries = []
1433
+ try { entries = readdirSync(tasksDir, { withFileTypes: true }) } catch { entries = [] }
1434
+ for (const e of entries) {
1435
+ if (!e.isDirectory()) continue
1436
+ const tm = e.name.match(TASK_DIR_RE)
1437
+ if (tm) taskIds.add(normTaskId(tm[1]))
1438
+ const rjPath = join(tasksDir, e.name, 'review.json')
1439
+ if (!existsSync(rjPath)) continue
1440
+ try {
1441
+ const rj = JSON.parse(readFileSync(rjPath, 'utf8'))
1442
+ const files = Array.isArray(rj.reviewedFiles) ? rj.reviewedFiles : []
1443
+ for (const f of files) {
1444
+ const m = String(f || '').replace(/\\/g, '/').match(CHANGE_PATH_RE)
1445
+ if (m) pathOwners.add(m[1])
1446
+ }
1447
+ } catch { /* 坏 JSON 不据此猜归属 */ }
1448
+ }
1449
+ }
1450
+ return { hasStamp: stamp.length > 0, stamp, pathOwners, taskIds }
1451
+ }
1452
+
1453
+ /**
1454
+ * 无戳 run 的归属判定。禁 mtime。命中活跃变更一律 skip。
1455
+ *
1456
+ * 1. reviewedFiles 路径 `changes/<name>/` 首段精确命中:
1457
+ * - ∩ active → matches_active
1458
+ * - ∩ archive 多个 → ambiguous_archive
1459
+ * - ∩ archive 恰好 1:若 tasks.md 有任务且 run 有多余 task-NN → tasks_md_mismatch;否则 via path
1460
+ * 2. 无路径(或路径名既不在 active 也不在 archive):
1461
+ * - taskIds 空 → no_attribution
1462
+ * - 与恰好一个归档 tasks.md **集合全等**且无 active 全等 → via tasks_md
1463
+ * (子集太弱:task-01 几乎每个变更都有)
1464
+ */
1465
+ function classifyUnstampedRun(inspected, { active, archive, archiveTasks, activeTasks }) {
1466
+ const pathActive = [...inspected.pathOwners].filter((n) => active.has(n))
1467
+ if (pathActive.length) {
1468
+ return { skip: true, reason: 'matches_active' }
1469
+ }
1470
+ const pathArchived = [...inspected.pathOwners].filter((n) => archive.has(n))
1471
+ if (pathArchived.length > 1) {
1472
+ return { skip: true, reason: 'ambiguous_archive' }
1473
+ }
1474
+ if (pathArchived.length === 1) {
1475
+ const owner = pathArchived[0]
1476
+ const mdTasks = archiveTasks.get(owner) || new Set()
1477
+ if (mdTasks.size > 0 && inspected.taskIds.size > 0 && !isSubset(inspected.taskIds, mdTasks)) {
1478
+ return { skip: true, reason: 'tasks_md_mismatch', owner }
1479
+ }
1480
+ return { delete: true, owner, via: 'path' }
1481
+ }
1482
+
1483
+ if (inspected.taskIds.size === 0) {
1484
+ return { skip: true, reason: 'no_attribution' }
1485
+ }
1486
+ const activeEquals = [...active].filter((n) => setEq(inspected.taskIds, activeTasks.get(n) || new Set()))
1487
+ if (activeEquals.length) {
1488
+ return { skip: true, reason: 'matches_active' }
1489
+ }
1490
+ const archivedEquals = [...archive].filter((n) => setEq(inspected.taskIds, archiveTasks.get(n) || new Set()))
1491
+ if (archivedEquals.length === 1) {
1492
+ return { delete: true, owner: archivedEquals[0], via: 'tasks_md' }
1493
+ }
1494
+ if (archivedEquals.length > 1) {
1495
+ return { skip: true, reason: 'ambiguous_archive' }
1496
+ }
1497
+ return { skip: true, reason: 'no_attribution' }
1498
+ }
1499
+
1500
+ /**
1501
+ * 存量无戳 execute-runs 一次性清扫(ql-20260908-006-5f04)。
1502
+ *
1503
+ * 不进 archive 热路径:pruneArchivedChangeRuntime 故意不猜无戳 run(禁 mtime)。
1504
+ * 本函数是 doctor 旁路,对齐 --cleanup-ghosts:默认 dry-run,--confirm 才 rmSync。
1505
+ * 只处理 execute-runs/<runId>/(有 change 戳的留给归档精确回收;stage-reviews / verify-runs 本轮不动)。
1506
+ *
1507
+ * @param {{ cwd: string, specDir?: string|null, confirm?: boolean }} opts
1508
+ */
1509
+ export async function gcUnstampedExecuteRuns({ cwd, specDir = null, confirm = false }) {
1510
+ const pointer = resolvePointer(cwd)
1511
+ const localSpec = specDir && existsSync(specDir) ? specDir : join(cwd, '.sillyspec')
1512
+ const authoritySpecRoot = (pointer.present && pointer.specRoot && existsSync(pointer.specRoot))
1513
+ ? pointer.specRoot
1514
+ : localSpec
1515
+ const runtimeRoot = (pointer.runtimeRoot && existsSync(pointer.runtimeRoot))
1516
+ ? pointer.runtimeRoot
1517
+ : join(authoritySpecRoot, '.runtime')
1518
+ const execDir = join(runtimeRoot, 'execute-runs')
1519
+ if (!existsSync(execDir)) {
1520
+ return {
1521
+ action: 'skipped',
1522
+ reason: '未找到 execute-runs 目录',
1523
+ runtime_root: runtimeRoot,
1524
+ candidates: [],
1525
+ skipped: [],
1526
+ errors: [],
1527
+ count: 0,
1528
+ }
1529
+ }
1530
+
1531
+ const activeNames = listChanges(authoritySpecRoot)
1532
+ const archiveNames = listDirNames(join(authoritySpecRoot, 'changes', 'archive'))
1533
+ .filter((n) => n !== 'archive')
1534
+ const active = new Set(activeNames)
1535
+ const archive = new Set(archiveNames)
1536
+ const archiveTasks = new Map()
1537
+ for (const n of archiveNames) {
1538
+ archiveTasks.set(n, loadTasksMd(join(authoritySpecRoot, 'changes', 'archive', n)))
1539
+ }
1540
+ const activeTasks = new Map()
1541
+ for (const n of activeNames) {
1542
+ activeTasks.set(n, loadTasksMd(join(authoritySpecRoot, 'changes', n)))
1543
+ }
1544
+
1545
+ const candidates = []
1546
+ const skipped = []
1547
+ const errors = []
1548
+ let entries = []
1549
+ try {
1550
+ entries = readdirSync(execDir, { withFileTypes: true })
1551
+ } catch (e) {
1552
+ return {
1553
+ action: 'error',
1554
+ reason: e.message,
1555
+ runtime_root: runtimeRoot,
1556
+ candidates: [],
1557
+ skipped: [],
1558
+ errors: [{ error: e.message }],
1559
+ count: 0,
1560
+ }
1561
+ }
1562
+
1563
+ for (const entry of entries) {
1564
+ if (!entry.isDirectory()) continue
1565
+ const runId = entry.name
1566
+ const runDir = join(execDir, runId)
1567
+ let inspected
1568
+ try {
1569
+ inspected = inspectExecuteRun(runDir)
1570
+ } catch (e) {
1571
+ errors.push({ runId, error: e.message })
1572
+ continue
1573
+ }
1574
+ if (inspected.hasStamp) {
1575
+ skipped.push({ runId, reason: 'has_stamp' })
1576
+ continue
1577
+ }
1578
+ const verdict = classifyUnstampedRun(inspected, { active, archive, archiveTasks, activeTasks })
1579
+ if (verdict.skip) {
1580
+ skipped.push({ runId, reason: verdict.reason, owner: verdict.owner || null })
1581
+ continue
1582
+ }
1583
+ candidates.push({ runId, owner: verdict.owner, via: verdict.via })
1584
+ }
1585
+
1586
+ const removed = []
1587
+ if (confirm) {
1588
+ for (const c of candidates) {
1589
+ try {
1590
+ rmSync(join(execDir, c.runId), { recursive: true, force: true })
1591
+ removed.push(c.runId)
1592
+ } catch (e) {
1593
+ errors.push({ runId: c.runId, error: e.message })
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ return {
1599
+ action: confirm ? 'deleted' : 'dry_run',
1600
+ runtime_root: runtimeRoot,
1601
+ candidates,
1602
+ skipped,
1603
+ errors,
1604
+ removed: confirm ? removed : [],
1605
+ count: candidates.length,
1606
+ }
1607
+ }
@@ -14,6 +14,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { parseFileChangeListDetailed } from './change-list.js'
16
16
  import { safeGit, unquoteGitPath } from './git-helper.js'
17
+ import { parseAllowedPaths, parseTargetFiles } from './stages/plan-postcheck.js'
17
18
 
18
19
  /**
19
20
  * 主仓未提交文件集(staged + unstaged + untracked,rename 取新路径)。
@@ -138,8 +139,53 @@ export function collectForeignDeclaredFiles(cwd, currentChangeName, opts = {}) {
138
139
  return foreign
139
140
  }
140
141
 
142
+ /**
143
+ * 本变更(currentChangeName)的 own 声明集——own 优先判据(坑
144
+ * verify-reconcile-own-file-foreign-false-positive,2026-09-09 实证):
145
+ * quick 会话 = guard.allowedFiles;变更 = design §6 清单 ∪ task 卡 allowed_paths/target_files。
146
+ * 文件被本变更与他者**双方声明**时归 own:foreign 排除的职责是「他者的工作不混进我的
147
+ * 判定」,不是「我声明并真实修改的文件因他者(尤其未归档旧变更的陈旧 design 声明)也
148
+ * 声明过就被剔出我的对账」。读不出来(无 design/无 task 卡/quick guard 损坏)→ 空集
149
+ * (退回旧 foreign-first 行为,fail-closed 不放大放行面)。
150
+ * @returns {Set<string>} 正斜杠归一后的声明路径集
151
+ */
152
+ function loadOwnDeclaredSet(specBase, runtimeRoot, currentChangeName) {
153
+ const set = new Set()
154
+ if (!currentChangeName) return set
155
+ const norm = (p) => String(p || '').replace(/\\/g, '/')
156
+ const addAll = (paths) => { for (const p of paths) { const n = norm(p); if (n) set.add(n) } }
157
+ try {
158
+ if (/^quick-[0-9a-f]{8}$/.test(currentChangeName)) {
159
+ try {
160
+ const guard = JSON.parse(readFileSync(join(runtimeRoot, 'quick-sessions', currentChangeName, 'guard.json'), 'utf8'))
161
+ addAll(Array.isArray(guard?.allowedFiles) ? guard.allowedFiles : [])
162
+ } catch { /* guard 缺失/损坏 → 空集退回旧行为 */ }
163
+ return set
164
+ }
165
+ const changeDir = join(specBase, 'changes', currentChangeName)
166
+ try { addAll(parseFileChangeListDetailed(join(changeDir, 'design.md')).map(e => e.path)) } catch {}
167
+ let taskFiles = []
168
+ try { taskFiles = readdirSync(join(changeDir, 'tasks')).filter(f => /^task-\d+\.md$/.test(f)) } catch {}
169
+ for (const tf of taskFiles) {
170
+ try {
171
+ const content = readFileSync(join(changeDir, 'tasks', tf), 'utf8')
172
+ addAll(parseAllowedPaths(content))
173
+ addAll(parseTargetFiles(content).entries.map(e => e.path))
174
+ } catch { /* 单卡损坏跳过 */ }
175
+ }
176
+ } catch { /* 整体异常 → 空集 */ }
177
+ return set
178
+ }
179
+
141
180
  /**
142
181
  * 按他者声明集切分 diff 文件(collectForeignDeclaredFiles 的消费端快捷封装)。
182
+ *
183
+ * own 优先(2026-09-09 坑 verify-reconcile-own-file-foreign-false-positive 修复):文件在
184
+ * 本变更 own 声明集(design §6 / task allowed_paths / target_files / quick guard)内时
185
+ * 永不判 foreign——哪怕他者变更(含未归档旧变更的陈旧声明,其活性被本变更自己的 dirty
186
+ * 反向喂活)也声明过。双方都声明 = 归属歧义按「自己声明优先」收敛:本变更的 dirty 是
187
+ * 自己工作的一部分,剔除它只会制造「声明未做」假红。
188
+ *
143
189
  * @param {string} cwd 项目根
144
190
  * @param {string|null} currentChangeName 本变更名
145
191
  * @param {string[]|null} diffFiles 待切分文件列表(null 透传,保持调用方 null 语义)
@@ -148,12 +194,17 @@ export function collectForeignDeclaredFiles(cwd, currentChangeName, opts = {}) {
148
194
  */
149
195
  export function splitOwnVsForeignDiffFiles(cwd, currentChangeName, diffFiles, opts = {}) {
150
196
  if (!Array.isArray(diffFiles)) return { own: diffFiles, foreign: [] }
151
- const foreignMap = collectForeignDeclaredFiles(cwd, currentChangeName, opts)
197
+ const specBase = opts.specBase || join(cwd, '.sillyspec')
198
+ const runtimeRoot = opts.runtimeRoot || join(specBase, '.runtime')
199
+ const foreignMap = collectForeignDeclaredFiles(cwd, currentChangeName, { specBase, runtimeRoot })
152
200
  if (foreignMap.size === 0) return { own: diffFiles, foreign: [] }
201
+ const ownDeclared = loadOwnDeclaredSet(specBase, runtimeRoot, currentChangeName)
153
202
  const own = []
154
203
  const foreign = []
155
204
  for (const f of diffFiles) {
156
- const owners = foreignMap.get(String(f).replace(/\\/g, '/'))
205
+ const n = String(f).replace(/\\/g, '/')
206
+ if (ownDeclared.size > 0 && ownDeclared.has(n)) { own.push(f); continue } // own 优先
207
+ const owners = foreignMap.get(n)
157
208
  if (owners) foreign.push({ file: f, owners })
158
209
  else own.push(f)
159
210
  }