sillyspec 3.26.11 → 3.26.13
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-archive/SKILL.md +1 -1
- 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 +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +161 -12
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +184 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +31 -8
- package/src/run/complete-handlers.js +21 -14
- package/src/run/complete.js +1181 -1121
- package/src/run/gates.js +148 -25
- package/src/run/prompt.js +9 -5
- package/src/run/shared.js +138 -58
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +2 -1
- package/src/stages/doctor.js +28 -13
- package/src/stages/execute.js +164 -153
- package/src/stages/plan-postcheck.js +49 -28
- package/src/stages/plan.js +57 -40
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +323 -221
- package/src/task-review.js +246 -61
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- package/.npmrc-publish +0 -2
package/src/run/stage.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { join, dirname } from 'node:path'
|
|
20
20
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
21
21
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
22
|
-
import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
|
|
22
|
+
import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown } from './shared.js'
|
|
23
23
|
import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
|
|
24
24
|
import { outputStep } from './prompt.js'
|
|
25
25
|
import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
|
|
@@ -56,6 +56,11 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
56
56
|
console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
|
|
57
57
|
console.log(' 提示:使用 --skip-approval 跳过审批检查')
|
|
58
58
|
}
|
|
59
|
+
// HUB-07:unknown(连接了平台但 404/断网/非 JSON)此前静默落空(fail-open 无提示)——
|
|
60
|
+
// 醒目警告 + 留痕,放行但可审计
|
|
61
|
+
if (approval.status === 'unknown') {
|
|
62
|
+
warnApprovalUnknown(cwd, changeName, approval.reason)
|
|
63
|
+
}
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
|
|
@@ -118,6 +123,10 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
118
123
|
`请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
|
|
119
124
|
}
|
|
120
125
|
writeFileSync(runIdFile, currentExecuteRunId + '\n')
|
|
126
|
+
// change 归属戳(坑 worktree-cleanup-marker-chain 根治):run 目录自带变更身份,
|
|
127
|
+
// marker 断裂(worktree cleanup / 归档清理 / 并行误删)后按戳归属,不再错配其他变更的 run
|
|
128
|
+
const { stampExecuteRunChange } = await import('../task-review.js')
|
|
129
|
+
stampExecuteRunChange(runtimeRoot, currentExecuteRunId, changeName)
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
|
|
@@ -244,6 +253,25 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
244
253
|
if (existingGuard) {
|
|
245
254
|
// 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
|
|
246
255
|
progress.quickGuard = existingGuard
|
|
256
|
+
// 中途追加边界:会话中途发现要改启动声明外的文件,此前 resume 会把 --files 静默丢弃、
|
|
257
|
+
// 边界冻结在启动时刻,只能靠事后归属/审计行兜底。恢复时带 --files 即并入 allowedFiles
|
|
258
|
+
// (追加不替换、去重保序),同时点录 allowedFilesHash 供同文件并发检测(文件不存在跳过,
|
|
259
|
+
// 与启动同语义)。--done 审计直读 guard.json(complete-handlers §4.6),追加即被归属消费。
|
|
260
|
+
const resumeFiles = Array.isArray(quickOpts?.quickFiles) ? quickOpts.quickFiles.filter(Boolean) : []
|
|
261
|
+
if (resumeFiles.length > 0) {
|
|
262
|
+
const known = new Set(progress.quickGuard.allowedFiles || [])
|
|
263
|
+
const added = resumeFiles.filter(f => !known.has(f))
|
|
264
|
+
if (added.length > 0) {
|
|
265
|
+
progress.quickGuard.allowedFiles = [...(progress.quickGuard.allowedFiles || []), ...added]
|
|
266
|
+
progress.quickGuard.allowedFilesHash = { ...(progress.quickGuard.allowedFilesHash || {}) }
|
|
267
|
+
for (const f of added) {
|
|
268
|
+
try { progress.quickGuard.allowedFilesHash[f] = createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex') }
|
|
269
|
+
catch { /* 预声明将新建的文件,与启动时 allowedFilesHash 同语义:不存在跳过 */ }
|
|
270
|
+
}
|
|
271
|
+
writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
|
|
272
|
+
console.log(`🛡️ quick 边界已追加: ${added.length} 个文件(累计 ${progress.quickGuard.allowedFiles.length} 个): ${added.join(', ')}`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
247
275
|
} else {
|
|
248
276
|
// baseline 采集用 safeGit(带 -c safe.directory,避免 linked worktree/容器异 uid/Windows 挂载点
|
|
249
277
|
// 下裸 `git status` 抛错)。safeGit 已消除 safe.directory 类失败;若仍失败(真非 git 目录等),
|
|
@@ -317,6 +345,16 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
317
345
|
if (allowDelete) parts.push('允许删除文件')
|
|
318
346
|
console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
|
|
319
347
|
console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
|
|
348
|
+
// 缺 --input 且关联变更无可提取标题 → 条目落「(quick 任务)」占位标题,平台「快速修复」列表
|
|
349
|
+
// 默认隐藏进行中占位(task-06 口径),长会话全程不可见、语义标题要到最终 --done 才回填。
|
|
350
|
+
// 此刻会话刚起步零沉没成本,提示放弃重启带 --input 是最便宜的自愈点。
|
|
351
|
+
if (!quickDesc) {
|
|
352
|
+
console.warn('')
|
|
353
|
+
console.warn('⚠️ 本次 quick 未带 --input:QUICKLOG 条目将以占位标题「(quick 任务)」落盘')
|
|
354
|
+
console.warn(' 平台「快速修复」列表默认隐藏进行中的占位条目,语义标题要到最终 --done 才回填。')
|
|
355
|
+
console.warn(' 长任务建议放弃本会话、带一句话任务描述重启(旧会话 run quick --reset --change <sessionId> 重置):')
|
|
356
|
+
console.warn(' sillyspec run quick --input "<一句话任务描述>" [--linked-changes <a,b>] [--files <...>]')
|
|
357
|
+
}
|
|
320
358
|
// 回填 DB changes 行的 title + quicklog_id,让 quick-<hex> 可读、DB↔QUICKLOG 可对账。
|
|
321
359
|
// title 用 quickDesc(任务描述或关联变更标题)经 sanitizeDesc 压一行限长,与 QUICKLOG 条目标题同源。
|
|
322
360
|
try {
|
|
@@ -380,6 +418,8 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
380
418
|
await executeScanPostcheck(cwd, platformOpts, scanProfile)
|
|
381
419
|
} else if (cliAction === 'planPostcheck') {
|
|
382
420
|
await executePlanPostcheck(cwd, platformOpts, progress)
|
|
421
|
+
} else {
|
|
422
|
+
throw new Error(`noAI 步骤 ${stepName} 的未知 _cliAction: ${cliAction}——请在 stage.js 注册对应分支`)
|
|
383
423
|
}
|
|
384
424
|
stageData.steps[currentIdx].status = 'completed'
|
|
385
425
|
stageData.steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
package/src/setup.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
|
-
import { execSync
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { gitQuiet } from './git-helper.js';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
import ora from 'ora';
|
|
6
7
|
import { checkbox, input } from '@inquirer/prompts';
|
|
@@ -359,16 +360,13 @@ export async function cmdSetup(dir, options = {}) {
|
|
|
359
360
|
// 凭据提交风险提示(sec-c):.claude/mcp.json / .cursor/mcp.json 是项目共享文件、通常随 git
|
|
360
361
|
// 提交,而 DB MCP 的 env 含明文连接串/密码。检测 git 跟踪状态,已跟踪/未忽略时显式警告。
|
|
361
362
|
if (selectedDb.length > 0) {
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
// QUAL-01 收口:裸 execFileSync → git-helper(-C 由 helper 统一注入)。
|
|
364
|
+
// 语义映射:ls-files --error-unmatch 命中(exit 0)= 已跟踪 → 警告;
|
|
365
|
+
// check-ignore -q 命中(exit 0)= 已忽略 → 不警告
|
|
366
|
+
if (gitQuiet(dir, ['ls-files', '--error-unmatch', path]) !== null) {
|
|
364
367
|
console.warn(chalk.yellow(` ⚠️ ${path} 已被 git 跟踪——其中数据库连接串/密码会随提交进入仓库,建议加入 .gitignore 或改用环境变量`));
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
execFileSync('git', ['-C', dir, 'check-ignore', '-q', path], { stdio: 'pipe' });
|
|
369
|
-
} catch {
|
|
370
|
-
console.warn(chalk.yellow(` ⚠️ ${path} 含数据库连接串/密码且未被 .gitignore 忽略——提交前请先加入 .gitignore 或改用环境变量`));
|
|
371
|
-
}
|
|
368
|
+
} else if (gitQuiet(dir, ['check-ignore', '-q', path]) === null) {
|
|
369
|
+
console.warn(chalk.yellow(` ⚠️ ${path} 含数据库连接串/密码且未被 .gitignore 忽略——提交前请先加入 .gitignore 或改用环境变量`));
|
|
372
370
|
}
|
|
373
371
|
}
|
|
374
372
|
}
|
|
@@ -49,7 +49,6 @@ export class SillyHubMcpClient {
|
|
|
49
49
|
if (this._url && !/^https:\/\//i.test(this._url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(this._url)) {
|
|
50
50
|
console.warn(`[sillyhub-mcp] mcp.url 非 https(${this._url.slice(0, 60)}),Bearer token 将明文传输——请确认为受控环境`);
|
|
51
51
|
}
|
|
52
|
-
this._token = typeof t === 'string' ? t : '';
|
|
53
52
|
this._timeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0
|
|
54
53
|
? timeoutMs
|
|
55
54
|
: DEFAULT_TIMEOUT_MS;
|
|
@@ -206,6 +205,13 @@ export class SillyHubMcpClient {
|
|
|
206
205
|
}
|
|
207
206
|
|
|
208
207
|
if (rpc.error) {
|
|
208
|
+
// HUB-11:session 过期第二种形态——HTTP 200 + JSON-RPC error -32600「Missing session」
|
|
209
|
+
// (api-reference 记载的形态;FastMCP 实测走 HTTP 400,两种都认)。落哨兵触发
|
|
210
|
+
// _sendRpc 重置重连;其余 error 维持 warn+null(如 -32602 Invalid params 是调用侧问题,
|
|
211
|
+
// 重连救不回,不误触发)
|
|
212
|
+
if (rpc.error.code === -32600 && /missing session/i.test(String(rpc.error.message || ''))) {
|
|
213
|
+
return SESSION_EXPIRED;
|
|
214
|
+
}
|
|
209
215
|
if (!quiet) console.warn(`[sillyhub-mcp] ${tag} JSON-RPC error: ${JSON.stringify(rpc.error)}`);
|
|
210
216
|
return null;
|
|
211
217
|
}
|
|
@@ -228,11 +234,8 @@ export class SillyHubMcpClient {
|
|
|
228
234
|
|
|
229
235
|
/**
|
|
230
236
|
* MCP initialize 握手:POST initialize → 从响应 header(或 body _meta)读 mcp-session-id。
|
|
231
|
-
* 成功存 this._sessionId
|
|
232
|
-
*
|
|
233
|
-
* 注:协议要求 initialize 后发 notifications/initialized,本客户端只做探测
|
|
234
|
-
* (tools/list / 只读 tool),实测 FastMCP 不强制该通知即可用(2026-08-14 验证),
|
|
235
|
-
* 为省一次 RPC 不发;若未来 server 强校验再补。
|
|
237
|
+
* 成功存 this._sessionId 并补发 notifications/initialized(协议要求;FastMCP 实测不强制,
|
|
238
|
+
* best-effort 失败仅 warn);失败返回 false(best-effort,warn 不抛)。
|
|
236
239
|
* @returns {Promise<boolean>}
|
|
237
240
|
*/
|
|
238
241
|
async _initialize(quiet = false) {
|
|
@@ -299,6 +302,28 @@ export class SillyHubMcpClient {
|
|
|
299
302
|
return false;
|
|
300
303
|
}
|
|
301
304
|
this._sessionId = sid;
|
|
305
|
+
// MCP 2025-11-25 协议要求 initialize 后发 notifications/initialized(无 id 通知,server 不回包)。
|
|
306
|
+
// 2026-08-14 实测 FastMCP v1.29 不强制;补发是为防未来 server 强校验拒掉所有 tools/call。
|
|
307
|
+
// best-effort:失败仅 warn,不阻断(session 已建,宽松 server 仍可用)。
|
|
308
|
+
try {
|
|
309
|
+
const noteController = new AbortController();
|
|
310
|
+
const noteTimer = setTimeout(() => noteController.abort(), this._timeoutMs);
|
|
311
|
+
await fetch(this._endpoint, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: {
|
|
314
|
+
Authorization: `Bearer ${this._token}`,
|
|
315
|
+
'Content-Type': 'application/json',
|
|
316
|
+
Accept: 'application/json, text/event-stream',
|
|
317
|
+
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
318
|
+
'Mcp-Session-Id': this._sessionId,
|
|
319
|
+
},
|
|
320
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
321
|
+
signal: noteController.signal,
|
|
322
|
+
}).catch(() => { /* best-effort */ });
|
|
323
|
+
clearTimeout(noteTimer);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (!quiet) console.warn(`[sillyhub-mcp] notifications/initialized 发送失败(忽略): ${err && err.message ? err.message : err}`);
|
|
326
|
+
}
|
|
302
327
|
return true;
|
|
303
328
|
}
|
|
304
329
|
|
|
@@ -387,6 +412,49 @@ export class SillyHubMcpClient {
|
|
|
387
412
|
return null;
|
|
388
413
|
}
|
|
389
414
|
|
|
415
|
+
/**
|
|
416
|
+
* HUB-12a:tools/list 的完整 result(一次请求同时喂路径A schema 预热与 root_path 越界
|
|
417
|
+
* 校验——此前 listTools + getRootPath 各发一次同 method 请求,probe 链路重复 RPC)。
|
|
418
|
+
* best-effort:未配置/失败 → null 不抛。形态:{tools:[...], root_path?} 或裸数组。
|
|
419
|
+
*/
|
|
420
|
+
async listToolsWithMeta({ quiet = true } = {}) {
|
|
421
|
+
if (!this._configured) return null;
|
|
422
|
+
return this._sendRpc('tools/list', {}, { quiet, label: 'tools/list' });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* HUB-12b:关闭 MCP session(streamable HTTP DELETE,2025-11-25 协议规定的会话终止方式)。
|
|
427
|
+
* CLI 短进程原本把 session 留给 server TTL 清理;长驻场景(未来 daemon 化)会积累半开
|
|
428
|
+
* session。best-effort:网络失败/无 session 返回 false 不抛;成功后清本地 session 态,
|
|
429
|
+
* 后续调用自动重新 initialize。
|
|
430
|
+
* @returns {Promise<boolean>} 是否发出了有效的 DELETE
|
|
431
|
+
*/
|
|
432
|
+
async close() {
|
|
433
|
+
if (!this._configured || !this._sessionId) return false;
|
|
434
|
+
const sid = this._sessionId;
|
|
435
|
+
this._sessionId = null; // 先清本地态:DELETE 失败也不留「以为还活着」的 session
|
|
436
|
+
try {
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
439
|
+
try {
|
|
440
|
+
const res = await fetch(this._endpoint, {
|
|
441
|
+
method: 'DELETE',
|
|
442
|
+
headers: {
|
|
443
|
+
Authorization: `Bearer ${this._token}`,
|
|
444
|
+
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
|
|
445
|
+
'Mcp-Session-Id': sid,
|
|
446
|
+
},
|
|
447
|
+
signal: controller.signal,
|
|
448
|
+
});
|
|
449
|
+
return res.ok;
|
|
450
|
+
} finally {
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
390
458
|
/**
|
|
391
459
|
* best-effort 从 daemon 拿 workspace root_path(task-12 路径A 越界校验用)。
|
|
392
460
|
*
|
package/src/spec-dir-typo.js
CHANGED
|
@@ -13,30 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
import { dirname, basename, join } from 'path'
|
|
15
15
|
import { readdirSync } from 'fs'
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* @param {string} a
|
|
20
|
-
* @param {string} b
|
|
21
|
-
* @returns {number}
|
|
22
|
-
*/
|
|
23
|
-
function levenshtein(a, b) {
|
|
24
|
-
const m = a.length
|
|
25
|
-
const n = b.length
|
|
26
|
-
if (m === 0) return n
|
|
27
|
-
if (n === 0) return m
|
|
28
|
-
let prev = Array.from({ length: n + 1 }, (_, j) => j)
|
|
29
|
-
let curr = new Array(n + 1)
|
|
30
|
-
for (let i = 1; i <= m; i++) {
|
|
31
|
-
curr[0] = i
|
|
32
|
-
for (let j = 1; j <= n; j++) {
|
|
33
|
-
const cost = a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? 0 : 1
|
|
34
|
-
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
|
|
35
|
-
}
|
|
36
|
-
;[prev, curr] = [curr, prev]
|
|
37
|
-
}
|
|
38
|
-
return prev[n]
|
|
39
|
-
}
|
|
16
|
+
// levenshtein 单一实现收敛到 run/shared.js(原本地副本与 shared.js 逐行相同);
|
|
17
|
+
// shared.js 不静态 import review 族,此处引入不成环
|
|
18
|
+
import { levenshtein } from './run/shared.js'
|
|
40
19
|
|
|
41
20
|
/**
|
|
42
21
|
* 检测 spec 目录拼写变体。
|
package/src/spec-sync.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash } from 'crypto';
|
|
14
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
|
15
15
|
import { join, relative, sep } from 'path';
|
|
16
16
|
|
|
17
17
|
// 与 sillyhub-daemon/src/spec-sync.ts 共用排除口径(task-07 / FR-06 / D-008@v2):
|
|
@@ -127,6 +127,19 @@ export function computeSpecOps(serverManifest, localFiles) {
|
|
|
127
127
|
const serverPaths = Object.keys(server).filter((p) => server[p]?.exists !== false);
|
|
128
128
|
const serverSet = new Set(serverPaths);
|
|
129
129
|
|
|
130
|
+
// fail-closed 护栏(2026-08-20 全量体检 BUG-01):本地树为空而服务器非空时,
|
|
131
|
+
// 全量 delete 几乎必然是「锚错了目录」(如平台模式 cwd/.sillyspec 只剩 local.yaml
|
|
132
|
+
// 被 walk 排除),而不是用户真的删光了所有文件——一旦发出就是服务器整树清空。
|
|
133
|
+
// 唯一放行:服务器只剩 local.yaml 存量(顶层单文件),保留「delete 清 token 存量」
|
|
134
|
+
// 的既定语义(见 UPLOAD_EXCLUDE_FILENAMES 注释)。真实清空需求需本地树非空逐轮收敛。
|
|
135
|
+
const nonTrivialServer = serverPaths.filter((p) => p !== 'local.yaml');
|
|
136
|
+
if (localFiles.length === 0 && nonTrivialServer.length > 0) {
|
|
137
|
+
console.warn(
|
|
138
|
+
`[spec-sync] 本地 spec 树为空但服务器有 ${serverPaths.length} 个文件,跳过同步(防误清空;请检查 spec 目录锚点)`
|
|
139
|
+
);
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
130
143
|
// rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
|
|
131
144
|
const renames = [];
|
|
132
145
|
const consumedNew = new Set();
|
|
@@ -243,11 +256,13 @@ function collectChangeDir(dirs, p) {
|
|
|
243
256
|
* @param {string} changeName - 当前变更名
|
|
244
257
|
* @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
|
|
245
258
|
*/
|
|
246
|
-
export async function syncSpecTree(specRoot, platform, changeName) {
|
|
259
|
+
export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
|
|
247
260
|
if (!platform || !platform.url || !platform.token) {
|
|
248
261
|
debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
|
|
249
262
|
return { synced: 0 };
|
|
250
263
|
}
|
|
264
|
+
// HUB-09:单请求超时与外部熔断 signal 合并——trigger* 熔断时在飞请求被真实取消
|
|
265
|
+
const withSignal = (timeoutMs) => (opts.signal ? AbortSignal.any([AbortSignal.timeout(timeoutMs), opts.signal]) : AbortSignal.timeout(timeoutMs));
|
|
251
266
|
|
|
252
267
|
const manifestUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-manifest`;
|
|
253
268
|
const syncUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-sync`;
|
|
@@ -257,7 +272,7 @@ export async function syncSpecTree(specRoot, platform, changeName) {
|
|
|
257
272
|
try {
|
|
258
273
|
const res = await fetch(manifestUrl, {
|
|
259
274
|
headers: { Authorization: `Bearer ${platform.token}` },
|
|
260
|
-
signal:
|
|
275
|
+
signal: withSignal(10000),
|
|
261
276
|
});
|
|
262
277
|
if (!res.ok) {
|
|
263
278
|
// ql-20260818-008:debugLog → console.warn。树同步失败原本完全静默(要
|
|
@@ -293,7 +308,7 @@ export async function syncSpecTree(specRoot, platform, changeName) {
|
|
|
293
308
|
Authorization: `Bearer ${platform.token}`,
|
|
294
309
|
},
|
|
295
310
|
body: JSON.stringify({ ops }),
|
|
296
|
-
signal:
|
|
311
|
+
signal: withSignal(30000),
|
|
297
312
|
});
|
|
298
313
|
if (!res.ok) {
|
|
299
314
|
console.warn(`[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`);
|
|
@@ -301,10 +316,30 @@ export async function syncSpecTree(specRoot, platform, changeName) {
|
|
|
301
316
|
}
|
|
302
317
|
const body = await res.json().catch(() => ({}));
|
|
303
318
|
if (body.conflict) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
319
|
+
// HUB-08 冲突闭环:落 spec-sync-conflict-<change>.json(与进度 sync-conflict-* 同目录),
|
|
320
|
+
// 供 platform status 列出 + platform resolve 三态处置。此前只 warn 返回,下次 sync 用
|
|
321
|
+
// 同一 base_version 继续冲突循环,无人工裁决入口。
|
|
322
|
+
const serverVersions = body.server_versions || {};
|
|
323
|
+
let conflictPath = null;
|
|
324
|
+
try {
|
|
325
|
+
const runtimeDir = join(specRoot, '.runtime');
|
|
326
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
327
|
+
conflictPath = join(runtimeDir, `spec-sync-conflict-${changeName}.json`);
|
|
328
|
+
writeFileSync(conflictPath, JSON.stringify({
|
|
329
|
+
change: changeName,
|
|
330
|
+
kind: 'spec-tree',
|
|
331
|
+
created_at: new Date().toISOString(),
|
|
332
|
+
server_versions: serverVersions,
|
|
333
|
+
conflicting_paths: Object.keys(serverVersions),
|
|
334
|
+
note: 'spec 树文件冲突(服务器版本领先于本地 diff 基线)。resolve --keep-local 以本地为准重推;--take-platform 暂不支持(平台无文件下载端点)',
|
|
335
|
+
}, null, 2) + '\n', 'utf8');
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.warn(`[spec-sync] 冲突文件写入失败(冲突信息仅打印): ${e.message}`);
|
|
338
|
+
}
|
|
339
|
+
console.warn('');
|
|
340
|
+
console.warn(`⚠️ [spec-sync] 检测到 spec 树冲突(${Object.keys(serverVersions).length} 个文件,服务器版本: ${JSON.stringify(serverVersions)})`);
|
|
341
|
+
console.warn(`⚠️ 处置:sillyspec platform resolve ${changeName} --keep-local | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
|
|
342
|
+
return { synced: 0, conflict: true, serverVersions, conflictPath };
|
|
308
343
|
}
|
|
309
344
|
console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
|
|
310
345
|
return { synced: ops.length };
|
package/src/stage-contract.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
9
9
|
import { join, basename } from 'path'
|
|
10
|
-
import {
|
|
10
|
+
import { safeGit } from './git-helper.js'
|
|
11
11
|
import { detectChangeRisk, checkIntegrationEvidence, VERIFICATION_NEEDS, RISK_LEVEL_CAUSES } from './change-risk-profile.js'
|
|
12
12
|
import { SCAN_REQUIRED_DOCS, AUXILIARY_STAGES } from './constants.js'
|
|
13
13
|
import { evaluateRules } from './stage-contract-engine.js'
|
|
@@ -61,22 +61,12 @@ export function validateChangeExists(specBase, stageName, changeName) {
|
|
|
61
61
|
if (/^quick-[0-9a-f]{8}$/.test(changeName)) return null
|
|
62
62
|
if (existsSync(join(specBase, 'changes', changeName))) return null
|
|
63
63
|
|
|
64
|
-
// archive 特例:step4 --confirm 已把变更从 changes/<name>/ 移到 changes/archive/<
|
|
64
|
+
// archive 特例:step4 --confirm 已把变更从 changes/<name>/ 移到 changes/archive/<name>/。
|
|
65
65
|
// step5(更新路线图/提交)--change <name> 不应因 changes/<name>/ 已移走而被前置校验误拦。
|
|
66
|
-
// 精确匹配归档目录名(YYYY-MM-DD-<changeName>),避免后缀子串误匹配(auth ≠ deep-auth)。
|
|
67
66
|
if (stageName === 'archive') {
|
|
68
67
|
const archiveRoot = join(specBase, 'changes', 'archive')
|
|
69
|
-
if (existsSync(archiveRoot)) {
|
|
70
|
-
|
|
71
|
-
// 校验侧同样 strip changeName 前导日期再比 —— 否则「写 strip、读拿完整源名」语义不一致,
|
|
72
|
-
// 会让带日期前缀的源名(brainstorm 约定,如 2026-07-29-sidebar-menu-restructure)在 step5
|
|
73
|
-
// 被前置校验误拦(坑 archive-step5-leading-date-change-name)。
|
|
74
|
-
const stripped = String(changeName).replace(/^\d{4}-\d{2}-\d{2}-/, '')
|
|
75
|
-
const archived = readdirSync(archiveRoot).find(d => {
|
|
76
|
-
const m = d.match(/^\d{4}-\d{2}-\d{2}-(.+)$/)
|
|
77
|
-
return m && m[1] === stripped
|
|
78
|
-
})
|
|
79
|
-
if (archived) return null
|
|
68
|
+
if (existsSync(archiveRoot) && existsSync(join(archiveRoot, changeName))) {
|
|
69
|
+
return null
|
|
80
70
|
}
|
|
81
71
|
}
|
|
82
72
|
return {
|
|
@@ -527,19 +517,10 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
|
|
|
527
517
|
}
|
|
528
518
|
|
|
529
519
|
/**
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* 变更名创建时常已带 YYYY-MM-DD- 前缀(brainstorm 强制约定 + CLI 自动占位名),
|
|
533
|
-
* 若归档时再无条件拼一次 date- 前缀,会产生 2026-07-28-2026-07-28-<desc> 双日期。
|
|
534
|
-
* 这里先剥离源名前导日期,再统一拼归档当天日期 → 归档目录名恒为 <归档日期>-<纯描述>。
|
|
535
|
-
* 不带日期的源名(如 quick-<hash>)strip 无效,正常补日期,行为不变。
|
|
536
|
-
*
|
|
537
|
-
* 写(archiveChangeDirectory)与校验(validateArchiveOutputs)共用此函数,
|
|
538
|
-
* 避免「写和校验各自硬编码同公式」的漂移(曾因此导致双日期 bug 写校验都自洽通过)。
|
|
520
|
+
* 计算归档目标目录名:保持原变更名不变,直接移入 archive/。
|
|
539
521
|
*/
|
|
540
522
|
export function archiveDestDirName(date, changeName) {
|
|
541
|
-
|
|
542
|
-
return `${date}-${stripped}`
|
|
523
|
+
return String(changeName)
|
|
543
524
|
}
|
|
544
525
|
|
|
545
526
|
/**
|
|
@@ -547,8 +528,7 @@ export function archiveDestDirName(date, changeName) {
|
|
|
547
528
|
*/
|
|
548
529
|
function validateArchiveOutputs(cwd, changeName) {
|
|
549
530
|
const archiveDir = join(cwd, '.sillyspec', 'changes', 'archive')
|
|
550
|
-
const
|
|
551
|
-
const destDir = join(archiveDir, archiveDestDirName(date, changeName))
|
|
531
|
+
const destDir = join(archiveDir, changeName)
|
|
552
532
|
|
|
553
533
|
// 归档目录不存在 early return(引擎在存在时才跑)
|
|
554
534
|
if (!existsSync(destDir)) {
|
|
@@ -583,18 +563,12 @@ function validateChangeClosed(cwd, changeName) {
|
|
|
583
563
|
|
|
584
564
|
// ============ Execute 代码变更客观核验 ============
|
|
585
565
|
|
|
566
|
+
// QUAL-01 收口:本地 execFileSync 裸调(无 safe.directory)→ git-helper safeGit 统一入口,
|
|
567
|
+
// {ok,out,error} 适配语义与 timeout 15000 不变
|
|
586
568
|
function gitTry(dir, args) {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
encoding: 'utf8',
|
|
591
|
-
timeout: 15000,
|
|
592
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
593
|
-
}).trim()
|
|
594
|
-
return { ok: true, out }
|
|
595
|
-
} catch (e) {
|
|
596
|
-
return { ok: false, out: '', error: e.message?.split('\n')[0] || String(e) }
|
|
597
|
-
}
|
|
569
|
+
const r = safeGit(dir, args, { timeout: 15000 })
|
|
570
|
+
if (r.value !== null) return { ok: true, out: r.value }
|
|
571
|
+
return { ok: false, out: '', error: r.error || 'git 执行失败' }
|
|
598
572
|
}
|
|
599
573
|
|
|
600
574
|
/**
|
package/src/stages/archive.js
CHANGED
|
@@ -12,8 +12,9 @@ export const definition = {
|
|
|
12
12
|
|
|
13
13
|
### 判定规则
|
|
14
14
|
- **真相源 = review.json verdict**:上方报告由 CLI 从 .runtime/execute-runs/<runId>/tasks/task-NN/review.json 的 specVerdict + qualityVerdict 算出。
|
|
15
|
-
-
|
|
15
|
+
- tasks.md 的 - [x] checkbox **仅作显示态参考**:它依赖自动回填,runId marker / review 缺失时会停在未勾态,与客观 verdict 不一致时**以本报告为准**,不要被未勾 checkbox 误导。
|
|
16
16
|
- 报告「未通过 / 缺失」= 0 且 source 为 review.json → 完成度合格,进入下一步。
|
|
17
|
+
- 报告含「cannot_verify 草稿(未真正复核)」计数 > 0 → 这些 task 只有 cannot_verify 草稿兜底、未被真正复核:先确认 verify 阶段已兑现其 requiredEvidence(verify-result.md 有对应证据),缺证据的派独立子代理对照 task brief + git diff 补真实复核(升级为 pass/fail),勿静默放行。
|
|
17
18
|
- 报告 source 为 plan-checkbox-fallback(客观源不可用)→ 完成度无法客观确认,**必须暂停**让用户交叉核对,不要直接放行。
|
|
18
19
|
- 存在未完成任务 → **必须暂停等待用户决定**,不要自行判断"可以归档"。
|
|
19
20
|
- 调用:sillyspec run archive --wait --reason "存在未完成任务,是否继续归档" --options "继续归档,回到execute完成剩余任务" --output "未完成任务列表"
|
|
@@ -126,10 +127,10 @@ module_id: <module-id>
|
|
|
126
127
|
|
|
127
128
|
### 操作
|
|
128
129
|
1. 展示:变更目录名、包含的文件列表(含 module-impact.md)、生成总结
|
|
129
|
-
2.
|
|
130
|
+
2. 确保任务清单(tasks.md)所有 task checkbox 都已勾选
|
|
130
131
|
3. 让用户确认后,用 \`--confirm\` 完成本步骤:
|
|
131
132
|
\`sillyspec run archive --done --confirm --output "确认归档"\`
|
|
132
|
-
4. CLI 会创建 \`.sillyspec/changes/archive/\`,并将变更目录移动到 \`.sillyspec/changes/archive
|
|
133
|
+
4. CLI 会创建 \`.sillyspec/changes/archive/\`,并将变更目录移动到 \`.sillyspec/changes/archive/<原变更名>/\`
|
|
133
134
|
|
|
134
135
|
### 输出
|
|
135
136
|
归档完成 + archive 目录路径`,
|
|
@@ -217,7 +217,7 @@ AC-011: 不涉及业务规则/产品范围/默认行为/用户可见行为变更
|
|
|
217
217
|
1. 基于变更根目录下的设计产物(design.md / decisions.md / gaps.md / assumptions.md / next-action.json),生成规范文件(直接写入变更根目录):
|
|
218
218
|
- **proposal.md**:动机、关键问题、变更范围、不在范围内、成功标准
|
|
219
219
|
- **requirements.md**:角色表 + FR 编号需求 + Given/When/Then + 非功能需求
|
|
220
|
-
- **tasks.md
|
|
220
|
+
- **tasks.md**:任务列表骨架(只列名称;任务清单唯一真相,plan 阶段展开细节并写回本文件——execute 勾选与 verify 对照都在本文件)
|
|
221
221
|
2. 所有规范文件头部包含 YAML frontmatter
|
|
222
222
|
3. \`git add {SPEC_ROOT}/changes/<change-name>/\` — 暂存本变更的规范文件(勿用 .sillyspec/ 整目录——会裹挟其他活跃变更;不要 commit)
|
|
223
223
|
|
package/src/stages/brainstorm.js
CHANGED
|
@@ -537,7 +537,7 @@ Then 期望结果
|
|
|
537
537
|
| D-001@v1 | FR-01 | ... |
|
|
538
538
|
\`\`\`
|
|
539
539
|
|
|
540
|
-
### tasks.md 格式要求(scale=large
|
|
540
|
+
### tasks.md 格式要求(scale=large 时生成骨架;任务清单唯一真相——plan 阶段展开细节并写回本文件)
|
|
541
541
|
\`\`\`markdown
|
|
542
542
|
---
|
|
543
543
|
author: <git-user>
|
|
@@ -548,6 +548,7 @@ created_at: <now-datetime>
|
|
|
548
548
|
- [ ] task-01: <任务名>
|
|
549
549
|
- [ ] task-02: <任务名>
|
|
550
550
|
\`\`\`
|
|
551
|
+
> 骨架只列任务名。plan 阶段会把展开后的清单**写回本文件**(checkbox 行带一句话名,可附 [model:xxx]/(depends_on: …) 标注;保留 frontmatter/标题/ql-xxx 等非 task-XX 行);execute 勾选与 verify 对照都在本文件。
|
|
551
552
|
|
|
552
553
|
### decisions.md 格式要求(仅在有 Grill/重大决策时生成)
|
|
553
554
|
\`\`\`markdown
|
package/src/stages/doctor.js
CHANGED
|
@@ -12,7 +12,7 @@ export const definition = {
|
|
|
12
12
|
name: 'SillySpec 内部检查',
|
|
13
13
|
prompt: `运行 SillySpec 内部检查。逐项执行以下命令并汇总结果:
|
|
14
14
|
|
|
15
|
-
>
|
|
15
|
+
> ✅ Windows 兼容(SS-1,2026-08-20):db 查询已全部改走 node:sqlite(node≥22.13 内置),不再依赖外部 sqlite3 CLI。若 node 脚本失败,改用 sillyspec 自身命令替代——sillyspec progress show 查看当前变更与各阶段状态,sillyspec doctor --json 查看 db 健康与活跃变更列表。
|
|
16
16
|
|
|
17
17
|
### 1. 目录结构完整性
|
|
18
18
|
\`\`\`bash
|
|
@@ -44,16 +44,22 @@ done
|
|
|
44
44
|
|
|
45
45
|
### 3. 进度数据一致性
|
|
46
46
|
\`\`\`bash
|
|
47
|
-
# 从 sillyspec.db
|
|
47
|
+
# 从 sillyspec.db 读取活跃变更并检查目录存在性(node:sqlite 主路径,Windows 无 sqlite3 CLI)
|
|
48
48
|
DB_FILE='.sillyspec/.runtime/sillyspec.db'
|
|
49
49
|
if [ ! -f "$DB_FILE" ]; then echo "⚠️ sillyspec.db 不存在"; exit 0; fi
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
node --input-type=module -e "
|
|
51
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
52
|
+
import { existsSync } from 'node:fs';
|
|
53
|
+
const db = new DatabaseSync(process.argv[1], { readOnly: true });
|
|
54
|
+
const row = db.prepare(\\"SELECT name FROM changes WHERE status='active' ORDER BY last_active DESC LIMIT 1\\").get();
|
|
55
|
+
if (!row) { console.log('ℹ️ 无当前变更'); process.exit(0); }
|
|
56
|
+
console.log('当前变更: ' + row.name);
|
|
57
|
+
const dir = '.sillyspec/changes/' + row.name;
|
|
58
|
+
console.log(existsSync(dir) ? '✅ 变更目录存在: ' + row.name : '❌ 变更目录不存在: ' + row.name);
|
|
59
|
+
const stages = db.prepare('SELECT s.stage AS stage, s.status AS status FROM stages s JOIN changes c ON s.change_id=c.id WHERE c.name=? ORDER BY s.stage').all(row.name);
|
|
60
|
+
if (stages.length === 0) console.log('⚠️ 无阶段数据'); else for (const s of stages) console.log(' ' + s.stage + ': ' + s.status);
|
|
61
|
+
db.close();
|
|
62
|
+
" "$DB_FILE" 2>/dev/null || echo "⚠️ 无法查询阶段数据"
|
|
57
63
|
\`\`\`
|
|
58
64
|
|
|
59
65
|
### 4. 孤儿文件检查
|
|
@@ -62,11 +68,14 @@ DB_FILE='.sillyspec/.runtime/sillyspec.db'
|
|
|
62
68
|
if [ ! -f "$DB_FILE" ]; then echo "⚠️ sillyspec.db 不存在"; exit 0; fi
|
|
63
69
|
node -e "
|
|
64
70
|
const fs = require('fs');
|
|
71
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
65
72
|
const dir = '.sillyspec/changes';
|
|
66
|
-
const subs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => { try { return fs.statSync(dir+'/'+f).isDirectory(); } catch { return false; } }) : [];
|
|
67
|
-
const { execSync } = require('child_process');
|
|
73
|
+
const subs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => { try { return fs.statSync(dir+'/'+f).isDirectory() && f !== 'archive'; } catch { return false; } }) : [];
|
|
68
74
|
let known;
|
|
69
|
-
try { const
|
|
75
|
+
try { const db = new DatabaseSync('.sillyspec/.runtime/sillyspec.db', { readOnly: true }); known = new Set(db.prepare(\"SELECT name FROM changes WHERE status='active'\").all().map(r => r.name)); db.close(); } catch (e) { console.log('⚠️ db 读取失败: ' + e.message + '(跳过孤儿判定,不做任何清理建议)'); known = null; }
|
|
76
|
+
if (known) subs.forEach(s => {
|
|
77
|
+
console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(先 dump 取证再手工归位,勿直接 rm)');
|
|
78
|
+
});
|
|
70
79
|
subs.forEach(s => {
|
|
71
80
|
console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(可清理)');
|
|
72
81
|
});
|
|
@@ -132,7 +141,13 @@ DB_FILE='.sillyspec/.runtime/sillyspec.db'
|
|
|
132
141
|
if [ -f "$DB_FILE" ]; then
|
|
133
142
|
echo ""
|
|
134
143
|
echo "isolation 状态(来自 sillyspec.db):"
|
|
135
|
-
|
|
144
|
+
node -e "
|
|
145
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
146
|
+
const db = new DatabaseSync(process.argv[1], { readOnly: true });
|
|
147
|
+
const rows = db.prepare(\"SELECT name, isolation_status AS status, isolation_mode AS mode, isolation_reason AS reason FROM changes WHERE status='active'\").all();
|
|
148
|
+
for (const r of rows) console.log(' ' + r.name + ' | ' + r.status + ' | ' + r.mode + ' | ' + (r.reason || ''));
|
|
149
|
+
db.close();
|
|
150
|
+
" "$DB_FILE" 2>/dev/null || echo "⚠️ 查询 isolation 失败"
|
|
136
151
|
else
|
|
137
152
|
echo ""
|
|
138
153
|
echo "ℹ️ sillyspec.db 不存在(尚未初始化)"
|