sillyspec 3.26.6 → 3.26.8

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.
Files changed (42) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  5. package/.husky/pre-push +9 -2
  6. package/bin/sillyspec.js +1 -1
  7. package/package.json +3 -3
  8. package/packages/dashboard/server/index.js +8 -1
  9. package/src/config-schema.js +305 -287
  10. package/src/docs-check.js +333 -0
  11. package/src/docs-debt.js +226 -0
  12. package/src/docs-gate.js +106 -0
  13. package/src/doctor-diagnostics.js +23 -2
  14. package/src/index.js +1880 -1750
  15. package/src/init.js +81 -14
  16. package/src/modules.js +490 -486
  17. package/src/progress.js +37 -13
  18. package/src/quicklog.js +19 -4
  19. package/src/run/command.js +54 -24
  20. package/src/run/complete-handlers.js +1254 -1128
  21. package/src/run/complete.js +8 -34
  22. package/src/run/gates.js +32 -3
  23. package/src/run/prompt.js +713 -633
  24. package/src/run/quick-audit.js +137 -123
  25. package/src/run/shared.js +1109 -929
  26. package/src/run/stage.js +8 -3
  27. package/src/scan-staleness.js +90 -0
  28. package/src/setup.js +27 -9
  29. package/src/sillyhub-mcp/client.js +149 -14
  30. package/src/stage-contract-spec.js +15 -0
  31. package/src/stage-contract.js +8 -5
  32. package/src/stages/brainstorm-auto.js +0 -1
  33. package/src/stages/brainstorm.js +1 -1
  34. package/src/stages/execute.js +985 -967
  35. package/src/stages/plan-postcheck.js +93 -36
  36. package/src/stages/verify.js +1 -1
  37. package/src/sync.js +60 -32
  38. package/src/task-review.js +38 -6
  39. package/src/verify-postcheck.js +22 -5
  40. package/src/worktree-apply.js +6 -3
  41. package/src/worktree-deps.js +5 -3
  42. package/src/worktree.js +4 -2
@@ -31,6 +31,7 @@ import { validateScriptCommands } from './cmd-existence.js'
31
31
  * @returns {string[]}
32
32
  */
