sillyspec 3.27.7 → 3.27.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/src/index.js CHANGED
@@ -98,7 +98,7 @@ SillySpec CLI — 规范驱动开发工具包
98
98
  sillyspec commit [--json] 智能提交建议:收集 QUICKLOG/已勾 task/阶段产出语义,生成建议 message(只建议不执行)
99
99
  sillyspec verify-probes --change <name> [--init] verify 机械探针(TODO 标记/测试覆盖/API 对账/删除对账);--init 生成 verify-result.md 骨架
100
100
  sillyspec module-impact --change <name> 生成 module-impact.md 骨架(文件×模块归属按 module-map 预填 + 未匹配清单)
101
- sillyspec endpoints extract --change <name> [--task task-NN] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
101
+ sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
102
102
  sillyspec scan-fix-headers [--project <名>] scan 文档补 author/created_at header(幂等)
103
103
  sillyspec plan-adopt-waves --change <名> [--dry-run] plan.md Wave 段一键重排为 depends_on 拓扑分组(同步任务总表 W 列,幂等)
104
104
  sillyspec workspace add <名> <路径> [--role] [--repo] | remove <名> | status 多项目工作区登记与状态探测
@@ -743,6 +743,20 @@ async function main() {
743
743
  }
744
744
  console.log(`📦 待提交变更(${cc.changedCount} 个文件${cc.untrackedCount > 0 ? `,其中未跟踪 ${cc.untrackedCount} 个` : ''};上方 stat 仅列已跟踪文件的改动)`);
745
745
  if (cc.stat) console.log(cc.stat.split('\n').map(l => ' ' + l).join('\n'));
