sillyspec 3.26.14 → 3.27.1
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-archive/SKILL.md +2 -0
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
- package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
- package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
- package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
- package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/dashboard/server/index.js +22 -6
- package/src/change-risk-profile.js +21 -6
- package/src/commit-suggest.js +202 -0
- package/src/config-cat.js +88 -0
- package/src/db.js +14 -3
- package/src/docs-check.js +598 -534
- package/src/doctor-diagnostics.js +102 -1
- package/src/endpoint-extractor.js +21 -2
- package/src/fs-atomic.js +4 -2
- package/src/git-helper.js +73 -5
- package/src/hooks/claude-pre-tool-use.cjs +10 -5
- package/src/hooks/worktree-guard.js +42 -4
- package/src/index.js +557 -12
- package/src/init.js +1 -1
- package/src/local-register.js +98 -0
- package/src/module-impact.js +166 -0
- package/src/module-resolve.js +211 -0
- package/src/modules.js +82 -2
- package/src/progress/change-registry.js +43 -4
- package/src/progress/stage-machine.js +10 -5
- package/src/progress/step-store.js +12 -8
- package/src/progress.js +19 -3
- package/src/quicklog.js +106 -1
- package/src/run/command.js +95 -1
- package/src/run/complete-handlers.js +73 -5
- package/src/run/complete.js +47 -14
- package/src/run/gates.js +144 -17
- package/src/run/multi-repo-context.js +3 -1
- package/src/run/next.js +128 -0
- package/src/run/prompt.js +36 -4
- package/src/run/quick-audit.js +5 -2
- package/src/run/shared.js +48 -20
- package/src/run/stage.js +37 -3
- package/src/scan-postcheck.js +142 -2
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-contract.js +14 -1
- package/src/stage-review.js +15 -6
- package/src/stages/archive.js +4 -2
- package/src/stages/brainstorm.js +1 -0
- package/src/stages/execute.js +275 -44
- package/src/stages/explore.js +11 -2
- package/src/stages/knowledge.js +3 -2
- package/src/stages/quick.js +18 -9
- package/src/stages/scan.js +1 -0
- package/src/stages/verify.js +16 -51
- package/src/sync.js +44 -3
- package/src/task-review.js +273 -11
- package/src/taskcard.js +52 -22
- package/src/verify-postcheck.js +223 -6
- package/src/verify-probes.js +264 -0
- package/src/workspace.js +125 -0
- package/src/worktree-apply.js +43 -11
- package/src/worktree-deps.js +59 -5
- package/src/worktree.js +128 -14
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -1
- package/templates/prompts/verify-probes.md +13 -47
|
@@ -282,13 +282,18 @@ export class StageMachine {
|
|
|
282
282
|
* @returns {{ text: string, command?: string }|null}
|
|
283
283
|
*/
|
|
284
284
|
_getNextSuggestion(data) {
|
|
285
|
+
// 建议命令携带 --change(坑 suggestion-command-missing-change,2026-08-21 实证):
|
|
286
|
+
// 多活跃变更仓 pm.read(cwd, null) 无法自动定位变更 → 照抄建议命令(不带 --change)执行
|
|
287
|
+
// 报「未找到进度数据」,阶段完结后的 --wait 确认门也登记不上。progress 带 currentChange
|
|
288
|
+
// 时附加;无(测试 fixture / 旧数据)保持裸命令(存量断言零回归)。
|
|
289
|
+
const cc = data.currentChange ? ` --change ${data.currentChange}` : '';
|
|
285
290
|
// 找到第一个 revising 阶段
|
|
286
291
|
const revisingStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'revising');
|
|
287
292
|
if (revisingStage) {
|
|
288
293
|
const sd = data.stages[revisingStage];
|
|
289
294
|
return {
|
|
290
295
|
text: `${STAGE_LABELS[revisingStage] || revisingStage} 正在修订中(revision ${sd.revision || 1}),请继续完成修订。`,
|
|
291
|
-
command: `sillyspec run ${revisingStage}`,
|
|
296
|
+
command: `sillyspec run ${revisingStage}${cc}`,
|
|
292
297
|
};
|
|
293
298
|
}
|
|
294
299
|
|
|
@@ -298,7 +303,7 @@ export class StageMachine {
|
|
|
298
303
|
const sd = data.stages[staleStage];
|
|
299
304
|
return {
|
|
300
305
|
text: `${STAGE_LABELS[staleStage] || staleStage} 已失效(${sd.staleReason || '上游修订'}),需要从第一步重建。`,
|
|
301
|
-
command: `sillyspec run ${staleStage} --reopen --from-step 1`,
|
|
306
|
+
command: `sillyspec run ${staleStage} --reopen --from-step 1${cc}`,
|
|
302
307
|
};
|
|
303
308
|
}
|
|
304
309
|
|
|
@@ -318,14 +323,14 @@ export class StageMachine {
|
|
|
318
323
|
const ws = sd.steps[waitIdx];
|
|
319
324
|
return {
|
|
320
325
|
text: `${STAGE_LABELS[s] || s} 进行中,Step ${waitIdx + 1}「${ws.name}」等待用户输入${ws.waitReason ? `(${ws.waitReason})` : ''},用 --answer 恢复。`,
|
|
321
|
-
command: `sillyspec run ${s} --continue --answer "用户回答"`,
|
|
326
|
+
command: `sillyspec run ${s} --continue --answer "用户回答"${cc}`,
|
|
322
327
|
};
|
|
323
328
|
}
|
|
324
329
|
const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
|
|
325
330
|
if (hasPending) {
|
|
326
331
|
return {
|
|
327
332
|
text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
|
|
328
|
-
command: `sillyspec run ${s}`,
|
|
333
|
+
command: `sillyspec run ${s}${cc}`,
|
|
329
334
|
};
|
|
330
335
|
}
|
|
331
336
|
}
|
|
@@ -349,7 +354,7 @@ export class StageMachine {
|
|
|
349
354
|
if (upstreamOk) {
|
|
350
355
|
return {
|
|
351
356
|
text: `可以开始 ${STAGE_LABELS[s] || s}。`,
|
|
352
|
-
command: `sillyspec run ${s}`,
|
|
357
|
+
command: `sillyspec run ${s}${cc}`,
|
|
353
358
|
};
|
|
354
359
|
}
|
|
355
360
|
}
|
|
@@ -88,16 +88,16 @@ export class StepStore {
|
|
|
88
88
|
}
|
|
89
89
|
const stageId = stageRow.id;
|
|
90
90
|
|
|
91
|
-
// 重复步骤名检查
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// INSERT INTO steps(ordering 递增)
|
|
91
|
+
// 重复步骤名检查 + INSERT 同事务(2026-08-21 审查 BUG-8):查重在事务外时,
|
|
92
|
+
// 并发 add-step 同名可双双通过检查双插(steps 无 UNIQUE 约束),另一份 status 后续被 _write 覆盖丢失
|
|
93
|
+
let dupFound = false;
|
|
99
94
|
db.transaction(() => {
|
|
100
95
|
const tDb = db.getDb();
|
|
96
|
+
const dupRow = tDb.prepare('SELECT id FROM steps WHERE stage_id = ? AND name = ?').get(stageId, stepName);
|
|
97
|
+
if (dupRow !== undefined) {
|
|
98
|
+
dupFound = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
101
|
tDb.prepare(
|
|
102
102
|
`INSERT INTO steps (stage_id, name, ordering, status)
|
|
103
103
|
VALUES (?, ?, (SELECT COALESCE(MAX(ordering), 0) + 1 FROM steps WHERE stage_id = ?), 'pending')`
|
|
@@ -106,6 +106,10 @@ export class StepStore {
|
|
|
106
106
|
// 本地脏度(D-013 / task-04):添加步骤是本地推进
|
|
107
107
|
this.pm._touchLocalModified(cwd, cn);
|
|
108
108
|
});
|
|
109
|
+
if (dupFound) {
|
|
110
|
+
console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
109
113
|
|
|
110
114
|
console.log(`✅ 已添加步骤: ${stage}/${stepName}`);
|
|
111
115
|
}
|
package/src/progress.js
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync } from 'fs';
|
|
14
14
|
import { join, basename, dirname, resolve, sep } from 'path';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
|
+
import { writeAtomicSync } from './fs-atomic.js';
|
|
16
17
|
import { DB } from './db.js';
|
|
17
18
|
import { checkExecuteCodeEvidence } from './stage-contract.js';
|
|
18
19
|
import { ConsistencyDoctor } from './progress/consistency-doctor.js';
|
|
19
20
|
import { ChangeRegistry } from './progress/change-registry.js';
|
|
20
21
|
import { StepStore } from './progress/step-store.js';
|
|
21
22
|
import { StageMachine } from './progress/stage-machine.js';
|
|
23
|
+
import { stageRegistry } from './stages/index.js';
|
|
22
24
|
import { STAGE_ORDER, MAIN_FLOW_ORDER, VALID_STAGES, STAGE_LABELS, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './progress/shared.js';
|
|
23
25
|
// resolveSpecDir 单一真相源在 src/run/shared.js(含 home 拒绝守卫,坑 cwd-correction-home-collision
|
|
24
26
|
// 根治:home 下 .sillyspec 恒不命中,防 smoke/临时目录污染自我延续)。此处 re-export 保持
|
|
@@ -812,7 +814,18 @@ export class ProgressManager {
|
|
|
812
814
|
|
|
813
815
|
renameChange(cwd, oldName, newName) { return this._changeRegistry.renameChange(cwd, oldName, newName); }
|
|
814
816
|
|
|
815
|
-
|
|
817
|
+
// opts.archiveStepNames 给定时做归档终态一致化收尾(见 change-registry.unregisterChange 注释,
|
|
818
|
+
// 坑 manual-archive-desync-status-only);archiveStepNamesForArchive() 取 stageRegistry 单一真相
|
|
819
|
+
//(stages/* 不反向依赖 progress.js,静态 import 无环)。
|
|
820
|
+
unregisterChange(cwd, changeName, opts) { return this._changeRegistry.unregisterChange(cwd, changeName, opts); }
|
|
821
|
+
|
|
822
|
+
archiveStepNamesForArchive() {
|
|
823
|
+
if (!this._archiveStepNamesCache) {
|
|
824
|
+
const defs = stageRegistry?.archive?.steps
|
|
825
|
+
this._archiveStepNamesCache = Array.isArray(defs) ? defs.map(s => s.name) : []
|
|
826
|
+
}
|
|
827
|
+
return this._archiveStepNamesCache
|
|
828
|
+
}
|
|
816
829
|
|
|
817
830
|
updateChangeMeta(cwd, changeName, meta) { return this._changeRegistry.updateChangeMeta(cwd, changeName, meta); }
|
|
818
831
|
|
|
@@ -1003,9 +1016,12 @@ export class ProgressManager {
|
|
|
1003
1016
|
if (existsSync(gitignorePath)) {
|
|
1004
1017
|
const content = readFileSync(gitignorePath, 'utf8');
|
|
1005
1018
|
if (content.includes(rule)) return;
|
|
1006
|
-
|
|
1019
|
+
// 按既有 EOL 追加(2026-08-21 审查 BUG-9):CRLF 检出的文件混入 LF 行产生全文件
|
|
1020
|
+
// diff 噪声/autocrlf 折腾;写走原子 rename 防并发 init 双跑交错
|
|
1021
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
1022
|
+
writeAtomicSync(gitignorePath, content.trimEnd() + eol + rule + eol);
|
|
1007
1023
|
} else {
|
|
1008
|
-
|
|
1024
|
+
writeAtomicSync(gitignorePath, rule + '\n');
|
|
1009
1025
|
}
|
|
1010
1026
|
}
|
|
1011
1027
|
|
package/src/quicklog.js
CHANGED
|
@@ -14,7 +14,7 @@ import { join, dirname, basename } from 'path'
|
|
|
14
14
|
import { getRule } from './stage-contract-spec.js'
|
|
15
15
|
import {
|
|
16
16
|
openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
|
|
17
|
-
readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync,
|
|
17
|
+
readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync, rmSync,
|
|
18
18
|
} from 'fs'
|
|
19
19
|
import { randomBytes } from 'crypto'
|
|
20
20
|
|
|
@@ -722,3 +722,108 @@ export function findQuicklogEntry(specBase, gitUser, qlId) {
|
|
|
722
722
|
}
|
|
723
723
|
return false
|
|
724
724
|
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 取消一个 quick 会话(2026-08-21 agent-手工产出审计第四批 C-2,`run quick --cancel` 数据源)。
|
|
728
|
+
*
|
|
729
|
+
* 误启动/放弃的 quick 会话此前无取消出口:QUICKLOG「进行中」条目 + 关联变更 tasks.md 挂载行
|
|
730
|
+
* + .runtime/quick-sessions/<id>/ 永久残留,doctor --cleanup-ghosts 只清 db 幽灵行不清这三处。
|
|
731
|
+
* 本函数做 allocateQuicklogEntry 的反操作(三步全是已有函数的反面):
|
|
732
|
+
* 1. QUICKLOG 条目状态翻「已取消」(非删除——保留痕迹可审计,与 doctor 宁漏勿杀口径一致)
|
|
733
|
+
* 2. tasks.md 里该 qlId 的挂载行整行移除(未勾的空壳行,勾了说明实际做了工作 → 拒绝取消)
|
|
734
|
+
* 3. 会话 guard 目录删除(quick-sessions/<sessionId>/,含 guard.json)
|
|
735
|
+
* 4. current-quick-run-id 若指向本会话则删除(防后续 --done fallback 命中死会话)
|
|
736
|
+
* db 侧 change 行由调用方 unregisterChange(quick 会话行,非真实变更)。
|
|
737
|
+
*
|
|
738
|
+
* fail-closed:已翻「已完成」的条目拒绝取消(那是真实工作);tasks.md 挂载行已勾选同样拒绝。
|
|
739
|
+
*
|
|
740
|
+
* @param {{ specBase: string, gitUser: string, qlId: string, sessionId: string|null }} opts
|
|
741
|
+
* @returns {Promise<{ ok: boolean, reason?: string, quicklogFile?: string, removedTaskRows: string[] }>}
|
|
742
|
+
*/
|
|
743
|
+
export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId = null }) {
|
|
744
|
+
if (!qlId) return { ok: false, reason: '缺 qlId(--change <quick-会话ID> 或 --ql <ql-xxx>)' }
|
|
745
|
+
const quicklogDir = join(specBase, 'quicklog')
|
|
746
|
+
const user = sanitizeQuicklogUser(gitUser) || 'unknown'
|
|
747
|
+
const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
|
|
748
|
+
|
|
749
|
+
// 1. QUICKLOG 翻「已取消」:找到条目,校验未完成,就地改状态行
|
|
750
|
+
let quicklogFile = null
|
|
751
|
+
let flipped = false
|
|
752
|
+
await withFileLock(lockPath, async () => {
|
|
753
|
+
// 用户文件缺失时扫全部 QUICKLOG 文件(条目可能在别的 user 文件里,如 CI 场景 user 漂移)
|
|
754
|
+
const files = existsSync(join(quicklogDir, `QUICKLOG-${user}.md`))
|
|
755
|
+
? [`QUICKLOG-${user}.md`]
|
|
756
|
+
: listQuicklogFiles(quicklogDir)
|
|
757
|
+
for (const f of files) {
|
|
758
|
+
const p = join(quicklogDir, f)
|
|
759
|
+
const content = existsSync(p) ? readFileSync(p, 'utf8') : ''
|
|
760
|
+
if (!content.includes(`## ${qlId} |`)) continue
|
|
761
|
+
quicklogFile = p
|
|
762
|
+
const lines = content.split('\n')
|
|
763
|
+
const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
|
|
764
|
+
let endIdx = lines.length
|
|
765
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
766
|
+
if (lines[i].startsWith('## ')) { endIdx = i; break }
|
|
767
|
+
}
|
|
768
|
+
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
769
|
+
if (/^状态:已完成/.test(lines[i])) {
|
|
770
|
+
throw new Error(`${qlId} 已完成(真实工作记录),拒绝取消;如需修正请手改 QUICKLOG`)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
774
|
+
if (/^状态:进行中/.test(lines[i])) {
|
|
775
|
+
lines[i] = '状态:已取消'
|
|
776
|
+
flipped = true
|
|
777
|
+
break
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (flipped) {
|
|
781
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n'
|
|
782
|
+
writeFileSync(p, lines.join(eol === '\r\n' ? '\r\n' : '\n'))
|
|
783
|
+
}
|
|
784
|
+
break
|
|
785
|
+
}
|
|
786
|
+
})
|
|
787
|
+
if (!quicklogFile) return { ok: false, reason: `未在任何 QUICKLOG 文件找到条目 ${qlId}` }
|
|
788
|
+
if (!flipped) return { ok: false, reason: `${qlId} 状态行异常(非进行中也非已完成),请手查 QUICKLOG` }
|
|
789
|
+
|
|
790
|
+
// 2. tasks.md 挂载行移除(所有活跃变更统一扫——allocate 时可能挂多个关联变更)
|
|
791
|
+
const removedTaskRows = []
|
|
792
|
+
const changesRoot = join(specBase, 'changes')
|
|
793
|
+
if (existsSync(changesRoot)) {
|
|
794
|
+
for (const c of readdirSync(changesRoot, { withFileTypes: true })) {
|
|
795
|
+
if (!c.isDirectory() || c.name === 'archive') continue
|
|
796
|
+
const tp = tasksPath(specBase, c.name)
|
|
797
|
+
if (!existsSync(tp)) continue
|
|
798
|
+
await withFileLock(tasksLockPath(tp), () => {
|
|
799
|
+
const content = readFileSync(tp, 'utf8')
|
|
800
|
+
if (!content.includes(qlId)) return
|
|
801
|
+
const lines = content.split('\n')
|
|
802
|
+
const kept = lines.filter(l => {
|
|
803
|
+
const m = l.match(/^[-*]\s*\[([ xX])\]\s*(\S+)/)
|
|
804
|
+
if (m && m[2] === qlId) {
|
|
805
|
+
if (m[1] !== ' ') throw new Error(`${c.name}/tasks.md 的 ${qlId} 行已勾选(实际做了工作),拒绝取消`)
|
|
806
|
+
removedTaskRows.push(`${c.name}/tasks.md`)
|
|
807
|
+
return false
|
|
808
|
+
}
|
|
809
|
+
return true
|
|
810
|
+
})
|
|
811
|
+
if (kept.length !== lines.length) {
|
|
812
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n'
|
|
813
|
+
writeFileSync(tp, kept.join(eol === '\r\n' ? '\r\n' : '\n'))
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// 3+4. 会话目录与 current marker
|
|
820
|
+
if (sessionId) {
|
|
821
|
+
try { rmSync(join(specBase, '.runtime', 'quick-sessions', sessionId), { recursive: true, force: true }) } catch {}
|
|
822
|
+
const marker = join(specBase, '.runtime', 'current-quick-run-id')
|
|
823
|
+
try {
|
|
824
|
+
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === sessionId) unlinkSync(marker)
|
|
825
|
+
} catch {}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return { ok: true, quicklogFile, removedTaskRows }
|
|
829
|
+
}
|
package/src/run/command.js
CHANGED
|
@@ -44,6 +44,8 @@ const VALUE_FLAGS = new Set([
|
|
|
44
44
|
'--output', '--input', '--change', '--linked-changes',
|
|
45
45
|
'--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
46
46
|
'--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
|
|
47
|
+
'--req', '--cause', '--solution', '--result', // quick 末步四字段参数(合成 --output,见 outputText 解析段)
|
|
48
|
+
'--ql', // quick --cancel 显式指定 qlId(缺省读会话 guard.json)
|
|
47
49
|
'--base', // scan diff 基线 commit(吃值;只在 run scan --diff 转发路径消费)
|
|
48
50
|
])
|
|
49
51
|
|
|
@@ -232,6 +234,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
232
234
|
const isStatus = flags.includes('--status')
|
|
233
235
|
const isReset = flags.includes('--reset')
|
|
234
236
|
const isReopen = flags.includes('--reopen')
|
|
237
|
+
// --cancel(2026-08-21 审计第四批 C-2):quick 误启动会话的官方取消口。取消 = QUICKLOG 条目
|
|
238
|
+
// 翻「已取消」+ tasks.md 挂载行移除 + 会话 guard 目录清理 + db 行注销(取消 quick 会话行,
|
|
239
|
+
// 非真实变更)。fail-closed:已完成/已勾选的拒绝(真实工作记录)。
|
|
240
|
+
const isCancel = flags.includes('--cancel')
|
|
241
|
+
if (isCancel && stageName !== 'quick') {
|
|
242
|
+
console.error('❌ --cancel 仅支持 quick 阶段(sillyspec run quick --cancel --change <quick-会话ID>)')
|
|
243
|
+
process.exit(2)
|
|
244
|
+
}
|
|
245
|
+
if (isCancel && (isDone || isSkip || isReset || isReopen || isStatus)) {
|
|
246
|
+
console.error('❌ --cancel 与 --done/--skip/--reset/--reopen/--status 互斥')
|
|
247
|
+
process.exit(2)
|
|
248
|
+
}
|
|
235
249
|
const fromStepValue = getFlagValue('--from-step')
|
|
236
250
|
const isConfirm = flags.includes('--confirm')
|
|
237
251
|
const isSkipApproval = flags.includes('--skip-approval')
|
|
@@ -425,6 +439,28 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
425
439
|
const outputValue = getFlagValue('--output')
|
|
426
440
|
if (outputValue !== null) outputText = outputValue
|
|
427
441
|
|
|
442
|
+
// 解析 quick 末步四字段参数(2026-08-21 agent-手工产出审计第二批 F6):--req/--cause/
|
|
443
|
+
// --solution/--result 各传一项,CLI 合成单行四字段 outputText(quicklog 落盘侧
|
|
444
|
+
// splitSingleLineFields 会归一为多行字段块)。消灭 agent 手拼「需求:… 根因:…」模板的
|
|
445
|
+
// 格式事故面(嵌套全角冒号拆分误判 / 缺字段被 --done 拒 / 标题截断规则踩坑)。
|
|
446
|
+
// 任一四参数出现即要求全四;与 --output 互斥(两路同时给说明意图不明,fail-fast)。
|
|
447
|
+
{
|
|
448
|
+
const FOUR = [['--req', '需求:'], ['--cause', '根因:'], ['--solution', '方案:'], ['--result', '结果:']]
|
|
449
|
+
const fourVals = FOUR.map(([f]) => [f, getFlagValue(f)])
|
|
450
|
+
if (fourVals.some(([, v]) => v !== null)) {
|
|
451
|
+
if (outputText !== null) {
|
|
452
|
+
console.error('❌ --req/--cause/--solution/--result 与 --output 互斥——四参数由 CLI 合成结构化 output,勿混用');
|
|
453
|
+
process.exit(2)
|
|
454
|
+
}
|
|
455
|
+
const missing = fourVals.filter(([, v]) => v === null).map(([f]) => f)
|
|
456
|
+
if (missing.length > 0) {
|
|
457
|
+
console.error(`❌ quick 末步四字段参数缺项:${missing.join(' ')}(--req/--cause/--solution/--result 必须全给,或缺字段 --done 会被拒)`);
|
|
458
|
+
process.exit(2)
|
|
459
|
+
}
|
|
460
|
+
outputText = FOUR.map(([f, label]) => `${label}${getFlagValue(f)}`).join(' ')
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
428
464
|
// 解析 --input
|
|
429
465
|
let inputText = null
|
|
430
466
|
const inputValue = getFlagValue('--input')
|
|
@@ -588,10 +624,11 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
588
624
|
|
|
589
625
|
// 未知参数 fail-fast
|
|
590
626
|
const knownFlags = new Set([
|
|
591
|
-
'--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
|
|
627
|
+
'--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval', '--cancel', '--ql',
|
|
592
628
|
'--wait', '--continue', '--non-interactive', '--interactive',
|
|
593
629
|
'--reason', '--options', '--answer', '--confirm-mode',
|
|
594
630
|
'--output', '--input', '--change', '--linked-changes',
|
|
631
|
+
'--req', '--cause', '--solution', '--result',
|
|
595
632
|
'--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
596
633
|
'--files', '--file-notes', '--allow-new', '--allow-delete', '--force-baseline', '--force-rescan',
|
|
597
634
|
'--json', '--dir', '--help',
|
|
@@ -830,6 +867,15 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
830
867
|
changeName = autoName
|
|
831
868
|
} else {
|
|
832
869
|
console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
|
|
870
|
+
// 自愈引导(坑 suggestion-command-missing-change 同族):多活跃变更仓不带 --change 时
|
|
871
|
+
// pm.read 无法自动定位(单活跃可自动),列出候选让 agent 一步补对,而非盲猜变更名
|
|
872
|
+
try {
|
|
873
|
+
const activeChanges = pm.listChanges(cwd)
|
|
874
|
+
if (activeChanges.length > 1) {
|
|
875
|
+
console.error(` 当前有 ${activeChanges.length} 个活跃变更(无法自动定位),请显式指定:`)
|
|
876
|
+
for (const c of activeChanges) console.error(` - --change ${c}`)
|
|
877
|
+
}
|
|
878
|
+
} catch { /* 列举失败不掩盖原报错 */ }
|
|
833
879
|
process.exit(2) // 环境错(未 init / 未传 --change)→ exit 2
|
|
834
880
|
}
|
|
835
881
|
}
|
|
@@ -950,6 +996,54 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
950
996
|
}
|
|
951
997
|
}
|
|
952
998
|
|
|
999
|
+
// ── quick --cancel:误启动会话的取消口(数据源 quicklog.js cancelQuickSession)──
|
|
1000
|
+
// --change 取 quick-<uuid8> 会话 ID(quick 启动时 CLI echo 的 sessionId);qlId 从会话 guard
|
|
1001
|
+
// 或 --ql 显式参数解析。取消后 db 行注销(quick 会话行非真实变更,直接 unregister 安全)。
|
|
1002
|
+
if (stageName === 'quick' && isCancel) {
|
|
1003
|
+
const cancelSessionRaw = quickSessionId || changeName
|
|
1004
|
+
const qlFlag = getFlagValue('--ql')
|
|
1005
|
+
if (!cancelSessionRaw || cancelSessionRaw === 'quick-unknown') {
|
|
1006
|
+
console.error('用法: sillyspec run quick --cancel --change <quick-会话ID> [--ql <ql-xxx>]\n 取消误启动的 quick 会话:QUICKLOG 条目翻「已取消」+ tasks.md 挂载行移除 + 会话目录清理 + db 行注销。\n 已完成/已勾选的会话拒绝取消(真实工作记录)')
|
|
1007
|
+
process.exit(2)
|
|
1008
|
+
}
|
|
1009
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
1010
|
+
// qlId 优先 --ql 显式;否则读会话 guard.json(allocate 时 CLI 写入);再退 current marker 旁的会话
|
|
1011
|
+
let qlId = qlFlag
|
|
1012
|
+
if (!qlId) {
|
|
1013
|
+
const guardPath = join(specBase, '.runtime', 'quick-sessions', cancelSessionRaw, 'guard.json')
|
|
1014
|
+
try {
|
|
1015
|
+
if (existsSync(guardPath)) qlId = JSON.parse(readFileSync(guardPath, 'utf8')).qlId
|
|
1016
|
+
} catch { /* guard 损坏 → 下面按找不到报错 */ }
|
|
1017
|
+
}
|
|
1018
|
+
if (!qlId) {
|
|
1019
|
+
console.error(`❌ 无法定位会话 ${cancelSessionRaw} 的 qlId(guard.json 缺失/损坏)——显式传 --ql <ql-xxx>(QUICKLOG 条目头)`)
|
|
1020
|
+
process.exit(1)
|
|
1021
|
+
}
|
|
1022
|
+
let gitUser = 'unknown'
|
|
1023
|
+
try { gitUser = (await import('../git-helper.js')).git(cwd, ['config', 'user.name']) || 'unknown' } catch {}
|
|
1024
|
+
const { cancelQuickSession } = await import('../quicklog.js')
|
|
1025
|
+
let result
|
|
1026
|
+
try {
|
|
1027
|
+
result = await cancelQuickSession({ specBase, gitUser, qlId, sessionId: cancelSessionRaw })
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
console.error(`❌ 取消失败: ${e.message}`)
|
|
1030
|
+
process.exit(1)
|
|
1031
|
+
}
|
|
1032
|
+
if (!result.ok) {
|
|
1033
|
+
console.error(`❌ 取消失败: ${result.reason}`)
|
|
1034
|
+
process.exit(1)
|
|
1035
|
+
}
|
|
1036
|
+
// db 侧:quick 会话行注销(quick-<uuid8> 是进度库里的会话行,非真实变更目录)
|
|
1037
|
+
try {
|
|
1038
|
+
pm.unregisterChange(cwd, cancelSessionRaw)
|
|
1039
|
+
} catch { /* 行不存在/平台模式 → 会话目录清理已完成,db 无行可注销不算失败 */ }
|
|
1040
|
+
console.log(`🗑️ 已取消 quick 会话 ${cancelSessionRaw}(qlId ${qlId})`)
|
|
1041
|
+
console.log(` QUICKLOG 条目翻「已取消」: ${result.quicklogFile}`)
|
|
1042
|
+
if (result.removedTaskRows.length > 0) console.log(` tasks.md 挂载行已移除: ${result.removedTaskRows.join('、')}`)
|
|
1043
|
+
console.log(' 会话 guard 目录与 current marker 已清理(已完成条目不会被取消)')
|
|
1044
|
+
return
|
|
1045
|
+
}
|
|
1046
|
+
|
|
953
1047
|
// quick 启动(非 --done):reset steps + 写 current-quick-run-id(本会话 sessionId,作 --done fallback)。
|
|
954
1048
|
// sessionId 已在参数解析阶段生成(quickSessionId == changeName == quick-<uuid8>),
|
|
955
1049
|
// 这里复用同一值写 current-quick-run-id,保证两处一致。
|
|
@@ -127,7 +127,7 @@ export function findAlreadyArchivedDir(archiveDir, changeName) {
|
|
|
127
127
|
* 见 stage.js execute 启动固定 executeRunId / stage-review.js stageReviewMarkerPath)。
|
|
128
128
|
* 从 archiveChangeDirectory 抽出,供正常归档 + 自愈归档复用。
|
|
129
129
|
*/
|
|
130
|
-
async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
|
|
130
|
+
export async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
|
|
131
131
|
// ── 清理 runId marker:execute(current-execute-run-id-<change>)与 stage-review
|
|
132
132
|
// (current-stage-review-run-id-<stage>-<change>)。marker 只服务 execute→verify→archive
|
|
133
133
|
// 期间,归档后无读者;不删则 .runtime 随变更数无限累积。runtimeRoot 解析同写入侧
|
|
@@ -158,7 +158,17 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platform
|
|
|
158
158
|
const { WorktreeManager } = await import('../worktree.js')
|
|
159
159
|
const wm = new WorktreeManager({ cwd })
|
|
160
160
|
const meta = wm.getMeta(archiveChangeName)
|
|
161
|
-
if (!meta)
|
|
161
|
+
if (!meta) {
|
|
162
|
+
// 无 meta 兜底(坑 archive-cleanup-orphan-physical-dir,2026-08-21 实证):meta 已被先行
|
|
163
|
+
// 流程注销(apply 自动 cleanup / doctor 幽灵清理)时此前直接 return——若物理目录因中途
|
|
164
|
+
// 失败残留下就是孤儿(无 meta=无锚定基准,残留即孤儿)。force 清理(幂等:什么都不存在
|
|
165
|
+
// 时 cleanup 返回 skipped 零副作用)。
|
|
166
|
+
const orphan = wm.cleanup(archiveChangeName, { force: true })
|
|
167
|
+
if (orphan.result === 'cleaned' || orphan.result === 'force-cleaned' || orphan.result === 'partial') {
|
|
168
|
+
console.log(`🧹 归档清理孤儿 worktree 残留(meta 已注销): ${archiveChangeName}${orphan.residual?.length ? '(残留: ' + orphan.residual.join('; ') + ')' : ''}`)
|
|
169
|
+
}
|
|
170
|
+
return
|
|
171
|
+
}
|
|
162
172
|
const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
|
|
163
173
|
if (check.hasChanges) {
|
|
164
174
|
console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
|
|
@@ -283,7 +293,10 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
|
|
|
283
293
|
const alreadyArchivedDir = findAlreadyArchivedDir(archiveDir, archiveChangeName)
|
|
284
294
|
if (alreadyArchivedDir) {
|
|
285
295
|
console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
|
|
286
|
-
|
|
296
|
+
// 终态一致化(坑 manual-archive-desync-status-only):手动搬目录绕过标准归档后,本自愈路径
|
|
297
|
+
// 不能只翻 status——同时收尾 archive 阶段(steps/stages/current_stage),否则推送平台的终态
|
|
298
|
+
// 是「已归档 + 归档 0/5 + 停在 execute」的矛盾体,详情页渲染成「进度丢失」
|
|
299
|
+
pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
|
|
287
300
|
await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
|
|
288
301
|
console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
|
|
289
302
|
return alreadyArchivedDir
|
|
@@ -332,7 +345,9 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
|
|
|
332
345
|
process.exit(1)
|
|
333
346
|
}
|
|
334
347
|
|
|
335
|
-
|
|
348
|
+
// 正常路径同样走终态一致化(坑 manual-archive-desync-status-only):标准 --done --confirm 流程
|
|
349
|
+
// 中 completeStep 随后也会逐项完成,此处先收尾是幂等写同值——保证任何走出本函数的归档终态一致
|
|
350
|
+
pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
|
|
336
351
|
|
|
337
352
|
// CLI 下沉 git add(坑4,FR-04):确定性暂存归档目录 + 模块文档,不靠 step5 prompt 驱动。
|
|
338
353
|
// step5 prompt 的 git add 保留作幂等兜底;POSIX 路径跨平台(git 接受正斜杠)。
|
|
@@ -343,6 +358,53 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
|
|
|
343
358
|
safeGit(cwd, ['add', '--', '.sillyspec/docs/'])
|
|
344
359
|
} catch {}
|
|
345
360
|
|
|
361
|
+
// ── 他者半归档残留探测(坑 archive-other-residual-rename,2026-08-21 实证)──
|
|
362
|
+
// 并行变更的手动归档把 R 残留(源目录 rename 目标行)留在暂存区;本变更归档提交时
|
|
363
|
+
// git status 显示它们,agent 会误判「还没提交完 / 要做第二次提交」。区分:
|
|
364
|
+
// 本变更:未暂存的源侧移动(` D changes/<me>/...` + 未跟踪 archive/<me>/...)→ 补暂存
|
|
365
|
+
// 让归档成单次原子提交;
|
|
366
|
+
// 他者:已暂存的 rename(`R changes/<他人>/... -> changes/archive/<他人>/...`)→ 只 warn
|
|
367
|
+
// 提示归属(不 stage 不动——别人的归别人的)。
|
|
368
|
+
try {
|
|
369
|
+
const raw = gitQuiet(cwd, ['status', '--porcelain'], { trim: false, timeout: 30000 })
|
|
370
|
+
if (raw && raw.trim()) {
|
|
371
|
+
const changesPrefix = '.sillyspec/changes/'
|
|
372
|
+
const minePaths = []
|
|
373
|
+
const othersResidual = []
|
|
374
|
+
for (const line of raw.split('\n')) {
|
|
375
|
+
if (!line || line.length < 4) continue
|
|
376
|
+
const x = line[0], y = line[1]
|
|
377
|
+
const body = line.slice(3).trim()
|
|
378
|
+
const arrow = body.indexOf(' -> ')
|
|
379
|
+
const src = arrow !== -1 ? body.slice(0, arrow).replace(/^"|"$/g, '') : null
|
|
380
|
+
const dst = (arrow !== -1 ? body.slice(arrow + 4) : body).replace(/^"|"$/g, '')
|
|
381
|
+
if (x === 'R' && y === ' ' && arrow !== -1 && dst.startsWith(changesPrefix + 'archive/')) {
|
|
382
|
+
// 已暂存 rename → 半归档残留;按目标目录名归属他者变更
|
|
383
|
+
const m = /^\.sillyspec\/changes\/archive\/([^/]+)\//.exec(dst)
|
|
384
|
+
const owner = m ? m[1] : '?'
|
|
385
|
+
if (owner !== archiveChangeName) othersResidual.push(owner)
|
|
386
|
+
} else if (x === ' ' && (y === 'D' || y === 'A' || y === 'M')) {
|
|
387
|
+
// 未暂存的源侧移动:源目录 D / 新位置 A——指向本变更的补暂存
|
|
388
|
+
const p = dst || src
|
|
389
|
+
if (p && (p.startsWith(changesPrefix + archiveChangeName + '/')
|
|
390
|
+
|| p.startsWith(changesPrefix + 'archive/' + archiveChangeName + '/'))) {
|
|
391
|
+
minePaths.push(p)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (minePaths.length > 0) {
|
|
396
|
+
try { safeGit(cwd, ['add', '-A', '--', changesPrefix]) } catch {}
|
|
397
|
+
console.log(`🧾 已补暂存本变更归档的源侧移动(${minePaths.length} 项,归档成单次原子提交)`)
|
|
398
|
+
}
|
|
399
|
+
if (othersResidual.length > 0) {
|
|
400
|
+
const owners = [...new Set(othersResidual)].filter(Boolean)
|
|
401
|
+
console.warn(`⚠️ 检测到「他者半归档」残留(暂存区存在其他变更的 rename 记录):${owners.join('、')}`)
|
|
402
|
+
console.warn(` 这些是别的变更此前手动归档留下的暂存项,不属于本次归档——git status 里看到它们是正常的,`)
|
|
403
|
+
console.warn(` 本变更归档已完成,无需为其做第二次提交;如需清理走它们自己的收尾(或 git restore --staged 后核对)。`)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch { /* 探测失败不阻断归档(advisory) */ }
|
|
407
|
+
|
|
346
408
|
// 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
|
|
347
409
|
await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
|
|
348
410
|
|
|
@@ -371,6 +433,11 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
|
|
|
371
433
|
}
|
|
372
434
|
const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts)
|
|
373
435
|
if (archivedDir && existsSync(archivedDir)) {
|
|
436
|
+
// 内存快照同步(坑 archive-progress-show-stale,2026-08-21 实证):archiveChangeDirectory 内
|
|
437
|
+
// unregisterChange 已在 DB 写 current_stage='archive'(终态一致化),但本进程 progress 是命令
|
|
438
|
+
// 开始时读的旧快照——不同步的话,completeStep 后续 _write 会用旧值(如停在 verify)把
|
|
439
|
+
// DB 覆盖回去,progress show 就一直显示归档前的阶段
|
|
440
|
+
progress.currentStage = 'archive'
|
|
374
441
|
const recommendedDocs = ['design.md', 'module-impact.md']
|
|
375
442
|
const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
|
|
376
443
|
if (missingRecommended.length > 0) {
|
|
@@ -1037,7 +1104,8 @@ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, pla
|
|
|
1037
1104
|
return { closed: false, reason: `移动目录失败:${e.message}` }
|
|
1038
1105
|
}
|
|
1039
1106
|
|
|
1040
|
-
|
|
1107
|
+
// 终态一致化同标准归档(坑 manual-archive-desync-status-only):轻量归档路径不能只翻 status
|
|
1108
|
+
pm.unregisterChange(cwd, changeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
|
|
1041
1109
|
await archiveWorktreeCleanup(cwd, changeName, specBase, platformOpts)
|
|
1042
1110
|
safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
|
|
1043
1111
|
|
package/src/run/complete.js
CHANGED
|
@@ -222,10 +222,13 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// ── execute deps 验证硬门(change 2026-06-28-worktree-deps-provision)──
|
|
225
|
-
|
|
225
|
+
// persist 回调:gate 置 step=blocked 后 exit(1) 前落库(否则 blocked 只在内存,
|
|
226
|
+
// progress show/doctor 看到的仍是 pending,误导诊断)
|
|
227
|
+
const persistBlocked = () => pm._write(cwd, progress, changeName)
|
|
228
|
+
await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
|
|
226
229
|
// review.json 字段硬门(坑 review-json-field-gap):已勾 [x] task 的 review.json 必须 schema 完整,
|
|
227
230
|
// 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
|
|
228
|
-
await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
|
|
231
|
+
await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
|
|
229
232
|
// 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
|
|
230
233
|
// symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
|
|
231
234
|
await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
|
|
@@ -456,7 +459,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
456
459
|
console.log(`\n${summary}`)
|
|
457
460
|
} catch (e) {
|
|
458
461
|
// summary 失败不影响主流程
|
|
459
|
-
console.log(
|
|
462
|
+
console.log(`\n👉 下一步:sillyspec run verify${changeName ? ` --change ${changeName}` : ''}(验证通过后才能归档)`)
|
|
460
463
|
}
|
|
461
464
|
} else if (stageName === 'archive') {
|
|
462
465
|
console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
|
|
@@ -534,12 +537,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
534
537
|
// 易被 tail 截断),agent 常到 --done 被拦才第一次见到模板。推进到末步的此刻用底部短块预告
|
|
535
538
|
// 模板 + 可照抄命令,省一轮拦截往返(拦截路径保留兜底)。
|
|
536
539
|
if (stageName === 'quick' && nextPendingIdx === steps.length - 1) {
|
|
537
|
-
console.log(`\n📌 本步是 quick 末步:--done
|
|
538
|
-
console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --
|
|
539
|
-
console.log(`
|
|
540
|
-
console.log(` 方案:怎么改的`)
|
|
541
|
-
console.log(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
|
|
542
|
-
console.log(` 四字段逐项一句话、不可「见前述」;可选 --file-notes "path::括注 || path2" 落多行文件括注`)
|
|
540
|
+
console.log(`\n📌 本步是 quick 末步:--done 必须给全四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。推荐四参数形式(CLI 自动合成,无格式事故面):`)
|
|
541
|
+
console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --req "一句话语义化短标题(即 QUICKLOG 条目标题)" --cause "为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)" --solution "怎么改的" --result "验证情况(测试数 / lint / typecheck / 部署状态)"`)
|
|
542
|
+
console.log(` 兼容旧形式:--output "需求:… 根因:… 方案:… 结果:…"(四字段逐项一句话、不可「见前述」,正文内禁嵌套全角冒号);可选 --file-notes "path::括注 || path2" 落多行文件括注`)
|
|
543
543
|
}
|
|
544
544
|
}
|
|
545
545
|
return { stageCompleted: false, currentIdx, nextPendingIdx }
|
|
@@ -597,6 +597,22 @@ async function buildDraftContext(cwd, changeName) {
|
|
|
597
597
|
}
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
/**
|
|
601
|
+
* 预取 base..head 全量 diff 文件集(一次 git spawn),供草稿零 diff 守卫按 task 内存归属判定。
|
|
602
|
+
* 此前每草稿 task 一次 `git diff --name-only base..head -- <files>`(同一对 base..head 查 N 次,
|
|
603
|
+
* 8 task ≈ 8-16 次串行 spawn)。失败返回 null → 调用方回退逐 task 实测路径。
|
|
604
|
+
*/
|
|
605
|
+
function prefetchDiffFileSet(ctx) {
|
|
606
|
+
if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
|
|
607
|
+
try {
|
|
608
|
+
const out = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`], { trim: true })
|
|
609
|
+
if (!out) return ctx
|
|
610
|
+
return { ...ctx, diffFileSet: new Set(out.split('\n').filter(Boolean)) }
|
|
611
|
+
} catch {
|
|
612
|
+
return ctx
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
600
616
|
/**
|
|
601
617
|
* 判定 task 是否该被 autoCheckPlanFromReviews 自动勾选。
|
|
602
618
|
* 端到端/deployment-critical task 要求 review spec+quality 双 pass(cannot_verify 不算,防批量完成
|
|
@@ -631,6 +647,16 @@ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
|
|
|
631
647
|
console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾`)
|
|
632
648
|
return false
|
|
633
649
|
}
|
|
650
|
+
// 预取路径:一次全量 diff 文件集内存归属(autoCheckPlanFromReviews/批量路径已 prefetch)
|
|
651
|
+
if (ctx.diffFileSet) {
|
|
652
|
+
const hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
|
|
653
|
+
if (hit.length === 0) {
|
|
654
|
+
console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选`)
|
|
655
|
+
return false
|
|
656
|
+
}
|
|
657
|
+
console.log(` ✓ 草稿 diff 校验通过(实测 ${hit.length}/${changedFiles.length} 个声明文件有改动)`)
|
|
658
|
+
return true
|
|
659
|
+
}
|
|
634
660
|
try {
|
|
635
661
|
// 实测 diff:git diff --name-only <base>..<head> -- <changedFiles>
|
|
636
662
|
// 用数组参数避免 shell 拆词,复用 git-helper 安全模式
|
|
@@ -673,7 +699,7 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
|
|
|
673
699
|
const executeRunId = c
|
|
674
700
|
|
|
675
701
|
// W2 task-04 FR-03:构造 ctx 供草稿零 diff 守卫用(与 task-review.js generateTaskReviewDrafts 同源)
|
|
676
|
-
const ctx = await buildDraftContext(cwd, changeName)
|
|
702
|
+
const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
|
|
677
703
|
|
|
678
704
|
// tasks.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
|
|
679
705
|
// 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
|
|
@@ -751,7 +777,7 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
|
|
|
751
777
|
}
|
|
752
778
|
|
|
753
779
|
// 构造 ctx 供草稿零 diff 校验用(与 task-04 同源)
|
|
754
|
-
const ctx = await buildDraftContext(cwd, changeName)
|
|
780
|
+
const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
|
|
755
781
|
const runtimeRoot = resolveRuntimeRoot({ specRoot: specBase }, specBase)
|
|
756
782
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
757
783
|
const blockedTasks = []
|
|
@@ -786,11 +812,18 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
|
|
|
786
812
|
blockedTasks.push(taskId)
|
|
787
813
|
continue
|
|
788
814
|
}
|
|
789
|
-
// 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04
|
|
815
|
+
// 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04 同源;优先用预取全量集,
|
|
816
|
+
// 免去每 task 一次 spawn——与 shouldAutoCheckTask 同口径)
|
|
790
817
|
if (ctx && ctx.gitDir && ctx.base && ctx.head) {
|
|
791
818
|
try {
|
|
792
|
-
|
|
793
|
-
if (
|
|
819
|
+
let hit = null
|
|
820
|
+
if (ctx.diffFileSet) {
|
|
821
|
+
hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
|
|
822
|
+
} else {
|
|
823
|
+
const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
|
|
824
|
+
hit = diffResult && diffResult.trim() !== '' ? changedFiles : []
|
|
825
|
+
}
|
|
826
|
+
if (hit.length === 0) {
|
|
794
827
|
blockedTasks.push(taskId)
|
|
795
828
|
}
|
|
796
829
|
} catch (e) {
|