33
33
  function parseDependsOn(content) {
34
+ content = content.replace(/\r\n/g, '\n') // 外部调用方(worktree-apply 等)原生 readFileSync 喂 CRLF 时 ^---\n 锚点失配
34
35
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
35
36
  if (!fmMatch) return []
36
37
  const fm = fmMatch[1]
@@ -38,9 +39,11 @@ function parseDependsOn(content) {
38
39
  if (inlineMatch) {
39
40
  return inlineMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)
40
41
  }
41
- const blockMatch = fm.match(/depends_on:\s*\n((?:\s+-\s+.+\n?)+)/)
42
+ // 块列表:列表项允许顶格或缩进([ \t]*,坑6——原 \s* 贪婪吃掉换行与前导空白,
43
+ // 顶格 `depends_on:\n- task-01` 标准 YAML 写法永远失配,静默判「无依赖」)
44
+ const blockMatch = fm.match(/depends_on:[ \t]*\n((?:[ \t]*-[ \t]+.+\n?)+)/)
42
45
  if (blockMatch) {
43
- return blockMatch[1].match(/-\s+(.+)/g)?.map(s => s.replace(/^-\s+/, '').trim().replace(/['"]/g, '')) || []
46
+ return blockMatch[1].match(/[ \t]*-[ \t]+(.+)/g)?.map(s => s.replace(/^[ \t]*-[ \t]+/, '').trim().replace(/['"]/g, '')) || []
44
47
  }
45
48
  return []
46
49
  }
@@ -67,16 +70,25 @@ function parseTaskId(content, filename) {
67
70
  * @returns {string[]}
68
71
  */
69
72
  export function parseAllowedPaths(content) {
73
+ content = content.replace(/\r\n/g, '\n') // 同 parseDependsOn:入口归一,保护喂原始 CRLF 内容的外部调用方
70
74
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
71
75
  if (!fmMatch) return []
72
76
  const fm = fmMatch[1]
73
77
  const inlineMatch = fm.match(/allowed_paths:\s*\[([^\]]*)\]/)
74
78
  if (inlineMatch) {
75
- return inlineMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)
79
+ return inlineMatch[1].split(',').map(s => s.trim().replace(/['"`]/g, '')).filter(Boolean)
76
80
  }
77
- const blockMatch = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/)
81
+ // 块列表:列表项允许顶格或缩进(坑6①——原 `\s*\n` 的 \s 贪婪匹配换行+前导空白,
82
+ // 标准 YAML 块列表 `allowed_paths:\n- src/a.js`(顶格)永远失配,静默判「缺 allowed_paths」;
83
+ // 缩进式 \s+- 恰可命中是巧合幸存。改 [ \t]*(不吃换行)+ 行内 [ \t]*-[ \t]+ 顶格缩进通吃)
84
+ const blockMatch = fm.match(/allowed_paths:[ \t]*\n((?:[ \t]*-[ \t]+.+\n?)+)/)
78
85
  if (blockMatch) {
79
- return blockMatch[1].match(/-\s+(.+)/g)?.map(s => s.replace(/^-\s+/, '').trim().replace(/['"]/g, '')) || []
86
+ // 剥首尾成对反引号(坑7 家族:`src/a.js` 整项以反引号开头时 js-yaml/正则路径带 ` 匹配失败报未覆盖;
87
+ // 与 change-list.js 的 cell 归一化同语义——剥两端包裹引号,不动路径内部字符)
88
+ return blockMatch[1].match(/[ \t]*-[ \t]+(.+)/g)?.map(s => {
89
+ const raw = s.replace(/^[ \t]*-[ \t]+/, '').trim()
90
+ return raw.replace(/^`([^`]*)`$/, '$1').replace(/^['"]|['"]$/g, '').trim()
91
+ }).filter(Boolean) || []
80
92
  }
81
93
  return []
82
94
  }
@@ -96,6 +108,7 @@ export function parseAllowedPaths(content) {
96
108
  * @returns {string|null}
97
109
  */
98
110
  function parseFrontmatterScalar(content, field) {
111
+ content = content.replace(/\r\n/g, '\n') // 同 parseAllowedPaths:入口归一 CRLF
99
112
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
100
113
  if (!fmMatch) return null
101
114
  let fm
@@ -439,6 +452,39 @@ export function validateBlueprintConsistency(changeDir) {
439
452
  }
440
453
  }
441
454
 
455
+ // plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b):
456
+ // plan 列 17 任务只出 12 卡(尾部文档同步类任务漏卡)时,无卡任务不进 Wave、
457
+ // 不受 execute 审计 → 零失败信号漏做。口径与 plan.js parseTaskCount/parseTaskNames 一致
458
+ // (`- [ ] task-NN` checkbox;file 命名 task-NN.md + 卡内 id task-NN 均取,缺一即漏卡)。
459
+ const planMdPath = pJoin(changeDir, 'plan.md')
460
+ if (existsSync(planMdPath)) {
461
+ const declaredIds = new Set()
462
+ for (const line of readFileSync(planMdPath, 'utf8').split('\n')) {
463
+ const m = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
464
+ if (m) declaredIds.add(m[1].toLowerCase())
465
+ }
466
+ if (declaredIds.size > 0) {
467
+ const tprRule = getRule('plan.task-plan-reconciliation')
468
+ const cardIds = new Set([...taskInfo.keys()].map(id => id.toLowerCase()))
469
+ const missing = [...declaredIds].filter(id => !cardIds.has(id))
470
+ if (missing.length > 0) {
471
+ errors.push(
472
+ tprRule.data.messageMissingCards
473
+ .replaceAll('${declared}', declaredIds.size)
474
+ .replaceAll('${missing}', missing.join(', '))
475
+ )
476
+ }
477
+ const orphans = [...cardIds].filter(id => !declaredIds.has(id))
478
+ if (orphans.length > 0) {
479
+ errors.push(
480
+ tprRule.data.messageOrphanCards.replaceAll('${orphans}', orphans.join(', '))
481
+ )
482
+ }
483
+ }
484
+ // declaredIds.size === 0:plan.md 无 task checkbox 声明(极端格式)→ 跳过对账,
485
+ // 由 validatePlanArtifacts/validatePlanForExecute 把关,不在此误伤。
486
+ }
487
+
442
488
  // 拓扑排序 + 循环依赖
443
489
  const depMap = new Map()
444
490
  for (const [taskId, info] of taskInfo) {
@@ -691,8 +737,10 @@ function parseDesignCoverageByRepo(designPath) {
691
737
  }
692
738
 
693
739
  if (!hasSegmentHeader) {
694
- // 无段头 → 整章节归 main(等价原 parseFileChangeList 行为)
695
- const main = new Set(parseFileChangeList(designPath))
740
+ // 无段头 → 整章节归 main(等价原 parseFileChangeList 行为)。
741
+ // keepSillyspecDocs=true:.sillyspec/docs/ 模块文档 = 交付物,与 apply 阶段
742
+ // resolveApplyAllowSet 口径一致(债单 D-2)——design 声明的模块文档更新必须被 task 认领。
743
+ const main = new Set(parseFileChangeList(designPath, { keepSillyspecDocs: true }))
696
744
  if (main.size > 0) byRepo.set('main', main)
697
745
  for (const p of main) allFiles.push(p)
698
746
  return { byRepo, hasSegmentHeader: false, allFiles }
@@ -731,7 +779,7 @@ function parseDesignCoverageByRepo(designPath) {
731
779
  const virtualDesign = ['## 文件变更清单', '', ...segLines, ''].join('\n')
732
780
  const tmpPath = pJoin(tmpDir, `${repo}.md`)
733
781
  writeFileSync(tmpPath, virtualDesign)
734
- const segFiles = [...parseFileChangeList(tmpPath)]
782
+ const segFiles = [...parseFileChangeList(tmpPath, { keepSillyspecDocs: true })]
735
783
  if (segFiles.length === 0) continue
736
784
  const set = byRepo.get(repo) || new Set()
737
785
  for (const p of segFiles) { set.add(p); allFiles.push(p) }
@@ -1062,41 +1110,41 @@ export async function executePlanPostcheck(context) {
1062
1110
 
1063
1111
  console.log(` 📂 变更目录: ${changeDir}`)
1064
1112
 
1113
+ // ── 报错聚合(坑6③):六个检查全部跑完后统一输出全部失败项,一轮 --done 暴露全部问题,
1114
+ // 不再「失败一个抛一个、修一个冒一个」的迭代盲盒。各检查相互独立(都只读 changeDir 产物),
1115
+ // 前置检查失败不使后续检查失真——唯一例外是 tasks/ 目录缺失时后续检查都无意义,仍提前短路。
1116
+ const failures = [] // { name, errors, hint }
1117
+ const printSectionWarnings = (label, warnings) => {
1118
+ if (warnings.length > 0) {
1119
+ console.warn(`\n⚠️ ${label}警告(不阻断):`)
1120
+ for (const w of warnings) console.warn(` - ${w}`)
1121
+ }
1122
+ }
1123
+
1065
1124
  // ── 1. 一致性校验 ──
1066
1125
  const consistency = validateBlueprintConsistency(changeDir)
1067
1126
  if (consistency.errors.length > 0) {
1068
- console.error('\n❌ 蓝图一致性校验失败:')
1069
- for (const err of consistency.errors) console.error(` - ${err}`)
1070
- console.error('\n 请修复上述问题后重新完成此步骤。')
1071
- throw new Error('planPostcheck: blueprint consistency check failed')
1072
- }
1073
- if (consistency.warnings.length > 0) {
1074
- console.warn('\n⚠️ 蓝图一致性警告(不阻断):')
1075
- for (const w of consistency.warnings) console.warn(` - ${w}`)
1127
+ failures.push({ name: '蓝图一致性校验', errors: consistency.errors, hint: '请修复上述问题后重新完成此步骤。' })
1076
1128
  }
1129
+ printSectionWarnings('蓝图一致性', consistency.warnings)
1077
1130
 
1078
1131
  // ── 1b. 可行性校验 ──
1079
1132
  const feasibility = validatePlanFeasibility(changeDir, context.cwd)
1080
1133
  if (feasibility.errors.length > 0) {
1081
- console.error('\n❌ Plan 可行性校验失败:')
1082
- for (const err of feasibility.errors) console.error(` - ${err}`)
1083
- throw new Error('planPostcheck: feasibility check failed')
1084
- }
1085
- if (feasibility.warnings.length > 0) {
1086
- console.warn('\n⚠️ Plan 可行性警告(不阻断):')
1087
- for (const w of feasibility.warnings) console.warn(` - ${w}`)
1134
+ failures.push({ name: 'Plan 可行性校验', errors: feasibility.errors, hint: null })
1088
1135
  }
1136
+ printSectionWarnings('Plan 可行性', feasibility.warnings)
1089
1137
 
1090
1138
  // ── 1c. 跨任务契约校验 ──
1091
1139
  // 对账 consumer.expects_from ↔ provider.provides,拦截「consumer 期望字段
1092
1140
  // 但 provider 未承诺」的契约断裂(避免到 execute/verify 才暴露成 403/500)
1093
1141
  const crossTask = validateCrossTaskContracts(changeDir)
1094
1142
  if (crossTask.errors.length > 0) {
1095
- console.error('\n❌ 跨任务契约校验失败(consumer 期望的字段未被 provider 承诺):')
1096
- for (const err of crossTask.errors) console.error(` - ${err}`)
1097
- console.error('\n 修复方式:要么在 provider task 的 provides.fields 补上缺失字段,')
1098
- console.error(' 要么修正 consumer task 的 expects_from.needs(确认依赖是否真实)。')
1099
- throw new Error('planPostcheck: cross-task contract check failed')
1143
+ failures.push({
1144
+ name: '跨任务契约校验(consumer 期望的字段未被 provider 承诺)',
1145
+ errors: crossTask.errors,
1146
+ hint: '修复方式:要么在 provider task 的 provides.fields 补上缺失字段,要么修正 consumer task 的 expects_from.needs(确认依赖是否真实)。',
1147
+ })
1100
1148
  }
1101
1149
 
1102
1150
  // ── 1c-b. TaskCard 命令存在性校验 ──
@@ -1111,11 +1159,11 @@ export async function executePlanPostcheck(context) {
1111
1159
  }
1112
1160
  const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules)
1113
1161
  if (taskCmds.errors.length > 0) {
1114
- console.error('\n❌ TaskCard 命令存在性校验失败(verify/implementation 的 npm/pnpm/yarn run <script> 不存在):')
1115
- for (const err of taskCmds.errors) console.error(` - ${err}`)
1116
- console.error('\n 修复方式:要么在 package.json scripts 补上缺失命令(monorepo 注意子包路径),')
1117
- console.error(' 要么修正 TaskCard 的 verify/implementation(确认命令是否真实、是否需 cd 子包)。')
1118
- throw new Error('planPostcheck: task command existence check failed')
1162
+ failures.push({
1163
+ name: 'TaskCard 命令存在性校验(verify/implementation 的 npm/pnpm/yarn run <script> 不存在)',
1164
+ errors: taskCmds.errors,
1165
+ hint: '修复方式:要么在 package.json scripts 补上缺失命令(monorepo 注意子包路径),要么修正 TaskCard 的 verify/implementation(确认命令是否真实、是否需 cd 子包)。',
1166
+ })
1119
1167
  }
1120
1168
 
1121
1169
  // ── 1d. design 文件覆盖对账 ──
@@ -1124,14 +1172,23 @@ export async function executePlanPostcheck(context) {
1124
1172
  const coverage = validateDesignFileCoverage(changeDir)
1125
1173
  for (const w of coverage.warnings) console.warn(`\n ⚠️ ${w}`)
1126
1174
  if (coverage.errors.length > 0) {
1127
- console.error('\n❌ design.md 文件覆盖对账失败(清单中的文件未被任何 task 覆盖):')
1128
- for (const err of coverage.errors) console.error(` - ${err}`)
1129
- throw new Error('planPostcheck: design file coverage check failed')
1175
+ failures.push({ name: 'design.md 文件覆盖对账(清单中的文件未被任何 task 覆盖)', errors: coverage.errors, hint: null })
1130
1176
  }
1131
1177
  if (coverage.designFiles.length > 0 && coverage.uncovered.length === 0) {
1132
1178
  console.log(` ✅ design.md ${coverage.designFiles.length} 个文件全部被 task allowed_paths 覆盖`)
1133
1179
  }
1134
1180
 
1181
+ // ── 聚合输出:一轮 --done 暴露全部失败项(坑6③)──
1182
+ if (failures.length > 0) {
1183
+ console.error(`\n❌ plan postcheck 失败(${failures.length} 类问题,已全部列出——一次修复后重跑,无需逐个迭代):`)
1184
+ for (const f of failures) {
1185
+ console.error(`\n 【${f.name}】`)
1186
+ for (const err of f.errors) console.error(` - ${err}`)
1187
+ if (f.hint) console.error(` ${f.hint}`)
1188
+ }
1189
+ throw new Error(`planPostcheck: ${failures.length} 组校验失败(${failures.map(f => f.name).join(';')})`)
1190
+ }
1191
+
1135
1192
  // ── 2. Wave 重排 ──
1136
1193
  const tasksDir = pJoin(changeDir, 'tasks')
1137
1194
  if (existsSync(tasksDir)) {
@@ -134,7 +134,7 @@ export const definition = {
134
134
  3. 数据模型是否符合
135
135
  4. API 设计是否符合
136
136
  5. **Reverse Sync 检查**:如果发现实现合理但 design.md 未覆盖,先更新 design.md 补充遗漏
137
- 6. **模块文档一致性检查**:如果在"加载规范并锚定"步骤中加载了模块文档,检查实现是否符合模块文档描述的当前设计(特别关注接口签名、数据流、依赖关系)。不符合时标记 ⚠️(不阻断,模块文档可能未及时更新)
137
+ 6. **模块文档一致性检查**:如果在"加载规范并锚定"步骤中加载了模块文档,检查实现是否符合模块文档描述的当前设计(特别关注接口签名、数据流、依赖关系)。不一致时**当场同步模块文档**(这是 verify 的收尾义务,不是可选项);module-impact.md「更新结果」表的文档同步项也须在本阶段回填 done/skipped——CLI 在 verify --done 时硬校验该表无 pending/待办死信行,未清即阻断完成
138
138
  7. **决策链路检查**:如果存在 decisions.md,输出 D-xxx@vN → FR-xxx → task-xx → evidence 的追踪矩阵;缺失项必须列为风险(CLI 只校验每个 D-xxx@vN ID 字面出现在 verify-result.md,warning 不阻断;矩阵的 D→FR→task→evidence 映射完整性供人类追溯,CLI 不校验——是否真覆盖由你诚实判定)
139
139
 
140
140
  ### 输出
package/src/sync.js CHANGED
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlink
12
12
  import { join } from 'path';
13
13
  import { resolvePlatformSpecDir } from './progress.js';
14
14
  import { safeGit } from './git-helper.js';
15
+ import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
15
16
 
16
17
  // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
17
18
  function safePlatformSpecDir(cwd) {
@@ -27,9 +28,10 @@ const LOCAL_YAML = '.sillyspec/local.yaml';
27
28
  const CHANGES_DIR = '.sillyspec/changes';
28
29
  const REQUEST_TIMEOUT_MS = 10_000;
29
30
 
30
- // 未连接平台是本地独立用户的合法默认状态。sync / syncDocuments / checkApproval 由 run 流程
31
- // 在后台 best-effort 触发(每步完成、execute 阶段启动),未连接时默认静默跳过——不每步催连
32
- // 平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
31
+ // 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
32
+ // 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
33
+ // 命令触发(index.js sync-docs 分支),run 流程不自动推文档。未连接时默认静默跳过——不每步
34
+ // 催连平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
33
35
  function debugLog(msg) {
34
36
  if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg)
35
37
  }
@@ -136,6 +138,13 @@ function writeLocalYamlRaw(cwd, text) {
136
138
  }
137
139
 
138
140
  function parseSimpleYaml(content) {
141
+ // 剥 YAML 双/单引号包裹(connect 写侧 yamlStr 加引号防 # : 注入,读侧对称剥;无引号值零影响)
142
+ const unquote = (v) => {
143
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
144
+ return v.slice(1, -1);
145
+ }
146
+ return v;
147
+ };
139
148
  const result = {};
140
149
  let currentSection = null;
141
150
  for (const line of content.split('\n')) {
@@ -147,7 +156,7 @@ function parseSimpleYaml(content) {
147
156
  const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
148
157
  if (m) {
149
158
  const key = m[1];
150
- const val = m[2].trim();
159
+ const val = unquote(m[2].trim());
151
160
  if (val) {
152
161
  result[key] = val;
153
162
  currentSection = null;
@@ -159,7 +168,7 @@ function parseSimpleYaml(content) {
159
168
  } else if (currentSection) {
160
169
  const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
161
170
  if (m && result[currentSection] && typeof result[currentSection] === 'object') {
162
- result[currentSection][m[1]] = m[2].trim();
171
+ result[currentSection][m[1]] = unquote(m[2].trim());
163
172
  }
164
173
  }
165
174
  }
@@ -296,46 +305,60 @@ export class SyncManager {
296
305
 
297
306
  // 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
298
307
  // (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
308
+ // YAML 值加引号:token/url 含 # : 或首尾空白时裸写会破坏段结构或注入额外键(shpsync_ token
309
+ // 当前不含这些字符,防御性包裹零成本;parseSimpleYaml 读侧剥引号兼容)
310
+ const yamlStr = (v) => JSON.stringify(String(v));
299
311
  const platformEntries = [
300
- ` url: ${normalizedUrl}`,
301
- ` token: ${effectiveToken}`,
302
- ` last_connected: ${new Date().toISOString()}`,
312
+ ` url: ${yamlStr(normalizedUrl)}`,
313
+ ` token: ${yamlStr(effectiveToken)}`,
314
+ ` last_connected: ${yamlStr(new Date().toISOString())}`,
303
315
  ];
304
316
  if (resolvedUser) {
305
- platformEntries.push(` user: ${resolvedUser}`);
317
+ platformEntries.push(` user: ${yamlStr(resolvedUser)}`);
306
318
  }
307
319
  let text = readLocalYamlRaw(this.cwd);
308
320
  text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
309
- // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
321
+ // mcp 段同源假设(design §7.4):url 复用 platform 的 url;token 用原始 user 级 token
322
+ // (非换发的 effectiveToken——MCP 派发需 user 级权限,platform 段才是最小权限的 shpsync_)。
323
+ // 用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
310
324
  if (findTopLevelSectionRange(text, 'mcp') === null) {
311
- text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
325
+ text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(normalizedUrl)}\n token: ${yamlStr(token)}`);
312
326
  }
313
327
  writeLocalYamlRaw(this.cwd, text);
314
328
  }
315
329
 
316
330
  /**
317
- * 断开平台连接。
318
- * 从 local.yaml 删除 platform 配置段。
331
+ * 断开平台连接(三清,D-C@v2)。
332
+ * 1. 从 local.yaml 删除 platform 配置段;
333
+ * 2. 删恢复指针 <cwd>/.sillyspec-platform.json;
334
+ * 3. 删平台接管声明 <cwd>/.sillyspec-platform-managed。
335
+ * 不删后两者则"disconnect 后恢复本地模式"不可达(指针健在时 resolvePlatformSpecDir
336
+ * 仍解析平台 specRoot;声明健在时指针缺失会 fail-closed)。
319
337
  */
320
338
  disconnect() {
321
339
  const p = join(this.cwd, LOCAL_YAML);
322
- if (!existsSync(p)) {
323
- console.log('[sync] 已断开连接(无配置文件)');
324
- return;
325
- }
326
- const text = readFileSync(p, 'utf8');
327
- if (findTopLevelSectionRange(text, 'platform') === null) {
328
- console.log('[sync] 已断开连接(未连接)');
329
- return;
340
+ if (existsSync(p)) {
341
+ const text = readFileSync(p, 'utf8');
342
+ if (findTopLevelSectionRange(text, 'platform') !== null) {
343
+ // 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
344
+ const newText = replaceTopLevelSection(text, 'platform', null);
345
+ if (newText.trim() === '') {
346
+ // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
347
+ try { unlinkSync(p); } catch { /* best effort */ }
348
+ } else {
349
+ writeFileSync(p, newText, 'utf8');
350
+ }
351
+ }
330
352
  }
331
- // 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
332
- const newText = replaceTopLevelSection(text, 'platform', null);
333
- if (newText.trim() === '') {
334
- // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
335
- try { unlinkSync(p); } catch { /* best effort */ }
336
- } else {
337
- writeFileSync(p, newText, 'utf8');
353
+ // 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)
354
+ let cleaned = [];
355
+ for (const f of ['.sillyspec-platform.json', PLATFORM_MANAGED_FILENAME]) {
356
+ const fp = join(this.cwd, f);
357
+ if (existsSync(fp)) {
358
+ try { unlinkSync(fp); cleaned.push(f); } catch { /* best effort */ }
359
+ }
338
360
  }
361
+ if (cleaned.length > 0) console.log(`[sync] 已清理平台指针与接管声明: ${cleaned.join(', ')}`);
339
362
  console.log('[sync] 已断开连接');
340
363
  }
341
364
 
@@ -390,8 +413,8 @@ export class SyncManager {
390
413
  if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
391
414
  headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
392
415
 
393
- // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)
394
- const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
416
+ // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)。changeName encode 对齐 pull(:673)
417
+ const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
395
418
  const res = await fetchJsonWithStatus(syncUrl, {
396
419
  method: 'POST',
397
420
  headers,
@@ -475,7 +498,7 @@ export class SyncManager {
475
498
  return { synced: 0, errors: [...errors, '无可用文档'] };
476
499
  }
477
500
 
478
- const docUrl = `${platform.url}/api/changes/${changeName}/documents`;
501
+ const docUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/documents`;
479
502
  const result = await fetchJson(docUrl, {
480
503
  method: 'POST',
481
504
  headers: {
@@ -510,7 +533,7 @@ export class SyncManager {
510
533
  return { status: 'pending', reason: '未指定变更名称' };
511
534
  }
512
535
 
513
- const approvalUrl = `${platform.url}/api/changes/${changeName}/approval`;
536
+ const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
514
537
  const result = await fetchJson(approvalUrl, {
515
538
  headers: { Authorization: `Bearer ${platform.token}` },
516
539
  });
@@ -924,6 +947,11 @@ export async function collectStatus(cwd) {
924
947
  return new SyncManager(cwd).collectStatus();
925
948
  }
926
949
 
950
+ // 未决冲突文件列表便捷导出(index.js platform resolve 参数解析/报错兜底用,只读)
951
+ export function listConflictFiles(cwd) {
952
+ return new SyncManager(cwd).listConflictFiles();
953
+ }
954
+
927
955
  // TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
928
956
  // 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
929
957
  /**
@@ -118,10 +118,16 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
118
118
  report: '⚠️ plan.md 未解析出任何 task-NN 条目(plan 可能未按规范写 checkbox 列表)。' }
119
119
  }
120
120
 
121
- // runId 解析:marker → 扫描最新目录
121
+ // runId 解析:marker → 扫描最新目录(marker 是 agent 可写内容,格式校验防注入/穿越)
122
122
  let runId = null
123
123
  const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
124
- try { if (existsSync(marker)) runId = readFileSync(marker, 'utf8').trim() } catch {}
124
+ try {
125
+ if (existsSync(marker)) {
126
+ const c = readFileSync(marker, 'utf8').trim()
127
+ if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
128
+ if (isValidExecuteRunId(c)) runId = c
129
+ }
130
+ } catch {}
125
131
  if (!runId) {
126
132
  try {
127
133
  const runsDir = join(runtimeRoot, 'execute-runs')
@@ -436,7 +442,8 @@ export function validateTaskReviews(opts) {
436
442
  } else if (repoKey !== 'main') {
437
443
  // 跨仓 repo 在 ctx 中未注册 → 不应发生(MultiRepoContext 构造已 fail-closed 拦截未注册),
438
444
  // 防御性降级为 warning 提示,不阻断(review.json schema 已过,仓库解析交给 ctx 上游负责)。
439
- warnings.push(`${taskId}: review.repo="${repoKey}" 在 MultiRepoContext 未解析到 entry,退回主仓 gitDir 校验`)
445
+ // 坑7 教训:此分支发生时后续主仓校验必报「疑似伪造」,文案须直接指向真实排查方向。
446
+ warnings.push(`${taskId}: review.repo="${repoKey}" 在 MultiRepoContext 未解析到 entry,退回主仓 gitDir 校验——下方若报 commit 不真实/疑似伪造,先查 repo 声明(plan.md 内联块或 tasks/task-NN.md 的 repo:)与 local.yaml repos: 注册,非 review 伪造`)
440
447
  }
441
448
  }
442
449
  if (reviewGitDir) {
@@ -657,6 +664,18 @@ export function generateExecuteRunId() {
657
664
  return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
658
665
  }
659
666
 
667
+ /**
668
+ * execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS。
669
+ * marker 文件是 agent 可写内容,读回后既注入 prompt({EXECUTE_RUN_ID})又拼进
670
+ * join(runtimeRoot, 'execute-runs', runId) 路径——格式校验同时防提示词注入与路径穿越
671
+ * (对齐 stage-review marker 的 review- 前缀校验范式)。
672
+ * @param {string} runId
673
+ * @returns {boolean}
674
+ */
675
+ export function isValidExecuteRunId(runId) {
676
+ return typeof runId === 'string' && /^exec-\d{4}-\d{2}-\d{2}-\d{6}$/.test(runId)
677
+ }
678
+
660
679
  /**
661
680
  * 解析最新 execute run id:优先 current-execute-run-id-<changeName> marker;
662
681
  * marker 缺失/为空则扫描 execute-runs/ 下子目录、取 mtime 最新的(尽力定位已有 run)。
@@ -699,7 +718,13 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
699
718
 
700
719
  export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
701
720
  const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
702
- try { if (existsSync(marker)) { const c = readFileSync(marker, 'utf8').trim(); if (c) return c } } catch {}
721
+ try {
722
+ if (existsSync(marker)) {
723
+ const c = readFileSync(marker, 'utf8').trim()
724
+ if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
725
+ if (isValidExecuteRunId(c)) return c
726
+ }
727
+ } catch {}
703
728
  try {
704
729
  const runsDir = join(runtimeRoot, 'execute-runs')
705
730
  if (existsSync(runsDir)) {
@@ -758,7 +783,13 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
758
783
  // exec-id:与 Task Review Gate(gates.js:269)/ autoCheckPlanFromReviews 同源
759
784
  const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
760
785
  let executeRunId = ''
761
- try { if (existsSync(runIdFile)) executeRunId = readFileSync(runIdFile, 'utf8').trim() } catch {}
786
+ try {
787
+ if (existsSync(runIdFile)) {
788
+ const c = readFileSync(runIdFile, 'utf8').trim()
789
+ if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
790
+ if (isValidExecuteRunId(c)) executeRunId = c
791
+ }
792
+ } catch {}
762
793
  if (!executeRunId) {
763
794
  executeRunId = generateExecuteRunId()
764
795
  try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
@@ -915,7 +946,8 @@ export function getLatestExecuteRunId(runtimeRoot) {
915
946
  try {
916
947
  if (existsSync(markerPath)) {
917
948
  const content = readFileSync(markerPath, 'utf8').trim()
918
- if (content) return content
949
+ if (content && !isValidExecuteRunId(content)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(content.slice(0, 60))}),忽略并回退目录扫描`)
950
+ if (isValidExecuteRunId(content)) return content
919
951
  }
920
952
  } catch {}
921
953
 
@@ -15,7 +15,7 @@
15
15
  * 测试,避免 monorepo 全量测试超 gate timeout。
16
16
  */
17
17
 
18
- import { execSync } from 'child_process'
18
+ import { execSync, execFileSync } from 'child_process'
19
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
20
20
  import { join } from 'path'
21
21
  import { verifyApiParity } from './contract-matrix.js'
@@ -367,14 +367,28 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
367
367
  }
368
368
  }
369
369
 
370
+ // refSpec 来源 meta.json(agent 可写),仅放行 git ref/range 安全字符(含 HEAD~1..HEAD 类区间),
371
+ // 防经 shell 的命令/参数注入。剥空白与引号/分号/反引号/$ 等 shell 元字符。
372
+ // 注:允许 ".."/"../.."(相对路径 ref 语法上合法)不影响安全——execFileSync 数组形式下它只是
373
+ // 一个 git rev 参数,git 自会报 not a valid ref,无 shell 解释面。
374
+ function assertSafeRefSpec(refSpec) {
375
+ if (typeof refSpec !== 'string') return null
376
+ if (!/^[A-Za-z0-9._~/^-]+$/.test(refSpec)) return null
377
+ if (/~~/.test(refSpec)) return null // 连续 ~(垃圾串)
378
+ return refSpec
379
+ }
380
+
370
381
  /**
371
382
  * 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
372
383
  * git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
373
384
  */
374
385
  function runGitDiffNameOnly(cwd, refSpec) {
375
386
  try {
376
- const cmd = refSpec ? `git diff --name-only ${refSpec}` : 'git diff --name-only'
377
- const out = execSync(cmd, {
387
+ const ref = refSpec ? assertSafeRefSpec(refSpec) : null
388
+ if (refSpec && !ref) return null
389
+ const args = ['diff', '--name-only']
390
+ if (ref) args.push(ref)
391
+ const out = execFileSync('git', args, {
378
392
  cwd,
379
393
  encoding: 'utf8',
380
394
  timeout: 30 * 1000,
@@ -392,8 +406,11 @@ function runGitDiffNameOnly(cwd, refSpec) {
392
406
  */
393
407
  function runGitDiffNameStatus(cwd, refSpec) {
394
408
  try {
395
- const cmd = refSpec ? `git diff --name-status ${refSpec}` : 'git diff --name-status'
396
- return execSync(cmd, {
409
+ const ref = refSpec ? assertSafeRefSpec(refSpec) : null
410
+ if (refSpec && !ref) return null
411
+ const args = ['diff', '--name-status']
412
+ if (ref) args.push(ref)
413
+ return execFileSync('git', args, {
397
414
  cwd,
398
415
  encoding: 'utf8',
399
416
  timeout: 30 * 1000,
@@ -877,7 +877,8 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
877
877
  }
878
878
  }
879
879
 
880
- // 检查 6: diff 规模异常(>2000 行变更视为异常)
880
+ // 检查 6: diff 规模异常(两档:>5000 行 BLOCKED;2000~5000 行 WARNING——正常规模 change
881
+ // 常超 2000 行(如 +2368),单档硬拦把正常变更逼进 rescue cp 手动路径,见 ql-20260815-001 复盘)
881
882
  const wtPath = meta?.worktreePath;
882
883
  const diffBase = meta?.baselineCommit || meta?.baseHash;
883
884
  let additions = 0, deletions = 0;
@@ -888,8 +889,10 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
888
889
  const delMatch = shortstat?.match(/(\d+) deletion/);
889
890
  additions = insMatch ? parseInt(insMatch[1]) : 0;
890
891
  deletions = delMatch ? parseInt(delMatch[1]) : 0;
891
- if (additions + deletions > 2000) {
892
- reasons.push(`diff 规模异常(${additions} additions + ${deletions} deletions = ${additions + deletions} 行)`);
892
+ if (additions + deletions > 5000) {
893
+ reasons.push(`diff 规模异常(${additions} additions + ${deletions} deletions = ${additions + deletions} 行,超过 5000 硬上限)`);
894
+ } else if (additions + deletions > 2000) {
895
+ warnings.push(`diff 规模偏大(${additions} additions + ${deletions} deletions = ${additions + deletions} 行,2000~5000 区间放行为 WARNING,请确认属正常变更规模)`);
893
896
  }
894
897
  } catch {}
895
898
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, realpathSync } from 'fs';
12
12
  import { join } from 'path';
13
- import { execSync } from 'child_process';
13
+ import { execSync, execFileSync } from 'child_process';
14
14
  import { createHash } from 'crypto';
15
15
 
16
16
  const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
@@ -118,10 +118,12 @@ function tryLink(mainNodeModules, linkPath) {
118
118
  }
119
119
  try {
120
120
  if (process.platform === 'win32') {
121
- execSync(`mklink /J "${linkPath}" "${mainNodeModules}"`, { shell: 'cmd.exe', stdio: ['pipe', 'pipe', 'pipe'] });
121
+ // execFileSync 数组形式不经 shell:POSIX 双引号内 `/$() 会执行、cmd.exe 双引号内 %VAR% 仍展开,
122
+ // linkPath 含 local.yaml 模块 path,属项目内可配置值(安全收敛,与 git-helper 同范式)
123
+ execFileSync('cmd.exe', ['/c', 'mklink', '/J', linkPath, mainNodeModules], { stdio: ['pipe', 'pipe', 'pipe'] });
122
124
  return { ok: true, method: 'junction' };
123
125
  }
124
- execSync(`ln -s "${mainNodeModules}" "${linkPath}"`, { stdio: ['pipe', 'pipe', 'pipe'] });
126
+ execFileSync('ln', ['-s', mainNodeModules, linkPath], { stdio: ['pipe', 'pipe', 'pipe'] });
125
127
  return { ok: true, method: 'symlink' };
126
128
  } catch (e) {
127
129
  return { ok: false, error: e.message };
package/src/worktree.js CHANGED
@@ -813,7 +813,8 @@ export class WorktreeManager {
813
813
  try {
814
814
  if (process.platform === 'win32') {
815
815
  // Windows rmdir 删 junction(reparse point)不跟随目标,保护主 checkout
816
- execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
816
+ // execFileSync 数组形式:路径含引号/特殊字符时不会经 shell 解析(安全收敛,与 git-helper 同范式)
817
+ execFileSync('cmd.exe', ['/c', 'rmdir', wtNodeModules]);
817
818
  } else {
818
819
  unlinkSync(wtNodeModules);
819
820
  }
@@ -946,7 +947,8 @@ export class WorktreeManager {
946
947
  if (isLink) {
947
948
  try {
948
949
  if (process.platform === 'win32') {
949
- execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
950
+ // execFileSync 数组形式:不经 shell,防 meta.worktreePath 注入(安全收敛)
951
+ execFileSync('cmd.exe', ['/c', 'rmdir', wtNodeModules]);
950
952
  } else {
951
953
  unlinkSync(wtNodeModules);
952
954
  }