746
+ // 暂存区快照(坑 same-main-staging-pollution,2026-08-28 实证:同 main 双会话共享暂存区,
747
+ // A 的 commit 扫入 B 暂存的文件):git commit 只含暂存内容——提交前把快照亮出来供核对。
748
+ {
749
+ const { collectStagedArea } = await import('./commit-suggest.js');
750
+ const staged = collectStagedArea({ cwd: dir });
751
+ if (staged.available && staged.staged.length > 0) {
752
+ console.log(`\n📌 暂存区快照(git diff --cached --name-only,你的下一次 git commit 将恰好含这 ${staged.staged.length} 个文件):`);
753
+ for (const f of staged.staged.slice(0, 15)) console.log(` - ${f}`);
754
+ if (staged.staged.length > 15) console.log(` … 共 ${staged.staged.length} 个`);
755
+ console.log(` ⚠️ 同 main 并行会话共享同一暂存区——快照里非本会话的文件请先 git restore --staged <file> 剔除,否则会被你的 commit 扫入`);
756
+ } else if (staged.available) {
757
+ console.log(`\n📌 暂存区为空(git commit 前需先 git add;用 pathspec 级 add 防扫入无关文件)`);
758
+ }
759
+ }
746
760
  if (cc.quickEntries.length > 0) {
747
761
  console.log(`\n📝 QUICKLOG(上次提交 ${cc.lastCommitTime || '(无)'} 之后 ${cc.quickEntries.length} 条):`);
748
762
  for (const q of cc.quickEntries) {
@@ -892,15 +906,19 @@ async function main() {
892
906
  // 此前靠 agent 手扫装饰器手写(易漏 endpoint)。CLI 复用 endpoint-extractor 全套提取器
893
907
  // (FastAPI @router.* / Express router.* / Spring @*Mapping)静态扫描生成,
894
908
  // verify 探针 5(verifyApiParity)直接消费该产物。
909
+ // --all-tasks(坑 probe5-single-task-artifact-scope,2026-08-28 实证:单 task 产物对账
910
+ // 产生大量假 missing):聚合模式——逐 task 卡提取,各自落 contract-artifacts/<change>/<task>/,
911
+ // 与 verifyApiParity 的聚合读侧(contract-artifacts/<change>/*/endpoints.json 全量并集)对齐。
895
912
  const epSub = filteredArgs[1];
896
913
  if (epSub !== 'extract') {
897
- console.error('用法: sillyspec endpoints extract --change <name> [--task task-NN] [--dir <backendRoot> | --files <a.py,b.js...>] [--spec-dir <path>]\n 静态扫描后端路由装饰器生成 endpoints.json(FastAPI/Express/Spring);verify 探针 5 消费');
914
+ console.error('用法: sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <backendRoot> | --files <a.py,b.js...>] [--spec-dir <path>]\n 静态扫描后端路由装饰器生成 endpoints.json(FastAPI/Express/Spring);verify 探针 5 消费。\n --all-tasks:聚合逐 task 卡 allowed_paths 各自提取(对账口径与探针 5 聚合读侧一致,推荐)');
898
915
  process.exit(2);
899
916
  }
900
917
  const epChangeIdx = args.indexOf('--change');
901
918
  const epChange = epChangeIdx >= 0 && args[epChangeIdx + 1] ? args[epChangeIdx + 1] : null;
902
919
  const epTaskIdx = args.indexOf('--task');
903
920
  const epTask = epTaskIdx >= 0 && args[epTaskIdx + 1] ? args[epTaskIdx + 1] : null;
921
+ const epAllTasks = args.includes('--all-tasks');
904
922
  const epDirIdx = args.indexOf('--dir');
905
923
  const epDir = epDirIdx >= 0 && args[epDirIdx + 1] ? args[epDirIdx + 1] : null;
906
924
  const epFilesIdx = args.indexOf('--files');
@@ -909,6 +927,10 @@ async function main() {
909
927
  console.error('❌ 缺 --change <变更名>');
910
928
  process.exit(2);
911
929
  }
930
+ if (epAllTasks && (epTask || epDir || epFilesRaw)) {
931
+ console.error('❌ --all-tasks 与 --task/--dir/--files 互斥(聚合模式逐 task 卡自动取 allowed_paths)');
932
+ process.exit(2);
933
+ }
912
934
  assertSafeChangeName(epChange, '--change 变更名');
913
935
  const { extractFastApiEndpoints, extractExpressEndpoints, extractSpringEndpoints, scanBackendEndpoints } = await import('./endpoint-extractor.js');
914
936
  const endpoints = [];
@@ -928,10 +950,56 @@ async function main() {
928
950
  else if (ext === '.java') endpoints.push(...extractSpringEndpoints(abs))
929
951
  } catch { /* 单文件解析失败不阻断整体 */ }
930
952
  }
953
+ // 单 task 产物写盘(taskName 归属目录);返回提取数
954
+ const epRuntimeRoot = (await import('./run/shared.js')).resolveRuntimeRoot({ specRoot: specDir }, epSpecBase);
955
+ const writeArtifact = (taskName, eps) => {
956
+ const artifact = {
957
+ generated_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
958
+ generator: 'sillyspec endpoints extract',
959
+ endpoints: eps.map(e => ({ method: e.method, path: e.path, source: e.source })),
960
+ }
961
+ const epOutDir = join(epRuntimeRoot, 'contract-artifacts', epChange, taskName);
962
+ mkdirSync(epOutDir, { recursive: true });
963
+ const epOutPath = join(epOutDir, 'endpoints.json');
964
+ writeFileSync(epOutPath, JSON.stringify(artifact, null, 2) + '\n');
965
+ return epOutPath;
966
+ }
931
967
  if (epDir) {
932
- endpoints.push(...scanBackendEndpoints(resolve(epDir)))
968
+ endpoints.push(...scanBackendEndpoints(resolve(epDir)));
933
969
  } else if (epFilesRaw) {
934
970
  for (const f of epFilesRaw.split(',').map(s => s.trim()).filter(Boolean)) collectFile(f)
971
+ } else if (epAllTasks) {
972
+ // 聚合模式:逐 task 卡 allowed_paths 提取,各自落产物目录(对齐 verifyApiParity 聚合读侧)
973
+ const { parseAllowedPaths } = await import('./stages/plan-postcheck.js')
974
+ const tasksDir = join(epChangeDir, 'tasks')
975
+ const taskCards = existsSync(tasksDir)
976
+ ? readdirSync(tasksDir).filter(f => /^task-[\w.-]+\.md$/.test(f)).sort()
977
+ : []
978
+ if (taskCards.length === 0) {
979
+ console.error(`❌ --all-tasks 无 task 卡(${tasksDir} 不存在或无 task-*.md)——先跑 plan 生成 TaskCard`);
980
+ process.exit(1);
981
+ }
982
+ let total = 0
983
+ for (const card of taskCards) {
984
+ const taskName = card.replace(/\.md$/, '')
985
+ const eps = []
986
+ const perFile = (f) => {
987
+ const abs = resolve(f)
988
+ if (!existsSync(abs)) return
989
+ const ext = extname(abs).toLowerCase()
990
+ try {
991
+ if (ext === '.py') eps.push(...extractFastApiEndpoints(abs))
992
+ else if (ext === '.js' || ext === '.ts') eps.push(...extractExpressEndpoints(abs))
993
+ else if (ext === '.java') eps.push(...extractSpringEndpoints(abs))
994
+ } catch { /* 单文件解析失败不阻断整体 */ }
995
+ }
996
+ for (const p of parseAllowedPaths(readFileSync(join(tasksDir, card), 'utf8'))) perFile(p)
997
+ const outPath = writeArtifact(taskName, eps)
998
+ total += eps.length
999
+ console.log(`ℹ️ ${taskName}: ${eps.length} 个端点 → ${outPath}`)
1000
+ }
1001
+ console.log(`✅ 聚合提取完成:${taskCards.length} 个 task 卡共 ${total} 个端点(探针 5 对账时全量并集消费)`);
1002
+ break;
935
1003
  } else {
936
1004
  // 默认扫描面:task 卡 allowed_paths ∪ design §6 清单里的具体源码文件
937
1005
  const { parseFileChangeListDetailed } = await import('./change-list.js')
@@ -957,22 +1025,13 @@ async function main() {
957
1025
  collectFile(p)
958
1026
  }
959
1027
  if (candidates.size === 0) {
960
- console.error('❌ 无扫描面:--task 卡无 allowed_paths 且 design.md 无具体文件清单——显式传 --dir <backendRoot> 或 --files <a.py,b.js>');
1028
+ console.error('❌ 无扫描面:--task 卡无 allowed_paths 且 design.md 无具体文件清单——显式传 --dir <backendRoot> 或 --files <a.py,b.js>,或用 --all-tasks 聚合逐卡提取');
961
1029
  process.exit(1);
962
1030
  }
963
1031
  console.log(`ℹ️ 扫描 ${scanned}/${candidates.size} 个清单文件(不存在的跳过)`);
964
1032
  }
965
- const artifact = {
966
- generated_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
967
- generator: 'sillyspec endpoints extract',
968
- endpoints: endpoints.map(e => ({ method: e.method, path: e.path, source: e.source })),
969
- }
970
- const epRuntimeRoot = (await import('./run/shared.js')).resolveRuntimeRoot({ specRoot: specDir }, epSpecBase);
971
- const epOutDir = join(epRuntimeRoot, 'contract-artifacts', epChange, epTask || 'scan');
972
- mkdirSync(epOutDir, { recursive: true });
973
- const epOutPath = join(epOutDir, 'endpoints.json');
974
- writeFileSync(epOutPath, JSON.stringify(artifact, null, 2) + '\n');
975
- console.log(`✅ 提取 ${endpoints.length} 个端点 → ${epOutPath}`);
1033
+ const outPath = writeArtifact(epTask || 'scan', endpoints);
1034
+ console.log(`✅ 提取 ${endpoints.length} 个端点 → ${outPath}`);
976
1035
  console.log(` verify 阶段探针 5(API 契约对账)自动消费该产物;FastAPI/Express/Spring 装饰器均支持。`);
977
1036
  break;
978
1037
  }
@@ -1692,6 +1751,19 @@ SillySpec worktree — git worktree 隔离管理
1692
1751
  for (const f of result.changedFiles) {
1693
1752
  console.log(` ${f}`);
1694
1753
  }
1754
+ // 跨仓 worktree 回落报告(坑 cross-repo-no-worktree-isolation):跨仓文件不进主仓
1755
+ // changedFiles(在其所属仓的 diff 里),单独列出防漏看
1756
+ if (Array.isArray(result.crossRepoApplied) && result.crossRepoApplied.length > 0) {
1757
+ for (const c of result.crossRepoApplied) {
1758
+ if (c.changedFiles.length > 0) {
1759
+ console.log(`✅ 跨仓 repo ${c.repoKey} 已回落 ${c.changedFiles.length} 个文件变更(worktree 已清理,分支保留作 review 锚点):`);
1760
+ for (const f of c.changedFiles.slice(0, 20)) console.log(` ${f}`);
1761
+ if (c.changedFiles.length > 20) console.log(` … 共 ${c.changedFiles.length} 个`);
1762
+ } else {
1763
+ console.log(`ℹ️ 跨仓 repo ${c.repoKey} 无交付变更(worktree 已清理)`);
1764
+ }
1765
+ }
1766
+ }
1695
1767
  // filterDeliverableFiles 精细化排除(保留 .sillyspec/docs/,排 changes/+.runtime/+quicklog/ + meta.json):
1696
1768
  // 模块文档(.sillyspec/docs/)= 交付物,会 auto-apply 到主工作区;变更文档/运行时/quicklog 不 apply。
1697
1769
  // 须告知 agent 哪些未落地,否则它期望这些文件落地却找不到(memory: worktree-apply-excludes-module-docs)。
@@ -1706,6 +1778,20 @@ SillySpec worktree — git worktree 隔离管理
1706
1778
  console.log(` git add --pathspec-from-file="${result.pathspecFile}"`);
1707
1779
  }
