sillyspec 3.28.3 → 3.28.5
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 +25 -0
- package/INSTRUCTIONS.md +12 -0
- package/package.json +1 -1
- package/results-review-tier.md +18 -0
- package/src/agent-session-log.js +4 -3
- package/src/archive-delta.js +82 -0
- package/src/change-delete.js +2 -1
- package/src/change-list.js +18 -3
- package/src/change-risk-profile.js +77 -6
- package/src/commit-suggest.js +52 -1
- package/src/config-cat.js +17 -1
- package/src/constants.js +3 -1
- package/src/decision-distill.js +45 -5
- package/src/decisions-io.js +96 -0
- package/src/design-facts.js +3 -0
- package/src/dispatch/probe.js +31 -8
- package/src/docs-check.js +102 -3
- package/src/doctor-diagnostics.js +441 -5
- package/src/foreign-declared.js +53 -2
- package/src/index.js +606 -22
- package/src/mcp-server.js +144 -0
- package/src/module-impact.js +84 -12
- package/src/modules.js +46 -34
- package/src/plan-adopt-waves.js +13 -5
- package/src/progress/change-registry.js +34 -0
- package/src/progress.js +75 -2
- package/src/quick-recommend.js +6 -1
- package/src/quicklog.js +70 -8
- package/src/review-dispatch.js +377 -0
- package/src/run/archive-distill.js +48 -0
- package/src/run/command.js +48 -13
- package/src/run/complete-handlers.js +215 -9
- package/src/run/complete.js +184 -2
- package/src/run/gate-snapshot.js +88 -0
- package/src/run/gates.js +206 -58
- package/src/run/prompt.js +150 -14
- package/src/run/quick-audit.js +38 -13
- package/src/run/shared.js +183 -12
- package/src/run/stage.js +52 -2
- package/src/run/verify-quality-scan.js +336 -0
- package/src/runtime-hygiene.js +84 -0
- package/src/scope-audit.js +735 -0
- package/src/sillyhub-mcp/client.js +58 -3
- package/src/spec-sync.js +63 -8
- package/src/stage-contract.js +83 -3
- package/src/stage-review-checklist.js +44 -0
- package/src/stage-review.js +125 -4
- package/src/stages/archive.js +60 -167
- package/src/stages/brainstorm.js +7 -17
- package/src/stages/doctor.js +41 -515
- package/src/stages/execute.js +13 -10
- package/src/stages/plan-postcheck.js +191 -7
- package/src/stages/plan.js +7 -35
- package/src/stages/quick.js +11 -11
- package/src/stages/verify.js +24 -39
- package/src/sync-noise.js +158 -0
- package/src/sync.js +93 -27
- package/src/task-review.js +133 -17
- package/src/taskcard.js +46 -3
- package/src/validate-artifacts.js +163 -0
- package/src/verify-facts-schema.js +141 -0
- package/src/verify-postcheck.js +347 -29
- package/src/verify-probes.js +275 -24
- package/src/workflow.js +6 -0
- package/src/worktree-apply.js +167 -24
- package/src/worktree.js +5 -1
- package/templates/agents-instruction.md +1 -0
package/src/docs-check.js
CHANGED
|
@@ -36,8 +36,11 @@ import { parseModuleMapSimple } from './modules.js'
|
|
|
36
36
|
* 展开循环每次迭代必含括号段 → 划分唯一 → 线性。 */
|
|
37
37
|
const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:\([A-Za-z0-9_.\-\/]+\)[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go)):(\d+)(?:-(\d+))?/g
|
|
38
38
|
|
|
39
|
-
/** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八)
|
|
40
|
-
|
|
39
|
+
/** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八)
|
|
40
|
+
* + .sillyspec/changes/ + .sillyspec/knowledge/(P1-1,noai-ir-roadmap §4:最活跃、最易产生
|
|
41
|
+
* 漂移引用的变更目录与决策库此前零校验——纳入后 verify/archive 收尾的 autoReanchorDocRefs
|
|
42
|
+
* 同时覆盖变更文档(archive/ 路径段与 doc_type: snapshot 双通道豁免不变,归档件不回检)。 */
|
|
43
|
+
export const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md', '.sillyspec/changes/**/*.md', '.sillyspec/knowledge/**/*.md']
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
|
|
@@ -73,6 +76,32 @@ export function readDocsCheckConfig(projectRoot) {
|
|
|
73
76
|
/** 配置错误(glob 形态不支持等)→ CLI exit 2 */
|
|
74
77
|
export class DocsCheckConfigError extends Error {}
|
|
75
78
|
|
|
79
|
+
/**
|
|
80
|
+
* 符号锚正则(P1-2,noai-ir-roadmap §4):`file.js::symbol` 形态——校验时解析符号定义行,
|
|
81
|
+
* 天然免疫行号漂移(重锚从「每次代码移动」降为「仅改名时」)。文件段与 REF_RE 同款(含
|
|
82
|
+
* repo:// 跨仓前缀与括号路径组展开),`::` 后只认 ASCII 标识符(自然语言/中文括注不触发,
|
|
83
|
+
* QUICKLOG file-notes 的 `path::注` 约定因注解非标识符而天然互斥)。与 REF_RE 互斥:
|
|
84
|
+
* `::ident` 无数字不匹配行号式;`file.js:12` 无 `::` 不匹配符号式。
|
|
85
|
+
*/
|
|
86
|
+
const SYMBOL_REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:\([A-Za-z0-9_.\-\/]+\)[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go))::([A-Za-z_$][A-Za-z0-9_$]*)/g
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 符号定义行定位(与 classifyFix 的 defRe 同款形态,单一判定口径):function/const/let/
|
|
90
|
+
* class/def(含 export/async 前缀)后的精确符号名。命中返回 1-based 行号;文件内仅
|
|
91
|
+
* 「使用」未「定义」(import 消费/注释提及)不算命中——符号锚指向定义文件。
|
|
92
|
+
* @param {string[]} lines 源文件行数组
|
|
93
|
+
* @param {string} symbol 符号名(须为合法标识符)
|
|
94
|
+
* @returns {number|null}
|
|
95
|
+
*/
|
|
96
|
+
export function findSymbolDefLine(lines, symbol) {
|
|
97
|
+
if (!Array.isArray(lines) || !symbol || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(symbol)) return null
|
|
98
|
+
const defRe = new RegExp(`(?:export\\s+)?(?:async\\s+)?(?:function\\*?\\s+|class\\s+|(?:const|let|var)\\s+|def\\s+)${escapeReLocal(symbol)}\\b`)
|
|
99
|
+
for (let i = 0; i < lines.length; i++) {
|
|
100
|
+
if (defRe.test(lines[i])) return i + 1
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
/**
|
|
77
106
|
* 纯函数:从 markdown 全文提取全部 file:line 引用。
|
|
78
107
|
* @param {string} md 文档全文
|
|
@@ -101,6 +130,22 @@ export function collectDocRefs(md) {
|
|
|
101
130
|
docLine: line,
|
|
102
131
|
})
|
|
103
132
|
}
|
|
133
|
+
// 符号锚引用(P1-2):独立正则二遍扫描(与行号式互斥,无重叠去重需求)。
|
|
134
|
+
// 符号式引用量级远小于行号式,docLine 逐个 O(index) 计算不构成退化面。
|
|
135
|
+
const symRe = new RegExp(SYMBOL_REF_RE.source, 'g')
|
|
136
|
+
let sm
|
|
137
|
+
while ((sm = symRe.exec(md)) !== null) {
|
|
138
|
+
refs.push({
|
|
139
|
+
ref: sm[0],
|
|
140
|
+
repo: sm[1] !== undefined ? sm[1] : null,
|
|
141
|
+
file: sm[2],
|
|
142
|
+
symbol: sm[3],
|
|
143
|
+
kind: 'symbol',
|
|
144
|
+
start: null,
|
|
145
|
+
end: null,
|
|
146
|
+
docLine: md.slice(0, sm.index).split('\n').length,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
104
149
|
return refs
|
|
105
150
|
}
|
|
106
151
|
|
|
@@ -452,6 +497,8 @@ export function runDocsCheck(opts) {
|
|
|
452
497
|
let skippedExempt = 0
|
|
453
498
|
// 模糊路径引用数(含 `...` 省略号,跳过校验——FR-1.2)
|
|
454
499
|
let skippedFuzzy = 0
|
|
500
|
+
// 符号锚(P1-2)命中数(计入 total,单独计数供可见性)
|
|
501
|
+
let symbolChecked = 0
|
|
455
502
|
// per-call 缓存:裸名引用的 src/ 全树扫描结果 + 候选文件行数组——100 文档 × 10 裸名引用
|
|
456
503
|
// 此前 = 1000 次全树 walk + 同文件 N 次重读(2026-08-21 性能审查 PERF-5)。仅本次调用
|
|
457
504
|
// 生命周期内有效,不跨调用(防测试流程中途改树后读到陈旧结果)
|
|
@@ -522,6 +569,24 @@ export function runDocsCheck(opts) {
|
|
|
522
569
|
})
|
|
523
570
|
continue
|
|
524
571
|
}
|
|
572
|
+
// 符号锚(P1-2):按「候选文件内存在符号定义行」判定——不校验行号(校验时解析,
|
|
573
|
+
// 行漂移天然免疫),层 2 关键词断言由符号本身命中自动满足。失效只可能是符号拼错/
|
|
574
|
+
// 文件归属错/符号被删改名——均非重锚能修,fix 显式不可自动。
|
|
575
|
+
if (r.kind === 'symbol') {
|
|
576
|
+
let hit = false
|
|
577
|
+
for (const candAbs of candidates) {
|
|
578
|
+
const lines = readLines(candAbs, linesCache)
|
|
579
|
+
if (lines === null) continue
|
|
580
|
+
if (findSymbolDefLine(lines, r.symbol) !== null) { hit = true; break }
|
|
581
|
+
}
|
|
582
|
+
if (hit) { symbolChecked++; continue }
|
|
583
|
+
invalid.push({
|
|
584
|
+
doc: docRel, docLine: r.docLine, ref: r.ref,
|
|
585
|
+
reason: `符号锚未命中:候选文件内无 \`${r.symbol}\` 的定义行(function/const/let/var/class/def 形态)——符号锚不随行号漂移,失效即符号拼错/归属错/已删改名`,
|
|
586
|
+
fix: { fixable: false, reason: '符号锚失效非重锚可修:核对符号拼写或文件归属(改名请同步文档符号)' },
|
|
587
|
+
})
|
|
588
|
+
continue
|
|
589
|
+
}
|
|
525
590
|
// 多候选宽容:逐候选跑层1+层2,任一全过即通过
|
|
526
591
|
const tokens = keywordAssert
|
|
527
592
|
? extractExpectedTokensFromLine(mdLines[r.docLine - 1] || '')
|
|
@@ -558,7 +623,7 @@ export function runDocsCheck(opts) {
|
|
|
558
623
|
}
|
|
559
624
|
}
|
|
560
625
|
|
|
561
|
-
return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped, skippedExempt, skippedFuzzy }
|
|
626
|
+
return { ok: invalid.length === 0, total, invalid, warnings, kwChecked, crossRepoSkipped, skippedExempt, skippedFuzzy, symbolChecked }
|
|
562
627
|
}
|
|
563
628
|
|
|
564
629
|
/**
|
|
@@ -1122,3 +1187,37 @@ export function runChangeNameAdvisory(opts) {
|
|
|
1122
1187
|
findings.sort((a, b) => a.doc.localeCompare(b.doc) || a.docLine - b.docLine || a.name.localeCompare(b.name))
|
|
1123
1188
|
return { empty: scanned === 0, scannedDocs: scanned, mentions, uniqueNames: seenNames.size, findings, exempted }
|
|
1124
1189
|
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* 行号漂移自动重锚(quick --done 接线面,2026-09-08 用户反馈②):活文档 file:line 硬编码
|
|
1193
|
+
* 随任何源码插入失效,每回手跑 `docs check --fix` 是机械活。本函数 = --fix 主链路的编程化
|
|
1194
|
+
* 封装:对给定文档集跑一次失效收集 → 取分类器判定确定性的条目(fixable 唯一/优选命中,
|
|
1195
|
+
* 与 index.js --fix 同源构造 fixes)→ applyFixes 写回 → 同口径复跑得回执。
|
|
1196
|
+
*
|
|
1197
|
+
* 边界(与 --fix 的既定红线一致):歧义/零命中条目不动(无 --force 逃生口);仅改写
|
|
1198
|
+
* fixable 条目的行号段,文件名与 token 原样。任何异常上抛由调用方 fail-open。
|
|
1199
|
+
*
|
|
1200
|
+
* @param {string} projectRoot 源码仓根
|
|
1201
|
+
* @param {string[]} docs 本次改动文档的相对路径列表(audit 口径:changedFiles 中的 .md)
|
|
1202
|
+
* @param {{ crossRepoRoots?: string[] }} [cfg] docs-check 配置(readDocsCheckConfig 产物)
|
|
1203
|
+
* @returns {{ applied: number, invalidBefore: number, invalidAfter: number, remaining: number }}
|
|
1204
|
+
* applied=0 时 invalidBefore/After 同值(无可自动修条目,调用方维持纯 advisory)
|
|
1205
|
+
*/
|
|
1206
|
+
export function autoReanchorDocRefs(projectRoot, docs, cfg = {}) {
|
|
1207
|
+
// 与 index.js --fix 完全同口径(skip/keywordAssert 透传;undefined 时 runDocsCheck 解构
|
|
1208
|
+
// 默认值兜底:paths 缺省 glob、keywordAssert=true、exempt=true)
|
|
1209
|
+
const scope = { projectRoot, docs, skip: cfg.skip, keywordAssert: cfg.keywordAssert, crossRepoRoots: cfg.crossRepoRoots };
|
|
1210
|
+
const before = runDocsCheck(scope);
|
|
1211
|
+
const fixes = [];
|
|
1212
|
+
for (const inv of before.invalid) {
|
|
1213
|
+
if (inv.fix && inv.fix.fixable === true && Number.isInteger(inv.fix.newLine)) {
|
|
1214
|
+
fixes.push({ doc: inv.doc, docLine: inv.docLine, ref: inv.ref, newRef: inv.ref.replace(/(\d+)(?:-\d+)?$/, String(inv.fix.newLine)) });
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (fixes.length === 0) {
|
|
1218
|
+
return { applied: 0, invalidBefore: before.invalid.length, invalidAfter: before.invalid.length, remaining: before.invalid.length };
|
|
1219
|
+
}
|
|
1220
|
+
const applied = applyFixes(projectRoot, fixes);
|
|
1221
|
+
const after = runDocsCheck(scope);
|
|
1222
|
+
return { applied: applied.applied, invalidBefore: before.invalid.length, invalidAfter: after.invalid.length, remaining: after.invalid.length };
|
|
1223
|
+
}
|
|
@@ -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
|
+
}
|