sillyspec 3.25.9 → 3.25.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
- package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/change-list.js +11 -8
- package/src/change-risk-profile.js +22 -0
- package/src/db.js +160 -159
- package/src/dispatch/backends/local-agent.js +152 -0
- package/src/dispatch/backends/sillyhub-mcp.js +242 -0
- package/src/dispatch/probe.js +208 -0
- package/src/dispatch/strategy.js +121 -0
- package/src/doctor-diagnostics.js +19 -25
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +66 -0
- package/src/hooks/worktree-guard.js +64 -55
- package/src/index.js +176 -34
- package/src/init.js +7 -14
- package/src/machine-interface.js +54 -19
- package/src/migrate.js +14 -4
- package/src/modules.js +16 -5
- package/src/progress/change-registry.js +72 -70
- package/src/progress/consistency-doctor.js +71 -14
- package/src/progress/shared.js +6 -3
- package/src/progress/stage-machine.js +61 -65
- package/src/progress/step-store.js +62 -67
- package/src/progress.js +155 -233
- package/src/quicklog.js +13 -5
- package/src/run/command.js +65 -37
- package/src/run/complete-handlers.js +1005 -889
- package/src/run/complete.js +115 -208
- package/src/run/concurrent-detect.js +132 -0
- package/src/run/gates.js +289 -17
- package/src/run/prompt.js +22 -68
- package/src/run/quick-audit.js +10 -9
- package/src/run/shared.js +648 -600
- package/src/run/stage.js +43 -24
- package/src/scan-postcheck.js +9 -31
- package/src/sillyhub-mcp/client.js +389 -0
- package/src/spec-dir-typo.js +71 -0
- package/src/stage-contract-engine.js +22 -1
- package/src/stage-contract-spec.js +11 -4
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +13 -9
- package/src/stages/archive.js +4 -6
- package/src/stages/brainstorm-auto.js +26 -20
- package/src/stages/brainstorm.js +29 -5
- package/src/stages/cmd-existence.js +145 -0
- package/src/stages/doctor.js +9 -6
- package/src/stages/execute.js +61 -20
- package/src/stages/plan-postcheck.js +152 -0
- package/src/stages/plan.js +16 -31
- package/src/stages/quick.js +1 -1
- package/src/stages/scan.js +9 -3
- package/src/stages/status.js +0 -1
- package/src/stages/verify.js +12 -7
- package/src/sync.js +4 -4
- package/src/task-review.js +30 -1
- package/src/verify-postcheck.js +933 -862
- package/src/version.js +19 -0
- package/src/worktree-apply.js +85 -68
- package/src/worktree-deps.js +89 -5
- package/src/worktree.js +213 -111
- package/templates/prompts/taskcard-rules.md +27 -0
- package/templates/prompts/verify-probes.md +3 -3
- package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
- package/src/stages/propose.js +0 -181
package/src/version.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 读取 package.json 版本号。
|
|
9
|
+
* 独立轻量模块:只依赖 fs/path/url,不拖入 init.js 的 @inquirer/prompts 等重型交互库。
|
|
10
|
+
* 供 index.js 顶部静态 import——--version 等高频路径不再为 getVersion 付 init.js 加载税。
|
|
11
|
+
*/
|
|
12
|
+
export function getVersion() {
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
15
|
+
return pkg.version;
|
|
16
|
+
} catch {
|
|
17
|
+
return '?.?.?';
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/worktree-apply.js
CHANGED
|
@@ -12,29 +12,17 @@
|
|
|
12
12
|
* 8. 成功后自动 cleanup
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
16
|
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
17
17
|
import { join, resolve } from 'path';
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
|
-
import { createHash } from 'crypto';
|
|
20
19
|
import { WorktreeManager } from './worktree.js';
|
|
21
20
|
import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
|
|
22
21
|
import { parseAllowedPaths } from './stages/plan-postcheck.js';
|
|
22
|
+
import { git, gitQuiet } from './git-helper.js';
|
|
23
23
|
|
|
24
24
|
const CHANGES_REL = '.sillyspec/changes';
|
|
25
25
|
|
|
26
|
-
function git(cwd, args) {
|
|
27
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function gitQuiet(cwd, args) {
|
|
31
|
-
try {
|
|
32
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
33
|
-
} catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
26
|
/**
|
|
39
27
|
* 过滤掉 worktree 基础设施文件(非交付物),让 apply 只关心真正的变更产出:
|
|
40
28
|
* - meta.json:worktree 元数据,baseline commit 中被跟踪、working-tree 被 CLI 改写
|
|
@@ -42,11 +30,19 @@ function gitQuiet(cwd, args) {
|
|
|
42
30
|
* 的锚点,保证 baseline overlay 文件不被误判为变更)。若不排除,modified-tracked 的
|
|
43
31
|
* meta.json 会落入 changedFiles,触发「不在 design.md 清单」误判,导致每个 execute 的
|
|
44
32
|
* assess 恒 BLOCKED。
|
|
45
|
-
* - .sillyspec
|
|
33
|
+
* - .sillyspec/changes/:变更文档(worktree 专属,apply 回主仓污染进度库)。
|
|
34
|
+
* - .sillyspec/.runtime/:运行时产物(进度库/锁/review 产物,非源码)。
|
|
35
|
+
* - .sillyspec/quicklog/:quicklog 条目(worktree 进度,非交付物)。
|
|
36
|
+
* 保留 .sillyspec/docs/(dogfood 模块规范文档 = 交付物,apply 回主仓)。
|
|
46
37
|
* 对 modified-tracked(git diff)与 untracked(ls-files --others)一视同仁。
|
|
47
38
|
*/
|
|
48
39
|
export function filterDeliverableFiles(files) {
|
|
49
|
-
return files.filter(f =>
|
|
40
|
+
return files.filter(f =>
|
|
41
|
+
!f.startsWith('.sillyspec/changes/') &&
|
|
42
|
+
!f.startsWith('.sillyspec/.runtime/') &&
|
|
43
|
+
!f.startsWith('.sillyspec/quicklog/') &&
|
|
44
|
+
f !== 'meta.json'
|
|
45
|
+
);
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
/**
|
|
@@ -63,6 +59,23 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
|
63
59
|
return changedFiles.filter(f => !allow.some(ap => pathMatches(f, ap)));
|
|
64
60
|
}
|
|
65
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 确定进 patch 的文件:有清单取「实际变更 ∩ 清单(pathMatches 容差)」,无清单取全部变更。
|
|
64
|
+
*
|
|
65
|
+
* 口径须与 classifyAllowListViolations 一致——否则 design §6 用 glob(如 test_*.py)或多路径
|
|
66
|
+
* 单 cell 覆盖的文件过 manifest 校验(Gate1 用 pathMatches 放行),却因字面 includes 不在
|
|
67
|
+
* changedFiles → 被滤出 patchFiles → patch 不含 → 主工作区静默丢失(坑 apply-glob-manifest)。
|
|
68
|
+
* 以 changedFiles 为基按容差圈定,glob/前缀覆盖的具体文件都能进 patch。
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} changedFiles filterDeliverableFiles 已过滤的实际变更路径
|
|
71
|
+
* @param {Set<string>} allowSet design §6 清单 ∪ task allowed_paths(可含 glob / 目录前缀)
|
|
72
|
+
* @param {boolean} hasAllowList 是否有清单
|
|
73
|
+
* @returns {string[]} 进 patch 的文件
|
|
74
|
+
*/
|
|
75
|
+
export function resolvePatchFiles(changedFiles, allowSet, hasAllowList) {
|
|
76
|
+
if (!hasAllowList) return changedFiles;
|
|
77
|
+
return changedFiles.filter(f => [...allowSet].some(ap => pathMatches(f, ap)));
|
|
78
|
+
}
|
|
66
79
|
/**
|
|
67
80
|
* 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
|
|
68
81
|
*
|
|
@@ -71,7 +84,7 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
|
71
84
|
* - 文件不在 tree 中 → 不在 Map 中(调用方 map.get(f) ?? null 得 null,等同 rev-parse 失败→null)
|
|
72
85
|
* ls-tree 输出 "<mode> <type> <hash>\t<path>"。path 恒为文件路径(来自 git diff/ls-files),
|
|
73
86
|
* 非目录,故不带 -r 也正确——文件在 tree 时 rev-parse treeish:path 与 ls-tree 都给同一 hash,
|
|
74
|
-
*
|
|
87
|
+
* 不在时都不给,等价。(沿用公共 git() 数组形式,不经 shell、不引入引号/空格破法。)
|
|
75
88
|
*
|
|
76
89
|
* @param {string} cwd
|
|
77
90
|
* @param {string} treeish
|
|
@@ -81,7 +94,7 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
|
81
94
|
function getBlobHashMap(cwd, treeish, files) {
|
|
82
95
|
const map = new Map();
|
|
83
96
|
if (files.length === 0) return map; // 空 pathspec 会让 ls-tree 列出整棵树,必须拦截
|
|
84
|
-
const raw = gitQuiet(cwd,
|
|
97
|
+
const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
|
|
85
98
|
if (!raw) return map;
|
|
86
99
|
for (const line of raw.split('\n')) {
|
|
87
100
|
if (!line) continue;
|
|
@@ -107,7 +120,7 @@ function getBlobHashMap(cwd, treeish, files) {
|
|
|
107
120
|
* @returns {Set<string>} 并集清单(无 design 清单且无 task 卡片时为空集)
|
|
108
121
|
*/
|
|
109
122
|
export function resolveApplyAllowSet(projectRoot, changeName) {
|
|
110
|
-
const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'));
|
|
123
|
+
const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'), { keepSillyspecDocs: true });
|
|
111
124
|
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
112
125
|
if (existsSync(tasksDir)) {
|
|
113
126
|
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
@@ -166,7 +179,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
166
179
|
let changedFiles;
|
|
167
180
|
try {
|
|
168
181
|
// 用 --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
|
|
169
|
-
const statusRaw = git(worktreePath,
|
|
182
|
+
const statusRaw = git(worktreePath, ['diff', '--name-status', diffBase]);
|
|
170
183
|
const statusFiles = new Set();
|
|
171
184
|
if (statusRaw) {
|
|
172
185
|
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
@@ -178,7 +191,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
178
191
|
}
|
|
179
192
|
|
|
180
193
|
// untracked 新文件(diffBase 中不存在的文件)
|
|
181
|
-
const untrackedRaw = gitQuiet(worktreePath,
|
|
194
|
+
const untrackedRaw = gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard']);
|
|
182
195
|
const untrackedFiles = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [];
|
|
183
196
|
|
|
184
197
|
// 排除 worktree 基础设施文件(meta.json / .sillyspec/,见 filterDeliverableFiles)。
|
|
@@ -227,31 +240,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
227
240
|
}
|
|
228
241
|
|
|
229
242
|
// --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
|
|
230
|
-
// 分工:4.5
|
|
243
|
+
// 分工:4.5(排除规则下当前 dirty)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
|
|
231
244
|
// 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
|
|
232
245
|
// 哪怕脏文件与 patch 不重叠)——这是 git 硬约束,故此处友好拦截,引导用户先 commit/stash。
|
|
233
246
|
// 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
|
|
234
|
-
// 否则别人改 CLAUDE.md/docs/.claude →
|
|
235
|
-
//
|
|
247
|
+
// 否则别人改 CLAUDE.md/docs/.claude → 判定 dirty → apply 误阻断(多操作者仓库高频踩坑)。
|
|
248
|
+
// 注意:排除规则必须和 computeBaselineHash (worktree.js) 一致(虽已不比对 hash,仍用同一口径判当前 dirty)。
|
|
236
249
|
if (meta.baselineHash) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
250
|
+
// pathspec:`--` 结束选项,`.` 包含全部,后续 :(exclude) 排除非交付物元数据/文档 churn
|
|
251
|
+
// (与 computeBaselineHash 同口径)。数组形式逐元素传递,:(exclude) magic 字面直传不经 shell。
|
|
252
|
+
const exclude = ['--', '.', ':(exclude).sillyspec/', ':(exclude).claude/', ':(exclude)docs/', ':(exclude)CLAUDE.md'];
|
|
253
|
+
const staged = gitQuiet(projectRoot, ['diff', '--cached', ...exclude]) || '';
|
|
254
|
+
const unstaged = gitQuiet(projectRoot, ['diff', ...exclude]) || '';
|
|
255
|
+
const untracked = gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard', ...exclude]) || '';
|
|
256
|
+
// 意图(与 computeBaselineHash 注释一致):只挡「未提交 dirty」——git apply --3way 对 dirty 工作区不稳。
|
|
257
|
+
// 不比对 hash 是否等于 execute 启动时 baselineHash:主仓 dirty→clean(execute 期间 commit 无关文件)后
|
|
258
|
+
// hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
|
|
259
|
+
const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
|
|
260
|
+
if (hasUncommittedDirty) {
|
|
244
261
|
// 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
|
|
245
262
|
const dirtyFiles = [...new Set(
|
|
246
|
-
((gitQuiet(projectRoot, 'diff --name-only HEAD') || '').split('\n').filter(Boolean))
|
|
247
|
-
.concat((gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '').split('\n').filter(Boolean))
|
|
263
|
+
((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
|
|
264
|
+
.concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
|
|
248
265
|
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
249
266
|
result.errors.push(
|
|
250
|
-
|
|
267
|
+
`主工作区有未提交的改动,git apply 无法安全应用。\n` +
|
|
251
268
|
(dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
|
|
252
|
-
`请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n`
|
|
253
|
-
`execute 前 baseline: ${meta.baselineHash}\n` +
|
|
254
|
-
`当前 baseline: ${currentHash}`
|
|
269
|
+
`请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n`
|
|
255
270
|
);
|
|
256
271
|
if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
|
|
257
272
|
}
|
|
@@ -259,7 +274,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
259
274
|
|
|
260
275
|
// --- 5. 校验:主工作区文件 base hash 一致 ---
|
|
261
276
|
// 5a. 检查主工作区是否有未 commit 的脏文件(会影响 apply)
|
|
262
|
-
const mainDirtyRaw = gitQuiet(projectRoot, 'diff --name-only HEAD');
|
|
277
|
+
const mainDirtyRaw = gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']);
|
|
263
278
|
const mainDirtyFiles = mainDirtyRaw ? mainDirtyRaw.split('\n').filter(Boolean) : [];
|
|
264
279
|
if (mainDirtyFiles.length > 0) {
|
|
265
280
|
// 如果脏文件和本次 apply 的文件有交集 → 报错
|
|
@@ -301,10 +316,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
301
316
|
}
|
|
302
317
|
|
|
303
318
|
// --- 7. 生成 patch 并 apply ---
|
|
304
|
-
// 确定要包含在 patch
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
: changedFiles;
|
|
319
|
+
// 确定要包含在 patch 中的文件:有清单用「实际变更 ∩ 清单(pathMatches 容差)」,无清单用全部变更。
|
|
320
|
+
// 口径与 classifyAllowListViolations 一致(坑 apply-glob-manifest-passes-check-but-not-patch)。
|
|
321
|
+
const patchFiles = resolvePatchFiles(changedFiles, allowSet, hasAllowList);
|
|
308
322
|
|
|
309
323
|
// 创建临时文件
|
|
310
324
|
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
|
|
@@ -321,35 +335,36 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
321
335
|
// ls-files --error-unmatch f 成功 ⟺ f 在 ls-files -- 输出。
|
|
322
336
|
const inTree = getBlobHashMap(worktreePath, diffBase, patchFiles);
|
|
323
337
|
const inIndexList = patchFiles.length > 0
|
|
324
|
-
? (gitQuiet(worktreePath,
|
|
338
|
+
? (gitQuiet(worktreePath, ['ls-files', '--', ...patchFiles]) || '').split('\n').filter(Boolean)
|
|
325
339
|
: [];
|
|
326
340
|
const inIndex = new Set(inIndexList);
|
|
327
341
|
const trackedFiles = patchFiles.filter(f => inTree.has(f) || inIndex.has(f));
|
|
328
342
|
const trackedSet = new Set(trackedFiles);
|
|
329
343
|
const untrackedPatchFiles = patchFiles.filter(f => !trackedSet.has(f));
|
|
330
344
|
|
|
331
|
-
// tracked 文件:git diff baseHash
|
|
345
|
+
// tracked 文件:git diff baseHash(数组形式,文件名逐个展开为独立 argv,不经 shell;
|
|
346
|
+
// trim:false 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时——原裸 execSync 无 timeout)
|
|
332
347
|
if (trackedFiles.length > 0) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
{
|
|
348
|
+
patchContent += git(
|
|
349
|
+
worktreePath,
|
|
350
|
+
['diff', '--binary', diffBase, '--', ...trackedFiles],
|
|
351
|
+
{ trim: false, timeout: 60000 }
|
|
337
352
|
);
|
|
338
353
|
}
|
|
339
354
|
|
|
340
|
-
// untracked 新文件:git add 到 index,git diff --cached,然后 reset
|
|
355
|
+
// untracked 新文件:git add 到 index,git diff --cached,然后 reset(均数组形式,文件名逐个展开)
|
|
341
356
|
if (untrackedPatchFiles.length > 0) {
|
|
342
|
-
|
|
343
|
-
git(worktreePath, `add ${addArgs}`);
|
|
357
|
+
git(worktreePath, ['add', '--', ...untrackedPatchFiles]);
|
|
344
358
|
try {
|
|
345
|
-
|
|
346
|
-
patchContent +=
|
|
347
|
-
|
|
348
|
-
|
|
359
|
+
// trim:false 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时
|
|
360
|
+
patchContent += git(
|
|
361
|
+
worktreePath,
|
|
362
|
+
['diff', '--binary', '--cached', '--', ...untrackedPatchFiles],
|
|
363
|
+
{ trim: false, timeout: 60000 }
|
|
349
364
|
);
|
|
350
365
|
} finally {
|
|
351
366
|
// 重置 index(不保留 staged 状态)
|
|
352
|
-
gitQuiet(worktreePath,
|
|
367
|
+
gitQuiet(worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
|
|
353
368
|
}
|
|
354
369
|
}
|
|
355
370
|
|
|
@@ -370,13 +385,13 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
370
385
|
// 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
|
|
371
386
|
const trackedPatchFiles = patchFiles.filter(f => {
|
|
372
387
|
// 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
|
|
373
|
-
return gitQuiet(projectRoot,
|
|
388
|
+
return gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null ? false : true;
|
|
374
389
|
});
|
|
375
390
|
const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
|
|
376
391
|
|
|
377
392
|
// apply --3way 正式应用(主干已提交推进时自动三路合并)
|
|
378
393
|
try {
|
|
379
|
-
git(projectRoot,
|
|
394
|
+
git(projectRoot, ['apply', '--3way', patchPath], { timeout: 30000 });
|
|
380
395
|
} catch (e) {
|
|
381
396
|
// --3way 冲突(exit 1,工作区已留冲突标记):回滚到 apply 前干净状态,不留半成品
|
|
382
397
|
const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles);
|
|
@@ -427,13 +442,13 @@ function rollbackApply(projectRoot, trackedFiles, newFiles) {
|
|
|
427
442
|
// 冲突文件:git status 里带冲突标记(UU/AA 等)的文件
|
|
428
443
|
let conflicts = [];
|
|
429
444
|
try {
|
|
430
|
-
const unmerged = gitQuiet(projectRoot, 'diff --name-only --diff-filter=U') || '';
|
|
445
|
+
const unmerged = gitQuiet(projectRoot, ['diff', '--name-only', '--diff-filter=U']) || '';
|
|
431
446
|
conflicts = unmerged.split('\n').filter(Boolean);
|
|
432
447
|
} catch {}
|
|
433
448
|
// 回滚 tracked 文件到 HEAD(强制从 HEAD 还原——--3way 冲突标记同时污染工作区和 index,
|
|
434
449
|
// `checkout -- f` 从 index 还原会拿到冲突版,必须 `checkout HEAD -- f` 才能还原干净)
|
|
435
450
|
for (const f of trackedFiles) {
|
|
436
|
-
try { gitQuiet(projectRoot,
|
|
451
|
+
try { gitQuiet(projectRoot, ['checkout', 'HEAD', '--', f]); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
|
|
437
452
|
}
|
|
438
453
|
// 删除 --3way 可能新建的文件(apply 前不存在)
|
|
439
454
|
for (const f of newFiles) {
|
|
@@ -443,7 +458,7 @@ function rollbackApply(projectRoot, trackedFiles, newFiles) {
|
|
|
443
458
|
} catch (e) { error = (error ? error + '; ' : '') + `delete ${f}: ${e.message}`; }
|
|
444
459
|
}
|
|
445
460
|
// 兜底:若 index 处于 unmerged 状态,重置 index(不影响工作区已还原的文件)
|
|
446
|
-
try { gitQuiet(projectRoot, 'reset --quiet'); } catch {}
|
|
461
|
+
try { gitQuiet(projectRoot, ['reset', '--quiet']); } catch {}
|
|
447
462
|
return { conflicts, error };
|
|
448
463
|
}
|
|
449
464
|
|
|
@@ -471,15 +486,15 @@ function applyByMerge(result, changeName, projectRoot, wm) {
|
|
|
471
486
|
const branch = meta.branch || `sillyspec/${changeName}`;
|
|
472
487
|
|
|
473
488
|
try {
|
|
474
|
-
git(projectRoot,
|
|
489
|
+
git(projectRoot, ['merge', '--no-ff', branch], { timeout: 30000 });
|
|
475
490
|
} catch (e) {
|
|
476
491
|
// merge 冲突:取冲突文件列表 + abort 回滚(不 cleanup,保留 worktree)
|
|
477
492
|
let conflictFiles = [];
|
|
478
493
|
try {
|
|
479
|
-
const cf = gitQuiet(projectRoot,
|
|
494
|
+
const cf = gitQuiet(projectRoot, ['diff', '--name-only', '--diff-filter=U']);
|
|
480
495
|
conflictFiles = cf ? cf.split('\n').filter(Boolean) : [];
|
|
481
496
|
} catch {}
|
|
482
|
-
try { gitQuiet(projectRoot,
|
|
497
|
+
try { gitQuiet(projectRoot, ['merge', '--abort']); } catch {}
|
|
483
498
|
result.errors.push(
|
|
484
499
|
`git merge ${branch} 冲突,请手动解决。冲突文件:\n` +
|
|
485
500
|
(conflictFiles.length ? ` ${conflictFiles.join('\n ')}\n` : ` (未能获取冲突文件列表)\n`) +
|
|
@@ -492,7 +507,7 @@ function applyByMerge(result, changeName, projectRoot, wm) {
|
|
|
492
507
|
// 分支可能只含 baseline checkpoint(子代理改动未 commit),merge 产生空内容合并,文件零落地。
|
|
493
508
|
// 逐个确认 changedFiles 在 main HEAD 存在。任一缺失 → 不 cleanup(fail-open:保留 worktree 唯一副本)。
|
|
494
509
|
result.merged = true;
|
|
495
|
-
const notLanded = changedFiles.filter(f => gitQuiet(projectRoot,
|
|
510
|
+
const notLanded = changedFiles.filter(f => gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null);
|
|
496
511
|
if (notLanded.length > 0) {
|
|
497
512
|
result.ok = false;
|
|
498
513
|
result.errors.push(
|
|
@@ -504,7 +519,7 @@ function applyByMerge(result, changeName, projectRoot, wm) {
|
|
|
504
519
|
}
|
|
505
520
|
|
|
506
521
|
result.ok = true;
|
|
507
|
-
try { result.mergeSummary = git(projectRoot,
|
|
522
|
+
try { result.mergeSummary = git(projectRoot, ['log', '--oneline', '-1']); } catch {}
|
|
508
523
|
try {
|
|
509
524
|
wm.cleanup(changeName);
|
|
510
525
|
} catch (cleanupErr) {
|
|
@@ -632,7 +647,7 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
632
647
|
let additions = 0, deletions = 0;
|
|
633
648
|
if (wtPath && diffBase) {
|
|
634
649
|
try {
|
|
635
|
-
const shortstat = gitQuiet(wtPath,
|
|
650
|
+
const shortstat = gitQuiet(wtPath, ['diff', '--shortstat', diffBase]);
|
|
636
651
|
const insMatch = shortstat?.match(/(\d+) insertion/);
|
|
637
652
|
const delMatch = shortstat?.match(/(\d+) deletion/);
|
|
638
653
|
additions = insMatch ? parseInt(insMatch[1]) : 0;
|
|
@@ -721,7 +736,9 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
|
|
|
721
736
|
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
722
737
|
try {
|
|
723
738
|
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
724
|
-
|
|
739
|
+
// execFileSync 数组形式:路径无需引号化(含空格也安全);stdio stderr=ignore
|
|
740
|
+
// 替代 `2>/dev/null`(Windows cmd.exe 无法解析该重定向,导致 Windows 上恒抛错→Changed Files 空)
|
|
741
|
+
const filesRaw = execFileSync('git', ['-C', meta.worktreePath, 'diff', '--name-only', diffBase], { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] });
|
|
725
742
|
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
726
743
|
if (files.length > 0) {
|
|
727
744
|
lines.push(``);
|
package/src/worktree-deps.js
CHANGED
|
@@ -66,7 +66,7 @@ function extractUserInstall(yamlText) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** 从 local.yaml 提取 project.type;缺失时按文件特征推断 */
|
|
69
|
-
function detectProjectType(worktreePath, specBase) {
|
|
69
|
+
export function detectProjectType(worktreePath, specBase) {
|
|
70
70
|
const yamlText = readLocalYaml(specBase, worktreePath);
|
|
71
71
|
if (yamlText) {
|
|
72
72
|
const m = yamlText.match(/type:\s*(\S+)/);
|
|
@@ -75,11 +75,14 @@ function detectProjectType(worktreePath, specBase) {
|
|
|
75
75
|
if (existsSync(join(worktreePath, 'pom.xml'))) return 'maven';
|
|
76
76
|
if (existsSync(join(worktreePath, 'build.gradle')) || existsSync(join(worktreePath, 'build.gradle.kts'))) return 'gradle';
|
|
77
77
|
if (existsSync(join(worktreePath, 'package.json'))) return 'nodejs';
|
|
78
|
+
// Python:pyproject.toml(uv/poetry/pdm)或 requirements.txt(pip)——治 worktree 内 ruff/pre-commit 等
|
|
79
|
+
// 二进制不供给(exec 复盘 3-②:detectProjectType 原无 python 分支,python 项目根被误判 generic → n/a)
|
|
80
|
+
if (existsSync(join(worktreePath, 'pyproject.toml')) || existsSync(join(worktreePath, 'requirements.txt'))) return 'python';
|
|
78
81
|
return 'generic';
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
/** 按 project.type + lockfile 推断 install 命令(无 commands.install 时)*/
|
|
82
|
-
function inferInstallCommand(projectType, worktreePath, userInstall) {
|
|
85
|
+
export function inferInstallCommand(projectType, worktreePath, userInstall) {
|
|
83
86
|
if (userInstall) return userInstall;
|
|
84
87
|
switch (projectType) {
|
|
85
88
|
case 'nodejs':
|
|
@@ -91,6 +94,11 @@ function inferInstallCommand(projectType, worktreePath, userInstall) {
|
|
|
91
94
|
return 'mvn -o test';
|
|
92
95
|
case 'gradle':
|
|
93
96
|
return './gradlew test';
|
|
97
|
+
case 'python':
|
|
98
|
+
// uv 优先(现代 Python 工具链,pyproject.toml/uv.lock 项目走 uv sync 建 .venv + 装依赖,
|
|
99
|
+
// 与 execute worktree 环境预告一致);纯 requirements.txt(无 pyproject)回退 pip
|
|
100
|
+
if (existsSync(join(worktreePath, 'uv.lock')) || existsSync(join(worktreePath, 'pyproject.toml'))) return 'uv sync';
|
|
101
|
+
return 'pip install -r requirements.txt';
|
|
94
102
|
default:
|
|
95
103
|
return null; // generic → n/a
|
|
96
104
|
}
|
|
@@ -190,11 +198,16 @@ function linkOneDir(wtDir, mainDir) {
|
|
|
190
198
|
*
|
|
191
199
|
* @param {string} worktreePath - worktree 根目录
|
|
192
200
|
* @param {string} mainCwd - 主 checkout 根目录(node_modules 来源)
|
|
193
|
-
* @param {{ specBase?: string, timeout?: number }} opts
|
|
201
|
+
* @param {{ specBase?: string, timeout?: number, force?: boolean }} opts
|
|
202
|
+
* - force=true(doctor --fix 强制重装):绕过根目录 lockfile 一致快路径(下方 line 216 区块),
|
|
203
|
+
* 强制走 install 分支重装。该快路径是 tryLink 的唯一根调用点,绕过它即同时绕过 tryLink
|
|
204
|
+
* 的 preexisting 幂等短路(101-110),实现"强制重装"语义。配合调用方先解 junction
|
|
205
|
+
* (worktree.js _doctorReprovision),避免 install 经 junction 误改主仓 node_modules。
|
|
206
|
+
* modules 子模块 link 不受 force 影响(仅根 install)。
|
|
194
207
|
* @returns {{ depsStatus, depsMethod, depsSource, depsLockHash, depsCheckedAt, depsError?, depsModules? }}
|
|
195
208
|
*/
|
|
196
209
|
export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
197
|
-
const { specBase = null, timeout = DEFAULT_TIMEOUT_MS } = opts;
|
|
210
|
+
const { specBase = null, timeout = DEFAULT_TIMEOUT_MS, force = false } = opts;
|
|
198
211
|
const depsCheckedAt = new Date().toISOString();
|
|
199
212
|
const yamlText = readLocalYaml(specBase, worktreePath);
|
|
200
213
|
const wtHash = lockfileHash(worktreePath);
|
|
@@ -210,10 +223,11 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
|
210
223
|
result = { depsStatus: 'n/a', depsMethod: null, depsSource: null, depsLockHash: wtHash };
|
|
211
224
|
} else {
|
|
212
225
|
// 快路径:main 有 node_modules 且 lockfile hash 一致 → junction/symlink
|
|
226
|
+
// force=true 时绕过(doctor --fix 强制重装;绕过此块即同时绕过 tryLink 101-110 幂等短路)
|
|
213
227
|
const mainNodeModules = mainCwd ? join(mainCwd, 'node_modules') : null;
|
|
214
228
|
const mainHash = lockfileHash(mainCwd);
|
|
215
229
|
let linked = false;
|
|
216
|
-
if (mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
|
|
230
|
+
if (!force && mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
|
|
217
231
|
const linkResult = tryLink(mainNodeModules, join(worktreePath, 'node_modules'));
|
|
218
232
|
if (linkResult.ok) {
|
|
219
233
|
result = {
|
|
@@ -265,3 +279,73 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
|
265
279
|
|
|
266
280
|
return result;
|
|
267
281
|
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 统一的 worktree deps 新鲜度判定(H1,change 2026-08-05-tooling-feedback-fixes)。
|
|
285
|
+
* 供 doctor(worktree.js)与 ensureDepsFreshness(run/stage.js)共用,消除两处双写漂移。
|
|
286
|
+
*
|
|
287
|
+
* 判定优先级:failed → missing → stale → main-drift → fresh
|
|
288
|
+
* - failed: meta.depsStatus==='failed'(上次供给失败,占最高优先级)
|
|
289
|
+
* - missing: depsStatus ∈ {linked, installed} 且 node_modules 不存在
|
|
290
|
+
* - stale: wtHash 与 meta.depsLockHash 不一致(worktree 自身 lockfile 在上次供给后变化)
|
|
291
|
+
* - main-drift: wtHash 与主仓 mainHash 不一致(主仓 lockfile 漂移;复用 linkOneDir 177-178 的 mismatch 判据)
|
|
292
|
+
* - fresh: 其余情况
|
|
293
|
+
*
|
|
294
|
+
* lockfileHash 返回 null(无 lockfile/package.json)时相关比较优雅降级——不报 stale / main-drift,
|
|
295
|
+
* 避免无 lockfile 项目(如纯 generic / 非 nodejs)误判漂移。
|
|
296
|
+
*
|
|
297
|
+
* @param {object} [meta] - worktree meta(读 depsStatus / depsLockHash / depsError)
|
|
298
|
+
* @param {string} wtPath - worktree 根目录
|
|
299
|
+
* @param {string} mainCwd - 主 checkout 根目录
|
|
300
|
+
* @returns {{ status: 'fresh'|'missing'|'stale'|'main-drift'|'failed', detail: string, wtHash: (string|null), mainHash: (string|null), metaLockHash: (string|null) }}
|
|
301
|
+
*/
|
|
302
|
+
export function checkDepsFreshness(meta, wtPath, mainCwd) {
|
|
303
|
+
const depsStatus = meta && meta.depsStatus ? meta.depsStatus : null;
|
|
304
|
+
const metaLockHash = meta && meta.depsLockHash ? meta.depsLockHash : null;
|
|
305
|
+
const wtHash = lockfileHash(wtPath);
|
|
306
|
+
const mainHash = lockfileHash(mainCwd);
|
|
307
|
+
|
|
308
|
+
// 1. failed 最高优先级(对齐 task-01 蓝图:failed → missing → stale → main-drift → fresh)
|
|
309
|
+
if (depsStatus === 'failed') {
|
|
310
|
+
return {
|
|
311
|
+
status: 'failed',
|
|
312
|
+
detail: '上次依赖供给失败' + (meta && meta.depsError ? ': ' + meta.depsError : ''),
|
|
313
|
+
wtHash, mainHash, metaLockHash,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 2. missing:曾 link/install 但 node_modules 已丢失(对齐 doctor 914-915 / ensure 403)
|
|
318
|
+
const nmExists = wtPath ? existsSync(join(wtPath, 'node_modules')) : false;
|
|
319
|
+
if (['linked', 'installed'].includes(depsStatus) && !nmExists) {
|
|
320
|
+
return {
|
|
321
|
+
status: 'missing',
|
|
322
|
+
detail: `meta.depsStatus=${depsStatus} 但 node_modules 缺失`,
|
|
323
|
+
wtHash, mainHash, metaLockHash,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 3. stale:worktree 自身 lockfile 与 meta 快照不一致(对齐 doctor 916-917 / ensure 404)
|
|
328
|
+
if (metaLockHash && wtHash && wtHash !== metaLockHash) {
|
|
329
|
+
return {
|
|
330
|
+
status: 'stale',
|
|
331
|
+
detail: `lockfile 变化 (${metaLockHash} -> ${wtHash})`,
|
|
332
|
+
wtHash, mainHash, metaLockHash,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 4. main-drift(新增):wtHash 与主仓 mainHash 不一致——主仓 lockfile 更新过、worktree 未跟
|
|
337
|
+
if (wtHash && mainHash && wtHash !== mainHash) {
|
|
338
|
+
return {
|
|
339
|
+
status: 'main-drift',
|
|
340
|
+
detail: `worktree lockfile 与主仓不一致 (wt=${wtHash} main=${mainHash})`,
|
|
341
|
+
wtHash, mainHash, metaLockHash,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 5. fresh
|
|
346
|
+
return {
|
|
347
|
+
status: 'fresh',
|
|
348
|
+
detail: '依赖新鲜',
|
|
349
|
+
wtHash, mainHash, metaLockHash,
|
|
350
|
+
};
|
|
351
|
+
}
|