1708
1780
  console.log(` (完整清单 ${result.commitPathspec.length} 个文件已存 ${result.pathspecFile},可 --pathspec-from-file 复用)`);
1781
+ // 他者暂存差集(坑 same-main-staging-pollution):暂存区已有非本变更文件时,
1782
+ // 普通 git commit 会把它们一并扫入——提交前亮出来。pathspec 级 commit
1783
+ // (git commit -m <msg> -- <paths>)只提交指定路径、不动他者暂存,双出口都给。
1784
+ try {
1785
+ const { collectStagedArea } = await import('./commit-suggest.js');
1786
+ const stagedNow = collectStagedArea({ cwd: dir, ownFiles: result.commitPathspec });
1787
+ if (stagedNow.available && stagedNow.foreign.length > 0) {
1788
+ console.warn(`\n⚠️ 暂存区已有 ${stagedNow.foreign.length} 个非本变更文件(很可能为他者并行会话暂存,普通 git commit 会扫入):`);
1789
+ for (const f of stagedNow.foreign.slice(0, 10)) console.warn(` - ${f}`);
1790
+ if (stagedNow.foreign.length > 10) console.warn(` … 共 ${stagedNow.foreign.length} 个`);
1791
+ console.warn(` 推荐用 pathspec 级 commit(不动他者暂存):git commit -m "<msg>" --pathspec-from-file="${result.pathspecFile}"`);
1792
+ console.warn(` 或先剔除:git restore --staged -- <文件>(工作区内容不丢,对方可重新 add)`);
1793
+ }
1794
+ } catch { /* 暂存区快照失败不影响 apply 结果展示 */ }
1709
1795
  }
