sillyspec 3.26.5 → 3.26.7
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-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/package.json +3 -3
- package/packages/dashboard/server/index.js +8 -1
- package/src/db.js +4 -2
- package/src/fs-atomic.js +2 -2
- package/src/index.js +41 -4
- package/src/progress/stage-machine.js +10 -4
- package/src/quicklog.js +19 -4
- package/src/review-tier.js +26 -14
- package/src/run/complete.js +66 -3
- package/src/run/gates.js +14 -3
- package/src/run/prompt.js +9 -2
- package/src/run/stage.js +8 -3
- package/src/setup.js +27 -9
- package/src/sillyhub-mcp/client.js +149 -14
- package/src/stages/brainstorm-auto.js +1 -0
- package/src/stages/brainstorm.js +1 -0
- package/src/sync.js +33 -15
- package/src/task-review.js +36 -5
- package/src/verify-postcheck.js +22 -5
- package/src/worktree-deps.js +5 -3
- package/src/worktree.js +4 -2
|
@@ -129,8 +129,8 @@ execute 还有**第二道**独立的 stage 级审查:除逐 task review.json
|
|
|
129
129
|
- **local.yaml `repos:` 段**:在 `.sillyspec/local.yaml` 注册跨仓仓路径(`main` 不用注册,隐式=当前项目):
|
|
130
130
|
```yaml
|
|
131
131
|
repos:
|
|
132
|
-
sillyspec: C:/Users/qinyi/IdeaProjects/sillyspec
|
|
133
132
|
shared-lib: ../shared-lib
|
|
133
|
+
tool-repo: C:/path/to/your/tool-repo
|
|
134
134
|
```
|
|
135
135
|
task 卡 `repo:` 引用的 key 必须在此注册,否则 execute 启动 fail-closed 阻断(跨仓 apply 走错仓=数据所有权事故,配置错误不降级)。
|
|
136
136
|
- **跨仓 task 的 `allowed_paths`**:指**相对跨仓仓根**的路径(非主仓根)。
|
|
@@ -80,7 +80,7 @@ sillyspec knowledge inspect --id "generated/worktree-baseline-conflict"
|
|
|
80
80
|
"zone": "manual",
|
|
81
81
|
"path": "knowledge/patterns.md",
|
|
82
82
|
"meta": {
|
|
83
|
-
"author": "
|
|
83
|
+
"author": "<git-user>",
|
|
84
84
|
"created_at": "2026-06-19T12:40:00+08:00",
|
|
85
85
|
"updated_at": "2026-06-24T10:00:00+08:00"
|
|
86
86
|
},
|
|
@@ -100,8 +100,8 @@ plan 完成校验会检查:design 提到入口文件(cli.ts/main.ts/server.t
|
|
|
100
100
|
|
|
101
101
|
```yaml
|
|
102
102
|
repos:
|
|
103
|
-
sillyspec: C:/Users/qinyi/IdeaProjects/sillyspec
|
|
104
103
|
shared-lib: ../shared-lib
|
|
104
|
+
tool-repo: C:/path/to/your/tool-repo
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
key = task 卡 `repo:` 引用名,value = 跨仓仓绝对路径(或相对主仓根的路径)。`main` 不用注册。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sillyspec",
|
|
3
|
-
"version": "3.26.
|
|
3
|
+
"version": "3.26.7",
|
|
4
4
|
"description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
|
|
5
5
|
"icon": "logo.jpg",
|
|
6
6
|
"homepage": "https://sillyspec.ppdmq.top/",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"@inquirer/prompts": "^7.10.1",
|
|
32
32
|
"chalk": "^5.6.2",
|
|
33
33
|
"chokidar": "^4.0",
|
|
34
|
-
"js-yaml": "^4.
|
|
34
|
+
"js-yaml": "^4.3.1",
|
|
35
35
|
"open": "^10.1",
|
|
36
36
|
"ora": "^9.3.0",
|
|
37
|
-
"ws": "^8.
|
|
37
|
+
"ws": "^8.21.3"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -103,6 +103,13 @@ function isSillyspecPath(filePath) {
|
|
|
103
103
|
return parts.includes('.sillyspec')
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// 凭据/敏感运行时文件:local.yaml 含 SillyHub token(connect 明文写入),.plan.md.lock 等锁文件
|
|
107
|
+
// 是并发原语非文档。/api/docs/content 无鉴权(仅 Origin 检查),这些文件绝不经 HTTP 暴露。
|
|
108
|
+
function isCredentialPath(filePath) {
|
|
109
|
+
const base = basename(resolve(filePath)).toLowerCase()
|
|
110
|
+
return base === 'local.yaml' || base === 'local.yml' || base.endsWith('.lock') || base === 'sillyspec-platform.json'
|
|
111
|
+
}
|
|
112
|
+
|
|
106
113
|
function isViewableDocPath(filePath) {
|
|
107
114
|
return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
|
|
108
115
|
}
|
|
@@ -377,7 +384,7 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
377
384
|
}
|
|
378
385
|
// Security: only allow reading viewable text documents under a .sillyspec tree.
|
|
379
386
|
const normalizedPath = resolve(filePath)
|
|
380
|
-
if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath)) {
|
|
387
|
+
if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath) || isCredentialPath(normalizedPath)) {
|
|
381
388
|
res.writeHead(403)
|
|
382
389
|
res.end(JSON.stringify({ error: 'Access denied' }))
|
|
383
390
|
return
|
package/src/db.js
CHANGED
|
@@ -93,8 +93,10 @@ export class DB {
|
|
|
93
93
|
* - 主库缺失/为空/损坏:尝试 .bak;成功则 warn 原因后 copy 回主库使用。
|
|
94
94
|
* - 都不可用:主库不存在→建全新空库;主库曾存在(空/损坏)→fail-loud 抛错(不静默建空库吞进度)。
|
|
95
95
|
* node:sqlite 打开 0 字节文件不抛错:主库须 statSync 显式判空(截断信号→走回退,防静默吞进度);
|
|
96
|
-
* .bak 作为恢复源只要存在且能打开即用(含 0 字节空库——node:sqlite
|
|
97
|
-
*
|
|
96
|
+
* .bak 作为恢复源只要存在且能打开即用(含 0 字节空库——node:sqlite 视其为合法空库)。
|
|
97
|
+
* 注:node:sqlite 提交即持久化,不再写前备份主 .bak(sql.js 时代 _save/_atomicWriteSync 已随
|
|
98
|
+
* 2026-08-11 迁移移除);此恢复分支是向后兼容兜底,仅对 sql.js 时代遗留的 .bak 生效,
|
|
99
|
+
* 全新 node:sqlite 项目不产生主 .bak)。
|
|
98
100
|
*/
|
|
99
101
|
_openWithFallback() {
|
|
100
102
|
const bakPath = `${this.dbPath}.bak`;
|
package/src/fs-atomic.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
|
|
5
5
|
* 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
|
|
6
6
|
*
|
|
7
|
-
* 注意:
|
|
8
|
-
*
|
|
7
|
+
* 注意:DB 持久化由 node:sqlite 引擎承担(提交即落盘 sillyspec.db + WAL 侧车),
|
|
8
|
+
* 不经此处的 writeAtomicSync;sillyspec.db 不走原子写改名。
|
|
9
9
|
*/
|
|
10
10
|
import { writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
11
11
|
import { dirname, basename, join } from 'path';
|
package/src/index.js
CHANGED
|
@@ -247,6 +247,8 @@ async function main() {
|
|
|
247
247
|
// 解析 --change 参数
|
|
248
248
|
const progChangeIdx = args.indexOf('--change');
|
|
249
249
|
const progChangeName = progChangeIdx >= 0 && args[progChangeIdx + 1] ? args[progChangeIdx + 1] : null;
|
|
250
|
+
// 与 run 入口同源消毒(防路径穿越;progress 下游 completeStage 写 history 文件名拼 change 名)
|
|
251
|
+
if (progChangeName) assertSafeChangeName(progChangeName, '--change 变更名');
|
|
250
252
|
|
|
251
253
|
switch (subCommand) {
|
|
252
254
|
case 'init':
|
|
@@ -353,6 +355,8 @@ async function main() {
|
|
|
353
355
|
console.error('用法: sillyspec gate <stage> --change <name> [--json]\n stage: brainstorm | plan | execute | verify | archive | ...');
|
|
354
356
|
process.exit(2);
|
|
355
357
|
}
|
|
358
|
+
// 与 run 入口同源消毒(防路径穿越;gate 下游拼 marker/changes 路径)
|
|
359
|
+
assertSafeChangeName(gateChange, '--change 变更名');
|
|
356
360
|
const { runGate } = await import('./machine-interface.js');
|
|
357
361
|
// --spec-dir 透传 runGate.specBase(gate CLI 接线修复,P3 坑 3 sillyspec 侧)。
|
|
358
362
|
// runGate 已分离 cwd(跑测试) + specBase(读 local.yaml/spec),但 gate case 之前
|
|
@@ -407,6 +411,8 @@ async function main() {
|
|
|
407
411
|
console.error('用法: sillyspec derive <facet> --change <name> [--json]\n facet: execute-evidence | verify-test | task-reviews | artifacts');
|
|
408
412
|
process.exit(2);
|
|
409
413
|
}
|
|
414
|
+
// 与 run 入口同源消毒(防路径穿越;derive 下游拼 marker/changes 路径)
|
|
415
|
+
assertSafeChangeName(deriveChange, '--change 变更名');
|
|
410
416
|
const { runDerive } = await import('./machine-interface.js');
|
|
411
417
|
// --spec-dir 透传 runDerive.specBase(与 gate case 对称,P3 坑 3 sillyspec 侧)。
|
|
412
418
|
// derive verify-test facet 内部同样调 runVerifyTestCheck 依赖 specBase,平台模式
|
|
@@ -454,6 +460,8 @@ async function main() {
|
|
|
454
460
|
console.error('用法: sillyspec backfill-reviews --change <name> [--json] [--spec-dir <path>]\n 为手动补的 task 生成 review.json 草稿(cannot_verify),解 archive 客观完成度阻断');
|
|
455
461
|
process.exit(2);
|
|
456
462
|
}
|
|
463
|
+
// 与 run 入口同源消毒(防路径穿越;backfill 下游拼 marker/changes/review 路径)
|
|
464
|
+
assertSafeChangeName(brChange, '--change 变更名');
|
|
457
465
|
const { generateTaskReviewDrafts } = await import('./task-review.js');
|
|
458
466
|
// --spec-dir 透传 platformOpts.specRoot(与 gate/derive 对称);不传走默认 join(cwd,'.sillyspec')。
|
|
459
467
|
const brPlatformOpts = {};
|
|
@@ -504,6 +512,8 @@ async function main() {
|
|
|
504
512
|
console.error('用法: sillyspec register-stage-review --change <名> --stage <brainstorm|plan|execute> [--from <review.json>] [--spec-dir <path>] [--json]\n 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker),治 tier=independent marker 死锁');
|
|
505
513
|
process.exit(2);
|
|
506
514
|
}
|
|
515
|
+
// 与 run 入口同源消毒(防路径穿越;register 下游写 marker、拼 changes/review 路径)
|
|
516
|
+
assertSafeChangeName(rsrChange, '--change 变更名');
|
|
507
517
|
const { registerStageReview } = await import('./stage-review.js');
|
|
508
518
|
const rsrPlatformOpts = {};
|
|
509
519
|
if (specDir) rsrPlatformOpts.specRoot = specDir;
|
|
@@ -1326,10 +1336,31 @@ SillySpec platform — SillyHub 平台同步
|
|
|
1326
1336
|
}
|
|
1327
1337
|
case 'resolve': {
|
|
1328
1338
|
// task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(绝不字段级 auto-merge)
|
|
1329
|
-
|
|
1339
|
+
// 变更名解析(顺序):--change <name>(与 pull/sync 兄弟命令写法一致)→ platformArgs 中
|
|
1340
|
+
// 第一个非 flag 位置参数 → 唯一未决冲突自动选中。修复:旧实现盲取 platformArgs[0],
|
|
1341
|
+
// flag 放前面时(resolve --keep-local --change x)把 '--keep-local' 当变更名去读冲突文件。
|
|
1342
|
+
const changeIdx = args.indexOf('--change');
|
|
1343
|
+
let resolveName = changeIdx >= 0 && args[changeIdx + 1] ? args[changeIdx + 1] : null;
|
|
1330
1344
|
if (!resolveName) {
|
|
1331
|
-
|
|
1332
|
-
|
|
1345
|
+
resolveName = platformArgs.find((a) => a && !a.startsWith('--')) || null;
|
|
1346
|
+
}
|
|
1347
|
+
if (!resolveName) {
|
|
1348
|
+
// 无变更名:恰有一个未决冲突时自动选中,多个/零个则列出候选
|
|
1349
|
+
const pendingConflicts = syncModule.listConflictFiles(dir);
|
|
1350
|
+
if (pendingConflicts.length === 1) {
|
|
1351
|
+
resolveName = pendingConflicts[0].change;
|
|
1352
|
+
console.log(`ℹ️ 自动选中唯一未决冲突: ${resolveName}`);
|
|
1353
|
+
} else {
|
|
1354
|
+
console.error('❌ 用法: sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>');
|
|
1355
|
+
console.error(' 或: sillyspec platform resolve --change <name> <--keep-local|--take-platform|--abort>');
|
|
1356
|
+
if (pendingConflicts.length > 1) {
|
|
1357
|
+
console.error(` 现有 ${pendingConflicts.length} 个未决冲突,请指定变更名:`);
|
|
1358
|
+
for (const c of pendingConflicts) console.error(` - ${c.change}`);
|
|
1359
|
+
} else {
|
|
1360
|
+
console.error(' 当前无 sync-conflict 文件(可先运行 sillyspec platform status 查看)');
|
|
1361
|
+
}
|
|
1362
|
+
process.exit(1);
|
|
1363
|
+
}
|
|
1333
1364
|
}
|
|
1334
1365
|
// 解析 mode flag(三选一互斥,多传/不传均报错)
|
|
1335
1366
|
const resolveFlags = ['--keep-local', '--take-platform', '--abort'].filter((f) => args.includes(f));
|
|
@@ -1344,6 +1375,12 @@ SillySpec platform — SillyHub 平台同步
|
|
|
1344
1375
|
console.log(`✅ ${resolveName} [${resolveMode}]: ${r.reason}`);
|
|
1345
1376
|
} else {
|
|
1346
1377
|
console.error(`❌ ${resolveName}: ${r.reason}`);
|
|
1378
|
+
// 报错兜底:指定变更名无冲突文件时,列出 .runtime 下实际存在的冲突文件(防 flag 误当变更名再次误导)
|
|
1379
|
+
const pendingConflicts = syncModule.listConflictFiles(dir);
|
|
1380
|
+
if (pendingConflicts.length > 0 && !pendingConflicts.some((c) => c.change === resolveName)) {
|
|
1381
|
+
console.error(' 当前未决冲突:');
|
|
1382
|
+
for (const c of pendingConflicts) console.error(` - ${c.change}`);
|
|
1383
|
+
}
|
|
1347
1384
|
process.exit(1);
|
|
1348
1385
|
}
|
|
1349
1386
|
break;
|
|
@@ -1673,7 +1710,7 @@ SillySpec config — local.yaml 配置键速查
|
|
|
1673
1710
|
// 已知产物 → 用途说明。agent 据此判断每份产物能否作为证据/该读哪个。
|
|
1674
1711
|
const KNOWN = {
|
|
1675
1712
|
'sillyspec.db': '进度库 SQLite(stages/steps/changes 表)',
|
|
1676
|
-
'sillyspec.db.bak': '
|
|
1713
|
+
'sillyspec.db.bak': '进度库备份(sql.js 时代写前快照遗留,node:sqlite 不再写入,仅 _openWithFallback 向后兼容恢复用)',
|
|
1677
1714
|
'sillyspec.db.schema-version': '进度库 schema 版本标记',
|
|
1678
1715
|
'doctor-diagnosis.json': 'doctor --json 结构化诊断快照',
|
|
1679
1716
|
'doctor-dumps': 'doctor --dump-db 取证输出目录',
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 调 pm.read/_write/_ensureDB/_getSpecDir/readGlobal/_runtimePath/listChanges/_appendAuditLog/
|
|
4
4
|
// _renderBatchProgress(persistence-core + 其他组 delegate)。组内方法互调保持 this.X(同 class)。
|
|
5
5
|
// completeStage 五层(resolve/validate/force/tx/history/print)整体搬迁,不拆流水线(保行为、最低风险)。
|
|
6
|
-
import { mkdirSync } from 'fs';
|
|
6
|
+
import { mkdirSync, existsSync } from 'fs';
|
|
7
7
|
import { join, resolve, basename } from 'path';
|
|
8
8
|
import { writeAtomicSync } from '../fs-atomic.js';
|
|
9
9
|
import { runValidators } from '../stage-contract.js';
|
|
@@ -140,21 +140,27 @@ export class StageMachine {
|
|
|
140
140
|
console.log('');
|
|
141
141
|
console.log(' ═══════════════════════════════════════');
|
|
142
142
|
console.log(` 项目: ${(global?.project) || basename(cwd) || '(未命名)'}`);
|
|
143
|
-
|
|
143
|
+
// 当前操作目标与活跃列表区分(坑 status-change-pointer-ambiguous):多活跃时不带
|
|
144
|
+
// --change 的 run/progress 不隐式选任何一个(index.js --change 解析多活跃报错),明确
|
|
145
|
+
// 说出来,防止把残留空壳(default/quick-xxx/目录缺失记录)误当操作目标跑错 change。
|
|
146
|
+
console.log(` 当前操作目标: (多活跃——不带 --change 时不隐式选定,run 须显式 --change <name>)`);
|
|
147
|
+
console.log(` 活跃变更记录: ${changes.length} 个(下列为 DB 中存在的活跃记录,非操作目标)`);
|
|
144
148
|
console.log(' ═══════════════════════════════════════');
|
|
145
149
|
console.log('');
|
|
146
150
|
|
|
151
|
+
const changesRoot = join(this.pm._getSpecDir(cwd), 'changes');
|
|
147
152
|
for (const cn of changes) {
|
|
148
153
|
const data = this.pm.read(cwd, cn);
|
|
154
|
+
const dirMissing = !existsSync(join(changesRoot, cn));
|
|
149
155
|
if (!data) {
|
|
150
|
-
console.log(` 📂 ${cn} — (无法读取)`);
|
|
156
|
+
console.log(` 📂 ${cn} — (无法读取)${dirMissing ? ' ⚠️ 目录缺失(残留记录,可用 doctor 清理)' : ''}`);
|
|
151
157
|
continue;
|
|
152
158
|
}
|
|
153
159
|
const currentStage = data.currentStage || '(无)';
|
|
154
160
|
const stageLabel = STAGE_LABELS[data.currentStage] || currentStage;
|
|
155
161
|
const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
|
|
156
162
|
|
|
157
|
-
console.log(` 📂 ${cn}`);
|
|
163
|
+
console.log(` 📂 ${cn}${dirMissing ? ' ⚠️ 目录缺失(残留记录,可用 doctor 清理)' : ''}`);
|
|
158
164
|
console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
|
|
159
165
|
console.log('');
|
|
160
166
|
}
|
package/src/quicklog.js
CHANGED
|
@@ -81,6 +81,14 @@ export function sanitizeDesc(description) {
|
|
|
81
81
|
return s.length > 120 ? s.slice(0, 120) + '…' : s
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// git user.name → 文件名安全形式:白名单保留字母数字._- 与中文等 Unicode 字,
|
|
85
|
+
// 剥路径元字符(/ \ ..)与控制字符。防 QUICKLOG-<user>.md / 锁文件 / 轮转归档穿越写。
|
|
86
|
+
export function sanitizeQuicklogUser(user) {
|
|
87
|
+
const s = String(user || '').replace(/[\\/\r\n\0]/g, '').replace(/\.{2,}/g, '.').trim()
|
|
88
|
+
if (!s) return ''
|
|
89
|
+
return s.length > 64 ? s.slice(0, 64) : s
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
// 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
|
|
85
93
|
// 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
|
|
86
94
|
// 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
|
|
@@ -134,6 +142,9 @@ export function getQuickFileNotes() { return _pendingFileNotes }
|
|
|
134
142
|
*/
|
|
135
143
|
export function parseFileNotes(raw) {
|
|
136
144
|
if (!raw) return []
|
|
145
|
+
// note 单行化 + 限长:bullet 是「数组元素内嵌 \n」落盘(flipEntryInContent),note 带换行会伪造
|
|
146
|
+
// QUICKLOG 条目结构(\n## ql-… 可伪造后续条目头)。与 sanitizeDesc 同风格。
|
|
147
|
+
const sanitizeNote = (n) => n.replace(/[\r\n]+/g, ' ').trim().slice(0, 200)
|
|
137
148
|
return String(raw)
|
|
138
149
|
.split('||')
|
|
139
150
|
.map((seg) => {
|
|
@@ -141,7 +152,7 @@ export function parseFileNotes(raw) {
|
|
|
141
152
|
if (!s) return null
|
|
142
153
|
const idx = s.indexOf('::')
|
|
143
154
|
const path = (idx === -1 ? s : s.slice(0, idx)).replace(/\\/g, '/').trim()
|
|
144
|
-
const note = idx === -1 ? '' : s.slice(idx + 2)
|
|
155
|
+
const note = idx === -1 ? '' : sanitizeNote(s.slice(idx + 2))
|
|
145
156
|
return path ? { path, note } : null
|
|
146
157
|
})
|
|
147
158
|
.filter(Boolean)
|
|
@@ -234,7 +245,7 @@ async function rotateIfNeeded(userFile, gitUser) {
|
|
|
234
245
|
const dateStr = lastDate
|
|
235
246
|
? `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`
|
|
236
247
|
: nowDatetime().slice(0, 10)
|
|
237
|
-
const archiveFile = join(dirname(userFile), `QUICKLOG-${gitUser}-${dateStr}.md`)
|
|
248
|
+
const archiveFile = join(dirname(userFile), `QUICKLOG-${sanitizeQuicklogUser(gitUser) || 'unknown'}-${dateStr}.md`)
|
|
238
249
|
// 同日已轮转过:两步各自原子化(append+clear 无法单原子,reader 容忍中间态——见 writeAtomic 注释)
|
|
239
250
|
if (existsSync(archiveFile)) {
|
|
240
251
|
const existing = readFileSync(archiveFile, 'utf8')
|
|
@@ -394,7 +405,9 @@ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes
|
|
|
394
405
|
export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
|
|
395
406
|
const quicklogDir = join(specBase, 'quicklog')
|
|
396
407
|
mkdirSync(quicklogDir, { recursive: true })
|
|
397
|
-
|
|
408
|
+
// git user.name 无字符限制,可含 / \ .. 等路径元字符(git config 或 .git/config 可控)——
|
|
409
|
+
// 白名单消毒防穿越写(QUICKLOG-<user>.md / 锁文件 / 轮转归档三处拼接),与 assertSafeChangeName 同风格
|
|
410
|
+
const user = sanitizeQuicklogUser(gitUser) || 'unknown'
|
|
398
411
|
const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
|
|
399
412
|
const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
|
|
400
413
|
const desc = sanitizeDesc(description)
|
|
@@ -437,7 +450,9 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
|
|
|
437
450
|
*/
|
|
438
451
|
export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [] } = {}) {
|
|
439
452
|
const quicklogDir = join(specBase, 'quicklog')
|
|
440
|
-
|
|
453
|
+
// 与 allocateQuicklogEntry 同源消毒:锁文件路径含 user(防穿越写,两入口必须一致否则锁不上同一文件)
|
|
454
|
+
const user = sanitizeQuicklogUser(gitUser) || 'unknown'
|
|
455
|
+
const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
|
|
441
456
|
const result = sanitizeResult(resultText)
|
|
442
457
|
const linked = Array.isArray(linkedChanges) ? linkedChanges : []
|
|
443
458
|
// 实际改动文件(调用方 complete-handlers 已用 isQuickMetadata 过滤 quick 自身元数据)。空 → 不动文件行。
|
package/src/review-tier.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - tier=self → 当前 agent 自审,CLI 放行(不要求 review.json)
|
|
14
14
|
* - tier=independent → 强制独立审查子代理 + review.json,CLI 硬校验
|
|
15
15
|
*
|
|
16
|
-
* CLI 侧(run.js done gate)与 prompt
|
|
17
|
-
*
|
|
16
|
+
* CLI 侧(run.js done gate)与 prompt 注入侧用同一个函数,保证两边判定一致。
|
|
17
|
+
* 判定权归 agent 的 plan_level 自主判断(CLI 只做确定性映射,见 classifyReviewTier 注释);
|
|
18
|
+
* 无 plan_level 的阶段才退文件数启发式。
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { parseFileChangeList } from './change-list.js'
|
|
@@ -28,14 +29,21 @@ export const SELF_REVIEW_FILE_THRESHOLD = 3
|
|
|
28
29
|
/**
|
|
29
30
|
* 审查分级
|
|
30
31
|
*
|
|
32
|
+
* 判定权归 agent 的 plan_level 判定(2026-08-14 tier-plan-level 改造):plan_level 是 agent
|
|
33
|
+
* 在 plan step1 按 prompt 分级规则做的自主判断,tier 直接映射它——CLI 不再用文件数启发式
|
|
34
|
+
* 推翻 agent 判定(此前 light + 4 文件会被文件数强制 independent,两套标准打架且不透明,
|
|
35
|
+
* 实证:agent 判 light 自审通过,完成时 CLI 按 7 文件强制 independent review.json)。
|
|
36
|
+
*
|
|
31
37
|
* 判定顺序(短路):
|
|
32
|
-
* 1. plan_level === 'none'
|
|
33
|
-
* 2.
|
|
34
|
-
* 3.
|
|
38
|
+
* 1. plan_level === 'none' → self(极小变更)
|
|
39
|
+
* 2. plan_level === 'light' → self(agent 自主判定轻量,自审)
|
|
40
|
+
* 3. plan_level === 'full' → independent(full 语义即大变更需独立审查,CLI 确定性强制)
|
|
41
|
+
* 4. 无 plan_level(brainstorm 等阶段 plan.md 未生成)→ 退文件数启发式:
|
|
42
|
+
* design.md 变更文件数 ≤ SELF_REVIEW_FILE_THRESHOLD → self,否则 independent
|
|
35
43
|
*
|
|
36
|
-
* fail-safe
|
|
37
|
-
*
|
|
38
|
-
* (宁严勿松)。
|
|
44
|
+
* fail-safe:无 plan_level 且 designPath 传了但解析不到文件清单(fileCount=0)按字面 0
|
|
45
|
+
* 处理(真无文件变更 = 极小);designPath 完全没传(fileCount=null,无法证明规模)→
|
|
46
|
+
* independent(宁严勿松)。
|
|
39
47
|
*
|
|
40
48
|
* @param {object} [opts]
|
|
41
49
|
* @param {string} [opts.planLevel] - plan_level 分类结果(none/light/full)。brainstorm/propose 在 plan 之前,可能拿不到。
|
|
@@ -44,7 +52,13 @@ export const SELF_REVIEW_FILE_THRESHOLD = 3
|
|
|
44
52
|
*/
|
|
45
53
|
export function classifyReviewTier({ planLevel, designPath } = {}) {
|
|
46
54
|
if (planLevel === 'none') {
|
|
47
|
-
return { tier: 'self', reason:
|
|
55
|
+
return { tier: 'self', reason: 'plan_level=none(极小变更,agent 自主判定自审)', fileCount: null }
|
|
56
|
+
}
|
|
57
|
+
if (planLevel === 'light') {
|
|
58
|
+
return { tier: 'self', reason: 'plan_level=light(agent 自主判定轻量变更,自审)', fileCount: null }
|
|
59
|
+
}
|
|
60
|
+
if (planLevel === 'full') {
|
|
61
|
+
return { tier: 'independent', reason: 'plan_level=full(agent 自主判定大变更,独立审查防确认偏差)', fileCount: null }
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
let fileCount = null
|
|
@@ -53,13 +67,11 @@ export function classifyReviewTier({ planLevel, designPath } = {}) {
|
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
if (fileCount !== null && fileCount <= SELF_REVIEW_FILE_THRESHOLD) {
|
|
56
|
-
return { tier: 'self', reason:
|
|
70
|
+
return { tier: 'self', reason: `无 plan_level,变更文件 ${fileCount} ≤ ${SELF_REVIEW_FILE_THRESHOLD}(启发式)`, fileCount }
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
const reason = fileCount === null
|
|
60
|
-
?
|
|
61
|
-
|
|
62
|
-
: '无法解析 design.md 变更文件清单,按正常规模处理')
|
|
63
|
-
: `变更文件 ${fileCount} > ${SELF_REVIEW_FILE_THRESHOLD}`
|
|
74
|
+
? '无法解析 design.md 变更文件清单,按正常规模处理(fail-safe)'
|
|
75
|
+
: `无 plan_level,变更文件 ${fileCount} > ${SELF_REVIEW_FILE_THRESHOLD}(启发式)`
|
|
64
76
|
return { tier: 'independent', reason, fileCount }
|
|
65
77
|
}
|
package/src/run/complete.js
CHANGED
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
* 依赖(completeStep 仅用已抽叶子 + 随搬 helper,零 run.js 闭包,零动态 import):
|
|
13
13
|
* - shared.js: triggerSync/WAIT_MARKER_RE/getStageSteps;prompt.js: outputStep;gates.js: enforceDepsGate/runStageCompletionGates
|
|
14
14
|
* - complete-handlers.js: 8 handler;stages/index.js: stageRegistry;worktree-apply.js: formatExecuteSummary
|
|
15
|
+
* - scan-profile.js: executeScanPreflight/executeScanPostcheck/computeScanProfile(noAI 硬门)
|
|
16
|
+
* - stages/plan-postcheck.js: executePlanPostcheck(noAI 硬门)
|
|
15
17
|
* - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
|
|
16
18
|
*/
|
|
17
19
|
import { join } from 'node:path'
|
|
18
20
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
19
21
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
20
22
|
import { withFileLock } from '../quicklog.js'
|
|
21
|
-
import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext } from './shared.js'
|
|
23
|
+
import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir } from './shared.js'
|
|
24
|
+
import { executeScanPreflight, executeScanPostcheck, computeScanProfile } from './scan-profile.js'
|
|
25
|
+
import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
|
|
22
26
|
import { outputStep } from './prompt.js'
|
|
23
27
|
import { enforceDepsGate, enforceReviewJsonGate, completeStageGates, readDesignScale } from './gates.js'
|
|
24
28
|
import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
|
|
@@ -60,6 +64,32 @@ export function resolveWaitingStepWithAnswer(steps, doneAnswer, nowStr) {
|
|
|
60
64
|
return waitIdx
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* wait 选项单选校验(坑 wait-choice-enforcement):定义了 waitOptions 的 requiresWait/
|
|
69
|
+
* repeatableWait 步骤,--answer 必须命中预设选项之一——防止 agent 一句话代答绕过人工
|
|
70
|
+
* 选择(方案选择类 wait 的 answer 本就该是选项本身)。开放回答型步骤(澄清追问,answer
|
|
71
|
+
* 为自由文本)在 step 定义显式声明 waitFreeAnswer: true 豁免。
|
|
72
|
+
* 校验失败打印选项清单 + exit 1(fail-loud,与 requiresWait 门同风格)。
|
|
73
|
+
*
|
|
74
|
+
* waitOptions 优先取 stepDef(运行时构建的步骤定义,与 brainstorm/plan 静态定义同源),
|
|
75
|
+
* 回退 DB step entry(--continue 路径 waiting 步骤的 waitOptions 已落盘)。
|
|
76
|
+
*/
|
|
77
|
+
function enforceWaitChoice(stepDef, stepEntry, answer, stageName, changeName) {
|
|
78
|
+
if (answer == null) return
|
|
79
|
+
const opts = Array.isArray(stepDef?.waitOptions) ? stepDef.waitOptions
|
|
80
|
+
: (Array.isArray(stepEntry?.waitOptions) ? stepEntry.waitOptions : null)
|
|
81
|
+
if (!opts || opts.length === 0) return
|
|
82
|
+
if (stepDef?.waitFreeAnswer === true) return
|
|
83
|
+
if (opts.includes(answer)) return
|
|
84
|
+
console.error(`❌ Step "${stepEntry?.name || '当前步骤'}" 是封闭单选 wait,--answer 必须命中预设选项之一(防 AI 代答绕过人工选择)。`)
|
|
85
|
+
console.error(` 你的 --answer: ${JSON.stringify(answer)}`)
|
|
86
|
+
console.error(` 可选值(复制其一): ${opts.join(' / ')}`)
|
|
87
|
+
console.error(` 自由文本回答的场景,该步骤应声明 waitFreeAnswer(当前未声明)。`)
|
|
88
|
+
if (changeName) console.error(` 重试:sillyspec run ${stageName} --done --answer "<选项>" --change ${changeName}`)
|
|
89
|
+
else console.error(` 重试:sillyspec run ${stageName} --done --answer "<选项>"`)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
63
93
|
export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
64
94
|
const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false, isAllowDelete = false } = options
|
|
65
95
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
@@ -118,6 +148,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
118
148
|
const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
119
149
|
const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
|
|
120
150
|
const currentStep = steps[currentIdx]
|
|
151
|
+
// --done --answer 解 waiting 的路径同样过单选校验(与下方 requiresWait doneAnswer 分支同规则)
|
|
152
|
+
if (_resolvedWaitIdx !== -1 && _doneAnswer) {
|
|
153
|
+
enforceWaitChoice(currentStepDef, currentStep, _doneAnswer, stageName, changeName)
|
|
154
|
+
}
|
|
121
155
|
if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
|
|
122
156
|
// 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
|
|
123
157
|
const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
|
|
@@ -134,6 +168,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
134
168
|
currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
|
|
135
169
|
console.log(`⚠️ Step "${currentStep.name}" 的确认(${currentStepDef.waitReason})已在前置步骤完成,自动跳过。`)
|
|
136
170
|
} else if (doneAnswer) {
|
|
171
|
+
enforceWaitChoice(currentStepDef, currentStep, doneAnswer, stageName, changeName)
|
|
137
172
|
currentStep.status = 'waiting'
|
|
138
173
|
currentStep.waitAnswer = doneAnswer
|
|
139
174
|
currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
|
|
@@ -157,6 +192,27 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
157
192
|
// 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
|
|
158
193
|
await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
|
|
159
194
|
|
|
195
|
+
// ── noAI 步骤硬门(坑 noai-done-bypass):noAI 步骤的确定性校验不可被 --done 绕过 ──
|
|
196
|
+
// 正常路径 agent 跑 `run <stage>` 推进到 noAI step 时,runStage 自动执行 _cliAction
|
|
197
|
+
// (stage.js noAI 分支,不写 step output);若 agent 对 noAI step 直接 --done,此前
|
|
198
|
+
// completeStep 无 noAI 检测直接标 completed,executePlanPostcheck 等 CLI 确定性校验被
|
|
199
|
+
// 静默跳过(实证:multi-agent-platform 2026-08-13-spec-sync-visibility tasks/ 从未
|
|
200
|
+
// 生成但 plan 阶段 completed——step4 output 为 agent 口吻 178 字符而非 CLI 自动路径的
|
|
201
|
+
// 空 output)。修复:--done 落到 noAI step 时同样执行 _cliAction,校验 throw →
|
|
202
|
+
// completeStep 不推进(step 保持 pending)。分支对齐 stage.js 的 noAI 自动执行。
|
|
203
|
+
const _isNoAIStep = currentStepDef.noAI === true || currentStep?.noAI === true
|
|
204
|
+
if (_isNoAIStep) {
|
|
205
|
+
const _cliAction = currentStepDef._cliAction || currentStep?._cliAction
|
|
206
|
+
console.log(`⚙️ Step ${currentIdx + 1}/${steps.length}: ${steps[currentIdx].name}(noAI,--done 路径执行 CLI 校验)`)
|
|
207
|
+
if (_cliAction === 'scanPreflight') {
|
|
208
|
+
await executeScanPreflight(cwd, platformOpts, stageData.scanProfile || computeScanProfile(cwd, platformOpts))
|
|
209
|
+
} else if (_cliAction === 'scanPostcheck') {
|
|
210
|
+
await executeScanPostcheck(cwd, platformOpts, stageData.scanProfile || computeScanProfile(cwd, platformOpts))
|
|
211
|
+
} else if (_cliAction === 'planPostcheck') {
|
|
212
|
+
await runPlanPostcheckLib({ cwd, specRoot: platformOpts?.specRoot, resolveChangeDir, progress })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
steps[currentIdx].status = 'completed'
|
|
161
217
|
steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
162
218
|
if (outputText) {
|
|
@@ -452,8 +508,14 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
|
|
|
452
508
|
if (!existsSync(planPath) || !existsSync(runIdFile)) {
|
|
453
509
|
return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
|
|
454
510
|
}
|
|
455
|
-
const
|
|
456
|
-
const { readReview } = await import('../task-review.js')
|
|
511
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
512
|
+
const { readReview, isValidExecuteRunId } = await import('../task-review.js')
|
|
513
|
+
// marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失(跳过 autoCheck,不误勾)
|
|
514
|
+
if (c && !isValidExecuteRunId(c)) {
|
|
515
|
+
console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),跳过 review 自动勾选`)
|
|
516
|
+
return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
|
|
517
|
+
}
|
|
518
|
+
const executeRunId = c
|
|
457
519
|
// plan.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
|
|
458
520
|
// 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
|
|
459
521
|
// (后到者覆盖先到者);写入用 writeAtomicSync(tmp+rename 原子),防 Windows 整文件覆盖被读半截
|
|
@@ -688,6 +750,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
|
|
|
688
750
|
const prevOutput = currentStep.output || ''
|
|
689
751
|
const waitRound = (currentStep.waitRound || 0) + 1
|
|
690
752
|
currentStep.waitRound = waitRound
|
|
753
|
+
enforceWaitChoice(currentStepDef, currentStep, answer, stageName, changeName)
|
|
691
754
|
currentStep.waitAnswer = answer
|
|
692
755
|
currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
|
|
693
756
|
currentStep.waitAnswers.push({
|
package/src/run/gates.js
CHANGED
|
@@ -115,9 +115,14 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
|
|
|
115
115
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
116
116
|
const planPath = join(specBase, 'changes', changeName, 'plan.md')
|
|
117
117
|
if (!existsSync(runIdFile) || !existsSync(planPath)) return true
|
|
118
|
+
const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
|
|
118
119
|
let executeRunId = readFileSync(runIdFile, 'utf8').trim()
|
|
120
|
+
// marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失走漂移兜底重定位
|
|
121
|
+
if (executeRunId && !isValidExecuteRunId(executeRunId)) {
|
|
122
|
+
console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(executeRunId.slice(0, 60))}),改扫真实 run 目录`)
|
|
123
|
+
executeRunId = ''
|
|
124
|
+
}
|
|
119
125
|
const planContent = readFileSync(planPath, 'utf8')
|
|
120
|
-
const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks } = await import('../task-review.js')
|
|
121
126
|
// marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
|
|
122
127
|
// marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
|
|
123
128
|
// 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
|
|
@@ -333,12 +338,18 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
333
338
|
const planContent = readFileSync(planPath, 'utf8')
|
|
334
339
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
|
|
335
340
|
|
|
336
|
-
// execute run id
|
|
341
|
+
// execute run id:从变更专属标记文件读取(agent 可写内容,格式校验防注入/穿越)
|
|
337
342
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
338
343
|
let executeRunId = ''
|
|
339
344
|
try {
|
|
340
345
|
if (existsSync(runIdFile)) {
|
|
341
|
-
|
|
346
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
347
|
+
const { isValidExecuteRunId } = await import('../task-review.js')
|
|
348
|
+
if (c && !isValidExecuteRunId(c)) {
|
|
349
|
+
console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失回退扫描`)
|
|
350
|
+
} else {
|
|
351
|
+
executeRunId = c
|
|
352
|
+
}
|
|
342
353
|
}
|
|
343
354
|
} catch {}
|
|
344
355
|
if (!executeRunId) {
|
package/src/run/prompt.js
CHANGED
|
@@ -24,7 +24,7 @@ import { stageRegistry } from '../stages/index.js'
|
|
|
24
24
|
import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE } from './shared.js'
|
|
25
25
|
import { renderStageContract } from '../stage-contract-spec.js'
|
|
26
26
|
import { parseModuleMapSimple } from '../modules.js'
|
|
27
|
-
import { REVIEW_SCHEMA_VERSION } from '../task-review.js'
|
|
27
|
+
import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* 从 _module-map.yaml 读取模块上下文索引
|
|
@@ -428,7 +428,14 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
|
|
|
428
428
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
429
429
|
try {
|
|
430
430
|
if (existsSync(runIdFile)) {
|
|
431
|
-
|
|
431
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
432
|
+
// marker 是 agent 可写内容,直接注入 prompt + 拼进 gate 读 review.json 的路径——
|
|
433
|
+
// 格式校验同时防提示词注入与路径穿越(对齐 stage-review marker 的前缀校验范式)
|
|
434
|
+
if (c && !isValidExecuteRunId(c)) {
|
|
435
|
+
console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
|
|
436
|
+
} else {
|
|
437
|
+
runId = c
|
|
438
|
+
}
|
|
432
439
|
}
|
|
433
440
|
} catch {}
|
|
434
441
|
if (!runId) {
|
package/src/run/stage.js
CHANGED
|
@@ -89,15 +89,20 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
89
89
|
// ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
|
|
90
90
|
let currentExecuteRunId = null
|
|
91
91
|
if (stageName === 'execute') {
|
|
92
|
-
const { generateExecuteRunId } = await import('../task-review.js')
|
|
92
|
+
const { generateExecuteRunId, isValidExecuteRunId } = await import('../task-review.js')
|
|
93
93
|
const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
94
94
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
|
|
95
95
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
96
96
|
mkdirSync(runtimeRoot, { recursive: true })
|
|
97
|
-
//
|
|
97
|
+
// 优先读取已有的变更专属标记文件(agent 可写内容,格式校验防注入/穿越,非法视为缺失重生成)
|
|
98
98
|
try {
|
|
99
99
|
if (existsSync(runIdFile)) {
|
|
100
|
-
|
|
100
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
101
|
+
if (c && !isValidExecuteRunId(c)) {
|
|
102
|
+
console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),重新生成`)
|
|
103
|
+
} else {
|
|
104
|
+
currentExecuteRunId = c
|
|
105
|
+
}
|
|
101
106
|
}
|
|
102
107
|
} catch {}
|
|
103
108
|
if (!currentExecuteRunId) {
|
package/src/setup.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
|
-
import { execSync } from 'child_process';
|
|
3
|
+
import { execSync, execFileSync } from 'child_process';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import ora from 'ora';
|
|
6
6
|
import { checkbox, input } from '@inquirer/prompts';
|
|
@@ -13,7 +13,9 @@ const MCP_TOOLS = [
|
|
|
13
13
|
name: 'Context7',
|
|
14
14
|
description: '查询最新库文档和 API 参考',
|
|
15
15
|
command: 'npx',
|
|
16
|
-
|
|
16
|
+
// 版本锁定(sec-c):MCP server 常驻且有系统访问能力,@latest = trust-on-future-publish,
|
|
17
|
+
// 上游被攻破会直接进用户 AI 工具执行环境。升级走 setup 重跑。
|
|
18
|
+
args: ['-y', '@upstash/context7-mcp@4.0.2'],
|
|
17
19
|
url: 'https://github.com/upstash/context7-mcp',
|
|
18
20
|
},
|
|
19
21
|
{
|
|
@@ -21,7 +23,7 @@ const MCP_TOOLS = [
|
|
|
21
23
|
name: 'Chrome DevTools MCP',
|
|
22
24
|
description: '浏览器自动化,支持 E2E 测试(需 Chrome 已运行)',
|
|
23
25
|
command: 'npx',
|
|
24
|
-
args: ['chrome-devtools-mcp@
|
|
26
|
+
args: ['chrome-devtools-mcp@1.7.0'],
|
|
25
27
|
url: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
|
|
26
28
|
},
|
|
27
29
|
{
|
|
@@ -29,7 +31,7 @@ const MCP_TOOLS = [
|
|
|
29
31
|
name: 'Agent Browser (Vercel)',
|
|
30
32
|
description: 'Rust 原生浏览器 CLI,token 消耗极低,50+ 命令覆盖导航/表单/截图/网络',
|
|
31
33
|
command: 'npx',
|
|
32
|
-
args: ['
|
|
34
|
+
args: ['agent-browser@0.34.0'],
|
|
33
35
|
url: 'https://github.com/vercel-labs/agent-browser',
|
|
34
36
|
},
|
|
35
37
|
{
|
|
@@ -37,7 +39,7 @@ const MCP_TOOLS = [
|
|
|
37
39
|
name: 'PinchTab',
|
|
38
40
|
description: '12MB Go 二进制,零依赖,accessibility tree 极省 token,有 MCP 支持',
|
|
39
41
|
command: 'npx',
|
|
40
|
-
args: ['pinchtab-mcp@
|
|
42
|
+
args: ['pinchtab-mcp@1.4.1'],
|
|
41
43
|
url: 'https://github.com/pinchtab/pinchtab',
|
|
42
44
|
},
|
|
43
45
|
];
|
|
@@ -50,7 +52,7 @@ const DB_MCP_TOOLS = [
|
|
|
50
52
|
name: 'PostgreSQL',
|
|
51
53
|
description: '只读访问 PostgreSQL 数据库',
|
|
52
54
|
command: 'npx',
|
|
53
|
-
args: ['-y', '@modelcontextprotocol/server-postgres'],
|
|
55
|
+
args: ['-y', '@modelcontextprotocol/server-postgres@0.6.2'],
|
|
54
56
|
envTemplate: { POSTGRES_CONNECTION_STRING: 'postgresql://user:password@localhost:5432/dbname' },
|
|
55
57
|
url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/postgres',
|
|
56
58
|
},
|
|
@@ -59,7 +61,7 @@ const DB_MCP_TOOLS = [
|
|
|
59
61
|
name: 'SQLite',
|
|
60
62
|
description: '访问 SQLite 数据库文件',
|
|
61
63
|
command: 'npx',
|
|
62
|
-
args: ['-y', '
|
|
64
|
+
args: ['-y', 'mcp-server-sqlite@0.0.2'],
|
|
63
65
|
envTemplate: { SQLITE_DB_PATH: './data.db' },
|
|
64
66
|
url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite',
|
|
65
67
|
},
|
|
@@ -68,7 +70,8 @@ const DB_MCP_TOOLS = [
|
|
|
68
70
|
name: 'MySQL / MariaDB',
|
|
69
71
|
description: '访问 MySQL / MariaDB 数据库',
|
|
70
72
|
command: 'npx',
|
|
71
|
-
|
|
73
|
+
// 原 @nicobailon/mysql-mcp-server 已 404(包下架),换存在的 mysql-mcp-server(dpflucas,read-only)
|
|
74
|
+
args: ['-y', 'mysql-mcp-server@0.1.3'],
|
|
72
75
|
envTemplate: { MYSQL_HOST: 'localhost', MYSQL_PORT: '3306', MYSQL_USER: 'root', MYSQL_PASSWORD: '', MYSQL_DATABASE: '' },
|
|
73
76
|
url: 'https://github.com/modelcontextprotocol/servers',
|
|
74
77
|
},
|
|
@@ -77,7 +80,7 @@ const DB_MCP_TOOLS = [
|
|
|
77
80
|
name: 'Redis',
|
|
78
81
|
description: '访问 Redis 数据库,查看缓存和键值数据',
|
|
79
82
|
command: 'npx',
|
|
80
|
-
args: ['-y', '@modelcontextprotocol/server-redis'],
|
|
83
|
+
args: ['-y', '@modelcontextprotocol/server-redis@2025.4.25'],
|
|
81
84
|
envTemplate: { REDIS_URL: 'redis://localhost:6379' },
|
|
82
85
|
url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/redis',
|
|
83
86
|
},
|
|
@@ -353,6 +356,21 @@ export async function cmdSetup(dir, options = {}) {
|
|
|
353
356
|
}
|
|
354
357
|
writeMcpConfig(dir, path, config);
|
|
355
358
|
spinner.succeed(`${tool} MCP 完成 (${allMcp.length} 个工具)`);
|
|
359
|
+
// 凭据提交风险提示(sec-c):.claude/mcp.json / .cursor/mcp.json 是项目共享文件、通常随 git
|
|
360
|
+
// 提交,而 DB MCP 的 env 含明文连接串/密码。检测 git 跟踪状态,已跟踪/未忽略时显式警告。
|
|
361
|
+
if (selectedDb.length > 0) {
|
|
362
|
+
try {
|
|
363
|
+
execFileSync('git', ['-C', dir, 'ls-files', '--error-unmatch', path], { stdio: 'pipe' });
|
|
364
|
+
console.warn(chalk.yellow(` ⚠️ ${path} 已被 git 跟踪——其中数据库连接串/密码会随提交进入仓库,建议加入 .gitignore 或改用环境变量`));
|
|
365
|
+
} catch {
|
|
366
|
+
// 未被跟踪:再查是否被 ignore;都不满足(未忽略未跟踪)时提示防止将来误提交
|
|
367
|
+
try {
|
|
368
|
+
execFileSync('git', ['-C', dir, 'check-ignore', '-q', path], { stdio: 'pipe' });
|
|
369
|
+
} catch {
|
|
370
|
+
console.warn(chalk.yellow(` ⚠️ ${path} 含数据库连接串/密码且未被 .gitignore 忽略——提交前请先加入 .gitignore 或改用环境变量`));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
356
374
|
}
|
|
357
375
|
}
|
|
358
376
|
|
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { readMcpConfig } from './config.js';
|
|
23
|
+
import { getVersion } from '../version.js';
|
|
23
24
|
|
|
24
25
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
25
26
|
// MCP streamable HTTP 协议版本(task 指定 2025-11-25)
|
|
26
27
|
const MCP_PROTOCOL_VERSION = '2025-11-25';
|
|
28
|
+
// 单次 RPC 返回哨兵:server 回 400 Missing session(session 未建立/过期)→ _sendRpc 重连重试
|
|
29
|
+
const SESSION_EXPIRED = Symbol('SESSION_EXPIRED');
|
|
27
30
|
|
|
28
31
|
export class SillyHubMcpClient {
|
|
29
32
|
/**
|
|
@@ -38,9 +41,15 @@ export class SillyHubMcpClient {
|
|
|
38
41
|
const cfg = readMcpConfig(workCwd);
|
|
39
42
|
const u = url !== undefined ? url : (cfg?.url ?? '');
|
|
40
43
|
const t = token !== undefined ? token : (cfg?.token ?? '');
|
|
41
|
-
//
|
|
44
|
+
// 去 url 尾部斜杠,便于后续稳定拼 /mcp/(跨平台:URL 一律用正斜杠,不用 path.join)
|
|
42
45
|
this._url = typeof u === 'string' ? u.replace(/\/+$/, '') : '';
|
|
43
46
|
this._token = typeof t === 'string' ? t : '';
|
|
47
|
+
// 安全提示(sec-e 缓解):url 来自 local.yaml(agent 可写),非 https 时 Bearer token 明文上线。
|
|
48
|
+
// 一次性 warn(构造时)不刷屏;本机 http://localhost 调试场景 warn 可接受。
|
|
49
|
+
if (this._url && !/^https:\/\//i.test(this._url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(this._url)) {
|
|
50
|
+
console.warn(`[sillyhub-mcp] mcp.url 非 https(${this._url.slice(0, 60)}),Bearer token 将明文传输——请确认为受控环境`);
|
|
51
|
+
}
|
|
52
|
+
this._token = typeof t === 'string' ? t : '';
|
|
44
53
|
this._timeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0
|
|
45
54
|
? timeoutMs
|
|
46
55
|
: DEFAULT_TIMEOUT_MS;
|
|
@@ -49,6 +58,9 @@ export class SillyHubMcpClient {
|
|
|
49
58
|
// 端点必须带尾斜杠
|
|
50
59
|
this._endpoint = this._url ? `${this._url}/mcp/` : '';
|
|
51
60
|
this._rpcId = 0;
|
|
61
|
+
// MCP streamable HTTP session 状态:惰性 initialize 后才有(见 _ensureSession)
|
|
62
|
+
this._sessionId = null;
|
|
63
|
+
this._sessionPromise = null;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
// ── 内部辅助 ──
|
|
@@ -69,7 +81,15 @@ export class SillyHubMcpClient {
|
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
/**
|
|
72
|
-
* 发 JSON-RPC 请求(fetch/SSE
|
|
84
|
+
* 发 JSON-RPC 请求(fetch/SSE/鉴权/session 骨架,tools/call 与 tools/list 共用)。best-effort 不抛。
|
|
85
|
+
*
|
|
86
|
+
* MCP streamable HTTP(2025-11-25)要求先 initialize 拿 session,后续请求带
|
|
87
|
+
* Mcp-Session-Id header,否则 server 回 -32600 Missing session ID(2026-08-14 实测:
|
|
88
|
+
* SillyHub FastMCP v1.29 streamable_http_app 强制 session;此前本 client 直接发
|
|
89
|
+
* tools/call 无 session → 一律 400,probe 误判 daemon-unreachable)。本方法:
|
|
90
|
+
* 1. 惰性 _ensureSession(并发安全,仅 initialize 一次);
|
|
91
|
+
* 2. _rpcOnce 发单次请求;
|
|
92
|
+
* 3. 遇 session 过期(400 Missing session)→ 重置重连重试一次。
|
|
73
93
|
*
|
|
74
94
|
* task-11 抽出:原 _callTool 的网络骨架;method/params 泛化——tools/call 带 {name,arguments},
|
|
75
95
|
* tools/list 带空 params。warn 文案用 label 作 tag(tools/call 传 toolName 保文案不变,
|
|
@@ -88,14 +108,43 @@ export class SillyHubMcpClient {
|
|
|
88
108
|
return null;
|
|
89
109
|
}
|
|
90
110
|
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
// 惰性 initialize + 过期重连(最多 2 次:首次 + 重试)
|
|
112
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
113
|
+
await this._ensureSession(quiet);
|
|
114
|
+
if (!this._sessionId) {
|
|
115
|
+
// initialize 失败(网络/非 2xx/无 session)→ best-effort 降级,不再发业务请求
|
|
116
|
+
if (!quiet) console.warn(`[sillyhub-mcp] ${tag} initialize 失败,跳过`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
this._rpcId += 1;
|
|
120
|
+
const result = await this._rpcOnce(method, params, { quiet, tag, id: this._rpcId });
|
|
121
|
+
if (result !== SESSION_EXPIRED) return result;
|
|
122
|
+
// session 过期(400 Missing session)→ 重置重连重试一次
|
|
123
|
+
if (!quiet) console.warn(`[sillyhub-mcp] ${tag} session 过期,重连重试`);
|
|
124
|
+
this._sessionId = null;
|
|
125
|
+
this._sessionPromise = null;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 发单次 JSON-RPC 请求(不管理 session)。返回 rpc.result;失败返回 null;
|
|
132
|
+
* 400 Missing session ID → 返回 SESSION_EXPIRED 哨兵(调用方 _sendRpc 据此重连重试)。
|
|
133
|
+
*/
|
|
134
|
+
async _rpcOnce(method, params, { quiet, tag, id }) {
|
|
93
135
|
const body = {
|
|
94
136
|
jsonrpc: '2.0',
|
|
95
137
|
id,
|
|
96
138
|
method,
|
|
97
139
|
params,
|
|
98
140
|
};
|
|
141
|
+
const headers = {
|
|
142
|
+
Authorization: `Bearer ${this._token}`,
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
Accept: 'application/json, text/event-stream',
|
|
145
|
+
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
146
|
+
};
|
|
147
|
+
if (this._sessionId) headers['Mcp-Session-Id'] = this._sessionId;
|
|
99
148
|
|
|
100
149
|
const controller = new AbortController();
|
|
101
150
|
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
@@ -103,12 +152,7 @@ export class SillyHubMcpClient {
|
|
|
103
152
|
try {
|
|
104
153
|
res = await fetch(this._endpoint, {
|
|
105
154
|
method: 'POST',
|
|
106
|
-
headers
|
|
107
|
-
Authorization: `Bearer ${this._token}`,
|
|
108
|
-
'Content-Type': 'application/json',
|
|
109
|
-
Accept: 'application/json, text/event-stream',
|
|
110
|
-
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
111
|
-
},
|
|
155
|
+
headers,
|
|
112
156
|
body: JSON.stringify(body),
|
|
113
157
|
signal: controller.signal,
|
|
114
158
|
});
|
|
@@ -127,11 +171,13 @@ export class SillyHubMcpClient {
|
|
|
127
171
|
}
|
|
128
172
|
|
|
129
173
|
if (!res.ok) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
174
|
+
let detail = '';
|
|
175
|
+
try { detail = await res.text(); } catch { /* best effort */ }
|
|
176
|
+
// session 未建立/过期:FastMCP 回 HTTP 400 + body 含 "Missing session ID"
|
|
177
|
+
if (res.status === 400 && detail.includes('Missing session')) {
|
|
178
|
+
return SESSION_EXPIRED;
|
|
134
179
|
}
|
|
180
|
+
if (!quiet) console.warn(`[sillyhub-mcp] ${tag} → HTTP ${res.status} ${String(detail).slice(0, 200)}`);
|
|
135
181
|
return null;
|
|
136
182
|
}
|
|
137
183
|
|
|
@@ -167,6 +213,95 @@ export class SillyHubMcpClient {
|
|
|
167
213
|
return rpc.result || null;
|
|
168
214
|
}
|
|
169
215
|
|
|
216
|
+
/**
|
|
217
|
+
* 确保已建立 MCP session(惰性 initialize,并发安全)。已初始化直接返回 true;
|
|
218
|
+
* 初始化进行中则复用同一 promise。失败返回 false 且不留半成品状态。
|
|
219
|
+
* @param {boolean} [quiet] - 静默初始化失败警告(探测路径默认用)
|
|
220
|
+
* @returns {Promise<boolean>}
|
|
221
|
+
*/
|
|
222
|
+
async _ensureSession(quiet = false) {
|
|
223
|
+
if (this._sessionId) return true;
|
|
224
|
+
if (this._sessionPromise) return this._sessionPromise;
|
|
225
|
+
this._sessionPromise = this._initialize(quiet).finally(() => { this._sessionPromise = null; });
|
|
226
|
+
return this._sessionPromise;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* MCP initialize 握手:POST initialize → 从响应 header(或 body _meta)读 mcp-session-id。
|
|
231
|
+
* 成功存 this._sessionId;失败返回 false(best-effort,warn 不抛)。
|
|
232
|
+
*
|
|
233
|
+
* 注:协议要求 initialize 后发 notifications/initialized,本客户端只做探测
|
|
234
|
+
* (tools/list / 只读 tool),实测 FastMCP 不强制该通知即可用(2026-08-14 验证),
|
|
235
|
+
* 为省一次 RPC 不发;若未来 server 强校验再补。
|
|
236
|
+
* @returns {Promise<boolean>}
|
|
237
|
+
*/
|
|
238
|
+
async _initialize(quiet = false) {
|
|
239
|
+
this._rpcId += 1;
|
|
240
|
+
const id = this._rpcId;
|
|
241
|
+
const body = {
|
|
242
|
+
jsonrpc: '2.0',
|
|
243
|
+
id,
|
|
244
|
+
method: 'initialize',
|
|
245
|
+
params: {
|
|
246
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
247
|
+
capabilities: {},
|
|
248
|
+
// MCP 2025-11-25 协议要求 clientInfo 必含 name + version,缺 version FastMCP
|
|
249
|
+
// 回 -32602 Invalid request parameters(2026-08-14 实测锁定)
|
|
250
|
+
clientInfo: { name: 'sillyspec-cli', version: getVersion() },
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
const controller = new AbortController();
|
|
254
|
+
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
255
|
+
let res;
|
|
256
|
+
try {
|
|
257
|
+
res = await fetch(this._endpoint, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: {
|
|
260
|
+
Authorization: `Bearer ${this._token}`,
|
|
261
|
+
'Content-Type': 'application/json',
|
|
262
|
+
Accept: 'application/json, text/event-stream',
|
|
263
|
+
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify(body),
|
|
266
|
+
signal: controller.signal,
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (!quiet) {
|
|
270
|
+
const msg = err && err.message ? err.message : String(err);
|
|
271
|
+
console.warn(`[sillyhub-mcp] initialize 请求失败: ${msg}`);
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
} finally {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!res.ok) {
|
|
279
|
+
if (!quiet) console.warn(`[sillyhub-mcp] initialize → HTTP ${res.status}`);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 优先取 header mcp-session-id(FastMCP 实测返回);缺失再查 body _meta.sessionId。
|
|
284
|
+
// ⚠️ 无论 header 是否有 session,都必须读完 body(消费 SSE 流)——FastMCP 在
|
|
285
|
+
// initialize 响应流未消费完时 session 未就绪,后续 tools/call 回 -32602
|
|
286
|
+
// "Invalid request parameters"(2026-08-14 实测:node 只读 header 不读 body 必现,
|
|
287
|
+
// python 读完 body 则过)。
|
|
288
|
+
let sid = res.headers.get('mcp-session-id');
|
|
289
|
+
let text = '';
|
|
290
|
+
try { text = await res.text(); } catch { /* best effort */ }
|
|
291
|
+
if (!sid && text) {
|
|
292
|
+
const rpc = this._parseSseResponse(text, id);
|
|
293
|
+
if (rpc && rpc.result && rpc.result._meta && typeof rpc.result._meta.sessionId === 'string') {
|
|
294
|
+
sid = rpc.result._meta.sessionId;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!sid) {
|
|
298
|
+
if (!quiet) console.warn('[sillyhub-mcp] initialize 响应无 mcp-session-id');
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
this._sessionId = sid;
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
170
305
|
/**
|
|
171
306
|
* 解析 SSE 响应:取含 JSON-RPC result/error 的事件;优先 id 精确匹配,否则取首个。
|
|
172
307
|
* 事件间空行分隔;事件内 data: 行可多行,拼装时用 \n 连接。
|
package/src/stages/brainstorm.js
CHANGED
|
@@ -79,6 +79,7 @@ export const definition = {
|
|
|
79
79
|
maxWaitRounds: 8,
|
|
80
80
|
waitReason: '等待用户回答需求问题或澄清',
|
|
81
81
|
waitOptions: ['回答见--answer', '信息够了,进入方案讨论'],
|
|
82
|
+
waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
|
|
82
83
|
prompt: `通过对话探索需求细节、分析原型(如有)、判断拆分/批量、并对歧义点做需求澄清 Grill。本步骤合并了原有的对话探索、原型分析、需求范围评估、需求澄清 Grill 四个环节——按下方 A→D 顺序按需执行。提问遵守「一次只问一个」(见 D 节追问策略):必须问的问题逐个问,能查代码/文档解决的不问;不要把多个歧义打包成一屏清单一次抛给用户(用户记不住、答不全,反而漏掉关键约束);也不要为凑数把单个问题拖成多轮。
|
|
83
84
|
|
|
84
85
|
### A. 原型/设计图分析(如用户提供)
|
package/src/sync.js
CHANGED
|
@@ -27,9 +27,10 @@ const LOCAL_YAML = '.sillyspec/local.yaml';
|
|
|
27
27
|
const CHANGES_DIR = '.sillyspec/changes';
|
|
28
28
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
29
29
|
|
|
30
|
-
// 未连接平台是本地独立用户的合法默认状态。sync /
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
// 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
|
|
31
|
+
// 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
|
|
32
|
+
// 命令触发(index.js sync-docs 分支),run 流程不自动推文档。未连接时默认静默跳过——不每步
|
|
33
|
+
// 催连平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
|
|
33
34
|
function debugLog(msg) {
|
|
34
35
|
if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg)
|
|
35
36
|
}
|
|
@@ -136,6 +137,13 @@ function writeLocalYamlRaw(cwd, text) {
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
function parseSimpleYaml(content) {
|
|
140
|
+
// 剥 YAML 双/单引号包裹(connect 写侧 yamlStr 加引号防 # : 注入,读侧对称剥;无引号值零影响)
|
|
141
|
+
const unquote = (v) => {
|
|
142
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
|
|
143
|
+
return v.slice(1, -1);
|
|
144
|
+
}
|
|
145
|
+
return v;
|
|
146
|
+
};
|
|
139
147
|
const result = {};
|
|
140
148
|
let currentSection = null;
|
|
141
149
|
for (const line of content.split('\n')) {
|
|
@@ -147,7 +155,7 @@ function parseSimpleYaml(content) {
|
|
|
147
155
|
const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
|
|
148
156
|
if (m) {
|
|
149
157
|
const key = m[1];
|
|
150
|
-
const val = m[2].trim();
|
|
158
|
+
const val = unquote(m[2].trim());
|
|
151
159
|
if (val) {
|
|
152
160
|
result[key] = val;
|
|
153
161
|
currentSection = null;
|
|
@@ -159,7 +167,7 @@ function parseSimpleYaml(content) {
|
|
|
159
167
|
} else if (currentSection) {
|
|
160
168
|
const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
|
|
161
169
|
if (m && result[currentSection] && typeof result[currentSection] === 'object') {
|
|
162
|
-
result[currentSection][m[1]] = m[2].trim();
|
|
170
|
+
result[currentSection][m[1]] = unquote(m[2].trim());
|
|
163
171
|
}
|
|
164
172
|
}
|
|
165
173
|
}
|
|
@@ -296,19 +304,24 @@ export class SyncManager {
|
|
|
296
304
|
|
|
297
305
|
// 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
|
|
298
306
|
// (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
|
|
307
|
+
// YAML 值加引号:token/url 含 # : 或首尾空白时裸写会破坏段结构或注入额外键(shpsync_ token
|
|
308
|
+
// 当前不含这些字符,防御性包裹零成本;parseSimpleYaml 读侧剥引号兼容)
|
|
309
|
+
const yamlStr = (v) => JSON.stringify(String(v));
|
|
299
310
|
const platformEntries = [
|
|
300
|
-
` url: ${normalizedUrl}`,
|
|
301
|
-
` token: ${effectiveToken}`,
|
|
302
|
-
` last_connected: ${new Date().toISOString()}`,
|
|
311
|
+
` url: ${yamlStr(normalizedUrl)}`,
|
|
312
|
+
` token: ${yamlStr(effectiveToken)}`,
|
|
313
|
+
` last_connected: ${yamlStr(new Date().toISOString())}`,
|
|
303
314
|
];
|
|
304
315
|
if (resolvedUser) {
|
|
305
|
-
platformEntries.push(` user: ${resolvedUser}`);
|
|
316
|
+
platformEntries.push(` user: ${yamlStr(resolvedUser)}`);
|
|
306
317
|
}
|
|
307
318
|
let text = readLocalYamlRaw(this.cwd);
|
|
308
319
|
text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
|
|
309
|
-
// mcp 段同源假设(design §7.4
|
|
320
|
+
// mcp 段同源假设(design §7.4):url 复用 platform 的 url;token 用原始 user 级 token
|
|
321
|
+
// (非换发的 effectiveToken——MCP 派发需 user 级权限,platform 段才是最小权限的 shpsync_)。
|
|
322
|
+
// 用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
|
|
310
323
|
if (findTopLevelSectionRange(text, 'mcp') === null) {
|
|
311
|
-
text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
|
|
324
|
+
text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(normalizedUrl)}\n token: ${yamlStr(token)}`);
|
|
312
325
|
}
|
|
313
326
|
writeLocalYamlRaw(this.cwd, text);
|
|
314
327
|
}
|
|
@@ -390,8 +403,8 @@ export class SyncManager {
|
|
|
390
403
|
if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
|
|
391
404
|
headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
|
|
392
405
|
|
|
393
|
-
// POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)
|
|
394
|
-
const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
|
|
406
|
+
// POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)。changeName encode 对齐 pull(:673)
|
|
407
|
+
const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
|
|
395
408
|
const res = await fetchJsonWithStatus(syncUrl, {
|
|
396
409
|
method: 'POST',
|
|
397
410
|
headers,
|
|
@@ -475,7 +488,7 @@ export class SyncManager {
|
|
|
475
488
|
return { synced: 0, errors: [...errors, '无可用文档'] };
|
|
476
489
|
}
|
|
477
490
|
|
|
478
|
-
const docUrl = `${platform.url}/api/changes/${changeName}/documents`;
|
|
491
|
+
const docUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/documents`;
|
|
479
492
|
const result = await fetchJson(docUrl, {
|
|
480
493
|
method: 'POST',
|
|
481
494
|
headers: {
|
|
@@ -510,7 +523,7 @@ export class SyncManager {
|
|
|
510
523
|
return { status: 'pending', reason: '未指定变更名称' };
|
|
511
524
|
}
|
|
512
525
|
|
|
513
|
-
const approvalUrl = `${platform.url}/api/changes/${changeName}/approval`;
|
|
526
|
+
const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
|
|
514
527
|
const result = await fetchJson(approvalUrl, {
|
|
515
528
|
headers: { Authorization: `Bearer ${platform.token}` },
|
|
516
529
|
});
|
|
@@ -924,6 +937,11 @@ export async function collectStatus(cwd) {
|
|
|
924
937
|
return new SyncManager(cwd).collectStatus();
|
|
925
938
|
}
|
|
926
939
|
|
|
940
|
+
// 未决冲突文件列表便捷导出(index.js platform resolve 参数解析/报错兜底用,只读)
|
|
941
|
+
export function listConflictFiles(cwd) {
|
|
942
|
+
return new SyncManager(cwd).listConflictFiles();
|
|
943
|
+
}
|
|
944
|
+
|
|
927
945
|
// TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
|
|
928
946
|
// 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
|
|
929
947
|
/**
|
package/src/task-review.js
CHANGED
|
@@ -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 {
|
|
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')
|
|
@@ -657,6 +663,18 @@ export function generateExecuteRunId() {
|
|
|
657
663
|
return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
658
664
|
}
|
|
659
665
|
|
|
666
|
+
/**
|
|
667
|
+
* execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS。
|
|
668
|
+
* marker 文件是 agent 可写内容,读回后既注入 prompt({EXECUTE_RUN_ID})又拼进
|
|
669
|
+
* join(runtimeRoot, 'execute-runs', runId) 路径——格式校验同时防提示词注入与路径穿越
|
|
670
|
+
* (对齐 stage-review marker 的 review- 前缀校验范式)。
|
|
671
|
+
* @param {string} runId
|
|
672
|
+
* @returns {boolean}
|
|
673
|
+
*/
|
|
674
|
+
export function isValidExecuteRunId(runId) {
|
|
675
|
+
return typeof runId === 'string' && /^exec-\d{4}-\d{2}-\d{2}-\d{6}$/.test(runId)
|
|
676
|
+
}
|
|
677
|
+
|
|
660
678
|
/**
|
|
661
679
|
* 解析最新 execute run id:优先 current-execute-run-id-<changeName> marker;
|
|
662
680
|
* marker 缺失/为空则扫描 execute-runs/ 下子目录、取 mtime 最新的(尽力定位已有 run)。
|
|
@@ -699,7 +717,13 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
|
699
717
|
|
|
700
718
|
export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
701
719
|
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
702
|
-
try {
|
|
720
|
+
try {
|
|
721
|
+
if (existsSync(marker)) {
|
|
722
|
+
const c = readFileSync(marker, 'utf8').trim()
|
|
723
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
|
|
724
|
+
if (isValidExecuteRunId(c)) return c
|
|
725
|
+
}
|
|
726
|
+
} catch {}
|
|
703
727
|
try {
|
|
704
728
|
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
705
729
|
if (existsSync(runsDir)) {
|
|
@@ -758,7 +782,13 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
758
782
|
// exec-id:与 Task Review Gate(gates.js:269)/ autoCheckPlanFromReviews 同源
|
|
759
783
|
const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
|
|
760
784
|
let executeRunId = ''
|
|
761
|
-
try {
|
|
785
|
+
try {
|
|
786
|
+
if (existsSync(runIdFile)) {
|
|
787
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
788
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
|
|
789
|
+
if (isValidExecuteRunId(c)) executeRunId = c
|
|
790
|
+
}
|
|
791
|
+
} catch {}
|
|
762
792
|
if (!executeRunId) {
|
|
763
793
|
executeRunId = generateExecuteRunId()
|
|
764
794
|
try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
|
|
@@ -915,7 +945,8 @@ export function getLatestExecuteRunId(runtimeRoot) {
|
|
|
915
945
|
try {
|
|
916
946
|
if (existsSync(markerPath)) {
|
|
917
947
|
const content = readFileSync(markerPath, 'utf8').trim()
|
|
918
|
-
if (content)
|
|
948
|
+
if (content && !isValidExecuteRunId(content)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(content.slice(0, 60))}),忽略并回退目录扫描`)
|
|
949
|
+
if (isValidExecuteRunId(content)) return content
|
|
919
950
|
}
|
|
920
951
|
} catch {}
|
|
921
952
|
|
package/src/verify-postcheck.js
CHANGED
|
@@ -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
|
|
377
|
-
|
|
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
|
|
396
|
-
|
|
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,
|
package/src/worktree-deps.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
950
|
+
// execFileSync 数组形式:不经 shell,防 meta.worktreePath 注入(安全收敛)
|
|
951
|
+
execFileSync('cmd.exe', ['/c', 'rmdir', wtNodeModules]);
|
|
950
952
|
} else {
|
|
951
953
|
unlinkSync(wtNodeModules);
|
|
952
954
|
}
|