sillyspec 3.28.7 → 3.28.9
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-quick/SKILL.md +2 -0
- package/.husky/pre-push +3 -1
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/change-risk-profile.js +20 -0
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +32 -0
- package/src/constants.js +3 -0
- package/src/db.js +23 -6
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +108 -6
- package/src/doctor-diagnostics.js +243 -9
- package/src/friction-ledger.js +171 -0
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +72 -30
- package/src/index.js +356 -81
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +224 -43
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/shared.js +3 -2
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +123 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +199 -32
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +124 -15
- package/src/run/complete-handlers.js +514 -24
- package/src/run/complete.js +82 -25
- package/src/run/concurrent-detect.js +99 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +85 -14
- package/src/run/prompt.js +143 -6
- package/src/run/quick-audit.js +136 -15
- package/src/run/shared.js +174 -20
- package/src/run/stage.js +6 -0
- package/src/run/verify-quality-scan.js +14 -3
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +365 -35
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +29 -12
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +91 -4
- package/src/stages/knowledge.js +52 -3
- package/src/stages/plan-postcheck.js +57 -12
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +114 -6
- package/src/sync.js +201 -11
- package/src/task-review.js +333 -27
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +41 -6
- package/src/verify-probes.js +348 -7
- package/src/worktree-apply.js +594 -62
- package/src/worktree-cross.js +12 -18
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +255 -30
- package/src/wt-commit.js +128 -0
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/sa-rename-probe/src/new.js +0 -4
package/src/local-register.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* (tmp+rename,防多会话并发写半截,与 local detect 写入口径一致)。
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync, readFileSync, mkdirSync } from 'fs'
|
|
19
|
-
import { dirname } from 'path'
|
|
19
|
+
import { dirname, join } from 'path'
|
|
20
20
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
21
|
+
import { withFileLock } from './quicklog.js'
|
|
21
22
|
|
|
22
23
|
function escapeRe(s) {
|
|
23
24
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
@@ -26,13 +27,18 @@ function escapeRe(s) {
|
|
|
26
27
|
/**
|
|
27
28
|
* 把 `<key>: <path>` 注册进 local.yaml 的 repos: 段(幂等,已有该 key 则改值)。
|
|
28
29
|
*
|
|
30
|
+
* 读-改-写整段持 .local.yaml.lock(2026-09-12 审查批 C-②):writeAtomicSync 只保证单次
|
|
31
|
+
* 写不撕裂,不串行化 RMW——多 agent 并行 register-repo(不同 key)或与 local detect /
|
|
32
|
+
* platform connect 的写入口并发时,后写者整体覆盖前写者、先注册条目静默丢失
|
|
33
|
+
* (quicklog 侧同场景用 withFileLock,此处对齐)。async 化:调用方须 await。
|
|
34
|
+
*
|
|
29
35
|
* @param {string} yamlPath - local.yaml 绝对路径(不存在则新建)
|
|
30
36
|
* @param {string} key - repo key(限 [A-Za-z0-9_.\-]+,'main' 隐式禁止注册)
|
|
31
37
|
* @param {string} repoPath - 跨仓仓根绝对路径(写入时统一正斜杠,与示例格式一致)
|
|
32
|
-
* @returns {{ fileCreated: boolean, sectionCreated: boolean, replaced: boolean }}
|
|
38
|
+
* @returns {Promise<{ fileCreated: boolean, sectionCreated: boolean, replaced: boolean }>}
|
|
33
39
|
* @throws {Error} key 非法 / key='main' / repoPath 空 / 读写失败
|
|
34
40
|
*/
|
|
35
|
-
export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
|
|
41
|
+
export async function registerRepoInLocalYaml(yamlPath, key, repoPath) {
|
|
36
42
|
if (!yamlPath) throw new Error('registerRepoInLocalYaml: yamlPath 不能为空')
|
|
37
43
|
if (!key || !/^[A-Za-z0-9_.\-]+$/.test(key)) {
|
|
38
44
|
throw new Error(`repo key 非法: "${key}"(限字母数字 . _ -,与 parseRepoRegistry 读侧口径一致)`)
|
|
@@ -42,7 +48,9 @@ export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
|
|
|
42
48
|
}
|
|
43
49
|
if (!repoPath) throw new Error('registerRepoInLocalYaml: repoPath 不能为空')
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
// 锁文件与 yaml 同目录(与 QUICKLOG/tasks 锁同机制;锁内读-判定-写,后写者不再覆盖前写者)
|
|
52
|
+
return await withFileLock(join(dirname(yamlPath), '.local.yaml.lock'), async () => {
|
|
53
|
+
const existed = existsSync(yamlPath)
|
|
46
54
|
// CRLF 归一后按 LF 写回(同 task 卡锚点写入的 CRLF 坑;local.yaml 各 CLI 写入口径均 LF)
|
|
47
55
|
const raw0 = existed ? readFileSync(yamlPath, 'utf8') : ''
|
|
48
56
|
const hadCr = raw0.includes('\r') // 磁盘原文带 CRLF → 幂等路径也要落盘治愈(见下方幂等分支)
|
|
@@ -101,4 +109,5 @@ export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
|
|
|
101
109
|
lines.splice(insertAt, 0, entryLine)
|
|
102
110
|
write(lines.join('\n'))
|
|
103
111
|
return { fileCreated: false, sectionCreated: false, replaced: false }
|
|
112
|
+
})
|
|
104
113
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -64,8 +64,6 @@ const TOOLS = [
|
|
|
64
64
|
|
|
65
65
|
/** tools/call 路由:子进程 --json(stdout 纪律隔离;CLI 的 stderr 透传进 text 供诊断) */
|
|
66
66
|
async function callTool(name, args) {
|
|
67
|
-
const argv = ['--meta'] // 占位防误配:--meta 是 run 的 flag,工具不走 run
|
|
68
|
-
argv.length = 0
|
|
69
67
|
const specDir = args && typeof args.spec_dir === 'string' && args.spec_dir ? ['--spec-dir', args.spec_dir] : []
|
|
70
68
|
switch (name) {
|
|
71
69
|
case 'sillyspec_next':
|
|
@@ -81,6 +79,14 @@ async function callTool(name, args) {
|
|
|
81
79
|
}
|
|
82
80
|
}
|
|
83
81
|
|
|
82
|
+
// 子进程挂死兜底(2026-09-12 审查批 A-②):gate/derive 会真实跑测试与 git 全家,测试进程
|
|
83
|
+
// 不退/git 等锁时 tools/call 若无超时则永久挂起(stdio MCP 无请求级超时,host 侧表现为工具
|
|
84
|
+
// 卡死)。默认 300s(全量测试套件 ~90s 的余量),env 可调;输出封顶防 stderr 刷屏型无界累积。
|
|
85
|
+
const CALL_TIMEOUT_MS = Number(process.env.SILLYSPEC_MCP_CALL_TIMEOUT_MS) > 0
|
|
86
|
+
? Number(process.env.SILLYSPEC_MCP_CALL_TIMEOUT_MS)
|
|
87
|
+
: 300_000
|
|
88
|
+
const OUTPUT_CAP_BYTES = 8 * 1024 * 1024
|
|
89
|
+
|
|
84
90
|
function runCli(cliArgs) {
|
|
85
91
|
return new Promise((resolve) => {
|
|
86
92
|
const p = spawn(process.execPath, [CLI_ENTRY, ...cliArgs], {
|
|
@@ -90,14 +96,38 @@ function runCli(cliArgs) {
|
|
|
90
96
|
})
|
|
91
97
|
let out = ''
|
|
92
98
|
let err = ''
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
let outTrunc = false
|
|
100
|
+
let errTrunc = false
|
|
101
|
+
let settled = false
|
|
102
|
+
const finish = (r) => {
|
|
103
|
+
if (settled) return
|
|
104
|
+
settled = true
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
resolve(r)
|
|
107
|
+
}
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
try { p.kill() } catch { /* 已退出 */ }
|
|
110
|
+
finish({ text: `CLI 子进程超时(${CALL_TIMEOUT_MS}ms)被终止: sillyspec ${cliArgs.join(' ')}(挂死兜底;可用 SILLYSPEC_MCP_CALL_TIMEOUT_MS 调整)`, isError: true, exitCode: null })
|
|
111
|
+
}, CALL_TIMEOUT_MS)
|
|
112
|
+
p.stdout.on('data', (d) => {
|
|
113
|
+
if (out.length < OUTPUT_CAP_BYTES) out += String(d)
|
|
114
|
+
else outTrunc = true
|
|
115
|
+
})
|
|
116
|
+
p.stderr.on('data', (d) => {
|
|
117
|
+
if (err.length < OUTPUT_CAP_BYTES) err += String(d)
|
|
118
|
+
else errTrunc = true
|
|
119
|
+
})
|
|
95
120
|
p.on('close', (code) => {
|
|
96
|
-
// envelope 原样透传(text = stdout JSON 全文;machine 侧自解);stderr
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
// envelope 原样透传(text = stdout JSON 全文;machine 侧自解);stderr 仅失败时附诊断。
|
|
122
|
+
// isError 口径(批 E-④):exit 0=正常;1=BLOCKED(合法裁决结果);2=EXIT_UNKNOWN
|
|
123
|
+
// (envelope 合法、含诊断信息)——三者都是机器可解析的合法结果,不标 isError;
|
|
124
|
+
// 部分 MCP host 把 isError 当「工具执行失败」处理而不向模型展示 text,exit 2 的
|
|
125
|
+
// 诊断信息会被吞。真失败(spawn error/超时)由 error/timeout 路径标 isError。
|
|
126
|
+
const truncNote = outTrunc || errTrunc ? `\n(输出超 ${OUTPUT_CAP_BYTES} 字节封顶截断)` : ''
|
|
127
|
+
const text = (out.trim() || err.trim().slice(-2000) || `(exit ${code},无输出)`) + truncNote
|
|
128
|
+
finish({ text, isError: code !== null && code !== 0 && code !== 1 && code !== 2, exitCode: code })
|
|
99
129
|
})
|
|
100
|
-
p.on('error', (e) =>
|
|
130
|
+
p.on('error', (e) => finish({ text: `CLI 子进程失败: ${e.message}`, isError: true }))
|
|
101
131
|
})
|
|
102
132
|
}
|
|
103
133
|
|
|
@@ -1,7 +1,45 @@
|
|
|
1
1
|
// W6 Step9b: 变更注册表(changes 表生命周期)—— 注册/注销/重命名/隔离状态/平台同步戳/审批状态。
|
|
2
2
|
// 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm._ensureDB /
|
|
3
3
|
// pm._changePath(persistence-core 留 facade 本体)。无共享常量依赖。
|
|
4
|
-
import { existsSync, mkdirSync, renameSync } from 'fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
|
|
7
|
+
// 所有权活跃心跳窗缺省(2026-09-14-change-ownership-guards task-02 / FR-01):owner 非本会话
|
|
8
|
+
// 且 now-last_active < 窗口 → 拒绝接管;窗口外 → 自动接管。local.yaml change-ownership.
|
|
9
|
+
// heartbeat_minutes 可调(resolveHeartbeatMs),缺省 15 分钟(config-schema 已登记 live 读者=本文件)。
|
|
10
|
+
const DEFAULT_HEARTBEAT_MINUTES = 15;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 读 local.yaml 的 change-ownership.heartbeat_minutes → 毫秒窗(2026-09-14-change-ownership-guards
|
|
14
|
+
* task-02)。路径口径与 sync.js readLocalYaml / worktree-cross.js 一致:cwd/.sillyspec/local.yaml。
|
|
15
|
+
* 定向行扫描(只找 change-ownership: 顶层段下的 heartbeat_minutes),不引 YAML 解析依赖、
|
|
16
|
+
* 字节级容错(CRLF 去 \r、注释行天然不匹配)。缺文件/缺段/缺键/值非正整数(含 0——误配把
|
|
17
|
+
* 活跃窗清零会让护栏形同虚设)→ 缺省 15 分钟。纯读不抛。模块私有(唯一消费者=
|
|
18
|
+
* assertChangeOwnership 心跳窗缺省解析;config-schema 登记的读者即本文件)。
|
|
19
|
+
* @param {string} cwd - 项目根目录
|
|
20
|
+
* @returns {number} 心跳窗口毫秒
|
|
21
|
+
*/
|
|
22
|
+
function resolveHeartbeatMs(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
const p = join(cwd || process.cwd(), '.sillyspec', 'local.yaml');
|
|
25
|
+
if (!existsSync(p)) return DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
|
|
26
|
+
let inSection = false;
|
|
27
|
+
for (const rawLine of readFileSync(p, 'utf8').split('\n')) {
|
|
28
|
+
const line = rawLine.replace(/\r$/, ''); // CRLF 兼容(CLAUDE.md #13)
|
|
29
|
+
if (/^\S/.test(line)) { // 顶层 key 行:进/出 change-ownership 段
|
|
30
|
+
inSection = line.startsWith('change-ownership:');
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!inSection) continue;
|
|
34
|
+
const m = line.match(/^\s*heartbeat_minutes\s*:\s*(\d+)\s*$/);
|
|
35
|
+
if (m) {
|
|
36
|
+
const minutes = parseInt(m[1], 10);
|
|
37
|
+
return (Number.isFinite(minutes) && minutes >= 1) ? minutes * 60 * 1000 : DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch { /* 读失败 → 缺省(缺省是安全下界,不是语义分支) */ }
|
|
41
|
+
return DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
|
|
42
|
+
}
|
|
5
43
|
|
|
6
44
|
export class ChangeRegistry {
|
|
7
45
|
constructor(pm) {
|
|
@@ -76,15 +114,21 @@ export class ChangeRegistry {
|
|
|
76
114
|
const sqlDb = db.getDb();
|
|
77
115
|
const ch = sqlDb.prepare(`SELECT id FROM changes WHERE name = ?`).get(changeName);
|
|
78
116
|
if (!ch) return null;
|
|
79
|
-
const
|
|
80
|
-
`SELECT
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
const rows = sqlDb.prepare(
|
|
118
|
+
`SELECT completed_at AS ts FROM stages WHERE change_id = ? AND completed_at IS NOT NULL
|
|
119
|
+
UNION ALL
|
|
120
|
+
SELECT st.completed_at AS ts FROM steps st
|
|
121
|
+
JOIN stages s ON st.stage_id = s.id WHERE s.change_id = ? AND st.completed_at IS NOT NULL`
|
|
122
|
+
).all(ch.id, ch.id);
|
|
123
|
+
// JS 侧按解析后的时间取最新(归一 ISO 返回):SQL 字符串 MAX 在 zh-CN('/')与 ISO('-')
|
|
124
|
+
// 混存时恒取 zh-CN('/'0x2F>'-'0x2D)——存量 zh-CN 陈旧值会盖过新 ISO 致时近性闸取错
|
|
125
|
+
// 时间戳(活跃变更被误归档方向,2026-09-11 审查)。不可解析串跳过。
|
|
126
|
+
let latestMs = -Infinity;
|
|
127
|
+
for (const r of rows) {
|
|
128
|
+
const ms = new Date(r.ts).getTime();
|
|
129
|
+
if (Number.isFinite(ms) && ms > latestMs) latestMs = ms;
|
|
130
|
+
}
|
|
131
|
+
return latestMs === -Infinity ? null : new Date(latestMs).toISOString();
|
|
88
132
|
} catch { return null; }
|
|
89
133
|
}
|
|
90
134
|
|
|
@@ -132,8 +176,13 @@ export class ChangeRegistry {
|
|
|
132
176
|
/**
|
|
133
177
|
* 注册变更到活跃列表
|
|
134
178
|
* SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
|
|
179
|
+
* @param {string} cwd
|
|
180
|
+
* @param {string} changeName
|
|
181
|
+
* @param {{ ownerSession?: string|null }} [opts] - owner_session(2026-09-14-change-ownership-guards
|
|
182
|
+
* task-01:首建者获得所有权;INSERT OR IGNORE 语义天然「已有行不覆盖」——已存在行(含他人
|
|
183
|
+
* 活跃 change)owner 不动。会话标识三级方案的接线在 task-02,缺省 undefined=不写(列默认 NULL=无主))
|
|
135
184
|
*/
|
|
136
|
-
registerChange(cwd, changeName) {
|
|
185
|
+
registerChange(cwd, changeName, opts = {}) {
|
|
137
186
|
if (!changeName) {
|
|
138
187
|
console.warn('⚠️ registerChange: changeName 为空,跳过');
|
|
139
188
|
return;
|
|
@@ -142,11 +191,11 @@ export class ChangeRegistry {
|
|
|
142
191
|
db.transaction(() => {
|
|
143
192
|
const sqlDb = db.getDb();
|
|
144
193
|
const now = new Date().toISOString();
|
|
145
|
-
//
|
|
194
|
+
// 尝试插入新行(带 owner_session——首次创建者获得所有权,已有值不覆盖)
|
|
146
195
|
const ins = sqlDb.prepare(
|
|
147
|
-
`INSERT OR IGNORE INTO changes (name, created_at, last_active)
|
|
148
|
-
VALUES (?, ?, ?)`
|
|
149
|
-
).run(changeName, now, now);
|
|
196
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
|
|
197
|
+
VALUES (?, ?, ?, ?)`
|
|
198
|
+
).run(changeName, now, now, opts.ownerSession || null);
|
|
150
199
|
// 只在真正创建 change 时标脏(D-013/task-04);已存在行不标(读路径/重复调用不误判脏)
|
|
151
200
|
if (ins.changes > 0) this.pm._touchLocalModified(cwd, changeName, now);
|
|
152
201
|
// 注意:不复活已归档的变更——归档是不可逆操作
|
|
@@ -154,6 +203,131 @@ export class ChangeRegistry {
|
|
|
154
203
|
});
|
|
155
204
|
}
|
|
156
205
|
|
|
206
|
+
/**
|
|
207
|
+
* 读变更所有者会话标识(2026-09-14-change-ownership-guards task-01 数据载体,D-001/D-005@v1)。
|
|
208
|
+
* 只读不抛(与 getQuicklogId 同族容错):行缺失/未登记/读取失败 → null。
|
|
209
|
+
* NULL=无主(存量行 v6 迁移后即此态,任何会话可接管——判定/接管语义在 assertChangeOwnership,task-02)。
|
|
210
|
+
* @param {string} cwd
|
|
211
|
+
* @param {string} changeName
|
|
212
|
+
* @returns {string|null} owner 会话标识,无主/无行/读失败返回 null
|
|
213
|
+
*/
|
|
214
|
+
getChangeOwner(cwd, changeName) {
|
|
215
|
+
if (!changeName) return null;
|
|
216
|
+
try {
|
|
217
|
+
const db = this.pm._ensureDB(cwd);
|
|
218
|
+
const row = db.getDb().prepare('SELECT owner_session FROM changes WHERE name = ?').get(changeName);
|
|
219
|
+
return (row && typeof row.owner_session === 'string' && row.owner_session) || null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 认领变更所有权(2026-09-14-change-ownership-guards task-01 数据载体):
|
|
227
|
+
* - 行不存在 → 创建并写 owner=session(首建者获得);
|
|
228
|
+
* - 行存在且 owner 非空 → 不覆盖,返回既有 owner(调用方对比可知认领未成功);
|
|
229
|
+
* - 行存在且 owner 为 NULL(存量无主行)→ 认领写入。
|
|
230
|
+
* 单事务内「INSERT OR IGNORE + 守卫 UPDATE + 读回」,并发两个会话同时首认领时靠
|
|
231
|
+
* SQLite 单写者串行化——后者命中守卫(owner 已非 NULL)不覆盖,返回先到者。
|
|
232
|
+
* 实际变更(首建/认领)标本地脏度(owner 随同步 payload 上行,D-013 同族)。
|
|
233
|
+
* @param {string} cwd
|
|
234
|
+
* @param {string} changeName
|
|
235
|
+
* @param {string|null} session - 本会话标识(空值视为无主认领,只建行不写 owner)
|
|
236
|
+
* @returns {string|null} 实际 owner(首建/认领成功=session;他人已持有=其值;行刚建未写=null)
|
|
237
|
+
*/
|
|
238
|
+
claimChangeOwner(cwd, changeName, session) {
|
|
239
|
+
if (!changeName) return null;
|
|
240
|
+
const db = this.pm._ensureDB(cwd);
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
return db.transaction(() => {
|
|
243
|
+
const sqlDb = db.getDb();
|
|
244
|
+
// 1) 首建:INSERT OR IGNORE 写 own(行已存在则零行、既有 owner 不动)
|
|
245
|
+
const ins = sqlDb.prepare(
|
|
246
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
|
|
247
|
+
VALUES (?, ?, ?, ?)`
|
|
248
|
+
).run(changeName, now, now, session || null);
|
|
249
|
+
// 2) 存量无主行(owner NULL/空串)补认领;WHERE 守卫保证已有值(他人)绝不覆盖
|
|
250
|
+
let claimed = 0;
|
|
251
|
+
if (session) {
|
|
252
|
+
claimed = sqlDb.prepare(
|
|
253
|
+
`UPDATE changes SET owner_session = ? WHERE name = ? AND (owner_session IS NULL OR owner_session = '')`
|
|
254
|
+
).run(session, changeName).changes;
|
|
255
|
+
}
|
|
256
|
+
// 3) 实际变更(首建/认领命中)才标脏;纯读回(他人持有)不扰动同步脏度
|
|
257
|
+
if (ins.changes > 0 || claimed > 0) this.pm._touchLocalModified(cwd, changeName, now);
|
|
258
|
+
// 4) 读回实际 owner
|
|
259
|
+
const row = sqlDb.prepare('SELECT owner_session FROM changes WHERE name = ?').get(changeName);
|
|
260
|
+
return (row && row.owner_session) || null;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 所有权判定(2026-09-14-change-ownership-guards task-02,FR-01 / D-001@v1):
|
|
266
|
+
* **读 changes 行不写库**——接管写库归调用方(setChangeOwner / claimChangeOwner),
|
|
267
|
+
* 判定与写分离让接线点可在锁内先判后写(判定-执行无 TOCTOU 由调用方锁保证)。
|
|
268
|
+
* 分支(self 判定 = 精确字符串等值):
|
|
269
|
+
* - opts.forced(--takeover) → takeover-forced 放行(无条件接管,调用方重写 owner 留痕)
|
|
270
|
+
* - owner 为空(无行/NULL) → no-owner 放行(存量无主行,任何会话可接管)
|
|
271
|
+
* - owner === selfSession → self 放行(本会话自有 change 零路径变化)
|
|
272
|
+
* - owner 他人 且 now-last_active ≥ 窗口 → takeover-stale 放行(窗口外自动接管重写)
|
|
273
|
+
* - owner 他人 且活跃窗内 → blocked-active-owner 拒绝(allowed:false,调用方打结构化错误)
|
|
274
|
+
* last_active 缺失/不可解析按陈旧处理:无法证明新鲜即不拦——护栏拦「可证活跃」,不因时间戳
|
|
275
|
+
* 损坏把 change 锁死到无人能接管(逃生通道优先,与 getLatestActivityAt 族 fail-open 同哲学)。
|
|
276
|
+
* 心跳即 last_active:每次 CLI 写操作既有刷新(updateChangeIsolation/registerChange 等族)。
|
|
277
|
+
* @param {string} cwd
|
|
278
|
+
* @param {string} changeName
|
|
279
|
+
* @param {{ selfSession?: string|null, nowMs?: number, heartbeatMs?: number, forced?: boolean }} [opts]
|
|
280
|
+
* @returns {{allowed: boolean, action: 'self'|'takeover-stale'|'takeover-forced'|'no-owner'|'blocked-active-owner', owner: string|null, lastActive: string|null, heartbeatMs: number}}
|
|
281
|
+
*/
|
|
282
|
+
assertChangeOwnership(cwd, changeName, opts = {}) {
|
|
283
|
+
const heartbeatMs = (Number.isFinite(opts.heartbeatMs) && opts.heartbeatMs > 0)
|
|
284
|
+
? opts.heartbeatMs
|
|
285
|
+
: resolveHeartbeatMs(cwd);
|
|
286
|
+
const deny = (action, owner, lastActive) => ({ allowed: false, action, owner, lastActive, heartbeatMs });
|
|
287
|
+
if (!changeName) return { allowed: true, action: 'no-owner', owner: null, lastActive: null, heartbeatMs };
|
|
288
|
+
const row = this.pm._ensureDB(cwd).getDb()
|
|
289
|
+
.prepare('SELECT owner_session, last_active FROM changes WHERE name = ?').get(changeName);
|
|
290
|
+
const owner = (row && typeof row.owner_session === 'string' && row.owner_session) || null;
|
|
291
|
+
const lastActive = (row && typeof row.last_active === 'string' && row.last_active) || null;
|
|
292
|
+
if (opts.forced) return { allowed: true, action: 'takeover-forced', owner, lastActive, heartbeatMs };
|
|
293
|
+
if (owner === null) return { allowed: true, action: 'no-owner', owner: null, lastActive, heartbeatMs };
|
|
294
|
+
if (owner === opts.selfSession) return { allowed: true, action: 'self', owner, lastActive, heartbeatMs };
|
|
295
|
+
// owner 他人:新鲜度决定拒绝 / 自动接管
|
|
296
|
+
const lastMs = lastActive ? new Date(lastActive).getTime() : NaN;
|
|
297
|
+
const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
|
|
298
|
+
const stale = !Number.isFinite(lastMs) || (nowMs - lastMs) >= heartbeatMs;
|
|
299
|
+
return stale
|
|
300
|
+
? { allowed: true, action: 'takeover-stale', owner, lastActive, heartbeatMs }
|
|
301
|
+
: deny('blocked-active-owner', owner, lastActive);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 强制重写所有者(task-02 接管写库侧;claimChangeOwner 的对照——那把守卫「已有值不覆盖」,
|
|
306
|
+
* 本方法无条件写):assertChangeOwnership 放行 no-owner/takeover-stale/takeover-forced 后由
|
|
307
|
+
* 接线调用。同事务刷新 last_active——新 owner 的心跳从接管时刻起算,否则下次校验仍读到
|
|
308
|
+
* 旧 owner 的陈旧时间戳,接管形同没发生(立刻又可被他人 takeover-stale 抢走)。行缺失先建
|
|
309
|
+
* (INSERT OR IGNORE)。实际变更标本地脏度(owner_session 随同步 payload 上行,D-013 同族)。
|
|
310
|
+
* @param {string} cwd
|
|
311
|
+
* @param {string} changeName
|
|
312
|
+
* @param {string|null} session - 接管者的会话标识
|
|
313
|
+
*/
|
|
314
|
+
setChangeOwner(cwd, changeName, session) {
|
|
315
|
+
if (!changeName) return;
|
|
316
|
+
const db = this.pm._ensureDB(cwd);
|
|
317
|
+
const now = new Date().toISOString();
|
|
318
|
+
db.transaction(() => {
|
|
319
|
+
const sqlDb = db.getDb();
|
|
320
|
+
const ins = sqlDb.prepare(
|
|
321
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
|
|
322
|
+
VALUES (?, ?, ?, ?)`
|
|
323
|
+
).run(changeName, now, now, session || null);
|
|
324
|
+
const upd = sqlDb.prepare(
|
|
325
|
+
'UPDATE changes SET owner_session = ?, last_active = ? WHERE name = ?'
|
|
326
|
+
).run(session || null, now, changeName);
|
|
327
|
+
if (ins.changes > 0 || upd.changes > 0) this.pm._touchLocalModified(cwd, changeName, now);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
157
331
|
/**
|
|
158
332
|
* 更新变更的隔离状态
|
|
159
333
|
* @param {string} cwd - 项目根目录
|
|
@@ -257,9 +431,16 @@ export class ChangeRegistry {
|
|
|
257
431
|
// 永不携带(乐观锁失效)、本地脏度恒 false、platform status behind 恒跳过。值优先
|
|
258
432
|
// 平台回执 last_pushed_at,缺省回退本次 X-SillySpec-Pushed-At(后端 _apply 存的就是
|
|
259
433
|
// 该 header 原值,回写与服务器精确一致)。COALESCE 保旧值:无 syncedTs 只推进展示列。
|
|
434
|
+
// ql-20260914-001:COALESCE 直写改 MAX 单调推进(坑 sync-base-ts-out-of-order-backfill)——
|
|
435
|
+
// 同机多进程(CLI 会话 ×N + 步进链)并发推送时,A(t1)/B(t2) 乱序回填会让迟到的旧回执
|
|
436
|
+
// 覆盖已推进的 base(multi-agent-platform 实证:6 个假冲突的 base 全部停在上一轮值),
|
|
437
|
+
// 回声窗重开 → 下次 push 409/pull 假冲突。MAX 只升不降:base ≥ 回执恒安全(平台 409
|
|
438
|
+
// 判据是 stored > base,base 偏高只会让推送被接受,绝不产生假 409);仅当回执比现存
|
|
439
|
+
// base 旧(乱序/平台侧回退)时改变行为——正是要防的覆写。与 resolve/自愈路径的
|
|
440
|
+
// MAX 写法(sync.js 四处)对齐,消除本仓最后一个直写点。
|
|
260
441
|
sqlDb.prepare(
|
|
261
|
-
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1, last_synced_platform_ts = COALESCE(?, last_synced_platform_ts) WHERE name = ?'
|
|
262
|
-
).run(new Date().toISOString(), syncedTs, changeName);
|
|
442
|
+
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1, last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
|
|
443
|
+
).run(new Date().toISOString(), syncedTs, syncedTs, changeName);
|
|
263
444
|
});
|
|
264
445
|
}
|
|
265
446
|
|
|
@@ -309,44 +490,44 @@ export class ChangeRegistry {
|
|
|
309
490
|
// 注:新名日期前缀门禁只在 CLI 边界强制(index.js change-rename 入口)——renameChange 是
|
|
310
491
|
// 库函数,测试/平台工具合法用任意名(2026-09-11 决策)。
|
|
311
492
|
const db = this.pm._ensureDB(cwd);
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const row = sqlDb.prepare(`SELECT name, status FROM changes WHERE name = ?`).get(oldName);
|
|
316
|
-
if (row === undefined) return null;
|
|
317
|
-
return { name: row.name, status: row.status };
|
|
318
|
-
});
|
|
319
|
-
if (!existing) {
|
|
320
|
-
console.error(`❌ 变更 ${oldName} 不存在`);
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
// 检查新名是否已存在
|
|
324
|
-
const conflict = db.transaction(() => {
|
|
325
|
-
const sqlDb = db.getDb();
|
|
326
|
-
const row = sqlDb.prepare(`SELECT name FROM changes WHERE name = ?`).get(newName);
|
|
327
|
-
return row !== undefined;
|
|
328
|
-
});
|
|
329
|
-
if (conflict) {
|
|
330
|
-
console.error(`❌ 变更 ${newName} 已存在`);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
// 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
|
|
334
|
-
// (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
|
|
335
|
-
// 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
|
|
493
|
+
// 存在性/冲突检查与 UPDATE 收进同一事务 + 校验影响行数(坑 rename-concurrent-split,
|
|
494
|
+
// 2026-09-12 审查批 C-①):旧三段独立事务是 check-then-act——并发重命名 X→Y 与 X→Z 时,
|
|
495
|
+
// 后者的 UPDATE 命中 0 行不报错,继续搬目录造成 DB/目录分裂(空目录假成功)。
|
|
336
496
|
const oldDir = this.pm._changePath(cwd, oldName);
|
|
337
497
|
const newDir = this.pm._changePath(cwd, newName);
|
|
338
498
|
const now = new Date().toISOString();
|
|
499
|
+
let outcome;
|
|
339
500
|
try {
|
|
340
|
-
db.transaction(() => {
|
|
501
|
+
outcome = db.transaction(() => {
|
|
341
502
|
const sqlDb = db.getDb();
|
|
342
|
-
sqlDb.prepare(`
|
|
503
|
+
const row = sqlDb.prepare(`SELECT name FROM changes WHERE name = ?`).get(oldName);
|
|
504
|
+
if (row === undefined) return 'missing';
|
|
505
|
+
if (sqlDb.prepare(`SELECT 1 FROM changes WHERE name = ?`).get(newName) !== undefined) return 'conflict';
|
|
506
|
+
const r = sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
|
|
507
|
+
if (r.changes !== 1) return 'stolen'; // 并发窗口:检查后他进程已把 oldName 改走
|
|
343
508
|
// 本地脏度(D-013 / task-04):重命名也是本地推进(标新名)
|
|
344
509
|
this.pm._touchLocalModified(cwd, newName, now);
|
|
510
|
+
return 'ok';
|
|
345
511
|
});
|
|
346
512
|
} catch (e) {
|
|
347
513
|
console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
|
|
348
514
|
return;
|
|
349
515
|
}
|
|
516
|
+
if (outcome === 'missing') {
|
|
517
|
+
console.error(`❌ 变更 ${oldName} 不存在`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (outcome === 'conflict') {
|
|
521
|
+
console.error(`❌ 变更 ${newName} 已存在`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (outcome === 'stolen') {
|
|
525
|
+
console.error(`❌ 变更 ${oldName} 刚被并发操作重命名/删除(0 行更新),本次跳过——请重查后重试`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
// 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
|
|
529
|
+
// (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
|
|
530
|
+
// 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
|
|
350
531
|
let renamed = true;
|
|
351
532
|
if (existsSync(oldDir)) {
|
|
352
533
|
try {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { appendFileSync, existsSync, readFileSync, readdirSync } from 'fs';
|
|
5
5
|
import { join, resolve } from 'path';
|
|
6
6
|
import { DB } from '../db.js';
|
|
7
|
+
import { openDatabase } from '../db-engine.js';
|
|
7
8
|
import { summarizeTaskCompletion } from '../task-review.js';
|
|
8
9
|
import { STAGE_ORDER, MAIN_FLOW_ORDER } from './shared.js';
|
|
9
10
|
|
|
@@ -101,11 +102,15 @@ export class ConsistencyDoctor {
|
|
|
101
102
|
if (!existsSync(dbPath)) return null;
|
|
102
103
|
let db = null;
|
|
103
104
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// 真只读(坑 read-active-quiet-write-open,2026-09-12 审查批 C-⑤):旧 new DB().init()
|
|
106
|
+
// 会跑 WAL PRAGMA(创建 -wal/-shm 侧车)且 schema 戳不匹配时执行 DDL 迁移——「只读探测」
|
|
107
|
+
// 实际改写被探测库。readOnly 打开零副作用;打开失败(损坏/权限)→ unreadable 降级不变。
|
|
108
|
+
db = openDatabase(dbPath, { readOnly: true });
|
|
109
|
+
const rows = db.prepare("SELECT name FROM changes WHERE status = 'active' ORDER BY name").all();
|
|
110
|
+
db.close();
|
|
107
111
|
return { activeChanges: rows.map(r => r.name) };
|
|
108
112
|
} catch (e) {
|
|
113
|
+
try { if (db) db.close(); } catch { /* 已关闭/打开失败 */ }
|
|
109
114
|
return { activeChanges: [], unreadable: true, error: e.message };
|
|
110
115
|
}
|
|
111
116
|
}
|
|
@@ -314,7 +319,9 @@ export class ConsistencyDoctor {
|
|
|
314
319
|
const fixable = []; // { stage, action, description, apply: (data) => void }
|
|
315
320
|
const manual = [...splitManual]; // string
|
|
316
321
|
|
|
317
|
-
|
|
322
|
+
// ISO(Fix d 的 reopenedAt / Fix e 的 completedAt 均落库):zh-CN 串的 '/' 在字符串 MAX 中
|
|
323
|
+
// 恒盖过 ISO 值,会污染 getLatestActivityAt 时近性闸与平台同步(2026-09-11 审查)
|
|
324
|
+
const now = new Date().toISOString();
|
|
318
325
|
|
|
319
326
|
for (const stageName of STAGE_ORDER) {
|
|
320
327
|
const sd = data.stages[stageName];
|
package/src/progress/shared.js
CHANGED
|
@@ -26,8 +26,9 @@ export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', '
|
|
|
26
26
|
// (consistency-doctor 坑 + prompt-control-debt plan-c 同根因)。
|
|
27
27
|
export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
|
|
28
28
|
|
|
29
|
-
// progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8
|
|
30
|
-
|
|
29
|
+
// progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8;
|
|
30
|
+
// v6 = changes 加 owner_session 所有权列,2026-09-14-change-ownership-guards D-005@v1)
|
|
31
|
+
export const CURRENT_VERSION = 6;
|
|
31
32
|
|
|
32
33
|
// 滞留提示阈值(天,2026-08-30 用户反馈②:7 个「代码全落地但流程没收口」的变更挂 38 天
|
|
33
34
|
// 无人发现):活跃变更 last_active 距今超该天数且流程未收口(archive 未完成)时,progress
|
|
@@ -632,7 +632,9 @@ export class StageMachine {
|
|
|
632
632
|
// 执行重开操作
|
|
633
633
|
const newRevision = (stageData.revision || 0) + 1;
|
|
634
634
|
const fromStepName = steps[fromIdx].name;
|
|
635
|
-
|
|
635
|
+
// ISO(落库字段):toLocaleString('zh-CN') 的 '/' 在字符串 MAX 中恒大于 ISO 的 '-',污染
|
|
636
|
+
// getLatestActivityAt 等取最大值比较与平台 pydantic 解析(2026-09-11 审查)
|
|
637
|
+
const now = new Date().toISOString();
|
|
636
638
|
|
|
637
639
|
// 更新步骤状态:fromStep 之前的保持 completed,fromStep 变 pending,之后的变 stale
|
|
638
640
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -134,24 +134,21 @@ export class StepStore {
|
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
const stepRow = sqlDb.prepare(
|
|
140
|
-
`SELECT st.id, st.status FROM steps st
|
|
141
|
-
JOIN stages sg ON st.stage_id = sg.id
|
|
142
|
-
JOIN changes c ON sg.change_id = c.id
|
|
143
|
-
WHERE c.name = ? AND sg.stage = ? AND st.name = ?`
|
|
144
|
-
).get(cn, stage, stepName);
|
|
145
|
-
if (stepRow === undefined) {
|
|
146
|
-
console.log(`❌ 步骤不存在: ${stage}/${stepName}`);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const stepId = stepRow.id;
|
|
150
|
-
|
|
151
|
-
// UPDATE steps
|
|
137
|
+
// UPDATE steps(id 查找收进同一事务:事务外查询拿到的 id,可能在 UPDATE 前被他进程 _write 的
|
|
138
|
+
// steps DELETE+重插换成新自增 id——UPDATE 命中 0 行静默丢写且照常打印成功,2026-09-11 审查 P1)
|
|
152
139
|
let stageCompletionCandidateId = null;
|
|
140
|
+
let stepFound = false;
|
|
153
141
|
db.transaction(() => {
|
|
154
142
|
const tDb = db.getDb();
|
|
143
|
+
const stepRow = tDb.prepare(
|
|
144
|
+
`SELECT st.id FROM steps st
|
|
145
|
+
JOIN stages sg ON st.stage_id = sg.id
|
|
146
|
+
JOIN changes c ON sg.change_id = c.id
|
|
147
|
+
WHERE c.name = ? AND sg.stage = ? AND st.name = ?`
|
|
148
|
+
).get(cn, stage, stepName);
|
|
149
|
+
if (stepRow === undefined) return;
|
|
150
|
+
stepFound = true;
|
|
151
|
+
const stepId = stepRow.id;
|
|
155
152
|
const now = new Date().toISOString();
|
|
156
153
|
if (status) {
|
|
157
154
|
tDb.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?').run(status, status === 'completed' ? now : null, stepId, stepName);
|
|
@@ -179,6 +176,11 @@ export class StepStore {
|
|
|
179
176
|
this.pm._touchLocalModified(cwd, cn, now);
|
|
180
177
|
});
|
|
181
178
|
|
|
179
|
+
if (!stepFound) {
|
|
180
|
+
console.log(`❌ 步骤不存在: ${stage}/${stepName}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
182
184
|
if (stageCompletionCandidateId !== null) {
|
|
183
185
|
const { force = false } = options;
|
|
184
186
|
const validation = this.pm._validateStageArtifacts(cwd, stage, cn);
|
|
@@ -199,11 +201,22 @@ export class StepStore {
|
|
|
199
201
|
validationErrors: validation.errors,
|
|
200
202
|
});
|
|
201
203
|
}
|
|
204
|
+
let stageMarkedCompleted = false;
|
|
202
205
|
db.transaction(() => {
|
|
203
206
|
const tDb = db.getDb();
|
|
204
|
-
|
|
207
|
+
// 提交前复查:validator 窗口(execute 校验可 spawn git 最长 15s+)内他进程 addStep 新增
|
|
208
|
+
// pending 步或 reset 本阶段——按候选 id 盲写会把未完成阶段错标 completed(2026-09-11 审查)
|
|
209
|
+
const recheck = tDb.prepare("SELECT COUNT(*) AS cnt FROM steps WHERE stage_id = ? AND status NOT IN ('completed','skipped')").get(stageCompletionCandidateId);
|
|
210
|
+
if (recheck.cnt === 0) {
|
|
211
|
+
tDb.prepare(`UPDATE stages SET status = 'completed', completed_at = ? WHERE id = ?`).run(new Date().toISOString(), stageCompletionCandidateId);
|
|
212
|
+
stageMarkedCompleted = true;
|
|
213
|
+
}
|
|
205
214
|
});
|
|
206
|
-
|
|
215
|
+
if (stageMarkedCompleted) {
|
|
216
|
+
console.log(`✅ 阶段 ${stage} 所有步骤已完成,阶段已标记为 completed`);
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`ℹ️ 阶段 ${stage} 在校验窗口内出现新的未完成步骤,本次不标记 completed`);
|
|
219
|
+
}
|
|
207
220
|
}
|
|
208
221
|
}
|
|
209
222
|
|