1710
1796
  }
1711
1797
  if (result.warnings && result.warnings.length > 0) {
@@ -1878,6 +1964,18 @@ SillySpec worktree — git worktree 隔离管理
1878
1964
  // 与并行会话的 apply/cleanup 互踩会互相清——与其他写主仓操作共用一把 main-repo.lock
1879
1965
  const { withMainRepoLock } = await import('./worktree-apply.js');
1880
1966
  const result = await withMainRepoLock(dir, wtName, 'worktree-cleanup', () => wm.cleanup(wtName, { force: forceFlag }));
1967
+ // 跨仓 worktree(坑 cross-repo-no-worktree-isolation):与主仓同锁清理——跨仓 worktree
1968
+ // 注册在各跨仓仓 .git 内,但目录/meta 在主仓 .sillyspec 运行时区,同样怕并发互踩
1969
+ const { cleanupCrossWorktrees } = await import('./worktree-cross.js');
1970
+ const crossResult = cleanupCrossWorktrees({ cwd: dir, changeName: wtName, specBase: resolvePlatformSpecDir(dir, specDir), force: forceFlag });
1971
+ for (const cr of crossResult.results) {
1972
+ if (cr.result === 'cleaned') {
1973
+ console.log(`✅ 跨仓 worktree 已清理: repo=${cr.repoKey}(${(cr.details || []).filter(d => d.startsWith('分支')).join(';') || '详情见上'})`);
1974
+ } else if (cr.result === 'partial') {
1975
+ console.error(`🚫 跨仓 repo=${cr.repoKey} 清理受阻:${(cr.details || []).join('; ')}`);
1976
+ for (const r of cr.residual || []) console.error(` 残留: ${r}`);
1977
+ }
1978
+ }
1881
1979
  if (result.result === 'cleaned' || result.result === 'force-cleaned') {
1882
1980
  console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
1883
1981
  if (result.details?.length > 0) {
@@ -349,7 +349,9 @@ export class StageMachine {
349
349
  command: `sillyspec run ${s} --continue --answer "用户回答"${cc}`,
350
350
  };
351
351
  }
352
- const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
352
+ // blocked(deps/review.json 门控阻断标记)也计入待办(坑 deps-gate-blocked-invisible):
353
+ // 漏计会让全局「下一步」建议跳过被阻断的阶段/步骤,误导 agent 去跑更后面的阶段。
354
+ const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed', 'blocked'].includes(st.status));
353
355
  if (hasPending) {
354
356
  return {
355
357
  text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
@@ -23,7 +23,7 @@ import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, ancestorSpecDirs, resolveAncestorCeiling, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME } from './shared.js'
27
27
  import { resolveQuickLinkedChanges } from './quick-audit.js'
28
28
  import { outputStep, collectStageWaitHistory } from './prompt.js'
29
29
  import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
@@ -75,6 +75,38 @@ export function detectSpaceSeparatedFiles(flags) {
75
75
  return suspects
76
76
  }
77
77
 
78
+ /**
79
+ * 检测 prose 参数值是否疑似被 Git Bash(MSYS) 路径转换污染(坑 quick-req-msys-path-mangling)。
80
+ *
81
+ * MSYS2 对以 / 开头的命令行参数做 POSIX→Windows 自动转换:--req "/sessions 页修复" 到达
82
+ * CLI 时已是 "<Git 安装目录>/sessions 页修复"(如 E:/Software/Git/sessions 页修复),无感
83
+ * 写入 QUICKLOG 标题与「需求:」行(该标题也是平台「快速修复」列表展示标题)。启发式:
84
+ * 盘符绝对路径开头 + 紧随空白与中文正文——人写的 Windows 路径引用不会以「盘符路径+中文
85
+ * 句子」形态开头;纯英文正文检不出(v1 取零误报优先,本仓主流为中文文案)。
86
+ *
87
+ * 纯函数:无副作用,供 command.js 解析层告警 + 单测复用。只告警不阻断——无法确定性区分
88
+ * 误报(合法值确可能以盘符路径开头),由 agent 看到 stderr 告警后自查重发。
89
+ * @param {string|null} v 待检值
90
+ * @returns {boolean} 疑似污染返回 true
91
+ */
92
+ export function looksLikeMsysMangledPath(v) {
93
+ if (typeof v !== 'string' || v.length === 0) return false
94
+ const m = v.match(/^[A-Za-z]:[\\/]\S+/) // 盘符绝对路径 token(吃到首个空白)
95
+ if (!m) return false
96
+ const rest = v.slice(m[0].length)
97
+ return /^\s/.test(rest) && /[\u4e00-\u9fff]/.test(rest)
98
+ }
99
+
100
+ /**
101
+ * MSYS 污染告警出口:命中嗅探时向 stderr 打 flag 名 + 值前缀 + 修复指引(不阻断)。
102
+ */
103
+ function warnMsysMangledFlag(flag, value) {
104
+ if (!looksLikeMsysMangledPath(value)) return
105
+ console.error(`⚠️ ${flag} 的值疑似被 Git Bash(MSYS) 路径转换污染:「${value.slice(0, 60)}${value.length > 60 ? '…' : ''}」`)
106
+ console.error(' 以 / 开头的文案在 Git Bash 下会被展开成 <Git 安装目录>/… 绝对路径后才传入 CLI。')
107
+ console.error(' 非本意 → 去掉前导 / 或改写表述后重发本命令;确需原样 → 命令前加 MSYS_NO_PATHCONV=1。')
108
+ }
109
+
78
110
  /**
79
111
  * 从 progress 或变更目录推导变更名
80
112
  */
@@ -385,14 +417,26 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
385
417
  // let:下方 worktree 副本漂移守卫命中时锚回 wt.mainSpecBase(task-05/D-03)
386
418
  let specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
387
419
 
388
- // 漂移提醒:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
420
+ // 漂移阻断:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
389
421
  // 可能不是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)。
390
- // 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。仅提醒不阻断。
422
+ // 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。
423
+ // 当前实例 ≠ git 顶层仓库实例 → 默认拒绝(坑 monorepo-cwd-wrong-spec-instance)。
391
424
  if (!platformOpts.specRoot && !specDir) {
392
- const ancestorCount = countAncestorSpecDirs(cwd)
393
- if (ancestorCount >= 2) {
394
- console.warn(`⚠️ 检测到祖先链有 ${ancestorCount} 个 .sillyspec 实例(monorepo 多实例),当前使用: ${specBase}`)
395
- console.warn(` 若意图是另一个项目:cd 回该项目根,或用 --spec-dir <根>/.sillyspec 显式指定。`)
425
+ const ancestorSpecs = ancestorSpecDirs(cwd)
426
+ if (ancestorSpecs.length >= 2) {
427
+ // 判断当前命中的 .sillyspec 是否在 git 顶层仓库根下
428
+ const gitRoot = resolveAncestorCeiling(resolve(cwd))
429
+ const currentSpecParent = dirname(resolve(specBase))
430
+ const isAtGitRoot = gitRoot && resolve(currentSpecParent) === resolve(gitRoot)
431
+ if (!isAtGitRoot) {
432
+ console.error(`❌ 检测到祖先链有 ${ancestorSpecs.length} 个 .sillyspec 实例(monorepo 多实例),当前使用: ${specBase}`)
433
+ console.error(` 当前 cwd 不在 git 顶层仓库根下,静默绑定到子项目实例会导致进度/QUICKLOG 分裂。`)
434
+ console.error(` 修复:cd 回项目根目录,或用 --spec-dir <根>/.sillyspec 显式指定。`)
435
+ process.exit(2)
436
+ }
437
+ // 在 git 根下但有多实例(子项目也有 .sillyspec)→ 仅警告(正确实例已命中)
438
+ console.warn(`⚠️ 检测到祖先链有 ${ancestorSpecs.length} 个 .sillyspec 实例(monorepo 多实例),当前使用: ${specBase}`)
439
+ console.warn(` 当前已绑定 git 顶层仓库实例。若意图是子项目:用 --spec-dir 显式指定。`)
396
440
  }
397
441
  }
398
442
 
@@ -461,6 +505,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
461
505
  let outputText = null
462
506
  const outputValue = getFlagValue('--output')
463
507
  if (outputValue !== null) outputText = outputValue
508
+ warnMsysMangledFlag('--output', outputValue)
464
509
 
465
510
  // 解析 quick 末步四字段参数(2026-08-21 agent-手工产出审计第二批 F6):--req/--cause/
466
511
  // --solution/--result 各传一项,CLI 合成单行四字段 outputText(quicklog 落盘侧
@@ -480,6 +525,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
480
525
  console.error(`❌ quick 末步四字段参数缺项:${missing.join(' ')}(--req/--cause/--solution/--result 必须全给,或缺字段 --done 会被拒)`);
481
526
  process.exit(2)
482
527
  }
528
+ for (const [f] of FOUR) warnMsysMangledFlag(f, getFlagValue(f))
483
529
  outputText = FOUR.map(([f, label]) => `${label}${getFlagValue(f)}`).join(' ')
484
530
  }
485
531
  }
@@ -502,6 +548,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
502
548
  break
503
549
  }
