sillyspec 3.33.2 → 3.33.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-flow/SKILL.md +4 -0
- package/AGENTS.md +1 -1
- package/package.json +1 -1
- package/src/config-known-failures.js +258 -0
- package/src/decision-distill.js +26 -5
- package/src/decision-redomain.js +5 -4
- package/src/design-facts.js +1 -1
- package/src/docs-check.js +8 -4
- package/src/doctor-diagnostics.js +89 -6
- package/src/flow-review-pack.js +35 -3
- package/src/flow-review.js +31 -8
- package/src/flow.js +6 -2
- package/src/fr-index.js +7 -3
- package/src/git-helper.js +82 -0
- package/src/handoff.js +4 -0
- package/src/hooks/worktree-guard.js +12 -3
- package/src/hot-cache.js +60 -0
- package/src/index.js +99 -7
- package/src/knowledge-graph.js +82 -2
- package/src/knowledge-match.js +18 -2
- package/src/knowledge-quicklog.js +17 -2
- package/src/knowledge-vector.js +5 -3
- package/src/machine-interface.js +34 -13
- package/src/progress/change-registry.js +15 -0
- package/src/progress/shared.js +4 -0
- package/src/progress/stage-machine.js +15 -2
- package/src/progress.js +5 -0
- package/src/redomain-unmapped.js +204 -0
- package/src/redomain.js +5 -3
- package/src/review-dispatch.js +109 -47
- package/src/run/bg-sync.js +4 -0
- package/src/run/code-face-key.js +4 -1
- package/src/run/command.js +20 -5
- package/src/run/complete.js +185 -14
- package/src/run/gates.js +31 -2
- package/src/run/prompt.js +9 -1
- package/src/run/shared.js +18 -1
- package/src/run/stage.js +22 -19
- package/src/run/verify-quality-scan.js +51 -0
- package/src/runtime-paths.js +3 -0
- package/src/sillyhub-mcp/client.js +11 -6
- package/src/spawn-settle.js +47 -0
- package/src/spec-sync.js +89 -3
- package/src/stages/brainstorm.js +1 -0
- package/src/stages/knowledge.js +25 -3
- package/src/timing.js +55 -0
- package/src/ui-visual.js +3 -0
- package/src/verify-postcheck.js +73 -4
- package/src/watcher.js +4 -0
- package/src/worktree-apply.js +67 -4
- package/src/worktree.js +80 -11
- package/src/yaml-scalar-unquote.js +43 -0
- package/templates/agents-instruction.md +1 -1
package/src/review-dispatch.js
CHANGED
|
@@ -110,50 +110,65 @@ export function detectStall(record, nowMs, stallMs) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
*
|
|
113
|
+
* 首个平衡 JSON 对象扫描(通道②/③共用):从文本首个 { 起括号配对扫描(字符串字面量内的
|
|
114
|
+
* } 不计数),命中即 JSON.parse 返回对象;无 {} / 首个平衡段解析失败 → null。
|
|
115
|
+
*/
|
|
116
|
+
function scanFirstBalancedJson(text) {
|
|
117
|
+
const t = String(text)
|
|
118
|
+
const start = t.indexOf('{')
|
|
119
|
+
if (start === -1) return null
|
|
120
|
+
let depth = 0, inStr = false, esc = false
|
|
121
|
+
for (let i = start; i < t.length; i++) {
|
|
122
|
+
const ch = t[i]
|
|
123
|
+
if (esc) { esc = false; continue }
|
|
124
|
+
if (ch === '\\') { esc = true; continue }
|
|
125
|
+
if (ch === '"') { inStr = !inStr; continue }
|
|
126
|
+
if (inStr) continue
|
|
127
|
+
if (ch === '{') depth++
|
|
128
|
+
else if (ch === '}') {
|
|
129
|
+
depth--
|
|
130
|
+
if (depth === 0) {
|
|
131
|
+
try {
|
|
132
|
+
const obj = JSON.parse(t.slice(start, i + 1))
|
|
133
|
+
if (obj && typeof obj === 'object') return obj
|
|
134
|
+
} catch { return null }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 从 artifacts / answer 提取 review JSON(三通道容错,design 风险登记「平台 artifacts 形态演进」):
|
|
114
143
|
* ① kind 含 review/review_json 的 artifact,content/content_ref/text 字段 JSON.parse 优先;
|
|
115
|
-
* ② 兜底任一 artifact 文本字段提取首个平衡 {}
|
|
144
|
+
* ② 兜底任一 artifact 文本字段提取首个平衡 {} 对象;
|
|
145
|
+
* ③ answer 正文扫描(2026-10-21-review-dispatch-answer-salvage / dsh 会话实证:worker 把完整
|
|
146
|
+
* review JSON 放回答正文而非 artifacts——任务书已明示该兜底形态)。
|
|
147
|
+
* 第二参可缺省——只传 artifacts 时行为与改前逐字一致(向后兼容)。全失败 → null(调用方给 --salvage 兜底指引)。
|
|
116
148
|
*/
|
|
117
|
-
export function extractReviewFromArtifacts(artifacts) {
|
|
118
|
-
if (!Array.isArray(artifacts)) return null
|
|
149
|
+
export function extractReviewFromArtifacts(artifacts, answer) {
|
|
119
150
|
const texts = []
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
151
|
+
if (Array.isArray(artifacts)) {
|
|
152
|
+
for (const a of artifacts) {
|
|
153
|
+
if (!a || typeof a !== 'object') continue
|
|
154
|
+
const kind = String(a.kind || '')
|
|
155
|
+
const text = typeof a.content === 'string' ? a.content
|
|
156
|
+
: typeof a.content_ref === 'string' ? a.content_ref
|
|
157
|
+
: typeof a.text === 'string' ? a.text : null
|
|
158
|
+
if (text === null) continue
|
|
159
|
+
if (/review/i.test(kind)) {
|
|
160
|
+
try {
|
|
161
|
+
const obj = JSON.parse(text)
|
|
162
|
+
if (obj && typeof obj === 'object') return obj
|
|
163
|
+
} catch { /* 该条不是合法 JSON,落兜底通道 */ }
|
|
164
|
+
}
|
|
165
|
+
texts.push(text)
|
|
132
166
|
}
|
|
133
|
-
texts.push(text)
|
|
134
167
|
}
|
|
168
|
+
if (typeof answer === 'string' && answer) texts.push(answer)
|
|
135
169
|
for (const t of texts) {
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
// 首个平衡对象:括号配对扫描(字符串字面量内的 } 不计数)
|
|
139
|
-
let depth = 0, inStr = false, esc = false
|
|
140
|
-
for (let i = start; i < t.length; i++) {
|
|
141
|
-
const ch = t[i]
|
|
142
|
-
if (esc) { esc = false; continue }
|
|
143
|
-
if (ch === '\\') { esc = true; continue }
|
|
144
|
-
if (ch === '"') { inStr = !inStr; continue }
|
|
145
|
-
if (inStr) continue
|
|
146
|
-
if (ch === '{') depth++
|
|
147
|
-
else if (ch === '}') {
|
|
148
|
-
depth--
|
|
149
|
-
if (depth === 0) {
|
|
150
|
-
try {
|
|
151
|
-
const obj = JSON.parse(t.slice(start, i + 1))
|
|
152
|
-
if (obj && typeof obj === 'object') return obj
|
|
153
|
-
} catch { break }
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
170
|
+
const obj = scanFirstBalancedJson(t)
|
|
171
|
+
if (obj) return obj
|
|
157
172
|
}
|
|
158
173
|
return null
|
|
159
174
|
}
|
|
@@ -161,7 +176,8 @@ export function extractReviewFromArtifacts(artifacts) {
|
|
|
161
176
|
/**
|
|
162
177
|
* 组装 reviewer 任务书(FR-01 派发质量的核心):四要素——①单源清单(前缀按 checklist
|
|
163
178
|
* docblock 规则)②review.json 契约(与 gate 同源)③期望产出方式(read_only worker:
|
|
164
|
-
* 结论经 artifacts 返回,**不写任何文件**——落盘归 CLI
|
|
179
|
+
* 结论经 artifacts 返回,**不写任何文件**——落盘归 CLI;宿主不支持 artifacts 时放回答正文
|
|
180
|
+
* 首个 ```json 块,回收链第三通道兜底)④铁律段(禁改文件/禁 git commit)。
|
|
165
181
|
*/
|
|
166
182
|
export function buildReviewerTaskBook({ stage, changeDir, reviewRunId, channelPriority, probeLines = null }) {
|
|
167
183
|
const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
|
|
@@ -183,6 +199,7 @@ export function buildReviewerTaskBook({ stage, changeDir, reviewRunId, channelPr
|
|
|
183
199
|
'## 产出方式(read_only 约束)',
|
|
184
200
|
'- **禁止修改任何文件、禁止 git commit、禁止写盘**——你只读文档与必要的源码佐证。',
|
|
185
201
|
'- 把**完整 review.json 文本**(按上方契约 schema,含 reviewer: {"channel":"platform"})作为产出返回(artifacts 里的 content 字段放纯 JSON 文本)。',
|
|
202
|
+
'- 宿主/平台不支持 artifacts 回传时,把**完整 review.json 文本**放进回答正文的**首个 ```json 代码块**(CLI 回收链会从回答正文扫描兜底)。',
|
|
186
203
|
'- docHash 可占位(调度方 CLI 会按主文档机械重算),其余字段照实填写。',
|
|
187
204
|
].join('\n')
|
|
188
205
|
return {
|
|
@@ -219,6 +236,32 @@ export function persistStageReview({ runtimeRoot, stage, changeName, reviewRunId
|
|
|
219
236
|
return { ok: true, reviewPath }
|
|
220
237
|
}
|
|
221
238
|
|
|
239
|
+
/**
|
|
240
|
+
* worker 终态原文落盘(2026-10-21-review-dispatch-answer-salvage FR-04 证据链):completed
|
|
241
|
+
* 回收路径统一调用——answer 原文优先,缺省序列化 artifacts 原文;两者皆空返回 null(无原文
|
|
242
|
+
* 可落,调用方走「确无结果」口径)。与 review.json 同 run 目录(结构化结论 + 原文证据并存,
|
|
243
|
+
* dsh 事故里人工只能靠平台 UI 贴回原文——原文必须本地在场)。best-effort:写失败 warn 不抛。
|
|
244
|
+
* @returns {string|null} 落盘绝对路径;无内容/写失败 null
|
|
245
|
+
*/
|
|
246
|
+
function writeWorkerAnswer({ runtimeRoot, stage, reviewRunId, gr }) {
|
|
247
|
+
try {
|
|
248
|
+
const answer = gr && typeof gr.answer === 'string' && gr.answer
|
|
249
|
+
? gr.answer
|
|
250
|
+
: gr && Array.isArray(gr.artifacts) && gr.artifacts.length > 0
|
|
251
|
+
? JSON.stringify(gr.artifacts, null, 2) + '\n'
|
|
252
|
+
: null
|
|
253
|
+
if (!answer) return null
|
|
254
|
+
const dir = join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`)
|
|
255
|
+
mkdirSync(dir, { recursive: true })
|
|
256
|
+
const p = join(dir, 'worker-answer.md')
|
|
257
|
+
writeAtomicSync(p, answer.endsWith('\n') ? answer : answer + '\n')
|
|
258
|
+
return p
|
|
259
|
+
} catch (e) {
|
|
260
|
+
console.warn(`[sillyspec] worker-answer.md 落盘失败(不阻断回收): ${e && e.message ? e.message : String(e)}`)
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
222
265
|
/** 降级指引(channel_priority 去 platform 的剩余序渲染;probe 失败 / worker 失败共用)。 */
|
|
223
266
|
export function renderFallbackGuidance(cwd) {
|
|
224
267
|
const rest = readReviewChannelPriority(cwd).filter((c) => c !== 'platform')
|
|
@@ -235,13 +278,15 @@ export function renderFallbackGuidance(cwd) {
|
|
|
235
278
|
* @param {object} opts
|
|
236
279
|
* mode: 'create'|'status'|'kill';cwd/specBase/changeName 必填;stage(create 必填);
|
|
237
280
|
* client: SillyHubMcpClient 实例(create/status 用);probe: 可注入(默认真 probeSillyHub);
|
|
238
|
-
* budgetUsd(默认 1.0);stallMs(默认 900000);reviewRunId(status 回收时缺省读 marker
|
|
281
|
+
* budgetUsd(默认 1.0);stallMs(默认 900000);reviewRunId(status 回收时缺省读 marker);
|
|
282
|
+
* salvage(status 专用,默认 false——completed 提取失败时返回 salvagePath + --from 回收指引,
|
|
283
|
+
* 在途记录不清可重试;CLI 形态 `--status --salvage`)。
|
|
239
284
|
* @returns {Promise<{ok: boolean, ...}>} 永不抛——所有异常转 {ok:false, reason}
|
|
240
285
|
*/
|
|
241
286
|
export async function runReviewDispatch(opts = {}) {
|
|
242
287
|
const {
|
|
243
288
|
mode = 'create', cwd = process.cwd(), specBase, changeName,
|
|
244
|
-
stage, client, probe, budgetUsd = 1.0, stallMs = 900_000, reviewRunId,
|
|
289
|
+
stage, client, probe, budgetUsd = 1.0, stallMs = 900_000, reviewRunId, salvage = false,
|
|
245
290
|
} = opts
|
|
246
291
|
if (!specBase || !changeName) return { ok: false, reason: '缺少 specBase/changeName' }
|
|
247
292
|
|
|
@@ -340,21 +385,37 @@ export async function runReviewDispatch(opts = {}) {
|
|
|
340
385
|
}
|
|
341
386
|
if (st === 'completed') {
|
|
342
387
|
const gr = await client.getWorkerResult({ missionId: rec.missionId, workerId: rec.workerId })
|
|
343
|
-
const
|
|
388
|
+
const runtimeRoot = join(specBase, '.runtime')
|
|
389
|
+
// 终态原文先落盘再提取(FR-04:无论提取成败,原文证据与 review.json 同 run 目录在场)
|
|
390
|
+
const answerPath = writeWorkerAnswer({ runtimeRoot, stage: rec.stage, reviewRunId: rec.reviewRunId, gr })
|
|
391
|
+
const review = extractReviewFromArtifacts(gr && gr.artifacts, gr && gr.answer)
|
|
344
392
|
if (!review) {
|
|
345
|
-
|
|
393
|
+
// 两步出口(dsh 事故复盘:旧文案「--kill 降级」混写处置与降级,误导 agent 跳过回收直接自审):
|
|
394
|
+
// ① --salvage 兜底回收(落盘原文 + --from 指引)② 确认平台确无结果才降级
|
|
395
|
+
const reason = 'worker completed 但三通道(artifacts kind / artifacts 文本 / answer 正文)均提取不到 review JSON'
|
|
396
|
+
if (salvage) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false, state: 'completed-no-artifact', statusLine, salvagePath: answerPath, reason,
|
|
399
|
+
guidance: answerPath
|
|
400
|
+
? `① worker 终态原文已落盘 ${answerPath}——人工核对/修整出 review.json 后运行 sillyspec register-stage-review --change ${changeName} --stage ${rec.stage} --from <修整后的review.json> 回收\n② 确认平台确无结果(UI 核对 mission ${rec.missionId})才按通道优先序降级:\n${renderFallbackGuidance(cwd)}`
|
|
401
|
+
: `① 平台未回传任何终态原文(answer 与 artifacts 均空)——在平台 UI 核对 mission ${rec.missionId} / worker ${rec.workerId}\n② 确认确无结果才按通道优先序降级:\n${renderFallbackGuidance(cwd)}`,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
ok: false, state: 'completed-no-artifact', statusLine, reason,
|
|
406
|
+
guidance: `① 回收兜底:sillyspec review-dispatch --status --salvage --change ${changeName}(自动落盘 worker-answer.md 并提示 register-stage-review --from 回收;平台 UI 可先核对 mission ${rec.missionId})\n② 确认平台确无结果才按通道优先序降级:\n${renderFallbackGuidance(cwd)}`,
|
|
407
|
+
}
|
|
346
408
|
}
|
|
347
|
-
const runtimeRoot = join(specBase, '.runtime')
|
|
348
409
|
const mainDocPath = join(specBase, 'changes', changeName, STAGE_MAIN_DOC[rec.stage] || 'design.md')
|
|
349
410
|
const p = persistStageReview({
|
|
350
411
|
runtimeRoot, stage: rec.stage, changeName, reviewRunId: rec.reviewRunId,
|
|
351
412
|
reviewObj: review, missionId: rec.missionId, mainDocPath,
|
|
352
413
|
})
|
|
353
414
|
if (!p.ok) {
|
|
354
|
-
return { ok: false, state: 'bad-review', statusLine, errors: p.errors, guidance:
|
|
415
|
+
return { ok: false, state: 'bad-review', statusLine, errors: p.errors, guidance: `① 提取到的 review 不符 schema——按契约修整(终态原文参考:${answerPath || '同目录 worker-answer.md'})后运行 sillyspec register-stage-review --change ${changeName} --stage ${rec.stage} --from <修整后的review.json> 回收\n② 确认平台确无结果才按通道优先序降级:\n${renderFallbackGuidance(cwd)}` }
|
|
355
416
|
}
|
|
356
417
|
clearDispatchRecord(specBase, changeName)
|
|
357
|
-
return { ok: true, state: 'completed', statusLine, reviewPath: p.reviewPath, verdict: `${review.specVerdict}/${review.qualityVerdict}` }
|
|
418
|
+
return { ok: true, state: 'completed', statusLine, reviewPath: p.reviewPath, verdict: `${review.specVerdict}/${review.qualityVerdict}`, nextStep: answerPath ? `终态原文已留存 ${answerPath}` : undefined }
|
|
358
419
|
}
|
|
359
420
|
if (st === 'failed' || st === 'killed') {
|
|
360
421
|
clearDispatchRecord(specBase, changeName)
|
|
@@ -774,7 +835,8 @@ export async function dispatchShadowReview(opts = {}) {
|
|
|
774
835
|
}
|
|
775
836
|
if (st === 'completed') {
|
|
776
837
|
const gr = await client.getWorkerResult({ missionId: rec.missionId, workerId: rec.workerId })
|
|
777
|
-
|
|
838
|
+
// 通道③同步受益(answer 正文——主线同款;影子 accounting-only 不加 salvage 交互面)
|
|
839
|
+
const review = extractReviewFromArtifacts(gr && gr.artifacts, gr && gr.answer)
|
|
778
840
|
if (!review) {
|
|
779
841
|
await mutateShadowLedger(runtimeRoot, (ledger) => {
|
|
780
842
|
const e = ledger.entries.find((x) => x && x.shadowRunId === rec.shadowRunId)
|
package/src/run/bg-sync.js
CHANGED
|
@@ -182,6 +182,10 @@ export async function spawnBackgroundSync(cwd, changeName, platformOpts = {}, op
|
|
|
182
182
|
env,
|
|
183
183
|
});
|
|
184
184
|
child.unref?.();
|
|
185
|
+
// 孵化稳定化(FR-08 / dsh 实证 libuv 退出断言):等 spawn 事件落定(≤150ms 兜底)再放行,
|
|
186
|
+
// 压掉「父进程在子进程句柄建立中途退出」的崩溃竞态窗口。
|
|
187
|
+
const { awaitSpawnSettled } = await import('../spawn-settle.js');
|
|
188
|
+
await awaitSpawnSettled(child);
|
|
185
189
|
} finally {
|
|
186
190
|
if (logFd !== null) {
|
|
187
191
|
try { closeSync(logFd); } catch { /* 父侧 fd 副本关闭;子进程持有继承副本 */ }
|
package/src/run/code-face-key.js
CHANGED
|
@@ -24,7 +24,10 @@ import { createHash } from 'node:crypto';
|
|
|
24
24
|
|
|
25
25
|
function safeGit(cwd, args) {
|
|
26
26
|
try {
|
|
27
|
-
|
|
27
|
+
// maxBuffer:execFileSync 默认 1MB,ls-tree -r -z 大仓输出必超(multi-agent-platform 1.95MB /
|
|
28
|
+
// 本仓 1.76MB,dsh 0ff6f1c8 取证)——ENOBUFS 被 catch 吞成 null 曾让指纹/dedupKey/passedKey
|
|
29
|
+
// 全链失效、verify 同码态全量实测 6×10~13.5min。256MB 对齐 git-helper GIT_MAX_BUFFER 口径。
|
|
30
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
|
|
28
31
|
} catch {
|
|
29
32
|
return null;
|
|
30
33
|
}
|
package/src/run/command.js
CHANGED
|
@@ -54,6 +54,7 @@ const VALUE_FLAGS = new Set([
|
|
|
54
54
|
'--session', // 显式会话标识(吃值;2026-09-14-change-ownership-guards task-02 所有权三级解析最高优先级层,启动 claim 消费)
|
|
55
55
|
'--inherit-from', // wait 继承盖章锚点 <D-xxx@vN>(吃值;2026-09-18-preflight-slimming task-03 / D-003@v2:仅 --wait 场景合法,--wait 分发点校验消费)
|
|
56
56
|
'--step', // --done --step <名|序号> 意图断言(吃值;:340 消费。漏登记死路同 --wait-interactive 前例:burst 说明书出示该形态却被未知参数 exit(2) 拦死,2026-09-23 执行会话实证)
|
|
57
|
+
'--answers', // --continue --answers k1=v1,k2=v2 批量回填多个 waiting 步骤(吃值;2026-10-21-protocol-tax task-04/FR-04:仅 --continue 场景合法,complete.js continueStep 消费)
|
|
57
58
|
])
|
|
58
59
|
|
|
59
60
|
/**
|
|
@@ -272,8 +273,10 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
272
273
|
// W1-B: run <stage> 暂不支持 --json。之前 --json 进 knownFlags 白名单却从不读取(静默吞),
|
|
273
274
|
// agent 按 gate/derive 契约期望 envelope 实则拿到混合人类文本。显式 fail-fast 杜绝歧义;
|
|
274
275
|
// 完整 run envelope 待 outputStep 重构(W6)后支持(prompt + nextAction 结构化)。
|
|
275
|
-
|
|
276
|
-
|
|
276
|
+
// 例外(2026-10-21-protocol-tax task-04 / FR-04):--wait --json 放行——waitStep 输出唯一
|
|
277
|
+
// JSON 对象(问询+选项结构化,agent 直连 ask_user_question),是明确的局部契约而非静默吞。
|
|
278
|
+
if (opts.json && !args.includes('--wait')) {
|
|
279
|
+
console.error('❌ run <stage> 暂不支持 --json(静默吞是 bug,故显式拒绝;--wait 场景除外)。')
|
|
277
280
|
console.error(' 查事实用 gate/derive(支持 --json envelope);run 输出人类可读 prompt 供 agent 直接执行。')
|
|
278
281
|
process.exit(2) // 用法错 → exit 2
|
|
279
282
|
}
|
|
@@ -420,6 +423,15 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
420
423
|
process.exit(2) // 用法错 → exit 2
|
|
421
424
|
}
|
|
422
425
|
const continueAnswer = getFlagValue('--answer')
|
|
426
|
+
// ── --answers 批量回填(2026-10-21-protocol-tax task-04 / FR-04)──
|
|
427
|
+
// 仅 --continue 场景合法(同 --inherit-from 模式:脱离 --continue 无消费点,静默吞会让
|
|
428
|
+
// agent 误以为已回填);与 --answer 互斥校验在 complete.js 批路径入口(两值都解析完再判)。
|
|
429
|
+
const continueAnswers = getFlagValue('--answers')
|
|
430
|
+
if (continueAnswers !== null && !isContinue) {
|
|
431
|
+
console.error('❌ --answers 仅在 --continue 场景合法(一次回填多个 waiting 步骤:--answers k1=v1,k2=v2)')
|
|
432
|
+
console.error(` 用法: sillyspec run ${stageName} --continue --answers "1=答案A,3=答案B"`)
|
|
433
|
+
process.exit(2) // 用法错 → exit 2
|
|
434
|
+
}
|
|
423
435
|
const resolvedSpecDir = specDir || getFlagValue('--spec-dir') || getFlagValue('--spec-root')
|
|
424
436
|
const platformOpts = {
|
|
425
437
|
specRoot: resolvedSpecDir ? resolve(resolvedSpecDir) : null,
|
|
@@ -747,6 +759,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
747
759
|
'--skip-apply', // 归档收口跳过 apply 校验(task-02 只注册透传不改行为,消费归 task-03 archive 接线)
|
|
748
760
|
'--inherit-from', // wait 继承盖章 <D-xxx@vN>(2026-09-18-preflight-slimming task-03:仅 --wait 场景合法,hasDecisionId 校验+盖章在 --wait 分发点/complete 层)
|
|
749
761
|
'--step', // --done 意图断言 <名|序号>(吃值,VALUE_FLAGS 同步登记;:340 消费——漏登记则说明书出示的 --done --step 形态进命令即被未知参数拦死,2026-09-23 执行会话实证)
|
|
762
|
+
'--answers', // --continue 批量回填 k1=v1,k2=v2(吃值,VALUE_FLAGS 同步登记;2026-10-21-protocol-tax task-04:仅 --continue 场景合法,continueStep 消费)
|
|
750
763
|
'--upgrade-thick', // 轻量→完整升档用户同意门(2026-09-25-thin-upgrade-consent:布尔 flag,混跑回退写侧 :1362 消费——无 flag 拒跑,带 flag 落 legacy_fallback+同意时点留痕)
|
|
751
764
|
'--same-session', // R17 实验实证死路清偿(cli-protocol-trust):STAGE_WALL 报错指引「加 --same-session 重跑」(stage.js 硬墙文案),消费在 runStage 逃生口——此前漏登记,照指引重跑即被本白名单 exit 2(指引指向死路的自我矛盾)
|
|
752
765
|
'--force', // 同族第二实例(静态扫描新发现):quick --cancel 强清消费点与报错指引(可用 --force)在场但未登记——同死路;一致性钉 test/flag-contract.test.mjs 守护整类
|
|
@@ -1726,7 +1739,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
1726
1739
|
const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
|
|
1727
1740
|
if (changed && effectiveChange) {
|
|
1728
1741
|
pm._write(cwd, progress, effectiveChange)
|
|
1729
|
-
|
|
1742
|
+
// 只读查看(--status)不推平台(2026-10-21-protocol-tax task-06 / FR-06):重播种是本地
|
|
1743
|
+
// 自愈写(保留),平台同步面归写命令与 bg-sync——status 的同步语义对齐 progress show。
|
|
1744
|
+
if (!isStatus) triggerSync(cwd, effectiveChange, platformOpts)
|
|
1730
1745
|
progress = pm.read(cwd, effectiveChange) || progress
|
|
1731
1746
|
}
|
|
1732
1747
|
if (changed && _preReseedPending && (isDone || isSkip || isWait)
|
|
@@ -1795,12 +1810,12 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
1795
1810
|
}
|
|
1796
1811
|
inheritFrom = waitInheritFrom
|
|
1797
1812
|
}
|
|
1798
|
-
|
|
1813
|
+
return await waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, inheritFrom, waitJson: Boolean(opts.json) })
|
|
1799
1814
|
}
|
|
1800
1815
|
|
|
1801
1816
|
// --continue: 从 waiting 恢复
|
|
1802
1817
|
if (isContinue) {
|
|
1803
|
-
return await continueStep(pm, progress, stageName, cwd, continueAnswer, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, fromStep: fromStepValue })
|
|
1818
|
+
return await continueStep(pm, progress, stageName, cwd, continueAnswer, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, fromStep: fromStepValue, answersSpec: continueAnswers })
|
|
1804
1819
|
}
|
|
1805
1820
|
|
|
1806
1821
|
// --done
|
package/src/run/complete.js
CHANGED
|
@@ -161,7 +161,34 @@ export function synthesizeStepOutput({ stageName, stepName, cwd, platformOpts =
|
|
|
161
161
|
return `【CLI 合成】步骤「${stepName}」完成${windowNote};门禁以本次 CLI 校验输出为准(方案取舍等语义说明需 --output 手写)`
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* --done 心跳可观测 wrapper(FR-03 / dsh z2 实证:verify 纯文档步骤 --done 静默挂起 >5 分钟,
|
|
166
|
+
* agent 无从分辨在跑还是死锁):收口链(单步 --done 与阶段完成门全链)超过 60s 时每 60s 打一行
|
|
167
|
+
* stderr 进度行——stdout 保持纯净(--json envelope 消费方 JSON.parse(stdout) 不被污染);结束
|
|
168
|
+
* (任意 return/throw)finally 清理,interval unref(命令中断不残留定时器)。常规 <60s 路径
|
|
169
|
+
* 零输出零行为变化;intervalMs 可注入供测试。
|
|
170
|
+
*/
|
|
171
|
+
export async function withDoneHeartbeat(label, fn, { intervalMs = 60_000 } = {}) {
|
|
172
|
+
let beats = 0
|
|
173
|
+
const hb = setInterval(() => {
|
|
174
|
+
beats += 1
|
|
175
|
+
console.error(`⏳ ${label} 收口链仍在执行(已约 ${Math.round((beats * intervalMs) / 1000)}s)——实测门/探针/同步可能较慢;持续超 5 分钟可用 sillyspec flow status / progress show 旁路核对状态(非死锁判定)`)
|
|
176
|
+
}, intervalMs)
|
|
177
|
+
hb.unref?.()
|
|
178
|
+
try {
|
|
179
|
+
return await fn()
|
|
180
|
+
} finally {
|
|
181
|
+
clearInterval(hb)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
186
|
+
return withDoneHeartbeat(`${stageName} --done`,
|
|
187
|
+
() => _completeStepInner(pm, progress, stageName, cwd, outputText, inputText, options),
|
|
188
|
+
{ intervalMs: options.heartbeat?.intervalMs })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function _completeStepInner(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
165
192
|
const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false, isAllowDelete = false, isNoDocs = false, isSkipApply = false, sessionFlag = null, quickFiles = [], linkedChanges = [], linkedChangesAuto = [] } = options
|
|
166
193
|
// specRoot(平台) > specDriftAnchor(worktree 漂移锚定主仓) > cwd/.sillyspec(本地)——
|
|
167
194
|
// 与 prompt.js resolvePromptSpecBase 同序:--done 收尾的 user-inputs.md/超长 artifact 落盘
|
|
@@ -697,8 +724,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
697
724
|
// 批量保持单步推进(亲测步照 noAI 硬门走)——「批量省的是上下文往返,不省任何门」承诺恢复成立。
|
|
698
725
|
// 判定经 hasPendingVerifyScanStep:DB 步骤行只有 {name,status}(_cliAction 不持久化——评审
|
|
699
726
|
// P1 修复:初版直接读 s._cliAction 恒 false 死代码),按 stageRegistry 定义索引对齐判。
|
|
727
|
+
// 资格先行(2026-10-21-verify-reuse-large-repo FR-02):isVerifyBatchEligible 纯判定(只查
|
|
728
|
+
// 不改)先于亲测——批量不会触发时(报告未就绪/锚定步未过)中间步 --done 不再预跑整轮实测
|
|
729
|
+
// (dsh 0ff6f1c8 取证:step 3/4/5 各烧一轮 10~13.5 分钟全量实测只为随后单步推进)。资格成立
|
|
730
|
+
// 时行为不变:先亲测后批量;detectVerifyBatchFinish 内部全量复检不消费本判定,无 TOCTOU。
|
|
700
731
|
let _skipBatch = false
|
|
701
|
-
|
|
732
|
+
const _batchEligible = isVerifyBatchEligible({ changeName, specBase, steps })
|
|
733
|
+
if (!_batchEligible.eligible && hasPendingVerifyScanStep(stageName, steps)) {
|
|
734
|
+
console.log(` ℹ️ 批量对齐未就绪(${_batchEligible.reason})——亲测归质量扫描步与收口门,本步不预跑`)
|
|
735
|
+
}
|
|
736
|
+
if (_batchEligible.eligible && hasPendingVerifyScanStep(stageName, steps)) {
|
|
702
737
|
try {
|
|
703
738
|
const { executeVerifyQualityScan } = await import('./verify-quality-scan.js')
|
|
704
739
|
console.log(' 🧪 批量对齐面含 noAI 亲测步——先亲测(幂等,有记录秒回)…')
|
|
@@ -1372,6 +1407,12 @@ function prefetchDiffFileSet(ctx) {
|
|
|
1372
1407
|
* @returns {Promise<object|undefined>} 末轮 completeStep 返回值
|
|
1373
1408
|
*/
|
|
1374
1409
|
export async function completeStepBurst(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
1410
|
+
return withDoneHeartbeat(`${stageName} --done(burst)`,
|
|
1411
|
+
() => _completeStepBurstInner(pm, progress, stageName, cwd, outputText, inputText, options),
|
|
1412
|
+
{ intervalMs: options.heartbeat?.intervalMs })
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async function _completeStepBurstInner(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
1375
1416
|
if (outputText) console.log(`📦 burst 收口摘要:${outputText}`)
|
|
1376
1417
|
let roundOptions = { ...options, printNext: false }
|
|
1377
1418
|
// burst 单调时钟基准(坑 stage-burst ①):null=首轮(用真实时刻),此后每轮保严格递增
|
|
@@ -1735,24 +1776,42 @@ export function hasPendingVerifyScanStep(stageName, steps) {
|
|
|
1735
1776
|
}
|
|
1736
1777
|
}
|
|
1737
1778
|
|
|
1738
|
-
|
|
1739
|
-
|
|
1779
|
+
/**
|
|
1780
|
+
* 批量资格纯判定(2026-10-21-verify-reuse-large-repo FR-02,从 detectVerifyBatchFinish 抽出):
|
|
1781
|
+
* 只查不改(不 mutating 步骤状态)——verify --done 批量对齐块用它做「预跑亲测」的前置闸:
|
|
1782
|
+
* 资格不成立时中间步 --done 不预跑 executeVerifyQualityScan(实测归亲测步自身与收口门;
|
|
1783
|
+
* dsh 0ff6f1c8 取证:报告未就绪的每个中间步 --done 都整轮实测 10~13.5 分钟纯浪费)。
|
|
1784
|
+
* 判据与 detectVerifyBatchFinish 逐字同源(那边消费本函数,理由串不变)。
|
|
1785
|
+
*/
|
|
1786
|
+
export function isVerifyBatchEligible({ changeName, specBase, steps }) {
|
|
1787
|
+
if (!changeName) return { eligible: false, reason: '无 changeName' }
|
|
1740
1788
|
try {
|
|
1741
1789
|
const changeDir = join(specBase, 'changes', changeName)
|
|
1742
1790
|
const reportPath = join(changeDir, 'verify-result.md')
|
|
1743
|
-
if (!existsSync(reportPath)) return {
|
|
1791
|
+
if (!existsSync(reportPath)) return { eligible: false, reason: 'verify-result.md 缺席(报告未写不批量)' }
|
|
1744
1792
|
const report = readFileSync(reportPath, 'utf8')
|
|
1745
1793
|
if (!/结论枚举[::]\s*`?\s*(PASS WITH NOTES|PASS|FAIL)/.test(report)) {
|
|
1746
|
-
return {
|
|
1794
|
+
return { eligible: false, reason: '结论枚举未填(待填占位不批量)' }
|
|
1747
1795
|
}
|
|
1748
1796
|
if (!existsSync(join(changeDir, 'verify-facts.json'))) {
|
|
1749
|
-
return {
|
|
1797
|
+
return { eligible: false, reason: 'verify-facts.json 缺席(探针未跑不批量)' }
|
|
1750
1798
|
}
|
|
1751
|
-
const firstPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
1752
|
-
if (firstPendingIdx === -1) return {
|
|
1799
|
+
const firstPendingIdx = (steps || []).findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
1800
|
+
if (firstPendingIdx === -1) return { eligible: false, reason: '无剩余 step' }
|
|
1753
1801
|
if (firstPendingIdx < 2) {
|
|
1754
|
-
return {
|
|
1802
|
+
return { eligible: false, reason: '锚定步未过(step 1-2 须逐步完成,语义锚定不批量)' }
|
|
1755
1803
|
}
|
|
1804
|
+
return { eligible: true }
|
|
1805
|
+
} catch (e) {
|
|
1806
|
+
return { eligible: false, reason: `批量资格判定异常(fail-open 不批量):${e && e.message ? e.message : e}` }
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
export function detectVerifyBatchFinish({ changeName, specBase, steps }) {
|
|
1811
|
+
if (!changeName) return { batched: false, aligned: 0 }
|
|
1812
|
+
const pre = isVerifyBatchEligible({ changeName, specBase, steps })
|
|
1813
|
+
if (!pre.eligible) return { batched: false, aligned: 0, reason: pre.reason }
|
|
1814
|
+
try {
|
|
1756
1815
|
const now = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
1757
1816
|
let aligned = 0
|
|
1758
1817
|
for (const step of steps) {
|
|
@@ -2041,6 +2100,31 @@ export async function waitStep(pm, progress, stageName, cwd, outputText, waitRea
|
|
|
2041
2100
|
return
|
|
2042
2101
|
}
|
|
2043
2102
|
|
|
2103
|
+
// ── --wait --json(2026-10-21-protocol-tax task-04 / FR-04)──
|
|
2104
|
+
// stdout 输出唯一一个可 JSON.parse 的对象(问询+选项结构化,agent 直连 ask_user_question,
|
|
2105
|
+
// 免解析长文本);退出码与文本模式一致(成功 0)。仅非盖章路径——--inherit-from 保持文本契约
|
|
2106
|
+
//(盖章路径无问询语义,JSON 化无对象可给)。
|
|
2107
|
+
if (options.waitJson && !inheritFrom) {
|
|
2108
|
+
let waitOptionList = []
|
|
2109
|
+
try {
|
|
2110
|
+
const raw = stageData.steps[currentIdx].waitOptions
|
|
2111
|
+
if (raw) waitOptionList = JSON.parse(raw)
|
|
2112
|
+
} catch { /* 解析失败按无选项处理——JSON 面宁缺勿错 */ }
|
|
2113
|
+
console.log(JSON.stringify({
|
|
2114
|
+
ok: true,
|
|
2115
|
+
command: 'wait',
|
|
2116
|
+
change: changeName || null,
|
|
2117
|
+
stage: stageName,
|
|
2118
|
+
step: currentIdx + 1,
|
|
2119
|
+
stepName: stageData.steps[currentIdx].name,
|
|
2120
|
+
question: outputText || waitReason || null,
|
|
2121
|
+
reason: waitReason || null,
|
|
2122
|
+
options: (Array.isArray(waitOptionList) ? waitOptionList : []).map((o) => ({ label: String(o), description: '' })),
|
|
2123
|
+
resumeCommand: `sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`,
|
|
2124
|
+
}))
|
|
2125
|
+
return
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2044
2128
|
console.log(`⏸️ Step ${currentIdx + 1}/${stageData.steps.length} 已暂停等待:${stageData.steps[currentIdx].name}`)
|
|
2045
2129
|
if (waitReason) console.log(` 原因:${waitReason}`)
|
|
2046
2130
|
if (waitOptions) console.log(` 选项:${formatWaitOptions(waitOptions)}`)
|
|
@@ -2057,8 +2141,36 @@ export async function waitStep(pm, progress, stageName, cwd, outputText, waitRea
|
|
|
2057
2141
|
}
|
|
2058
2142
|
}
|
|
2059
2143
|
|
|
2144
|
+
/**
|
|
2145
|
+
* --answers 批量回填键值解析(2026-10-21-protocol-tax task-04 / FR-04)。
|
|
2146
|
+
* 形态:k1=v1,k2=v2;值含逗号用引号 k="v,含逗号"(引号包裹段内逗号不切分,引号字符本身不保留)。
|
|
2147
|
+
* 纯函数:解析失败返回 null(调用方 exit 2);空键/空值/无等号 token 均视为格式错。
|
|
2148
|
+
*/
|
|
2149
|
+
function parseAnswersSpec(spec) {
|
|
2150
|
+
const parts = []
|
|
2151
|
+
let cur = ''
|
|
2152
|
+
let inQuote = false
|
|
2153
|
+
for (const ch of String(spec)) {
|
|
2154
|
+
if (ch === '"') { inQuote = !inQuote; continue }
|
|
2155
|
+
if (ch === ',' && !inQuote) { parts.push(cur); cur = ''; continue }
|
|
2156
|
+
cur += ch
|
|
2157
|
+
}
|
|
2158
|
+
parts.push(cur)
|
|
2159
|
+
const pairs = []
|
|
2160
|
+
for (const p of parts) {
|
|
2161
|
+
const t = p.trim()
|
|
2162
|
+
const eq = t.indexOf('=')
|
|
2163
|
+
if (!t || eq <= 0) return null
|
|
2164
|
+
const key = t.slice(0, eq).trim()
|
|
2165
|
+
const value = t.slice(eq + 1).trim()
|
|
2166
|
+
if (!key || !value) return null
|
|
2167
|
+
pairs.push({ key, value })
|
|
2168
|
+
}
|
|
2169
|
+
return pairs.length > 0 ? pairs : null
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2060
2172
|
export async function continueStep(pm, progress, stageName, cwd, answer, options = {}) {
|
|
2061
|
-
const { changeName, platformOpts = {}, fromStep } = options
|
|
2173
|
+
const { changeName, platformOpts = {}, fromStep, answersSpec = null } = options
|
|
2062
2174
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
2063
2175
|
const stageData = progress.stages[stageName]
|
|
2064
2176
|
|
|
@@ -2067,16 +2179,75 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
|
|
|
2067
2179
|
process.exit(1)
|
|
2068
2180
|
}
|
|
2069
2181
|
|
|
2070
|
-
|
|
2071
|
-
|
|
2182
|
+
// --answers 格式错 fail-fast(先于幂等分支:格式错是用法错误,与等待态无关——
|
|
2183
|
+
// 幂等放行的是「合法调用但无等待对象」,不是「任何调用都吞」)
|
|
2184
|
+
if (answersSpec != null && parseAnswersSpec(answersSpec) == null) {
|
|
2185
|
+
console.error('❌ --answers 格式无法解析(期望 k1=v1,k2=v2 形态,值含逗号用引号 k="v,含逗号")')
|
|
2072
2186
|
process.exit(2) // 用法错 → exit 2
|
|
2073
2187
|
}
|
|
2074
2188
|
|
|
2075
2189
|
// 查找 waiting 的步骤
|
|
2076
2190
|
const waitingSteps = stageData.steps.map((s, i) => ({ ...s, idx: i })).filter(s => s.status === 'waiting')
|
|
2191
|
+
// ── 幂等 no-op(2026-10-21-protocol-tax task-05 / FR-05)──
|
|
2192
|
+
// 无 waiting 步骤时 --continue 从 exit=1 报错改为 exit 0 + 指引行:会话考古(z1)实证
|
|
2193
|
+
// 4 次「无等待步骤」失败全部来自 agent 在两次 --wait 之间干活后的链式防御写法误撞——
|
|
2194
|
+
// 等待态不存在本身不是协议错误,失败化只迫使 agent 多一轮试错。答案不落盘(无对象可回填)。
|
|
2077
2195
|
if (waitingSteps.length === 0) {
|
|
2078
|
-
console.
|
|
2079
|
-
|
|
2196
|
+
console.log(`ℹ️ 当前无等待中的步骤,--continue 幂等跳过(${answersSpec != null ? '--answers 未回填任何答案' : '--answer 未落盘'})。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
|
|
2197
|
+
const firstActive = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
|
|
2198
|
+
const firstPending = stageData.steps.findIndex(s => s.status === 'pending')
|
|
2199
|
+
return { stageCompleted: false, currentIdx: firstActive, nextPendingIdx: firstPending }
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// 有 waiting 步骤时 answer 仍是必填(用法错 exit 2)——放在幂等分支之后:裸 --continue
|
|
2203
|
+
// 无等待对象时幂等放行(FR-05),有等待对象时缺答案照旧硬拦(回填语义不容空答案)。
|
|
2204
|
+
if (!answer && answersSpec == null) {
|
|
2205
|
+
console.error('❌ --continue 需要 --answer 参数(批量回填用 --answers k1=v1,k2=v2)')
|
|
2206
|
+
process.exit(2) // 用法错 → exit 2
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ── --answers 批量回填(2026-10-21-protocol-tax task-04 / FR-04)──
|
|
2210
|
+
// 一次调用回填多个 waiting 步骤:k=步骤名或 1-based 序号,值含逗号用引号 k="v,含逗号"。
|
|
2211
|
+
// 契约:全键命中才动状态——任一键未命中/重复命中同一 步骤,整单拒绝 exit 1 并列出可用键
|
|
2212
|
+
//(禁止部分回填:半程状态比一次可重试的失败更难收拾)。
|
|
2213
|
+
// 实现:校验通过后按序号升序逐个递归单答路径(--from-step <序号> + --answer),记录/回当前步
|
|
2214
|
+
// 语义/收尾 gate/回放全部复用单答实现——批路径不复制状态机逻辑。
|
|
2215
|
+
if (answersSpec != null) {
|
|
2216
|
+
const pairs = parseAnswersSpec(answersSpec)
|
|
2217
|
+
if (!pairs) {
|
|
2218
|
+
console.error('❌ --answers 格式无法解析(期望 k1=v1,k2=v2 形态,值含逗号用引号 k="v,含逗号")')
|
|
2219
|
+
process.exit(2) // 用法错 → exit 2
|
|
2220
|
+
}
|
|
2221
|
+
if (answer) {
|
|
2222
|
+
console.error('❌ --answers 与 --answer 互斥(批量回填与单步回答是两条路径,不要同时给)')
|
|
2223
|
+
process.exit(2)
|
|
2224
|
+
}
|
|
2225
|
+
const targets = []
|
|
2226
|
+
const unmatched = []
|
|
2227
|
+
for (const { key, value } of pairs) {
|
|
2228
|
+
const idx = /^\d+$/.test(key)
|
|
2229
|
+
? parseInt(key, 10) - 1
|
|
2230
|
+
: stageData.steps.findIndex(s => s.name === key)
|
|
2231
|
+
const step = idx >= 0 ? stageData.steps[idx] : null
|
|
2232
|
+
if (!step || step.status !== 'waiting' || targets.some(t => t.idx === idx)) unmatched.push(key)
|
|
2233
|
+
else targets.push({ idx, value })
|
|
2234
|
+
}
|
|
2235
|
+
if (unmatched.length > 0) {
|
|
2236
|
+
console.error(`❌ --answers 键未命中等待中的步骤:${unmatched.join('、')}——整单拒绝,未回填任何步骤。`)
|
|
2237
|
+
console.error(` 可用键(步骤名或 1-based 序号):${waitingSteps.map(ws => `${ws.idx + 1}="${ws.name}"`).join('、')}`)
|
|
2238
|
+
process.exit(1)
|
|
2239
|
+
}
|
|
2240
|
+
console.log(`📦 --answers 批量回填开始:${targets.length} 个等待步骤(升序逐个回填)`)
|
|
2241
|
+
let lastResult = null
|
|
2242
|
+
for (const t of [...targets].sort((a, b) => a.idx - b.idx)) {
|
|
2243
|
+
lastResult = await continueStep(pm, progress, stageName, cwd, t.value, {
|
|
2244
|
+
...options,
|
|
2245
|
+
answersSpec: null,
|
|
2246
|
+
fromStep: String(t.idx + 1),
|
|
2247
|
+
})
|
|
2248
|
+
}
|
|
2249
|
+
console.log(`📦 --answers 批量回填完成:${targets.length} 步`)
|
|
2250
|
+
return lastResult
|
|
2080
2251
|
}
|
|
2081
2252
|
|
|
2082
2253
|
// --from-step 支持在多个 waiting 时指定恢复哪一个(避免被迫 --reset 破坏性重置)。
|