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.
- package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
- package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
- package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
- package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
- package/.claude/skills/sillyspec-state/SKILL.md +25 -25
- package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
- package/AGENTS.md +24 -0
- package/INSTRUCTIONS.md +12 -0
- package/package.json +1 -1
- package/src/archive-delta.js +82 -0
- package/src/change-delete.js +2 -1
- package/src/change-list.js +7 -2
- package/src/change-risk-profile.js +77 -6
- package/src/config-cat.js +12 -1
- package/src/constants.js +3 -1
- package/src/docs-check.js +34 -0
- package/src/doctor-diagnostics.js +441 -5
- package/src/foreign-declared.js +53 -2
- package/src/index.js +181 -6
- package/src/module-impact.js +77 -12
- package/src/plan-adopt-waves.js +13 -5
- package/src/progress/change-registry.js +16 -0
- package/src/progress.js +62 -1
- package/src/quick-recommend.js +6 -1
- package/src/quicklog.js +10 -2
- package/src/run/command.js +30 -9
- package/src/run/complete-handlers.js +152 -6
- package/src/run/complete.js +45 -0
- package/src/run/gates.js +94 -6
- package/src/run/prompt.js +96 -5
- package/src/run/shared.js +74 -6
- package/src/run/stage.js +35 -0
- package/src/runtime-hygiene.js +84 -0
- package/src/spec-sync.js +63 -8
- package/src/stage-contract.js +83 -3
- package/src/stage-review.js +3 -0
- package/src/stages/archive.js +4 -4
- package/src/stages/brainstorm.js +2 -8
- package/src/stages/doctor.js +41 -515
- package/src/stages/plan-postcheck.js +191 -7
- package/src/stages/plan.js +5 -26
- package/src/stages/quick.js +11 -11
- package/src/stages/verify.js +10 -6
- package/src/sync-noise.js +158 -0
- package/src/sync.js +42 -17
- package/src/task-review.js +25 -1
- package/src/verify-facts-schema.js +141 -0
- package/src/verify-postcheck.js +257 -19
- package/src/verify-probes.js +142 -13
- package/src/workflow.js +6 -0
- package/src/worktree-apply.js +51 -13
- 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
|
|
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
|
|
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
|
+
}
|
package/src/foreign-declared.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|