sillyspec 3.20.2 → 3.20.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-archive/SKILL.md +21 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
- package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-state/SKILL.md +54 -54
- package/.claude/skills/sillyspec-status/SKILL.md +21 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
- package/.husky/pre-push +13 -13
- package/CLAUDE.md +18 -18
- package/README.md +198 -188
- package/SKILL.md +90 -91
- package/bin/sillyspec.js +2 -2
- package/docs/brainstorm-plan-contract.md +64 -64
- package/docs/plan-execute-contract.md +123 -123
- package/docs/platform-scan-protocol.md +298 -298
- package/docs/revision-mode.md +115 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
- package/docs/sillyspec/file-lifecycle.md +125 -125
- package/docs/workflow-contract-regression.md +106 -106
- package/docs/worktree-isolation.md +252 -252
- package/package.json +40 -40
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/index.html +15 -15
- package/packages/dashboard/package-lock.json +2384 -2384
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +86 -86
- package/packages/dashboard/server/index.js +588 -588
- package/packages/dashboard/server/parser.js +526 -526
- package/packages/dashboard/server/watcher.js +344 -344
- package/packages/dashboard/src/App.vue +558 -558
- package/packages/dashboard/src/components/ActionBar.vue +93 -93
- package/packages/dashboard/src/components/CommandPalette.vue +96 -96
- package/packages/dashboard/src/components/DetailPanel.vue +137 -137
- package/packages/dashboard/src/components/LogStream.vue +65 -65
- package/packages/dashboard/src/components/PipelineStage.vue +95 -95
- package/packages/dashboard/src/components/PipelineView.vue +156 -156
- package/packages/dashboard/src/components/ProjectList.vue +210 -210
- package/packages/dashboard/src/components/StageBadge.vue +67 -67
- package/packages/dashboard/src/components/StepCard.vue +94 -94
- package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
- package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
- package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
- package/packages/dashboard/src/composables/useDashboard.js +170 -170
- package/packages/dashboard/src/composables/useKeyboard.js +119 -119
- package/packages/dashboard/src/composables/useWebSocket.js +129 -129
- package/packages/dashboard/src/main.js +8 -8
- package/packages/dashboard/src/style.css +132 -132
- package/packages/dashboard/vite.config.js +18 -18
- package/src/brainstorm-postcheck.js +158 -158
- package/src/change-list.js +52 -52
- package/src/change-risk-profile.js +352 -352
- package/src/classify-change.js +73 -73
- package/src/constants.js +70 -70
- package/src/contract-matrix.js +278 -278
- package/src/db.js +201 -201
- package/src/endpoint-extractor.js +315 -315
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +653 -653
- package/src/index.js +922 -900
- package/src/init.js +431 -431
- package/src/knowledge-match.js +130 -130
- package/src/migrate.js +117 -117
- package/src/modules.js +482 -482
- package/src/progress.js +1734 -1734
- package/src/run.js +3465 -3358
- package/src/scan-postcheck.js +387 -383
- package/src/setup.js +398 -398
- package/src/stage-contract.js +700 -700
- package/src/stages/archive.js +160 -160
- package/src/stages/brainstorm-auto.js +229 -229
- package/src/stages/brainstorm.js +645 -645
- package/src/stages/doctor.js +365 -365
- package/src/stages/execute.js +625 -625
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +511 -513
- package/src/stages/plan.js +582 -582
- package/src/stages/propose.js +174 -174
- package/src/stages/quick.js +82 -82
- package/src/stages/scan.js +558 -558
- package/src/stages/status.js +65 -65
- package/src/stages/verify.js +322 -322
- package/src/sync.js +497 -497
- package/src/task-review.js +346 -346
- package/src/workflow.js +785 -785
- package/src/worktree-apply.js +549 -549
- package/src/worktree-deps.js +185 -0
- package/src/worktree.js +982 -932
- package/templates/workflows/archive-impact.yaml +79 -79
- package/templates/workflows/scan-docs.yaml +132 -132
- package/test/brainstorm-plan-contract.test.mjs +273 -273
- package/test/check-syntax.mjs +26 -26
- package/test/contract-artifacts.test.mjs +323 -323
- package/test/decision-supersede.test.mjs +277 -277
- package/test/knowledge-match.test.mjs +231 -231
- package/test/plan-execute-contract.test.mjs +330 -330
- package/test/plan-optimization.test.mjs +572 -572
- package/test/platform-artifacts.test.mjs +166 -166
- package/test/platform-failure-samples.test.mjs +199 -199
- package/test/platform-recovery-chain.test.mjs +167 -167
- package/test/platform-recovery.test.mjs +136 -136
- package/test/platform-scan-p0.test.mjs +168 -168
- package/test/revision-v1.test.mjs +1145 -1145
- package/test/run-scan-project-parse.test.mjs +200 -200
- package/test/run-tests.mjs +48 -48
- package/test/scan-knowledge.test.mjs +175 -175
- package/test/scan-paths.test.mjs +68 -68
- package/test/scan-postcheck.test.mjs +197 -197
- package/test/spec-dir.test.mjs +206 -206
- package/test/stage-contract.test.mjs +299 -299
- package/test/stage-definitions.test.mjs +39 -39
- package/test/wait-gates.test.mjs +496 -496
- package/test/worktree-deps-provision.test.mjs +148 -0
- package/test/worktree-guard.test.mjs +71 -71
- package/test/worktree-native-overlay.test.mjs +188 -188
package/src/worktree-apply.js
CHANGED
|
@@ -1,549 +1,549 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SillySpec applyWorktree — 将 worktree 中的变更应用到主工作区
|
|
3
|
-
*
|
|
4
|
-
* 流程:
|
|
5
|
-
* 1. 读取 meta.json 获取 baseHash
|
|
6
|
-
* 2. git diff --name-only baseHash 获取 worktree 中所有变更文件
|
|
7
|
-
* 3. 从 design.md 解析文件变更清单(无清单 = 允许所有)
|
|
8
|
-
* 4. 校验:变更文件 ⊆ 清单
|
|
9
|
-
* 5. 校验:主工作区文件 base hash 一致
|
|
10
|
-
* 6. --check-only 模式只输出检查结果
|
|
11
|
-
* 7. 非 checkOnly:生成 patch → apply --check → apply --3way
|
|
12
|
-
* 8. 成功后自动 cleanup
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { execSync } from 'child_process';
|
|
16
|
-
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
17
|
-
import { join, resolve } from 'path';
|
|
18
|
-
import { tmpdir } from 'os';
|
|
19
|
-
import { createHash } from 'crypto';
|
|
20
|
-
import { WorktreeManager } from './worktree.js';
|
|
21
|
-
import { parseFileChangeList } from './change-list.js';
|
|
22
|
-
|
|
23
|
-
const CHANGES_REL = '.sillyspec/changes';
|
|
24
|
-
|
|
25
|
-
function git(cwd, args) {
|
|
26
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function gitQuiet(cwd, args) {
|
|
30
|
-
try {
|
|
31
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 获取文件在 git 中的 blob hash(基于某个 commit/tree)
|
|
39
|
-
* @param {string} cwd - git 工作区路径
|
|
40
|
-
* @param {string} treeish - commit hash、分支等
|
|
41
|
-
* @param {string} filePath - 相对路径
|
|
42
|
-
* @returns {string|null} blob hash,文件不存在返回 null
|
|
43
|
-
*/
|
|
44
|
-
function getFileBlobHash(cwd, treeish, filePath) {
|
|
45
|
-
return gitQuiet(cwd, `rev-parse ${treeish}:${filePath}`);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* apply worktree 变更到主工作区
|
|
50
|
-
*
|
|
51
|
-
* @param {string} changeName - 变更名
|
|
52
|
-
* @param {{ cwd?: string, checkOnly?: boolean }} opts
|
|
53
|
-
* @returns {{
|
|
54
|
-
* ok: boolean,
|
|
55
|
-
* changedFiles: string[],
|
|
56
|
-
* extraFiles: string[],
|
|
57
|
-
* hashMismatchFiles: string[],
|
|
58
|
-
* patchPath: string|null,
|
|
59
|
-
* errors: string[]
|
|
60
|
-
* }}
|
|
61
|
-
*/
|
|
62
|
-
export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
63
|
-
const projectRoot = cwd || process.cwd();
|
|
64
|
-
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
65
|
-
const meta = wm.getMeta(changeName);
|
|
66
|
-
const result = {
|
|
67
|
-
ok: false,
|
|
68
|
-
changedFiles: [],
|
|
69
|
-
extraFiles: [],
|
|
70
|
-
hashMismatchFiles: [],
|
|
71
|
-
patchPath: null,
|
|
72
|
-
errors: [],
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
// --- 1. 校验 worktree 存在 + meta.json 有效 ---
|
|
76
|
-
if (!meta) {
|
|
77
|
-
result.errors.push(`worktree not found: ${changeName}。meta.json 不存在或已损坏。`);
|
|
78
|
-
return result;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const { worktreePath, baseHash, baselineCommit } = meta;
|
|
82
|
-
// diff 起始点:有 baseline checkpoint 用它(只合子代理改动),否则 fallback 到 baseHash
|
|
83
|
-
const diffBase = baselineCommit || baseHash;
|
|
84
|
-
|
|
85
|
-
if (!existsSync(worktreePath)) {
|
|
86
|
-
result.errors.push(`worktree 目录不存在: ${worktreePath}`);
|
|
87
|
-
return result;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// --- 2. 获取变更文件列表 ---
|
|
91
|
-
// worktree 内修改可能没有 commit,用 git diff <baseHash>(比较 baseHash 到工作区内容)
|
|
92
|
-
// 同时检测 untracked 新文件(git diff 不包含 untracked)
|
|
93
|
-
let changedFiles;
|
|
94
|
-
try {
|
|
95
|
-
// 用 --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
|
|
96
|
-
const statusRaw = git(worktreePath, `diff --name-status ${diffBase}`);
|
|
97
|
-
const statusFiles = new Set();
|
|
98
|
-
if (statusRaw) {
|
|
99
|
-
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
100
|
-
const parts = line.split('\t');
|
|
101
|
-
// R100 old.txt new.txt → 提取两个文件
|
|
102
|
-
if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
|
|
103
|
-
if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// untracked 新文件(diffBase 中不存在的文件)
|
|
108
|
-
const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
|
|
109
|
-
const untrackedFiles = untrackedRaw
|
|
110
|
-
? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
|
|
111
|
-
: [];
|
|
112
|
-
|
|
113
|
-
changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
|
|
114
|
-
} catch (e) {
|
|
115
|
-
result.errors.push(`获取变更文件列表失败: ${e.message}`);
|
|
116
|
-
return result;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
result.changedFiles = changedFiles;
|
|
120
|
-
|
|
121
|
-
if (changedFiles.length === 0) {
|
|
122
|
-
// 没有变更
|
|
123
|
-
if (!checkOnly) {
|
|
124
|
-
wm.cleanup(changeName);
|
|
125
|
-
}
|
|
126
|
-
result.ok = true;
|
|
127
|
-
return result;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// --- 3. 解析 design.md 文件变更清单 ---
|
|
131
|
-
const designPath = join(projectRoot, CHANGES_REL, changeName, 'design.md');
|
|
132
|
-
const allowSet = parseFileChangeList(designPath);
|
|
133
|
-
const hasAllowList = allowSet.size > 0;
|
|
134
|
-
|
|
135
|
-
// --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
|
|
136
|
-
if (hasAllowList) {
|
|
137
|
-
for (const f of changedFiles) {
|
|
138
|
-
if (!allowSet.has(f)) {
|
|
139
|
-
result.extraFiles.push(f);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (result.extraFiles.length > 0) {
|
|
143
|
-
result.errors.push(
|
|
144
|
-
`文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${result.extraFiles.join('\n ')}`
|
|
145
|
-
);
|
|
146
|
-
return result;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// --- 4.5 校验:主工作区 baseline 是否变化(防 execute 期间主工作区被修改)---
|
|
151
|
-
// 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则
|
|
152
|
-
if (meta.baselineHash) {
|
|
153
|
-
const exclude = '-- . ":(exclude).sillyspec/"';
|
|
154
|
-
const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
|
|
155
|
-
const unstaged = gitQuiet(projectRoot, `diff ${exclude}`) || '';
|
|
156
|
-
const untracked = gitQuiet(projectRoot, `ls-files --others --exclude-standard ${exclude}`) || '';
|
|
157
|
-
const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
|
|
158
|
-
const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
159
|
-
if (currentHash !== meta.baselineHash) {
|
|
160
|
-
result.errors.push(
|
|
161
|
-
`主工作区 baseline 已变化(execute 前后不一致),不能直接 apply task.patch。\n` +
|
|
162
|
-
`建议:重新创建 worktree 或手动检查冲突。\n` +
|
|
163
|
-
`execute 前 baseline: ${meta.baselineHash}\n` +
|
|
164
|
-
`当前 baseline: ${currentHash}`
|
|
165
|
-
);
|
|
166
|
-
return result;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// --- 5. 校验:主工作区文件 base hash 一致 ---
|
|
171
|
-
// 5a. 检查主工作区是否有未 commit 的脏文件(会影响 apply)
|
|
172
|
-
const mainDirtyRaw = gitQuiet(projectRoot, 'diff --name-only HEAD');
|
|
173
|
-
const mainDirtyFiles = mainDirtyRaw ? mainDirtyRaw.split('\n').filter(Boolean) : [];
|
|
174
|
-
if (mainDirtyFiles.length > 0) {
|
|
175
|
-
// 如果脏文件和本次 apply 的文件有交集 → 报错
|
|
176
|
-
const conflictDirty = mainDirtyFiles.filter(f => changedFiles.includes(f));
|
|
177
|
-
if (conflictDirty.length > 0) {
|
|
178
|
-
result.errors.push(
|
|
179
|
-
`主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
|
|
180
|
-
);
|
|
181
|
-
return result;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
|
|
186
|
-
const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
|
|
187
|
-
for (const f of targetFiles) {
|
|
188
|
-
const wtBlob = getFileBlobHash(worktreePath, baseHash, f);
|
|
189
|
-
const mainBlob = getFileBlobHash(projectRoot, 'HEAD', f);
|
|
190
|
-
|
|
191
|
-
// 两者都为 null(文件在 base 时不存在)→ OK
|
|
192
|
-
if (wtBlob === null && mainBlob === null) continue;
|
|
193
|
-
// 两者一致 → OK
|
|
194
|
-
if (wtBlob === mainBlob) continue;
|
|
195
|
-
// 不一致 → 主工作区已被修改
|
|
196
|
-
result.hashMismatchFiles.push(f);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (result.hashMismatchFiles.length > 0) {
|
|
200
|
-
result.errors.push(
|
|
201
|
-
`base hash 不一致:以下文件在主工作区已被修改:\n ${result.hashMismatchFiles.join('\n ')}`
|
|
202
|
-
);
|
|
203
|
-
return result;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// --- 6. checkOnly 模式:到此返回 ---
|
|
207
|
-
if (checkOnly) {
|
|
208
|
-
result.ok = true;
|
|
209
|
-
return result;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// --- 7. 生成 patch 并 apply ---
|
|
213
|
-
// 确定要包含在 patch 中的文件:有清单用清单交集,无清单用全部变更
|
|
214
|
-
const patchFiles = hasAllowList
|
|
215
|
-
? [...allowSet].filter(f => changedFiles.includes(f))
|
|
216
|
-
: changedFiles;
|
|
217
|
-
const fileArgs = patchFiles.length > 0 ? `-- ${patchFiles.join(' ')}` : '';
|
|
218
|
-
|
|
219
|
-
// 创建临时文件
|
|
220
|
-
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
|
|
221
|
-
const patchPath = join(tmpDir, 'apply.patch');
|
|
222
|
-
result.patchPath = patchPath;
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
let patchContent = '';
|
|
226
|
-
|
|
227
|
-
// 分 tracked 变更和 untracked 新文件生成 patch
|
|
228
|
-
const trackedFiles = patchFiles.filter(f => {
|
|
229
|
-
// 文件在 diffBase 的 tree 中存在 → tracked(包括 rename 目标可能的情况)
|
|
230
|
-
if (gitQuiet(worktreePath, `cat-file -e ${diffBase}:${f}`) !== null) return true;
|
|
231
|
-
// 文件在工作区 index 中已存在(比如被 git mv 处理过)→ 也视为 tracked
|
|
232
|
-
if (gitQuiet(worktreePath, `ls-files --error-unmatch ${f}`) !== null) return true;
|
|
233
|
-
return false;
|
|
234
|
-
});
|
|
235
|
-
const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
|
|
236
|
-
|
|
237
|
-
// tracked 文件:git diff baseHash
|
|
238
|
-
if (trackedFiles.length > 0) {
|
|
239
|
-
const trackedArgs = trackedFiles.length > 0 ? `-- ${trackedFiles.join(' ')}` : '';
|
|
240
|
-
patchContent += execSync(
|
|
241
|
-
`git diff --binary ${diffBase} ${trackedArgs}`,
|
|
242
|
-
{ cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// untracked 新文件:git add 到 index,git diff --cached,然后 reset
|
|
247
|
-
if (untrackedPatchFiles.length > 0) {
|
|
248
|
-
const addArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
|
|
249
|
-
git(worktreePath, `add ${addArgs}`);
|
|
250
|
-
try {
|
|
251
|
-
const diffCachedArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
|
|
252
|
-
patchContent += execSync(
|
|
253
|
-
`git diff --binary --cached ${diffCachedArgs}`,
|
|
254
|
-
{ cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
255
|
-
);
|
|
256
|
-
} finally {
|
|
257
|
-
// 重置 index(不保留 staged 状态)
|
|
258
|
-
gitQuiet(worktreePath, `reset HEAD -- ${addArgs}`);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (!patchContent.trim()) {
|
|
263
|
-
// patch 为空(清单中部分文件可能没实际变更)
|
|
264
|
-
result.ok = true;
|
|
265
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
266
|
-
return result;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
writeFileSync(patchPath, patchContent);
|
|
270
|
-
|
|
271
|
-
// apply --check 预检
|
|
272
|
-
try {
|
|
273
|
-
git(projectRoot, `apply --check ${patchPath}`);
|
|
274
|
-
} catch (e) {
|
|
275
|
-
result.errors.push(`patch 预检失败: ${e.message}`);
|
|
276
|
-
return result;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// apply --3way 正式应用
|
|
280
|
-
try {
|
|
281
|
-
git(projectRoot, `apply --3way ${patchPath}`);
|
|
282
|
-
} catch (e) {
|
|
283
|
-
result.errors.push(`patch apply 失败: ${e.message}`);
|
|
284
|
-
return result;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
result.ok = true;
|
|
288
|
-
|
|
289
|
-
// --- 8. 成功后自动 cleanup(失败不影响整体结果) ---
|
|
290
|
-
try {
|
|
291
|
-
wm.cleanup(changeName);
|
|
292
|
-
} catch (cleanupErr) {
|
|
293
|
-
result.warnings = result.warnings || [];
|
|
294
|
-
result.warnings.push(`cleanup 失败(不影响应用结果): ${cleanupErr.message}`);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
} catch (e) {
|
|
298
|
-
result.errors.push(`patch 生成/应用异常: ${e.message}`);
|
|
299
|
-
return result;
|
|
300
|
-
} finally {
|
|
301
|
-
// 清理临时目录
|
|
302
|
-
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return result;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* 风险审计:评估 worktree 变更是否可以安全自动 apply
|
|
310
|
-
*
|
|
311
|
-
* 检查项:
|
|
312
|
-
* 1. patch --check 通过
|
|
313
|
-
* 2. 所有变更在 allowed_paths 内
|
|
314
|
-
* 3. 主工作区 baseline 未变化
|
|
315
|
-
* 4. 没有删除/重命名关键文件
|
|
316
|
-
* 5. 没有改高风险文件(lockfile/migration/配置/入口)除非任务显式允许
|
|
317
|
-
* 6. diff 规模没有异常膨胀
|
|
318
|
-
*
|
|
319
|
-
* @param {string} changeName
|
|
320
|
-
* @param {{ cwd?: string }} opts
|
|
321
|
-
* @returns {{
|
|
322
|
-
* decision: 'SAFE' | 'WARNING' | 'BLOCKED',
|
|
323
|
-
* changedFiles: string[],
|
|
324
|
-
* reasons: string[],
|
|
325
|
-
* warnings: string[],
|
|
326
|
-
* stats: { additions: number, deletions: number }
|
|
327
|
-
* }}
|
|
328
|
-
*/
|
|
329
|
-
export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
330
|
-
const projectRoot = cwd || process.cwd();
|
|
331
|
-
const reasons = [];
|
|
332
|
-
const warnings = [];
|
|
333
|
-
|
|
334
|
-
// 先跑 --check-only 模式的 applyWorktree 获取变更文件列表
|
|
335
|
-
const checkResult = applyWorktree(changeName, { cwd: projectRoot, checkOnly: true });
|
|
336
|
-
|
|
337
|
-
if (checkResult.errors.length > 0) {
|
|
338
|
-
return {
|
|
339
|
-
decision: 'BLOCKED',
|
|
340
|
-
changedFiles: checkResult.changedFiles,
|
|
341
|
-
reasons: checkResult.errors,
|
|
342
|
-
warnings: [],
|
|
343
|
-
stats: { additions: 0, deletions: 0 }
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const changedFiles = checkResult.changedFiles;
|
|
348
|
-
|
|
349
|
-
if (changedFiles.length === 0) {
|
|
350
|
-
return {
|
|
351
|
-
decision: 'SAFE',
|
|
352
|
-
changedFiles: [],
|
|
353
|
-
reasons: ['无变更需要应用'],
|
|
354
|
-
warnings: [],
|
|
355
|
-
stats: { additions: 0, deletions: 0 }
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// 解析 TaskCard allowed_paths
|
|
360
|
-
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
361
|
-
const meta = wm.getMeta(changeName);
|
|
362
|
-
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
363
|
-
const allowedPaths = new Set();
|
|
364
|
-
if (existsSync(tasksDir)) {
|
|
365
|
-
const { readdirSync, readFileSync } = require('fs');
|
|
366
|
-
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
367
|
-
const content = readFileSync(join(tasksDir, tf), 'utf8');
|
|
368
|
-
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
369
|
-
if (!fmMatch) continue;
|
|
370
|
-
const fm = fmMatch[1];
|
|
371
|
-
const inline = fm.match(/allowed_paths:\s*\[([^\]]*)\]/);
|
|
372
|
-
if (inline) {
|
|
373
|
-
inline[1].split(',').forEach(s => { const v = s.trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
|
|
374
|
-
}
|
|
375
|
-
const block = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/);
|
|
376
|
-
if (block) {
|
|
377
|
-
block[1].match(/-\s+(.+)/g)?.forEach(s => { const v = s.replace(/^-\s+/, '').trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时)
|
|
383
|
-
if (allowedPaths.size > 0) {
|
|
384
|
-
const outsidePaths = changedFiles.filter(f => !
|
|
385
|
-
[...allowedPaths].some(allowed => f === allowed || f.startsWith(allowed.replace(/\*$/, '')))
|
|
386
|
-
);
|
|
387
|
-
if (outsidePaths.length > 0) {
|
|
388
|
-
reasons.push(`变更文件超出 allowed_paths:\n ${outsidePaths.join('\n ')}`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// 检查 4+5: 高风险文件模式
|
|
393
|
-
const HIGH_RISK_PATTERNS = [
|
|
394
|
-
/(^|\/)package-lock\.json$/,
|
|
395
|
-
/(^|\/)pnpm-lock\.yaml$/,
|
|
396
|
-
/(^|\/)yarn\.lock$/,
|
|
397
|
-
/(^|\/)\.env($|\.)/,
|
|
398
|
-
/(^|\/)docker-compose.*\.ya?ml$/,
|
|
399
|
-
/(^|\/)Dockerfile$/,
|
|
400
|
-
/migration[\w.-]*\.(sql|js|ts)$/i,
|
|
401
|
-
/(^|\/).*entry.*\.(js|ts)$/i,
|
|
402
|
-
/(^|\/)main\.(js|ts)$/i,
|
|
403
|
-
/(^|\/)index\.(js|ts)$/i,
|
|
404
|
-
/(^|\/)app\.(js|ts)$/i,
|
|
405
|
-
];
|
|
406
|
-
const riskyFiles = changedFiles.filter(f => HIGH_RISK_PATTERNS.some(p => p.test(f)));
|
|
407
|
-
if (riskyFiles.length > 0) {
|
|
408
|
-
// 高风险文件只有在 allowedPaths 显式包含时才放行
|
|
409
|
-
const trulyRisky = riskyFiles.filter(f => !
|
|
410
|
-
[...allowedPaths].some(allowed => f === allowed)
|
|
411
|
-
);
|
|
412
|
-
if (trulyRisky.length > 0) {
|
|
413
|
-
reasons.push(`高风险文件变更(未在 allowed_paths 中显式声明):\n ${trulyRisky.join('\n ')}`);
|
|
414
|
-
} else {
|
|
415
|
-
warnings.push(`高风险文件变更(已在 allowed_paths 中声明):${riskyFiles.join(', ')}`);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// 检查 6: diff 规模异常(>2000 行变更视为异常)
|
|
420
|
-
const wtPath = meta?.worktreePath;
|
|
421
|
-
const diffBase = meta?.baselineCommit || meta?.baseHash;
|
|
422
|
-
let additions = 0, deletions = 0;
|
|
423
|
-
if (wtPath && diffBase) {
|
|
424
|
-
try {
|
|
425
|
-
const shortstat = gitQuiet(wtPath, `diff --shortstat ${diffBase}`);
|
|
426
|
-
const insMatch = shortstat?.match(/(\d+) insertion/);
|
|
427
|
-
const delMatch = shortstat?.match(/(\d+) deletion/);
|
|
428
|
-
additions = insMatch ? parseInt(insMatch[1]) : 0;
|
|
429
|
-
deletions = delMatch ? parseInt(delMatch[1]) : 0;
|
|
430
|
-
if (additions + deletions > 2000) {
|
|
431
|
-
reasons.push(`diff 规模异常(${additions} additions + ${deletions} deletions = ${additions + deletions} 行)`);
|
|
432
|
-
}
|
|
433
|
-
} catch {}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// 判定
|
|
437
|
-
let decision;
|
|
438
|
-
if (reasons.length > 0) {
|
|
439
|
-
decision = 'BLOCKED';
|
|
440
|
-
} else if (warnings.length > 0) {
|
|
441
|
-
decision = 'WARNING';
|
|
442
|
-
} else {
|
|
443
|
-
decision = 'SAFE';
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
return { decision, changedFiles, reasons, warnings, stats: { additions, deletions } };
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* 格式化 execute run summary(人类可读)
|
|
451
|
-
*
|
|
452
|
-
* 只展示 CLI 真实掌握的信息,不声称知道 per-task 状态。
|
|
453
|
-
* @param {object} opts
|
|
454
|
-
* @param {string} opts.changeName - 变更名
|
|
455
|
-
* @param {number} opts.stepsCompleted - 已完成步骤数
|
|
456
|
-
* @param {number} opts.stepsTotal - 总步骤数
|
|
457
|
-
* @param {string} opts.agentSummary - Agent 最终输出摘要
|
|
458
|
-
* @param {string} [opts.cwd] - 项目根目录(默认 process.cwd())
|
|
459
|
-
* @returns {string} 格式化的 summary 文本
|
|
460
|
-
*/
|
|
461
|
-
export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, agentSummary, cwd }) {
|
|
462
|
-
const wm = new WorktreeManager({ cwd });
|
|
463
|
-
const meta = wm.getMeta(changeName);
|
|
464
|
-
const lines = [];
|
|
465
|
-
|
|
466
|
-
const SEPARATOR = '─'.repeat(32);
|
|
467
|
-
|
|
468
|
-
// --- Header ---
|
|
469
|
-
lines.push(`Execute Summary`);
|
|
470
|
-
lines.push(SEPARATOR);
|
|
471
|
-
|
|
472
|
-
// --- Status ---
|
|
473
|
-
if (!meta) {
|
|
474
|
-
// worktree 不存在(可能已 cleanup 或没有用过 worktree)
|
|
475
|
-
lines.push(`Status: COMPLETED`);
|
|
476
|
-
lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
|
|
477
|
-
lines.push(`Apply: N/A`);
|
|
478
|
-
} else {
|
|
479
|
-
const hasBaseline = meta.baselineCommit != null;
|
|
480
|
-
const wtExists = existsSync(meta.worktreePath);
|
|
481
|
-
|
|
482
|
-
const applyStatus = wtExists ? 'pending' : 'applied';
|
|
483
|
-
const baselineCount = meta.baselineFiles?.length || 0;
|
|
484
|
-
const baselineStatus = hasBaseline
|
|
485
|
-
? `dirty (${baselineCount} baseline file${baselineCount === 1 ? '' : 's'} protected)`
|
|
486
|
-
: 'clean';
|
|
487
|
-
|
|
488
|
-
// Worktree 最终状态
|
|
489
|
-
const mode = meta.mode || 'worktree';
|
|
490
|
-
let worktreeStatus;
|
|
491
|
-
if (mode === 'native-worktree') {
|
|
492
|
-
worktreeStatus = 'kept (external worktree)';
|
|
493
|
-
} else if (mode === 'in-place-fallback') {
|
|
494
|
-
worktreeStatus = 'none (in-place)';
|
|
495
|
-
} else if (!wtExists) {
|
|
496
|
-
worktreeStatus = 'cleaned';
|
|
497
|
-
} else {
|
|
498
|
-
worktreeStatus = 'exists';
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
lines.push(`Status: COMPLETED`);
|
|
502
|
-
lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
|
|
503
|
-
lines.push(`Baseline: ${baselineStatus}`);
|
|
504
|
-
lines.push(`Apply: ${applyStatus}`);
|
|
505
|
-
lines.push(`Worktree: ${worktreeStatus}`);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// --- Changed files ---
|
|
509
|
-
// 从主工作区 diff 获取(worktree 已 apply)或从 worktree diff 获取
|
|
510
|
-
if (meta && existsSync(meta.worktreePath)) {
|
|
511
|
-
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
512
|
-
try {
|
|
513
|
-
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
514
|
-
const { execSync: es } = require('child_process');
|
|
515
|
-
const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
|
|
516
|
-
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
517
|
-
if (files.length > 0) {
|
|
518
|
-
lines.push(``);
|
|
519
|
-
const maxShow = 10;
|
|
520
|
-
const showFiles = files.slice(0, maxShow);
|
|
521
|
-
const remain = files.length - maxShow;
|
|
522
|
-
lines.push(`Changed Files (${files.length})`);
|
|
523
|
-
showFiles.forEach(f => lines.push(` ${f}`));
|
|
524
|
-
if (remain > 0) {
|
|
525
|
-
lines.push(` ... ${remain} more`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
} catch {}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// --- Agent Summary ---
|
|
532
|
-
if (agentSummary) {
|
|
533
|
-
lines.push(``);
|
|
534
|
-
lines.push(`Agent Summary`);
|
|
535
|
-
// 缩进每行,截断过长内容
|
|
536
|
-
const maxLen = 200;
|
|
537
|
-
const summary = agentSummary.length > maxLen
|
|
538
|
-
? agentSummary.slice(0, maxLen) + '...'
|
|
539
|
-
: agentSummary;
|
|
540
|
-
summary.split('\n').forEach(l => lines.push(` ${l}`));
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// --- Next ---
|
|
544
|
-
lines.push(``);
|
|
545
|
-
lines.push(`Next`);
|
|
546
|
-
lines.push(` → sillyspec run verify`);
|
|
547
|
-
|
|
548
|
-
return lines.join('\n');
|
|
549
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* SillySpec applyWorktree — 将 worktree 中的变更应用到主工作区
|
|
3
|
+
*
|
|
4
|
+
* 流程:
|
|
5
|
+
* 1. 读取 meta.json 获取 baseHash
|
|
6
|
+
* 2. git diff --name-only baseHash 获取 worktree 中所有变更文件
|
|
7
|
+
* 3. 从 design.md 解析文件变更清单(无清单 = 允许所有)
|
|
8
|
+
* 4. 校验:变更文件 ⊆ 清单
|
|
9
|
+
* 5. 校验:主工作区文件 base hash 一致
|
|
10
|
+
* 6. --check-only 模式只输出检查结果
|
|
11
|
+
* 7. 非 checkOnly:生成 patch → apply --check → apply --3way
|
|
12
|
+
* 8. 成功后自动 cleanup
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
17
|
+
import { join, resolve } from 'path';
|
|
18
|
+
import { tmpdir } from 'os';
|
|
19
|
+
import { createHash } from 'crypto';
|
|
20
|
+
import { WorktreeManager } from './worktree.js';
|
|
21
|
+
import { parseFileChangeList } from './change-list.js';
|
|
22
|
+
|
|
23
|
+
const CHANGES_REL = '.sillyspec/changes';
|
|
24
|
+
|
|
25
|
+
function git(cwd, args) {
|
|
26
|
+
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gitQuiet(cwd, args) {
|
|
30
|
+
try {
|
|
31
|
+
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 获取文件在 git 中的 blob hash(基于某个 commit/tree)
|
|
39
|
+
* @param {string} cwd - git 工作区路径
|
|
40
|
+
* @param {string} treeish - commit hash、分支等
|
|
41
|
+
* @param {string} filePath - 相对路径
|
|
42
|
+
* @returns {string|null} blob hash,文件不存在返回 null
|
|
43
|
+
*/
|
|
44
|
+
function getFileBlobHash(cwd, treeish, filePath) {
|
|
45
|
+
return gitQuiet(cwd, `rev-parse ${treeish}:${filePath}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* apply worktree 变更到主工作区
|
|
50
|
+
*
|
|
51
|
+
* @param {string} changeName - 变更名
|
|
52
|
+
* @param {{ cwd?: string, checkOnly?: boolean }} opts
|
|
53
|
+
* @returns {{
|
|
54
|
+
* ok: boolean,
|
|
55
|
+
* changedFiles: string[],
|
|
56
|
+
* extraFiles: string[],
|
|
57
|
+
* hashMismatchFiles: string[],
|
|
58
|
+
* patchPath: string|null,
|
|
59
|
+
* errors: string[]
|
|
60
|
+
* }}
|
|
61
|
+
*/
|
|
62
|
+
export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
63
|
+
const projectRoot = cwd || process.cwd();
|
|
64
|
+
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
65
|
+
const meta = wm.getMeta(changeName);
|
|
66
|
+
const result = {
|
|
67
|
+
ok: false,
|
|
68
|
+
changedFiles: [],
|
|
69
|
+
extraFiles: [],
|
|
70
|
+
hashMismatchFiles: [],
|
|
71
|
+
patchPath: null,
|
|
72
|
+
errors: [],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// --- 1. 校验 worktree 存在 + meta.json 有效 ---
|
|
76
|
+
if (!meta) {
|
|
77
|
+
result.errors.push(`worktree not found: ${changeName}。meta.json 不存在或已损坏。`);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { worktreePath, baseHash, baselineCommit } = meta;
|
|
82
|
+
// diff 起始点:有 baseline checkpoint 用它(只合子代理改动),否则 fallback 到 baseHash
|
|
83
|
+
const diffBase = baselineCommit || baseHash;
|
|
84
|
+
|
|
85
|
+
if (!existsSync(worktreePath)) {
|
|
86
|
+
result.errors.push(`worktree 目录不存在: ${worktreePath}`);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- 2. 获取变更文件列表 ---
|
|
91
|
+
// worktree 内修改可能没有 commit,用 git diff <baseHash>(比较 baseHash 到工作区内容)
|
|
92
|
+
// 同时检测 untracked 新文件(git diff 不包含 untracked)
|
|
93
|
+
let changedFiles;
|
|
94
|
+
try {
|
|
95
|
+
// 用 --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
|
|
96
|
+
const statusRaw = git(worktreePath, `diff --name-status ${diffBase}`);
|
|
97
|
+
const statusFiles = new Set();
|
|
98
|
+
if (statusRaw) {
|
|
99
|
+
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
100
|
+
const parts = line.split('\t');
|
|
101
|
+
// R100 old.txt new.txt → 提取两个文件
|
|
102
|
+
if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
|
|
103
|
+
if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// untracked 新文件(diffBase 中不存在的文件)
|
|
108
|
+
const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
|
|
109
|
+
const untrackedFiles = untrackedRaw
|
|
110
|
+
? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
|
|
111
|
+
: [];
|
|
112
|
+
|
|
113
|
+
changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
|
|
114
|
+
} catch (e) {
|
|
115
|
+
result.errors.push(`获取变更文件列表失败: ${e.message}`);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
result.changedFiles = changedFiles;
|
|
120
|
+
|
|
121
|
+
if (changedFiles.length === 0) {
|
|
122
|
+
// 没有变更
|
|
123
|
+
if (!checkOnly) {
|
|
124
|
+
wm.cleanup(changeName);
|
|
125
|
+
}
|
|
126
|
+
result.ok = true;
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- 3. 解析 design.md 文件变更清单 ---
|
|
131
|
+
const designPath = join(projectRoot, CHANGES_REL, changeName, 'design.md');
|
|
132
|
+
const allowSet = parseFileChangeList(designPath);
|
|
133
|
+
const hasAllowList = allowSet.size > 0;
|
|
134
|
+
|
|
135
|
+
// --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
|
|
136
|
+
if (hasAllowList) {
|
|
137
|
+
for (const f of changedFiles) {
|
|
138
|
+
if (!allowSet.has(f)) {
|
|
139
|
+
result.extraFiles.push(f);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (result.extraFiles.length > 0) {
|
|
143
|
+
result.errors.push(
|
|
144
|
+
`文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${result.extraFiles.join('\n ')}`
|
|
145
|
+
);
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- 4.5 校验:主工作区 baseline 是否变化(防 execute 期间主工作区被修改)---
|
|
151
|
+
// 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则
|
|
152
|
+
if (meta.baselineHash) {
|
|
153
|
+
const exclude = '-- . ":(exclude).sillyspec/"';
|
|
154
|
+
const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
|
|
155
|
+
const unstaged = gitQuiet(projectRoot, `diff ${exclude}`) || '';
|
|
156
|
+
const untracked = gitQuiet(projectRoot, `ls-files --others --exclude-standard ${exclude}`) || '';
|
|
157
|
+
const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
|
|
158
|
+
const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
159
|
+
if (currentHash !== meta.baselineHash) {
|
|
160
|
+
result.errors.push(
|
|
161
|
+
`主工作区 baseline 已变化(execute 前后不一致),不能直接 apply task.patch。\n` +
|
|
162
|
+
`建议:重新创建 worktree 或手动检查冲突。\n` +
|
|
163
|
+
`execute 前 baseline: ${meta.baselineHash}\n` +
|
|
164
|
+
`当前 baseline: ${currentHash}`
|
|
165
|
+
);
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- 5. 校验:主工作区文件 base hash 一致 ---
|
|
171
|
+
// 5a. 检查主工作区是否有未 commit 的脏文件(会影响 apply)
|
|
172
|
+
const mainDirtyRaw = gitQuiet(projectRoot, 'diff --name-only HEAD');
|
|
173
|
+
const mainDirtyFiles = mainDirtyRaw ? mainDirtyRaw.split('\n').filter(Boolean) : [];
|
|
174
|
+
if (mainDirtyFiles.length > 0) {
|
|
175
|
+
// 如果脏文件和本次 apply 的文件有交集 → 报错
|
|
176
|
+
const conflictDirty = mainDirtyFiles.filter(f => changedFiles.includes(f));
|
|
177
|
+
if (conflictDirty.length > 0) {
|
|
178
|
+
result.errors.push(
|
|
179
|
+
`主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
|
|
180
|
+
);
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
|
|
186
|
+
const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
|
|
187
|
+
for (const f of targetFiles) {
|
|
188
|
+
const wtBlob = getFileBlobHash(worktreePath, baseHash, f);
|
|
189
|
+
const mainBlob = getFileBlobHash(projectRoot, 'HEAD', f);
|
|
190
|
+
|
|
191
|
+
// 两者都为 null(文件在 base 时不存在)→ OK
|
|
192
|
+
if (wtBlob === null && mainBlob === null) continue;
|
|
193
|
+
// 两者一致 → OK
|
|
194
|
+
if (wtBlob === mainBlob) continue;
|
|
195
|
+
// 不一致 → 主工作区已被修改
|
|
196
|
+
result.hashMismatchFiles.push(f);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (result.hashMismatchFiles.length > 0) {
|
|
200
|
+
result.errors.push(
|
|
201
|
+
`base hash 不一致:以下文件在主工作区已被修改:\n ${result.hashMismatchFiles.join('\n ')}`
|
|
202
|
+
);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- 6. checkOnly 模式:到此返回 ---
|
|
207
|
+
if (checkOnly) {
|
|
208
|
+
result.ok = true;
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- 7. 生成 patch 并 apply ---
|
|
213
|
+
// 确定要包含在 patch 中的文件:有清单用清单交集,无清单用全部变更
|
|
214
|
+
const patchFiles = hasAllowList
|
|
215
|
+
? [...allowSet].filter(f => changedFiles.includes(f))
|
|
216
|
+
: changedFiles;
|
|
217
|
+
const fileArgs = patchFiles.length > 0 ? `-- ${patchFiles.join(' ')}` : '';
|
|
218
|
+
|
|
219
|
+
// 创建临时文件
|
|
220
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
|
|
221
|
+
const patchPath = join(tmpDir, 'apply.patch');
|
|
222
|
+
result.patchPath = patchPath;
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
let patchContent = '';
|
|
226
|
+
|
|
227
|
+
// 分 tracked 变更和 untracked 新文件生成 patch
|
|
228
|
+
const trackedFiles = patchFiles.filter(f => {
|
|
229
|
+
// 文件在 diffBase 的 tree 中存在 → tracked(包括 rename 目标可能的情况)
|
|
230
|
+
if (gitQuiet(worktreePath, `cat-file -e ${diffBase}:${f}`) !== null) return true;
|
|
231
|
+
// 文件在工作区 index 中已存在(比如被 git mv 处理过)→ 也视为 tracked
|
|
232
|
+
if (gitQuiet(worktreePath, `ls-files --error-unmatch ${f}`) !== null) return true;
|
|
233
|
+
return false;
|
|
234
|
+
});
|
|
235
|
+
const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
|
|
236
|
+
|
|
237
|
+
// tracked 文件:git diff baseHash
|
|
238
|
+
if (trackedFiles.length > 0) {
|
|
239
|
+
const trackedArgs = trackedFiles.length > 0 ? `-- ${trackedFiles.join(' ')}` : '';
|
|
240
|
+
patchContent += execSync(
|
|
241
|
+
`git diff --binary ${diffBase} ${trackedArgs}`,
|
|
242
|
+
{ cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// untracked 新文件:git add 到 index,git diff --cached,然后 reset
|
|
247
|
+
if (untrackedPatchFiles.length > 0) {
|
|
248
|
+
const addArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
|
|
249
|
+
git(worktreePath, `add ${addArgs}`);
|
|
250
|
+
try {
|
|
251
|
+
const diffCachedArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
|
|
252
|
+
patchContent += execSync(
|
|
253
|
+
`git diff --binary --cached ${diffCachedArgs}`,
|
|
254
|
+
{ cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
255
|
+
);
|
|
256
|
+
} finally {
|
|
257
|
+
// 重置 index(不保留 staged 状态)
|
|
258
|
+
gitQuiet(worktreePath, `reset HEAD -- ${addArgs}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!patchContent.trim()) {
|
|
263
|
+
// patch 为空(清单中部分文件可能没实际变更)
|
|
264
|
+
result.ok = true;
|
|
265
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
writeFileSync(patchPath, patchContent);
|
|
270
|
+
|
|
271
|
+
// apply --check 预检
|
|
272
|
+
try {
|
|
273
|
+
git(projectRoot, `apply --check ${patchPath}`);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
result.errors.push(`patch 预检失败: ${e.message}`);
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// apply --3way 正式应用
|
|
280
|
+
try {
|
|
281
|
+
git(projectRoot, `apply --3way ${patchPath}`);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
result.errors.push(`patch apply 失败: ${e.message}`);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
result.ok = true;
|
|
288
|
+
|
|
289
|
+
// --- 8. 成功后自动 cleanup(失败不影响整体结果) ---
|
|
290
|
+
try {
|
|
291
|
+
wm.cleanup(changeName);
|
|
292
|
+
} catch (cleanupErr) {
|
|
293
|
+
result.warnings = result.warnings || [];
|
|
294
|
+
result.warnings.push(`cleanup 失败(不影响应用结果): ${cleanupErr.message}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
} catch (e) {
|
|
298
|
+
result.errors.push(`patch 生成/应用异常: ${e.message}`);
|
|
299
|
+
return result;
|
|
300
|
+
} finally {
|
|
301
|
+
// 清理临时目录
|
|
302
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 风险审计:评估 worktree 变更是否可以安全自动 apply
|
|
310
|
+
*
|
|
311
|
+
* 检查项:
|
|
312
|
+
* 1. patch --check 通过
|
|
313
|
+
* 2. 所有变更在 allowed_paths 内
|
|
314
|
+
* 3. 主工作区 baseline 未变化
|
|
315
|
+
* 4. 没有删除/重命名关键文件
|
|
316
|
+
* 5. 没有改高风险文件(lockfile/migration/配置/入口)除非任务显式允许
|
|
317
|
+
* 6. diff 规模没有异常膨胀
|
|
318
|
+
*
|
|
319
|
+
* @param {string} changeName
|
|
320
|
+
* @param {{ cwd?: string }} opts
|
|
321
|
+
* @returns {{
|
|
322
|
+
* decision: 'SAFE' | 'WARNING' | 'BLOCKED',
|
|
323
|
+
* changedFiles: string[],
|
|
324
|
+
* reasons: string[],
|
|
325
|
+
* warnings: string[],
|
|
326
|
+
* stats: { additions: number, deletions: number }
|
|
327
|
+
* }}
|
|
328
|
+
*/
|
|
329
|
+
export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
330
|
+
const projectRoot = cwd || process.cwd();
|
|
331
|
+
const reasons = [];
|
|
332
|
+
const warnings = [];
|
|
333
|
+
|
|
334
|
+
// 先跑 --check-only 模式的 applyWorktree 获取变更文件列表
|
|
335
|
+
const checkResult = applyWorktree(changeName, { cwd: projectRoot, checkOnly: true });
|
|
336
|
+
|
|
337
|
+
if (checkResult.errors.length > 0) {
|
|
338
|
+
return {
|
|
339
|
+
decision: 'BLOCKED',
|
|
340
|
+
changedFiles: checkResult.changedFiles,
|
|
341
|
+
reasons: checkResult.errors,
|
|
342
|
+
warnings: [],
|
|
343
|
+
stats: { additions: 0, deletions: 0 }
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const changedFiles = checkResult.changedFiles;
|
|
348
|
+
|
|
349
|
+
if (changedFiles.length === 0) {
|
|
350
|
+
return {
|
|
351
|
+
decision: 'SAFE',
|
|
352
|
+
changedFiles: [],
|
|
353
|
+
reasons: ['无变更需要应用'],
|
|
354
|
+
warnings: [],
|
|
355
|
+
stats: { additions: 0, deletions: 0 }
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 解析 TaskCard allowed_paths
|
|
360
|
+
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
361
|
+
const meta = wm.getMeta(changeName);
|
|
362
|
+
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
363
|
+
const allowedPaths = new Set();
|
|
364
|
+
if (existsSync(tasksDir)) {
|
|
365
|
+
const { readdirSync, readFileSync } = require('fs');
|
|
366
|
+
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
367
|
+
const content = readFileSync(join(tasksDir, tf), 'utf8');
|
|
368
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
369
|
+
if (!fmMatch) continue;
|
|
370
|
+
const fm = fmMatch[1];
|
|
371
|
+
const inline = fm.match(/allowed_paths:\s*\[([^\]]*)\]/);
|
|
372
|
+
if (inline) {
|
|
373
|
+
inline[1].split(',').forEach(s => { const v = s.trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
|
|
374
|
+
}
|
|
375
|
+
const block = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/);
|
|
376
|
+
if (block) {
|
|
377
|
+
block[1].match(/-\s+(.+)/g)?.forEach(s => { const v = s.replace(/^-\s+/, '').trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时)
|
|
383
|
+
if (allowedPaths.size > 0) {
|
|
384
|
+
const outsidePaths = changedFiles.filter(f => !
|
|
385
|
+
[...allowedPaths].some(allowed => f === allowed || f.startsWith(allowed.replace(/\*$/, '')))
|
|
386
|
+
);
|
|
387
|
+
if (outsidePaths.length > 0) {
|
|
388
|
+
reasons.push(`变更文件超出 allowed_paths:\n ${outsidePaths.join('\n ')}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 检查 4+5: 高风险文件模式
|
|
393
|
+
const HIGH_RISK_PATTERNS = [
|
|
394
|
+
/(^|\/)package-lock\.json$/,
|
|
395
|
+
/(^|\/)pnpm-lock\.yaml$/,
|
|
396
|
+
/(^|\/)yarn\.lock$/,
|
|
397
|
+
/(^|\/)\.env($|\.)/,
|
|
398
|
+
/(^|\/)docker-compose.*\.ya?ml$/,
|
|
399
|
+
/(^|\/)Dockerfile$/,
|
|
400
|
+
/migration[\w.-]*\.(sql|js|ts)$/i,
|
|
401
|
+
/(^|\/).*entry.*\.(js|ts)$/i,
|
|
402
|
+
/(^|\/)main\.(js|ts)$/i,
|
|
403
|
+
/(^|\/)index\.(js|ts)$/i,
|
|
404
|
+
/(^|\/)app\.(js|ts)$/i,
|
|
405
|
+
];
|
|
406
|
+
const riskyFiles = changedFiles.filter(f => HIGH_RISK_PATTERNS.some(p => p.test(f)));
|
|
407
|
+
if (riskyFiles.length > 0) {
|
|
408
|
+
// 高风险文件只有在 allowedPaths 显式包含时才放行
|
|
409
|
+
const trulyRisky = riskyFiles.filter(f => !
|
|
410
|
+
[...allowedPaths].some(allowed => f === allowed)
|
|
411
|
+
);
|
|
412
|
+
if (trulyRisky.length > 0) {
|
|
413
|
+
reasons.push(`高风险文件变更(未在 allowed_paths 中显式声明):\n ${trulyRisky.join('\n ')}`);
|
|
414
|
+
} else {
|
|
415
|
+
warnings.push(`高风险文件变更(已在 allowed_paths 中声明):${riskyFiles.join(', ')}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 检查 6: diff 规模异常(>2000 行变更视为异常)
|
|
420
|
+
const wtPath = meta?.worktreePath;
|
|
421
|
+
const diffBase = meta?.baselineCommit || meta?.baseHash;
|
|
422
|
+
let additions = 0, deletions = 0;
|
|
423
|
+
if (wtPath && diffBase) {
|
|
424
|
+
try {
|
|
425
|
+
const shortstat = gitQuiet(wtPath, `diff --shortstat ${diffBase}`);
|
|
426
|
+
const insMatch = shortstat?.match(/(\d+) insertion/);
|
|
427
|
+
const delMatch = shortstat?.match(/(\d+) deletion/);
|
|
428
|
+
additions = insMatch ? parseInt(insMatch[1]) : 0;
|
|
429
|
+
deletions = delMatch ? parseInt(delMatch[1]) : 0;
|
|
430
|
+
if (additions + deletions > 2000) {
|
|
431
|
+
reasons.push(`diff 规模异常(${additions} additions + ${deletions} deletions = ${additions + deletions} 行)`);
|
|
432
|
+
}
|
|
433
|
+
} catch {}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 判定
|
|
437
|
+
let decision;
|
|
438
|
+
if (reasons.length > 0) {
|
|
439
|
+
decision = 'BLOCKED';
|
|
440
|
+
} else if (warnings.length > 0) {
|
|
441
|
+
decision = 'WARNING';
|
|
442
|
+
} else {
|
|
443
|
+
decision = 'SAFE';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { decision, changedFiles, reasons, warnings, stats: { additions, deletions } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 格式化 execute run summary(人类可读)
|
|
451
|
+
*
|
|
452
|
+
* 只展示 CLI 真实掌握的信息,不声称知道 per-task 状态。
|
|
453
|
+
* @param {object} opts
|
|
454
|
+
* @param {string} opts.changeName - 变更名
|
|
455
|
+
* @param {number} opts.stepsCompleted - 已完成步骤数
|
|
456
|
+
* @param {number} opts.stepsTotal - 总步骤数
|
|
457
|
+
* @param {string} opts.agentSummary - Agent 最终输出摘要
|
|
458
|
+
* @param {string} [opts.cwd] - 项目根目录(默认 process.cwd())
|
|
459
|
+
* @returns {string} 格式化的 summary 文本
|
|
460
|
+
*/
|
|
461
|
+
export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, agentSummary, cwd }) {
|
|
462
|
+
const wm = new WorktreeManager({ cwd });
|
|
463
|
+
const meta = wm.getMeta(changeName);
|
|
464
|
+
const lines = [];
|
|
465
|
+
|
|
466
|
+
const SEPARATOR = '─'.repeat(32);
|
|
467
|
+
|
|
468
|
+
// --- Header ---
|
|
469
|
+
lines.push(`Execute Summary`);
|
|
470
|
+
lines.push(SEPARATOR);
|
|
471
|
+
|
|
472
|
+
// --- Status ---
|
|
473
|
+
if (!meta) {
|
|
474
|
+
// worktree 不存在(可能已 cleanup 或没有用过 worktree)
|
|
475
|
+
lines.push(`Status: COMPLETED`);
|
|
476
|
+
lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
|
|
477
|
+
lines.push(`Apply: N/A`);
|
|
478
|
+
} else {
|
|
479
|
+
const hasBaseline = meta.baselineCommit != null;
|
|
480
|
+
const wtExists = existsSync(meta.worktreePath);
|
|
481
|
+
|
|
482
|
+
const applyStatus = wtExists ? 'pending' : 'applied';
|
|
483
|
+
const baselineCount = meta.baselineFiles?.length || 0;
|
|
484
|
+
const baselineStatus = hasBaseline
|
|
485
|
+
? `dirty (${baselineCount} baseline file${baselineCount === 1 ? '' : 's'} protected)`
|
|
486
|
+
: 'clean';
|
|
487
|
+
|
|
488
|
+
// Worktree 最终状态
|
|
489
|
+
const mode = meta.mode || 'worktree';
|
|
490
|
+
let worktreeStatus;
|
|
491
|
+
if (mode === 'native-worktree') {
|
|
492
|
+
worktreeStatus = 'kept (external worktree)';
|
|
493
|
+
} else if (mode === 'in-place-fallback') {
|
|
494
|
+
worktreeStatus = 'none (in-place)';
|
|
495
|
+
} else if (!wtExists) {
|
|
496
|
+
worktreeStatus = 'cleaned';
|
|
497
|
+
} else {
|
|
498
|
+
worktreeStatus = 'exists';
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
lines.push(`Status: COMPLETED`);
|
|
502
|
+
lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
|
|
503
|
+
lines.push(`Baseline: ${baselineStatus}`);
|
|
504
|
+
lines.push(`Apply: ${applyStatus}`);
|
|
505
|
+
lines.push(`Worktree: ${worktreeStatus}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// --- Changed files ---
|
|
509
|
+
// 从主工作区 diff 获取(worktree 已 apply)或从 worktree diff 获取
|
|
510
|
+
if (meta && existsSync(meta.worktreePath)) {
|
|
511
|
+
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
512
|
+
try {
|
|
513
|
+
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
514
|
+
const { execSync: es } = require('child_process');
|
|
515
|
+
const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
|
|
516
|
+
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
517
|
+
if (files.length > 0) {
|
|
518
|
+
lines.push(``);
|
|
519
|
+
const maxShow = 10;
|
|
520
|
+
const showFiles = files.slice(0, maxShow);
|
|
521
|
+
const remain = files.length - maxShow;
|
|
522
|
+
lines.push(`Changed Files (${files.length})`);
|
|
523
|
+
showFiles.forEach(f => lines.push(` ${f}`));
|
|
524
|
+
if (remain > 0) {
|
|
525
|
+
lines.push(` ... ${remain} more`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
} catch {}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// --- Agent Summary ---
|
|
532
|
+
if (agentSummary) {
|
|
533
|
+
lines.push(``);
|
|
534
|
+
lines.push(`Agent Summary`);
|
|
535
|
+
// 缩进每行,截断过长内容
|
|
536
|
+
const maxLen = 200;
|
|
537
|
+
const summary = agentSummary.length > maxLen
|
|
538
|
+
? agentSummary.slice(0, maxLen) + '...'
|
|
539
|
+
: agentSummary;
|
|
540
|
+
summary.split('\n').forEach(l => lines.push(` ${l}`));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// --- Next ---
|
|
544
|
+
lines.push(``);
|
|
545
|
+
lines.push(`Next`);
|
|
546
|
+
lines.push(` → sillyspec run verify`);
|
|
547
|
+
|
|
548
|
+
return lines.join('\n');
|
|
549
|
+
}
|