504
550
  }
551
+ warnMsysMangledFlag('--input', inputText)
505
552
 
506
553
  // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
507
554
  // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
@@ -896,7 +943,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
896
943
  if (readonlySteps && readonlySteps.length > 0) {
897
944
  // 已有进度(历史 seed 过)则渲染当前待办步;无进度从首步开始
898
945
  const existingSteps = progress.stages?.[stageName]?.steps
899
- const pendingIdx = existingSteps ? existingSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress') : -1
946
+ const pendingIdx = existingSteps ? existingSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked') : -1
900
947
  const stepIdx = pendingIdx !== -1 ? pendingIdx : 0
901
948
  await outputStep(stageName, stepIdx, readonlySteps, cwd, readOnlyChange, progress.project || null, platformOpts, null, collectStageWaitHistory(progress, stageName))
902
949
  }
@@ -1295,7 +1342,7 @@ function showStatus(progress, stageName, nextSuggestion = null) {
1295
1342
 
1296
1343
  console.log('')
1297
1344
 
1298
- const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1345
+ const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
1299
1346
 
1300
1347
  if (progress.batchProgress) {
1301
1348
  const bp = progress.batchProgress
@@ -1314,10 +1361,13 @@ function showStatus(progress, stageName, nextSuggestion = null) {
1314
1361
  }
1315
1362
 
1316
1363
  steps.forEach((step, i) => {
1317
- const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : step.status === 'waiting' ? '⏸️' : '⬜'
1318
- const isCurrent = (step.status === 'pending' || step.status === 'in-progress') && i === firstPending
1364
+ const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : step.status === 'waiting' ? '⏸️' : step.status === 'blocked' ? '🚫' : '⬜'
1365
+ const isCurrent = (step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') && i === firstPending
1319
1366
  const isWaiting = step.status === 'waiting'
1320
- console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}${isWaiting ? ' [WAITING]' : ''}`)
1367
+ console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}${isWaiting ? ' [WAITING]' : ''}${step.status === 'blocked' ? ' [BLOCKED]' : ''}`)
1368
+ if (step.status === 'blocked') {
1369
+ console.log(` 此前被门控阻断——${step.blockReason || 'deps 未就绪 / review.json 不完整'};修复后重跑 --done 即恢复,无需 --reset`)
1370
+ }
1321
1371
  if (isWaiting) {
1322
1372
  if (step.waitReason) console.log(` 原因:${step.waitReason}`)
1323
1373
  if (step.waitOptions) console.log(` 选项:${formatWaitOptions(step.waitOptions)}`)
@@ -1355,6 +1405,22 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
1355
1405
  } catch (e) {
1356
1406
  console.warn(`⚠️ reset 清理 worktree 失败(不阻断 reset): ${e.message}`)
1357
1407
  }
1408
+ // 跨仓 worktree 一并清理(坑 cross-repo-no-worktree-isolation):否则下次 execute 因 meta
1409
+ // 存在复用带脏状态的旧跨仓 worktree
1410
+ try {
1411
+ const { cleanupCrossWorktrees } = await import('../worktree-cross.js')
1412
+ const cross = cleanupCrossWorktrees({
1413
+ cwd, changeName,
1414
+ specBase: platformOpts?.specRoot || join(cwd, '.sillyspec'),
1415
+ force: true,
1416
+ })
1417
+ for (const cr of cross.results) {
1418
+ if (cr.result === 'cleaned') console.log(`🧹 已清理跨仓 worktree repo=${cr.repoKey},下次 execute 将重建`)
1419
+ else if (cr.result === 'partial') console.warn(`⚠️ 跨仓 repo=${cr.repoKey} 清理残留: ${(cr.details || []).join('; ')} ${(cr.residual || []).join('; ')}`)
1420
+ }
1421
+ } catch (e) {
1422
+ console.warn(`⚠️ reset 清理跨仓 worktree 失败(不阻断 reset): ${e.message}`)
1423
+ }
1358
1424
  }
1359
1425
  const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1360
1426
  progress.stages[stageName] = {
@@ -1484,7 +1550,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1484
1550
  console.log('')
1485
1551
 
1486
1552
  const defSteps = await getAutoSteps(currentStage)
1487
- const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1553
+ const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') ?? -1
1488
1554
  if (pendingIdx === -1) {
1489
1555
  const wsIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'waiting') ?? -1
1490
1556
  if (wsIdx !== -1) {
@@ -1536,7 +1602,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1536
1602
  return
1537
1603
  }
1538
1604
 
1539
- const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1605
+ const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') ?? -1
1540
1606
  if (nextPendingIdx !== -1) {
1541
1607
  const defSteps = await getAutoSteps(currentStage)
1542
1608
  // execute 阶段启动前检查审批
@@ -1615,7 +1681,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1615
1681
 
1616
1682
  console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
1617
1683
  const nextSteps = await getAutoSteps(next)
1618
- const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1684
+ const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') ?? -1
1619
1685
  if (firstPending !== -1) {
1620
1686
  // execute 阶段启动前检查审批
1621
1687
  if (next === 'execute' && !skipApproval) {
@@ -1365,6 +1365,20 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
1365
1365
  console.log(` 下一步: sillyspec worktree apply ${changeName}`);
1366
1366
  } else {
1367
1367
  console.log(`🔗 Worktree: ${cleanResult.result.result}`);
1368
+ // 正当清理回执(坑 deps-gate-cleanup-order,2026-08-28 实证:cleanup 后重试 --done 被
1369
+ // deps 门拦「worktree 不可用」,逼 doctor --align-execute-progress 手工对齐):execute 完成时的
1370
+ // 正当清理(无未应用变更)落 durable 标记,enforceDepsGate 读它区分「正当收尾」与「意外丢失」。
1371
+ // apply 路径不写——apply-pathspec-<change>.txt 已是既定 apply 凭据,双标记冗余。
1372
+ if (['cleaned', 'force-cleaned', 'partial'].includes(cleanResult.result.result)) {
1373
+ try {
1374
+ const runtimeDir = join(cwd, '.sillyspec', '.runtime');
1375
+ mkdirSync(runtimeDir, { recursive: true });
1376
+ writeFileSync(join(runtimeDir, `execute-cleanup-${changeName}.json`), JSON.stringify({
1377
+ change: changeName, cleanedAt: new Date().toISOString(),
1378
+ reason: 'execute-completed-no-unapplied-changes',
1379
+ }, null, 2) + '\n', 'utf8');
1380
+ } catch { /* 回执失败只损失 deps 门的放行判据,不影响 cleanup 结果 */ }
1381
+ }
1368
1382
  if (cleanResult.result.residual?.length > 0) {
1369
1383
  console.warn(` ⚠️ 清理残留: ${cleanResult.result.residual.join('; ')}`);
1370
1384
  console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
@@ -1385,6 +1399,26 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
1385
1399
  } catch (e) {
1386
1400
  console.warn(`🔗 Worktree: check failed — ${e.message}`);
1387
1401
  }
1402
+ // 跨仓 worktree(坑 cross-repo-no-worktree-isolation):execute 完成时不自动清——交付还在
1403
+ // 跨仓 worktree 分支上未回落主工作副本,与主仓「pending apply」同语义,报状态 + 下一步指引
1404
+ try {
1405
+ const { listCrossWorktreeMetas } = await import('../worktree-cross.js')
1406
+ const specBase = join(cwd, '.sillyspec')
1407
+ for (const { repoKey, meta: cm } of listCrossWorktreeMetas(specBase, changeName)) {
1408
+ let pending = 0
1409
+ try {
1410
+ const d = safeGit(cm.worktreePath, ['diff', '--name-only', cm.baseHash], { timeout: 30000 })
1411
+ const u = safeGit(cm.worktreePath, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 })
1412
+ pending = ((d.value || '') + '\n' + (u.value || '')).split('\n').filter(Boolean).length
1413
+ } catch { /* 状态探测失败按未知处理 */ }
1414
+ if (pending > 0) {
1415
+ console.log(`🔗 跨仓 worktree repo=${repoKey}: pending apply (${pending} 个未回落变更)`)
1416
+ console.log(` 下一步: sillyspec worktree apply ${changeName}(跨仓与主仓一并回落)`)
1417
+ } else {
1418
+ console.log(`🔗 跨仓 worktree repo=${repoKey}: 无未回落变更(apply 时将自动清理)`)
1419
+ }
1420
+ }
1421
+ } catch { /* 跨仓状态报告 best-effort */ }
1388
1422
  }
1389
1423
  return null
1390
1424
  }
@@ -103,7 +103,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
103
103
  }
104
104
 
105
105
  const steps = stageData.steps
106
- let currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
106
+ // blocked(deps/review.json 门控阻断标记)也视为当前步骤(坑 deps-gate-blocked-invisible,
107
+ // 2026-08-27 实证):门控阻断时 enforceDepsGate/enforceReviewJsonGate 把当前步置 blocked 后
108
+ // exit(1),若谓词漏 blocked,被阻断步骤对 findIndex 永久隐身——重试 --done 落到其后第一个
109
+ // pending 步骤上(错步记账,DB 显示与事实脱节),且 blocked 步骤无任何恢复路径(状态机卡死,
110
+ // 只能 --reset 丢进度)。门控本身在下方会重新校验:deps 已修复 → 正常完成;未修复 → 再次
111
+ // 阻断同一步(幂等,不污染后续步骤)。
112
+ let currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
113
+ if (currentIdx !== -1 && steps[currentIdx].status === 'blocked') {
114
+ console.log(`⚠️ Step "${steps[currentIdx].name}" 此前被门控阻断(blocked),本次 --done 重新校验门控后继续推进。`)
115
+ }
107
116
  // ── 坑1:--done --answer 解「已 waiting 的步骤」──
108
117
  // findIndex 仅查 pending/in-progress,排除 waiting;故 --done --answer 落到已 --wait 暂停的
109
118
  // requiresWait 步骤时会跳过它、把 --answer 静默丢弃,步骤永久卡 WAITING、末步报「等待用户输入」。