sillyspec 3.26.9 → 3.26.10

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.
@@ -30,6 +30,28 @@ function normalizeGitPath(p) {
30
30
  return String(p).replace(/\\/g, '/')
31
31
  }
32
32
 
33
+ /**
34
+ * 并发预检 ownFiles 锚点(2026-08-18 误归属修复)。
35
+ *
36
+ * 未声明会话(allowedFiles 空)维持旧口径 changedFiles ∪ baselineFiles——无声明即无归属信息,
37
+ * 收窄锚点会把本会话每次未声明的自身改动全误报他者。声明会话锚点 = baselineFiles ∪ allowedFiles
38
+ * (声明即归属):窗口 diff(changedFiles)在多 agent 并发仓库可能含他者污染(ql-20260818-003
39
+ * 实证:并行会话窗口内改的文件被算进本会话 changedFiles 再被 ownFiles 自吞),超出声明集的
40
+ * 窗口新脏文件才作为 foreign 信号报出(= 他者或漏声明,歧义由 advisory 文案兜住)。
41
+ *
42
+ * 纯函数:不查盘不调 git,调用点(complete-handlers task-02 钩子)传入三个列表。
43
+ * @param {{ changedFiles?: string[], baselineFiles?: string[], allowedFiles?: string[] }} opts
44
+ * @returns {string[]} ownFiles 锚点集合(去重)
45
+ */
46
+ export function resolveConcurrentAnchor({ changedFiles = [], baselineFiles = [], allowedFiles = [] } = {}) {
47
+ const arr = (x) => (Array.isArray(x) ? x : [])
48
+ const declared = arr(allowedFiles).map(normalizeGitPath).filter(Boolean)
49
+ if (declared.length === 0) {
50
+ return [...arr(changedFiles).map(normalizeGitPath), ...arr(baselineFiles).map(normalizeGitPath)]
51
+ }
52
+ return [...new Set([...arr(baselineFiles).map(normalizeGitPath), ...declared])]
53
+ }
54
+
33
55
  /**
34
56
  * 检测工作树里的并发他者改动(非阻塞 advisory 用)。
35
57
  *
package/src/run/prompt.js CHANGED
@@ -605,6 +605,46 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
605
605
  }
606
606
  }
607
607
 
608
+ // verify Step2「加载规范并锚定」worktree 基线锚点注入:execute 在 worktree 分支上 commit,
609
+ // verify 的 diff 对账基点 = worktree 分支的真实基点(git merge-base),不是主仓当前 HEAD。
610
+ // agent 拿不到基点时会拿主仓 HEAD / 旧 release commit 兜底——主仓在 execute 期间推进时
611
+ // (并行 session commit)会把别人的演进误判为本变更越权新增(2026-08-17 实录:误用 d192f89
612
+ // 而实际 merge-base 49ade71,第 6/7 条铁律被误判新增)。注入 fail-soft:worktree/分支/meta
613
+ // 读不到时降级为自查指引,绝不阻断 verify。
614
+ if (stageName === 'verify' && promptText.includes('{WORKTREE_BASELINE_INFO}')) {
615
+ let baselineInfo
616
+ try {
617
+ const { WorktreeManager } = await import('../worktree.js')
618
+ const wm = new WorktreeManager({ cwd })
619
+ const meta = changeName ? wm.getMeta(changeName) : null
620
+ const branch = meta?.branch || (changeName ? 'sillyspec/' + changeName : null)
621
+ const branchExists = branch
622
+ ? safeGit(cwd, ['rev-parse', '--verify', '--quiet', branch + '^{commit}']).error === null
623
+ : false
624
+ if (branchExists) {
625
+ const baseBranch = meta?.baseBranch || 'main'
626
+ const mergeBase = safeGit(cwd, ['merge-base', baseBranch, branch]).value
627
+ baselineInfo =
628
+ `- 本变更 worktree 分支:${branch}(存在)\n` +
629
+ (meta
630
+ ? `- worktree 基点(创建时锚定):baseHash=${meta.baseHash || '未知'},actualBaseHash=${meta.actualBaseHash || '未知'}(含 dirty overlay 后的 worktree 起点)\n`
631
+ : '- worktree meta 已不可读(可能已被 cleanup),用下方 merge-base 即可\n') +
632
+ `- ⚠️ diff 对账基点:\`git merge-base ${baseBranch} ${branch}\` = ${mergeBase || '(计算失败,手动跑上面命令)'}\n` +
633
+ ` 不要用主仓当前 HEAD / 最近 release commit 当基点——主仓在 execute 期间可能已被并行 session 推进,用错基点会把别人的演进误判为本变更越权改动。\n` +
634
+ `- task review 的 base/head 引用分支上的 commit,逐 task 核验 diff 用 \`git diff <base>..<head>\`(分支 ref 若已被 cleanup 删除,hash 仍可直达,但不要主动删分支)。`
635
+ } else if (meta) {
636
+ baselineInfo =
637
+ '- 本变更 worktree meta 存在但分支不可达(可能已被 cleanup):按主仓 git log 对照本变更各 commit 核验;task review 的 base/head 若悬空,在 verify-result.md 标注审计链风险。'
638
+ } else {
639
+ baselineInfo =
640
+ '- 本变更无 worktree(in-place / 纯文档变更 / worktree 已清理且 meta 不可读):按主仓 git log 最近提交对照核验。'
641
+ }
642
+ } catch (e) {
643
+ baselineInfo = '(worktree 基线注入失败:' + e.message + '——diff 对账前先 git branch -a 查 sillyspec/<change> 分支并用 git merge-base <main> <branch> 自查基点,勿用主仓 HEAD 当基点)'
644
+ }
645
+ promptText = promptText.split('{WORKTREE_BASELINE_INFO}').join(baselineInfo)
646
+ }
647
+
608
648
  // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
609
649
  if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
610
650
  const effectiveSpecBase = resolvePromptSpecBase(platformOpts, cwd)
@@ -58,7 +58,7 @@ export function printQuickAuditReview(review) {
58
58
  if (Array.isArray(review.docSyncHint.modules) && review.docSyncHint.modules.length > 0) {
59
59
  console.warn(` 涉及模块:${review.docSyncHint.modules.map(m => m.id).join(' · ')}(模块卡待同步,execute 场景详见 [docs-debt] 块)`)
60
60
  }
61
- console.warn(` quick 不强制文档同步,但欠账已记录(QUICKLOG reasons)。若改动触及接口/契约,建议顺手同步模块文档。`)
61
+ console.warn(` quick 不强制文档同步,欠账已随本条 QUICKLOG「审计:」行落盘(可事后追溯)。若改动触及接口/契约,建议顺手同步模块文档。`)
62
62
  }
63
63
  if (review.docsCheckHint && review.docsCheckHint.invalid > 0) {
64
64
  console.warn(`\n📎 文档引用失效(docs check):本次改动的文档含 ${review.docsCheckHint.invalid}/${review.docsCheckHint.total} 处失效 file:line 引用。`)
package/src/run/shared.js CHANGED
@@ -579,7 +579,7 @@ export function isQuickMetadata(p, linkedChanges = []) {
579
579
 
580
580
  /**
581
581
  * quick 完成审计:对比 baseline 与实际变更。
582
- * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number }}
582
+ * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[] }}
583
583
  */
584
584
  /**
585
585
  * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
@@ -669,7 +669,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
669
669
  // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
670
670
  // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
671
671
  // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
672
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0 }
672
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [] }
673
673
 
674
674
  try {
675
675
  // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
@@ -879,8 +879,10 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
879
879
 
880
880
  // task-02: 同文件并发检测——allowedFile 在 baseline(他者改过)且当前 hash ≠ step1 hash(我也改了)
881
881
  // → commit 整文件 pathspec 会夹带他者 hunk,warn 给分离指引(advisory,不阻断,D-002)
882
+ // sameFileHits 提升到本层作用域:下方归属切分要把「baseline 声明文件且我也改了」并入
883
+ // attributedFiles(这类文件先被 isBaselineFile 跳过不进 changedFiles,但确属本会话产物)。
884
+ const sameFileHits = []
882
885
  if (allowedFiles.length > 0 && guard.allowedFilesHash) {
883
- const sameFileHits = []
884
886
  for (const f of allowedFiles) {
885
887
  if (isBaselineFile(f) && guard.allowedFilesHash[f] !== undefined) {
886
888
  try {
@@ -899,6 +901,22 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
899
901
  }
900
902
  }
901
903
 
904
+ // 归属切分(2026-08-18 误归属修复,ql-20260818-003 实证):窗口 diff(changedFiles)在多 agent
905
+ // 并发仓库分不清「本会话改」与「他者窗口内改」,他者文件会污染 QUICKLOG「文件:」行。声明即归属:
906
+ // allowedFiles 非空时 attributedFiles = 窗口∩声明 ∪ sameFileHits(同文件并发 = 我也改了),
907
+ // undeclaredFiles = 窗口−声明(他者或漏声明,供「审计:」行落盘追溯——不静默丢,防漏声明时
908
+ // 真实改动被挤走无痕)。未声明会话无归属信息不猜,维持全量口径(attributed = changed)。
909
+ const declaredNorm = new Set(allowedFiles.map(f => normalizeGitPath(f)))
910
+ if (declaredNorm.size > 0) {
911
+ const own = result.changedFiles.filter(f => declaredNorm.has(normalizeGitPath(f)))
912
+ for (const f of sameFileHits) if (!own.includes(f)) own.push(f)
913
+ result.attributedFiles = own
914
+ result.undeclaredFiles = result.changedFiles.filter(f => !declaredNorm.has(normalizeGitPath(f)))
915
+ } else {
916
+ result.attributedFiles = result.changedFiles
917
+ result.undeclaredFiles = []
918
+ }
919
+
902
920
  // --confirm 模式:展示 diff 并等待确认
903
921
  if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
904
922
  console.log(`\n📋 quick 变更概览:`)
@@ -0,0 +1,319 @@
1
+ /**
2
+ * CLI 直跑 spec 文件增量同步模块。
3
+ *
4
+ * Change 2026-08-17-spec-file-incremental-sync:daemon↔后端已有增量同步协议,但 CLI
5
+ * 直跑场景(本地 agent 直接跑 sillyspec 无 daemon)只有四件套直推到 documents 列。
6
+ * 本模块补齐 CLI 直跑的文件树增量同步:以服务器清单为锚,walk/hash/diff 后只推送
7
+ * 变化文件(add/update/delete/rename),无差异时短路不发请求。
8
+ *
9
+ * 复用 daemon 已验证的排除口径(UPLOAD_EXCLUDE_TOP_BASE / UPLOAD_PRUNE_NAMES_BASE),
10
+ * 与 daemon 共用同一套清单语义(base_version 乐观锁、conflict 提示但不阻塞)。
11
+ */
12
+
13
+ import { createHash } from 'crypto';
14
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
15
+ import { join, relative, sep } from 'path';
16
+
17
+ // 与 sillyhub-daemon/src/spec-sync.ts 共用排除口径(task-07 / FR-06 / D-008@v2):
18
+ // .runtime(有点)/ runtime(无点)/ projects(本地环境文件)在顶层排除;
19
+ // worktrees 在任意深度按 basename 剪枝(可能嵌在 .runtime/worktrees 下)。
20
+ const UPLOAD_EXCLUDE_TOP_BASE = new Set(['.runtime', 'runtime', 'projects']);
21
+ const UPLOAD_PRUNE_NAMES_BASE = new Set(['worktrees']);
22
+ // ql-20260818-002:local.yaml 是本机连接配置(platform/mcp 段含 shpsync_ token),
23
+ // 不上传——与服务器侧过滤(backend spec_workspace SERVER_EXCLUDED_FILENAMES)双侧
24
+ // 对齐。服务器清单残留 local.yaml 行时,本地 walk 不含它 → 生成 delete op,
25
+ // 服务器放行 delete 清存量(token 不落 landing 树、不随 bundle 跨机分发)。
26
+ const UPLOAD_EXCLUDE_FILENAMES = new Set(['local.yaml']);
27
+
28
+ function isUploadExcludedPath(relPath) {
29
+ const segs = relPath.split('/');
30
+ if (UPLOAD_EXCLUDE_TOP_BASE.has(segs[0] ?? '')) return true;
31
+ if (UPLOAD_EXCLUDE_FILENAMES.has(segs[segs.length - 1] ?? '')) return true;
32
+ return segs.some((s) => UPLOAD_PRUNE_NAMES_BASE.has(s));
33
+ }
34
+
35
+ function toPosix(p) {
36
+ return p.replace(/\\/g, '/');
37
+ }
38
+
39
+ function debugLog(msg) {
40
+ if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg);
41
+ }
42
+
43
+ function _hashBuffer(buf) {
44
+ return createHash('sha256').update(buf).digest('hex');
45
+ }
46
+
47
+ /**
48
+ * 单遍 walk 本地 .sillyspec 树。
49
+ *
50
+ * 返回 { path, absPath, mtimeMs } 列表;path 已统一为 POSIX。
51
+ * 不收集目录项(只关心文件);命中排除规则的路径直接跳过不递归。
52
+ */
53
+ export function walkSpecTree(specRoot) {
54
+ const out = [];
55
+ if (!existsSync(specRoot)) return out;
56
+
57
+ function recurse(dir) {
58
+ let names;
59
+ try {
60
+ names = readdirSync(dir);
61
+ } catch {
62
+ return;
63
+ }
64
+ for (const name of names) {
65
+ const abs = join(dir, name);
66
+ let st;
67
+ try {
68
+ st = statSync(abs);
69
+ } catch {
70
+ continue;
71
+ }
72
+ const relToRoot = toPosix(relative(specRoot, abs));
73
+ if (isUploadExcludedPath(relToRoot)) continue;
74
+ // pruneNames:任意深度按 basename 命中即不收集/不递归
75
+ if (UPLOAD_PRUNE_NAMES_BASE.has(name)) continue;
76
+
77
+ if (st.isDirectory()) {
78
+ recurse(abs);
79
+ } else if (st.isFile()) {
80
+ out.push({ path: relToRoot, absPath: abs, mtimeMs: st.mtimeMs });
81
+ }
82
+ // symlink / 其他:跳过
83
+ }
84
+ }
85
+
86
+ recurse(specRoot);
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * 计算本地文件哈希。
92
+ *
93
+ * 返回 { path, hash, mtime, absPath }(mtime 为 Unix 秒,供 os.utime;absPath
94
+ * 保留给 computeSpecOps 读 content 用,避免按相对路径回退错位)。
95
+ */
96
+ export function hashFiles(entries) {
97
+ return entries.map((e) => {
98
+ const buf = readFileSync(e.absPath);
99
+ return {
100
+ path: e.path,
101
+ hash: _hashBuffer(buf),
102
+ mtime: Math.floor(e.mtimeMs / 1000),
103
+ absPath: e.absPath,
104
+ };
105
+ });
106
+ }
107
+
108
+ /**
109
+ * 根据服务器清单与本地文件差异生成 FileOp[]。
110
+ *
111
+ * 服务器清单格式:{ [path]: { hash, version, exists } }
112
+ * 本地文件格式:hashFiles 结果 { path, hash, mtime }
113
+ *
114
+ * 规则(与 daemon computeIncrementalOps 对齐):
115
+ * - 本地有、服务器无 → add(base_version=0)
116
+ * - 服务器有 exists=true、本地无 → delete(base_version=服务器 version)
117
+ * - 服务器有 exists=false、本地无 → 不生成 delete(已软删)
118
+ * - 同路径 hash 不同 → update(base_version=服务器 version)
119
+ * - 服务器旧路径有、本地新路径有且 hash 相同 → rename(base_version=服务器旧路径 version)
120
+ */
121
+ export function computeSpecOps(serverManifest, localFiles) {
122
+ const server = serverManifest || {};
123
+ const localMap = new Map(localFiles.map((f) => [f.path, f]));
124
+ const localSet = new Set(localMap.keys());
125
+
126
+ // 只拿服务器 exists=true 的行参与 diff;exists=false 视为已删除,不生成 delete
127
+ const serverPaths = Object.keys(server).filter((p) => server[p]?.exists !== false);
128
+ const serverSet = new Set(serverPaths);
129
+
130
+ // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
131
+ const renames = [];
132
+ const consumedNew = new Set();
133
+ for (const oldPath of serverPaths) {
134
+ if (localSet.has(oldPath)) continue;
135
+ const cachedEntry = server[oldPath];
136
+ for (const newPath of localMap.keys()) {
137
+ if (serverSet.has(newPath)) continue;
138
+ if (consumedNew.has(newPath)) continue;
139
+ if (localMap.get(newPath).hash === cachedEntry.hash) {
140
+ renames.push({ oldPath, newPath });
141
+ consumedNew.add(newPath);
142
+ break;
143
+ }
144
+ }
145
+ }
146
+ const renamedOld = new Set(renames.map((r) => r.oldPath));
147
+ const renamedNew = new Set(renames.map((r) => r.newPath));
148
+
149
+ const ops = [];
150
+
151
+ for (const r of renames) {
152
+ ops.push({
153
+ op: 'rename',
154
+ path: r.oldPath,
155
+ new_path: r.newPath,
156
+ base_version: server[r.oldPath].version,
157
+ });
158
+ }
159
+
160
+ for (const p of serverPaths) {
161
+ if (renamedOld.has(p)) continue;
162
+ if (localSet.has(p)) {
163
+ const localEntry = localMap.get(p);
164
+ if (localEntry.hash !== server[p].hash) {
165
+ const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
166
+ ops.push({
167
+ op: 'update',
168
+ path: p,
169
+ hash: localEntry.hash,
170
+ content,
171
+ base_version: server[p].version,
172
+ mtime: localEntry.mtime,
173
+ });
174
+ }
175
+ } else {
176
+ ops.push({
177
+ op: 'delete',
178
+ path: p,
179
+ base_version: server[p].version,
180
+ });
181
+ }
182
+ }
183
+
184
+ for (const p of localMap.keys()) {
185
+ if (renamedNew.has(p)) continue;
186
+ if (!serverSet.has(p)) {
187
+ const localEntry = localMap.get(p);
188
+ const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
189
+ ops.push({
190
+ op: 'add',
191
+ path: p,
192
+ hash: localEntry.hash,
193
+ content,
194
+ base_version: 0,
195
+ mtime: localEntry.mtime,
196
+ });
197
+ }
198
+ }
199
+
200
+ return ops;
201
+ }
202
+
203
+ /**
204
+ * 从 ops 提取本次涉及的变更目录名集合(change_dirs 标注,D-005@v1)。
205
+ *
206
+ * 对每个 op 的 path(rename 含新旧 path),取 `changes/<name>/` 或
207
+ * `changes/archive/<name>/` 前缀分组的 key(去掉前缀、取第一段目录名),去重。
208
+ * 非 changes 前缀路径不进结果。best-effort,失败降级 []。
209
+ */
210
+ export function extractChangeDirs(ops) {
211
+ const dirs = new Set();
212
+ for (const op of ops) {
213
+ collectChangeDir(dirs, op.path);
214
+ if (op.new_path) collectChangeDir(dirs, op.new_path);
215
+ }
216
+ return [...dirs];
217
+ }
218
+
219
+ function collectChangeDir(dirs, p) {
220
+ const norm = p.split(/[\\/]/).join('/');
221
+ let rest = null;
222
+ if (norm.startsWith('changes/')) {
223
+ rest = norm.slice('changes/'.length);
224
+ } else if (norm.startsWith('changes\\')) {
225
+ rest = norm.slice('changes\\'.length).replace(/\\/g, '/');
226
+ }
227
+ if (!rest) return;
228
+
229
+ if (rest.startsWith('archive/')) {
230
+ rest = rest.slice('archive/'.length);
231
+ }
232
+ const first = rest.split('/', 1)[0];
233
+ if (first && first !== 'archive' && !first.includes('/')) {
234
+ dirs.add(first);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * 同步本地 .sillyspec 树到平台(CLI 直跑增量同步入口)。
240
+ *
241
+ * @param {string} specRoot - 本地 .sillyspec 目录绝对路径
242
+ * @param {object} platform - { url, token }
243
+ * @param {string} changeName - 当前变更名
244
+ * @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
245
+ */
246
+ export async function syncSpecTree(specRoot, platform, changeName) {
247
+ if (!platform || !platform.url || !platform.token) {
248
+ debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
249
+ return { synced: 0 };
250
+ }
251
+
252
+ const manifestUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-manifest`;
253
+ const syncUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-sync`;
254
+
255
+ // 1. GET 服务器清单
256
+ let serverManifest = {};
257
+ try {
258
+ const res = await fetch(manifestUrl, {
259
+ headers: { Authorization: `Bearer ${platform.token}` },
260
+ signal: AbortSignal.timeout(10000),
261
+ });
262
+ if (!res.ok) {
263
+ debugLog(`[spec-sync] 拉取清单失败 ${res.status}: ${changeName}`);
264
+ return { synced: 0 };
265
+ }
266
+ const body = await res.json().catch(() => ({}));
267
+ serverManifest = body.files || {};
268
+ } catch (err) {
269
+ debugLog(`[spec-sync] 拉取清单异常(不影响进度): ${changeName}: ${err.message}`);
270
+ return { synced: 0 };
271
+ }
272
+
273
+ // 2. walk/hash/diff
274
+ const entries = walkSpecTree(specRoot);
275
+ const localFiles = hashFiles(entries);
276
+ const ops = computeSpecOps(serverManifest, localFiles);
277
+
278
+ if (ops.length === 0) {
279
+ debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
280
+ return { synced: 0 };
281
+ }
282
+
283
+ // 3. POST ops
284
+ try {
285
+ const res = await fetch(syncUrl, {
286
+ method: 'POST',
287
+ headers: {
288
+ 'Content-Type': 'application/json',
289
+ Authorization: `Bearer ${platform.token}`,
290
+ },
291
+ body: JSON.stringify({ ops }),
292
+ signal: AbortSignal.timeout(30000),
293
+ });
294
+ if (!res.ok) {
295
+ debugLog(`[spec-sync] 同步请求失败 ${res.status}: ${changeName}`);
296
+ return { synced: 0 };
297
+ }
298
+ const body = await res.json().catch(() => ({}));
299
+ if (body.conflict) {
300
+ console.warn(
301
+ `[spec-sync] 检测到冲突,请人工拍板。服务器版本: ${JSON.stringify(body.server_versions || {})}`
302
+ );
303
+ return { synced: 0, conflict: true, serverVersions: body.server_versions };
304
+ }
305
+ console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
306
+ return { synced: ops.length };
307
+ } catch (err) {
308
+ debugLog(`[spec-sync] 同步异常(不影响进度): ${changeName}: ${err.message}`);
309
+ return { synced: 0 };
310
+ }
311
+ }
312
+
313
+ export default {
314
+ walkSpecTree,
315
+ hashFiles,
316
+ computeSpecOps,
317
+ extractChangeDirs,
318
+ syncSpecTree,
319
+ };
@@ -81,6 +81,7 @@ export const definition = {
81
81
  3. 如果标记缺失或重复 → 在 _module-map.yaml 中标记 needs_review: true
82
82
 
83
83
  #### 新建模块卡片模板
84
+ 一级标题格式 \`# <中文名>(<module-id>)\`——中文名从模块职责提炼 2-8 字,括号内保留 module-id 原样(sillyhub 平台解析识别用,与 scan 生成约定一致)。
84
85
  \`\`\`markdown
85
86
  ---
86
87
  schema_version: 1
@@ -88,7 +89,7 @@ doc_type: module-card
88
89
  module_id: <module-id>
89
90
  ---
90
91
 
91
- # <module-id>
92
+ # <中文名>(<module-id>)
92
93
 
93
94
  ## 定位
94
95
 
@@ -843,15 +843,24 @@ ${workdirLines}
843
843
 
844
844
  ## 执行方式
845
845
 
846
- **每个任务必须由独立子代理执行,你不要自己写代码。**
846
+ **默认每个任务由独立子代理执行,你不要自己写代码。**
847
847
 
848
- 你的角色是调度者 + 审查者:
849
- 1. 为每个任务启动一个子代理(Agent tool),同 Wave 内可并行
850
- 2. 子代理完成后审查结果
848
+ 可选 batch(合并实现):同一 Wave 内,若一组任务同时满足以下三个条件,可把它们合并为一个 batch(最多 3 个 task),交给单个子代理串行逐个完成实现:
849
+ - 组内任意两个 task 的 allowed_paths 无交集(文件正交)
850
+ - 组内任意两个 task 之间无 provides / expects_from 契约链(契约 task 禁止同批——串行实现会读到半成品,契约 task 由独立子代理并行处理或落在不同 Wave)
851
+ - 组大小不超过 3 个 task
852
+
853
+ 任一条件不满足,该 task 走独立子代理(默认形态);拿不准就不合并。无论独立还是 batch,实现一律由子代理完成,你不要自己写代码。
854
+
855
+ 你的角色是调度者 + 审查者(batch 只合并实现、不合并审查):
856
+ 1. 为每个任务启动一个子代理(Agent tool),或按上述三条件把多个任务合并为一个 batch 子代理,同 Wave 内可并行
857
+ 2. 子代理完成后审查结果——batch 子代理只做实现与自验,task 审查、review.json 产出与 checkbox 勾选仍归你(主 agent),在子代理返回后逐 task 进行;审查 batch 报告时逐 task 对照 allowed_paths 检查改动文件清单有无越权
851
858
  3. 勾选 plan.md 中的 checkbox
852
859
  4. 记录改动文件和测试结果
853
860
 
854
861
  ${worktreeSection}${crossRepoCommitSection}${dispatchSection}
862
+ **SillyHub 派发互斥**:SillyHub 派发模式下按派发段执行(一 Wave 一 mission),不按 batch 分组;batch 分组指导仅适用于本地 Agent tool 派发。
863
+
855
864
  ### 任务摘要(按需读取完整蓝图)
856
865
  为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
857
866
 
@@ -863,6 +872,9 @@ ${taskSummary}
863
872
  3. 编码铁律:先读后写、TDD、不编造方法、只做蓝图里写的事、遵守边界处理规则、不超出 allowed_paths
864
873
  4. 如存在模块文档(.sillyspec/docs/*/modules/),按需读取涉及模块的 <module>.md 参考接口约定和数据流
865
874
  5. 任务含测试代码时,把下方「测试用例设计」整段复制进子代理 prompt,要求子代理按此设计测试用例
875
+ 6. **增量落盘与中断接手指引**:每完成一个可见产出(代码/测试/文档),立即写盘并执行一次最小验证(如语法检查、单跑相关测试)。工作过程中如被 429/API 配额/会话中断,应在最终回复里输出「已完成清单」(含文件路径、测试命令、当前卡点),不要只输出结论——主代理会依据磁盘产物和该清单判断哪些部分已完成,哪些需接手补做,避免重做已落盘的工作
876
+ 7. **任务边界铁律**:严格只实现本 task 的 \`allowed_paths\` 内文件;若 design.md/plan.md 明确指定了接口/回调/钩子接入位置,必须逐字遵守;不允许顺手实现其他 task 的内容(如 task-01 不要把 task-02 的接入也做了)。如发现必须改其他 task 文件才能继续,先回到主代理由主代理决定是否重分 Wave 或调整 plan,禁止子代理私自越界
877
+ 8. **batch 子代理协议**(仅当按「执行方式」节条件合并 batch 时附加进该子代理 prompt):按 batch 内 task 顺序逐个完成实现闭环——读取 tasks/task-N.md → 实现 → 跑该 task 的 verify 命令 → 记录该 task 报告(改动文件清单 / verify 结果 / 卡点)→ 才开始下一个 task;最终回复输出逐 task 报告清单。禁止写 review.json、禁止勾选 plan.md checkbox——task 审查与勾选归主 agent,在子代理返回后逐 task 进行。越权即停:发现必须改 batch 内其他 task 或任何 batch 外 task 的 allowed_paths 文件 → 立即停止本 task 及后续,报告冲突文件与卡点,回主 agent 裁决(重分 Wave / 调整 plan / 回退独立子代理)。第 7 条任务边界铁律在 batch 语境下的「本 task」= 当前正在实现的 task
866
878
 
867
879
  {{include: testcase-design}}
868
880
 
@@ -887,7 +899,7 @@ ${contractInjection}${prototypeInjection}
887
899
  ${taskList}
888
900
 
889
901
  ### 调度要求
890
- 1. **同一 Wave 内的任务必须并行启动子代理**(Wave 定义=无依赖可并行,不自行分析依赖关系;有依赖应在 plan.md 的不同 Wave 中)。
902
+ 1. **同一 Wave 的多个子代理(独立或 batch)必须并行启动,batch 内部串行**(batch 分组仅按文件正交 / 无契约链判定,不改变 Wave 依赖语义——Wave 定义=无依赖可并行;有依赖应在 plan.md 的不同 Wave 中)。
891
903
  2. **Reverse Sync**:子代理报告实现与 design.md 不一致时,先检查是代码错了还是文档有遗漏
892
904
  3. **不要频繁编译!** 编译很慢,只在以下情况运行:
893
905
  - 写了大量代码后需要验证语法正确性
@@ -14,7 +14,7 @@ import { existsSync, readFileSync as _readFileSync, readdirSync, mkdtempSync, wr
14
14
  // frontmatter/字段正则(`^---\n`、`allowed_paths:\s*\n…`、`^goal:` 等)失配,报「缺 frontmatter
15
15
  // /缺字段」假错误(见缺陷 windows-python-crlf-taskcard)。读取时统一转 LF,一处覆盖全部正则。
16
16
  const readFileSync = (filePath, encoding) => _readFileSync(filePath, encoding).replace(/\r\n/g, '\n')
17
- import { join as pJoin } from 'path'
17
+ import { join as pJoin, basename } from 'path'
18
18
  import { tmpdir } from 'os'
19
19
  import jsYaml from 'js-yaml'
20
20
  import { parseFileChangeList, pathMatches } from '../change-list.js'
@@ -1178,6 +1178,27 @@ export async function executePlanPostcheck(context) {
1178
1178
  console.log(` ✅ design.md ${coverage.designFiles.length} 个文件全部被 task allowed_paths 覆盖`)
1179
1179
  }
1180
1180
 
1181
+ // ── 1e. 阶段完成产物校验(plan stage contract)──
1182
+ // plan 完成 gate 还会跑 stage-contract.validatePlanOutputs;为免「postcheck 通过 →
1183
+ // stage gate 又报 module-impact 缺失/entry-point-wiring 未覆盖」的修一层撞一层,
1184
+ // 在此把 stage contract 的 error/warning 也聚合进本轮输出。
1185
+ const { validatePlanOutputs } = await import('../stage-contract.js')
1186
+ if (typeof validatePlanOutputs === 'function') {
1187
+ const contract = validatePlanOutputs(context.cwd, basename(changeDir), { specRoot })
1188
+ if (contract.errors.length > 0) {
1189
+ failures.push({
1190
+ name: 'plan 阶段产物契约校验(stage-contract validatePlanOutputs)',
1191
+ errors: contract.errors,
1192
+ hint: '修复方式:补缺失产物(如 module-impact.md)、在 design.md 明示不改理由、或修正 task allowed_paths 覆盖 design.md 提及的入口文件。',
1193
+ })
1194
+ }
1195
+ if (contract.warnings.length > 0) {
1196
+ for (const w of contract.warnings) {
1197
+ console.warn(`\n ⚠️ ${w}`)
1198
+ }
1199
+ }
1200
+ }
1201
+
1181
1202
  // ── 聚合输出:一轮 --done 暴露全部失败项(坑6③)──
1182
1203
  if (failures.length > 0) {
1183
1204
  console.error(`\n❌ plan postcheck 失败(${failures.length} 类问题,已全部列出——一次修复后重跑,无需逐个迭代):`)