sillyspec 3.28.2 → 3.28.4
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-auto/SKILL.md +16 -55
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-continue/SKILL.md +8 -38
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
- package/.claude/skills/sillyspec-knowledge/SKILL.md +19 -249
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-propose/SKILL.md +6 -77
- package/.claude/skills/sillyspec-resume/SKILL.md +8 -57
- package/.claude/skills/sillyspec-state/SKILL.md +7 -46
- package/.claude/skills/sillyspec-workspace/SKILL.md +14 -156
- package/AGENTS.md +24 -0
- package/INSTRUCTIONS.md +12 -0
- package/package.json +1 -1
- package/src/archive-delta.js +127 -2
- package/src/change-delete.js +2 -1
- package/src/change-list.js +7 -2
- package/src/change-risk-profile.js +77 -6
- package/src/config-cat.js +12 -1
- package/src/constants.js +9 -1
- package/src/design-facts.js +64 -0
- package/src/dispatch/backends/sillyhub-mcp.js +17 -5
- package/src/docs-check.js +153 -27
- package/src/docs-migrate.js +124 -0
- package/src/doctor-diagnostics.js +441 -5
- package/src/foreign-declared.js +53 -2
- package/src/index.js +353 -28
- package/src/module-impact.js +77 -12
- package/src/plan-adopt-waves.js +13 -5
- package/src/progress/change-registry.js +35 -0
- package/src/progress.js +64 -1
- package/src/quick-recommend.js +6 -1
- package/src/quicklog.js +10 -2
- package/src/run/command.js +159 -13
- package/src/run/complete-handlers.js +162 -9
- package/src/run/complete.js +104 -5
- package/src/run/gates.js +116 -8
- package/src/run/progress-confirm.js +66 -0
- package/src/run/prompt.js +235 -36
- package/src/run/quick-audit.js +12 -5
- package/src/run/scan-profile.js +20 -2
- package/src/run/shared.js +84 -11
- package/src/run/stage.js +42 -0
- package/src/runtime-hygiene.js +84 -0
- package/src/scan-postcheck.js +5 -1
- package/src/spec-sync.js +63 -8
- package/src/stage-contract.js +83 -3
- package/src/stage-review.js +3 -0
- package/src/stages/archive.js +4 -4
- package/src/stages/brainstorm-auto.js +2 -1
- package/src/stages/brainstorm.js +11 -25
- package/src/stages/doctor.js +41 -515
- package/src/stages/execute.js +10 -14
- package/src/stages/plan-postcheck.js +191 -7
- package/src/stages/plan.js +7 -27
- package/src/stages/quick.js +15 -13
- package/src/stages/verify.js +23 -21
- package/src/sync-noise.js +158 -0
- package/src/sync.js +42 -17
- package/src/task-review.js +225 -1
- package/src/verify-facts-schema.js +141 -0
- package/src/verify-postcheck.js +421 -49
- package/src/verify-probes.js +142 -13
- package/src/workflow.js +6 -0
- package/src/worktree-apply.js +51 -13
- package/src/worktree.js +5 -1
- package/templates/agents-instruction.md +1 -1
- package/.claude/skills/verify-per-user/SKILL.md +0 -6
package/src/sync.js
CHANGED
|
@@ -17,6 +17,7 @@ import { safeGit } from './git-helper.js';
|
|
|
17
17
|
import { openDatabase } from './db-engine.js';
|
|
18
18
|
import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
|
|
19
19
|
import { syncSpecTree } from './spec-sync.js';
|
|
20
|
+
import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
|
|
20
21
|
|
|
21
22
|
// sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
|
|
22
23
|
function safePlatformSpecDir(cwd) {
|
|
@@ -218,11 +219,18 @@ function combineSignals(external, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
218
219
|
|
|
219
220
|
async function fetchJson(url, options = {}) {
|
|
220
221
|
const signal = combineSignals(options.signal);
|
|
222
|
+
// noMute 是本模块的闸门开关不是 fetch 参数,先剥掉再透传
|
|
223
|
+
const { noMute, ...init } = options;
|
|
221
224
|
try {
|
|
222
|
-
const res = await fetch(url, { ...
|
|
225
|
+
const res = await fetch(url, { ...init, signal });
|
|
223
226
|
if (!res.ok) {
|
|
224
227
|
const text = await res.text().catch(() => '');
|
|
225
|
-
|
|
228
|
+
const line = `[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`;
|
|
229
|
+
// 连接类失败(404=端点未就绪 / 5xx)走跨进程噪音闸:平台未就绪期每条命令的自动
|
|
230
|
+
// 同步都撞同一批失败,重复刷屏淹没门禁输出(见 sync-noise.js 模块头)。409 冲突与
|
|
231
|
+
// 其余 4xx 业务态每轮都该看见,不走闸。用户显式命令(connect health ping)传 noMute。
|
|
232
|
+
if (!noMute && isConnectionClassStatus(res.status)) syncConnectionWarn(line);
|
|
233
|
+
else console.warn(line);
|
|
226
234
|
return null;
|
|
227
235
|
}
|
|
228
236
|
const ct = res.headers.get('content-type') || '';
|
|
@@ -231,11 +239,11 @@ async function fetchJson(url, options = {}) {
|
|
|
231
239
|
}
|
|
232
240
|
return null;
|
|
233
241
|
} catch (err) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
242
|
+
const line = err.name === 'AbortError' || err.name === 'TimeoutError'
|
|
243
|
+
? `[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`
|
|
244
|
+
: `[sync] ${url} 请求失败: ${err.message}`;
|
|
245
|
+
if (!noMute) syncConnectionWarn(line);
|
|
246
|
+
else console.warn(line);
|
|
239
247
|
return null;
|
|
240
248
|
}
|
|
241
249
|
}
|
|
@@ -247,23 +255,26 @@ async function fetchJson(url, options = {}) {
|
|
|
247
255
|
*/
|
|
248
256
|
async function fetchJsonWithStatus(url, options = {}) {
|
|
249
257
|
const signal = combineSignals(options.signal);
|
|
258
|
+
const { noMute, ...init } = options;
|
|
250
259
|
try {
|
|
251
|
-
const res = await fetch(url, { ...
|
|
260
|
+
const res = await fetch(url, { ...init, signal });
|
|
252
261
|
const text = await res.text().catch(() => '');
|
|
253
262
|
let body = null;
|
|
254
263
|
if (text) {
|
|
255
264
|
try { body = JSON.parse(text); } catch { body = null; }
|
|
256
265
|
}
|
|
257
266
|
if (!res.ok) {
|
|
258
|
-
|
|
267
|
+
const line = `[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`;
|
|
268
|
+
if (!noMute && isConnectionClassStatus(res.status)) syncConnectionWarn(line);
|
|
269
|
+
else console.warn(line);
|
|
259
270
|
}
|
|
260
271
|
return { ok: res.ok, status: res.status, body };
|
|
261
272
|
} catch (err) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
273
|
+
const line = err.name === 'AbortError' || err.name === 'TimeoutError'
|
|
274
|
+
? `[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`
|
|
275
|
+
: `[sync] ${url} 请求失败: ${err.message}`;
|
|
276
|
+
if (!noMute) syncConnectionWarn(line);
|
|
277
|
+
else console.warn(line);
|
|
267
278
|
return { ok: false, status: 0, body: null };
|
|
268
279
|
}
|
|
269
280
|
}
|
|
@@ -399,6 +410,12 @@ export class SyncManager {
|
|
|
399
410
|
// 归档尾声静默化旗标(坑 post-archive-sync-noise):sync() 探测到已归档时置位,
|
|
400
411
|
// 链内 syncDocuments 的「变更不存在」降 debug(每次 sync 调用重置)
|
|
401
412
|
this._suppressDocsMissingWarn = false;
|
|
413
|
+
// 绑定连接类失败噪音闸的 marker 目录(sync-noise.js):本实例所有 fetch 失败 warn 的
|
|
414
|
+
// 跨进程静默窗口落这里。bindSyncNoiseFromCwd 零副作用(只读指针 JSON,不触发
|
|
415
|
+
// resolvePlatformSpecDir 的指针守卫 warn——doctor 两路字节一致契约)。
|
|
416
|
+
try {
|
|
417
|
+
bindSyncNoiseFromCwd(cwd);
|
|
418
|
+
} catch { /* 未绑定 = 闸门直通(多一行 warn,无正确性影响) */ }
|
|
402
419
|
}
|
|
403
420
|
|
|
404
421
|
/**
|
|
@@ -423,7 +440,8 @@ export class SyncManager {
|
|
|
423
440
|
console.warn(`[sync] platform.url 非 https(${normalizedUrl.slice(0, 60)}),同步 token 将明文传输——请确认为受控内网环境`);
|
|
424
441
|
}
|
|
425
442
|
const healthUrl = `${normalizedUrl}/api/health`;
|
|
426
|
-
|
|
443
|
+
// noMute:connect 是用户显式命令,health ping 的失败细节不走静默闸(用户正在诊断连接)
|
|
444
|
+
const result = await fetchJson(healthUrl, { noMute: true });
|
|
427
445
|
if (result === null) {
|
|
428
446
|
console.warn(`[sync] 平台连接验证失败: ${url}`);
|
|
429
447
|
return;
|
|
@@ -661,6 +679,9 @@ export class SyncManager {
|
|
|
661
679
|
}
|
|
662
680
|
|
|
663
681
|
if (res.ok) {
|
|
682
|
+
// 平台连接恢复信号(sync-noise.js):此前静默窗口内的失败在恢复的第一笔成功时
|
|
683
|
+
// 打一行提示并清 marker;之后照常「成功不打扰」。
|
|
684
|
+
noteSyncSuccess();
|
|
664
685
|
// 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
|
|
665
686
|
// 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
|
|
666
687
|
// 修复前该列从不写,下次 push 永不带 X-SillySpec-Base-Ts、pull 脏度检测恒 false)
|
|
@@ -1268,8 +1289,12 @@ export class SyncManager {
|
|
|
1268
1289
|
signal: opts.signal, // HUB-09:熔断 abort 传到底层请求
|
|
1269
1290
|
});
|
|
1270
1291
|
if (result === null) {
|
|
1271
|
-
// sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort
|
|
1272
|
-
|
|
1292
|
+
// sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort 降级。
|
|
1293
|
+
// 自动注入点(triggerPull,opts.autoPull)走噪音闸防刷屏;手动 `platform pull`
|
|
1294
|
+
// 是用户显式诊断动作,恒可见。
|
|
1295
|
+
const line = `[sync] 拉取变更进度失败: ${changeName}(sillyhub 未就绪或变更不存在)`;
|
|
1296
|
+
if (opts.autoPull) syncConnectionWarn(line);
|
|
1297
|
+
else console.warn(line);
|
|
1273
1298
|
return { ok: false, imported: false, conflict: false, reason: '拉取变更进度失败' };
|
|
1274
1299
|
}
|
|
1275
1300
|
|
package/src/task-review.js
CHANGED
|
@@ -1398,7 +1398,21 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
|
|
|
1398
1398
|
? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
1399
1399
|
: crossDiffFiles
|
|
1400
1400
|
} else {
|
|
1401
|
-
|
|
1401
|
+
// 坑 backfill-reviews-adopt-empties-changedfiles(2026-09-08 实证):主仓 task 同样
|
|
1402
|
+
// 优先卡锚点切片(base_commit/head_commit),不再共用「worktree 基线..当前 HEAD」——
|
|
1403
|
+
// per-task commit 模式下 base 必须是本 task 起点commit,diff 才与 allowed_paths 对齐
|
|
1404
|
+
// (跨仓路径本就按卡切片);卡无锚点回落 change 级切片(旧语义,兼容 wave 级提交)。
|
|
1405
|
+
const cardHead = parseHeadCommit(content)
|
|
1406
|
+
const cardBase = parseBaseCommit(content)
|
|
1407
|
+
let mainDiff = Array.isArray(diffFiles) ? diffFiles : []
|
|
1408
|
+
if (cardHead) {
|
|
1409
|
+
taskHead = cardHead
|
|
1410
|
+
taskBase = cardBase || taskBase
|
|
1411
|
+
try {
|
|
1412
|
+
const out = runGit(reviewGitDir, ['diff', '--name-only', `${taskBase}..${taskHead}`])
|
|
1413
|
+
mainDiff = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
|
|
1414
|
+
} catch { /* 锚点不可解析(如卡记短 hash 仓内无引用)→ 回落 change 级切片 */ }
|
|
1415
|
+
}
|
|
1402
1416
|
taskChangedFiles = allowedPaths.length > 0
|
|
1403
1417
|
? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
1404
1418
|
: []
|
|
@@ -1410,6 +1424,16 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
|
|
|
1410
1424
|
continue
|
|
1411
1425
|
}
|
|
1412
1426
|
|
|
1427
|
+
// 切片空不冲声明(2026-09-08 用户反馈:backfill-reviews --adopt 冲掉 review):task 卡
|
|
1428
|
+
// 无 allowed_paths、或 allowed_paths 与 change 级 diff 的路径形态失配时,上方过滤产出
|
|
1429
|
+
// 空 changedFiles——旧代码照样整字段覆写,reviewer 已声明且过证据校验的 changedFiles
|
|
1430
|
+
// 被清空,gate(verifyReviewGitEvidence)随即按空集误判。adopt 的职责是重算 mechanics,
|
|
1431
|
+
// 不是降级声明:计算切片为空而原声明非空时保留原声明并留 reason 可追溯。
|
|
1432
|
+
if (taskChangedFiles.length === 0 && Array.isArray(prev.changedFiles) && prev.changedFiles.length > 0) {
|
|
1433
|
+
taskChangedFiles = prev.changedFiles
|
|
1434
|
+
reasons.push(`${taskId}: allowed_paths 未切出任何 diff 文件(卡无声明/路径形态失配),保留原 changedFiles ${prev.changedFiles.length} 条不冲空`)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1413
1437
|
const merged = { ...prev }
|
|
1414
1438
|
merged.schemaVersion = REVIEW_SCHEMA_VERSION
|
|
1415
1439
|
merged.task = taskId
|
|
@@ -1468,6 +1492,206 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
|
|
|
1468
1492
|
return { adopted, unchanged, skipped, missing, downgraded, reasons, executeRunId }
|
|
1469
1493
|
}
|
|
1470
1494
|
|
|
1495
|
+
/**
|
|
1496
|
+
* writeTaskReview —— 单 task review.json 的命令式写入(`sillyspec review write`,2026-09-07)。
|
|
1497
|
+
*
|
|
1498
|
+
* 背景(noAI 主流程批次 T2-4):task 级 review.json 此前由 agent/子代理手拼 JSON + 手算
|
|
1499
|
+
* base/head + 手抄 changedFiles——schemaVersion/目录创建/executeRunId 解析/git 锡点全部是
|
|
1500
|
+
* 纯机械活(generateTaskReviewDrafts/adoptTaskReviewMechanics 已证明 CLI 可代算),agent 只
|
|
1501
|
+
* 应贡献判断字段(verdict/notes/evidence)。命令式写入消灭「手拼 JSON→gate 拒→重写」循环。
|
|
1502
|
+
*
|
|
1503
|
+
* 职责分工:
|
|
1504
|
+
* - CLI 代算(mechanics):executeRunId(marker 优先)、base/head(worktree meta 优先,
|
|
1505
|
+
* --base/--head 显式覆盖)、changedFiles(diff × allowed_paths 归属切片,--changed-files
|
|
1506
|
+
* 显式覆盖)、diffPaths(allowed_paths 非空时)、repo 锡点(task 卡)。
|
|
1507
|
+
* - agent 供给(判断):--spec/--quality verdict、--notes、--evidence。
|
|
1508
|
+
*
|
|
1509
|
+
* 防伪/防误写:
|
|
1510
|
+
* - review.json 已存在 → 拒绝覆盖(--force 显式越过,控制台留痕)——保护独立 reviewer 产物。
|
|
1511
|
+
* - verdict=cannot_verify 且未给 --evidence → 报错(schema 硬要求非空 requiredEvidence),
|
|
1512
|
+
* 不静默填占位(与草稿语义不同:草稿是兜底待复核,这里是 agent 明确结论)。
|
|
1513
|
+
* - 归属切片为空(allowed_paths 未命中 diff)→ 报错并列出 diff 候选与 task allowed_paths
|
|
1514
|
+
* (帮 agent 当场发现「改了声明外文件 / 路径写错」),--changed-files 显式越过。
|
|
1515
|
+
*
|
|
1516
|
+
* @param {object} opts
|
|
1517
|
+
* @param {string} opts.changeName
|
|
1518
|
+
* @param {string} opts.cwd
|
|
1519
|
+
* @param {string} opts.taskId - 形如 task-NN
|
|
1520
|
+
* @param {string} opts.specVerdict - pass | fail | cannot_verify
|
|
1521
|
+
* @param {string} opts.qualityVerdict - 同上
|
|
1522
|
+
* @param {string} [opts.reviewerNotes]
|
|
1523
|
+
* @param {string[]} [opts.requiredEvidence]
|
|
1524
|
+
* @param {string|null} [opts.baseOverride] - 显式 base 锡点(无 worktree meta 时必填)
|
|
1525
|
+
* @param {string|null} [opts.headOverride]
|
|
1526
|
+
* @param {string[]|null} [opts.changedFilesOverride]
|
|
1527
|
+
* @param {boolean} [opts.force] - 覆盖已存在 review.json
|
|
1528
|
+
* @param {object} [opts.platformOpts]
|
|
1529
|
+
* @returns {Promise<{ok: boolean, reviewPath?: string, executeRunId?: string, errors: string[], warnings: string[]}>}
|
|
1530
|
+
*/
|
|
1531
|
+
export async function writeTaskReview({
|
|
1532
|
+
changeName, cwd, taskId, specVerdict, qualityVerdict,
|
|
1533
|
+
reviewerNotes = '', requiredEvidence = [],
|
|
1534
|
+
baseOverride = null, headOverride = null, changedFilesOverride = null,
|
|
1535
|
+
force = false, platformOpts = {},
|
|
1536
|
+
}) {
|
|
1537
|
+
const errors = []
|
|
1538
|
+
const warnings = []
|
|
1539
|
+
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1540
|
+
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
1541
|
+
const changeDir = join(specBase, 'changes', changeName)
|
|
1542
|
+
|
|
1543
|
+
// ── 入参校验 ──
|
|
1544
|
+
if (!changeName || typeof changeName !== 'string') errors.push('缺少 --change <变更名>')
|
|
1545
|
+
if (!taskId || !/^task-\d+$/.test(taskId)) errors.push(`--task 格式应为 task-NN(实际 ${taskId})`)
|
|
1546
|
+
if (!VALID_VERDICTS.includes(specVerdict)) errors.push(`--spec verdict 无效:${specVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
1547
|
+
if (!VALID_VERDICTS.includes(qualityVerdict)) errors.push(`--quality verdict 无效:${qualityVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
1548
|
+
if ((specVerdict === 'cannot_verify' || qualityVerdict === 'cannot_verify')
|
|
1549
|
+
&& (!Array.isArray(requiredEvidence) || requiredEvidence.filter(Boolean).length === 0)) {
|
|
1550
|
+
errors.push('verdict=cannot_verify 必须给 --evidence "<非空说明>"(schema 硬要求 requiredEvidence 非空)')
|
|
1551
|
+
}
|
|
1552
|
+
if (errors.length > 0) return { ok: false, errors, warnings }
|
|
1553
|
+
|
|
1554
|
+
// ── task 卡与 executeRunId 解析(与草稿生成同源)──
|
|
1555
|
+
const taskCardPath = join(changeDir, 'tasks', `${taskId}.md`)
|
|
1556
|
+
if (!existsSync(taskCardPath)) {
|
|
1557
|
+
return { ok: false, errors: [`task 卡不存在:${taskCardPath}(先由 plan 阶段生成或手建)`], warnings }
|
|
1558
|
+
}
|
|
1559
|
+
const markerFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
|
|
1560
|
+
let executeRunId = ''
|
|
1561
|
+
try {
|
|
1562
|
+
if (existsSync(markerFile)) {
|
|
1563
|
+
const c = readFileSync(markerFile, 'utf8').trim()
|
|
1564
|
+
if (isValidExecuteRunId(c)) executeRunId = c
|
|
1565
|
+
}
|
|
1566
|
+
} catch {}
|
|
1567
|
+
if (!executeRunId) {
|
|
1568
|
+
executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
|
|
1569
|
+
}
|
|
1570
|
+
if (!executeRunId) {
|
|
1571
|
+
executeRunId = generateExecuteRunId()
|
|
1572
|
+
warnings.push(`无 execute run marker——已生成新 runId ${executeRunId} 并建目录(正常应先跑过 execute Wave 步)`)
|
|
1573
|
+
try {
|
|
1574
|
+
mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
|
|
1575
|
+
writeFileSync(markerFile, executeRunId + '\n')
|
|
1576
|
+
stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
|
|
1577
|
+
} catch (e) {
|
|
1578
|
+
return { ok: false, errors: [`execute run 目录/marker 写入失败:${e.message}`], warnings }
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
1583
|
+
const reviewPath = join(reviewDir, 'review.json')
|
|
1584
|
+
|
|
1585
|
+
// ── 已存在保护(--force 越过)──
|
|
1586
|
+
if (existsSync(reviewPath) && !force) {
|
|
1587
|
+
return { ok: false, errors: [`review.json 已存在:${reviewPath}——拒绝覆盖(保护既有 reviewer 结论);确认重写加 --force`], warnings }
|
|
1588
|
+
}
|
|
1589
|
+
if (existsSync(reviewPath)) warnings.push(`--force 覆盖已存在 review:${reviewPath}`)
|
|
1590
|
+
|
|
1591
|
+
// ── mechanics 代算:base/head(worktree meta 优先,显式覆盖次之)──
|
|
1592
|
+
let base = baseOverride
|
|
1593
|
+
let head = headOverride
|
|
1594
|
+
let reviewGitDir = cwd
|
|
1595
|
+
if (!base || !head) {
|
|
1596
|
+
try {
|
|
1597
|
+
const wm = new WorktreeManager({ cwd })
|
|
1598
|
+
const meta = wm.getMeta(changeName)
|
|
1599
|
+
if (!base) base = (meta && (meta.baselineCommit || meta.baseHash)) || null
|
|
1600
|
+
if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
|
|
1601
|
+
reviewGitDir = meta.worktreePath
|
|
1602
|
+
}
|
|
1603
|
+
} catch { /* meta 读取失败走显式覆盖路径 */ }
|
|
1604
|
+
}
|
|
1605
|
+
if (!head) {
|
|
1606
|
+
try { head = runGit(reviewGitDir, ['rev-parse', 'HEAD']) } catch { head = null }
|
|
1607
|
+
}
|
|
1608
|
+
if (!base || !head) {
|
|
1609
|
+
return {
|
|
1610
|
+
ok: false,
|
|
1611
|
+
errors: [`无法确定 base/head(无 worktree meta 且未显式覆盖)——用 --base <commit> --head <commit> 显式给出(git rev-parse 取值)`],
|
|
1612
|
+
warnings,
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// ── mechanics 代算:changedFiles(diff × allowed_paths 归属切片;显式覆盖优先)──
|
|
1617
|
+
const content = readFileSync(taskCardPath, 'utf8')
|
|
1618
|
+
// 跨仓 task 守卫:base/head/changedFiles 的跨仓口径(task 卡锡点 + 跨仓仓 diff)本函数未实现
|
|
1619
|
+
//(草稿生成有专门分支)——显式拒绝并引导既有路径,防主仓锡点被误配到跨仓改动上
|
|
1620
|
+
const guardRepo = normalizeRepoKey(parseRepo(content))
|
|
1621
|
+
if (guardRepo && guardRepo !== 'main') {
|
|
1622
|
+
return {
|
|
1623
|
+
ok: false,
|
|
1624
|
+
errors: [`task 卡声明 repo=${guardRepo}(跨仓 task)——review write 暂不支持跨仓口径,请手写 review.json 后跑 sillyspec backfill-reviews --change ${changeName} --adopt 代填 mechanics(base/head 从 task 卡锡点取)`],
|
|
1625
|
+
warnings,
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const allowedPaths = parseAllowedPaths(content)
|
|
1629
|
+
let changedFiles = null
|
|
1630
|
+
if (Array.isArray(changedFilesOverride)) {
|
|
1631
|
+
// 显式覆盖(含空数组=纯验证任务的合法零 diff 声明)
|
|
1632
|
+
changedFiles = changedFilesOverride.map(p => String(p).replace(/\\/g, '/'))
|
|
1633
|
+
warnings.push(`changedFiles 采用显式覆盖(${changedFiles.length} 个)——evidence 校验仍会与 git diff 相交比对`)
|
|
1634
|
+
} else {
|
|
1635
|
+
let diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { specBase })
|
|
1636
|
+
// 并入 worktree 未提交改动(与草稿生成同源口径:子代理默认不 commit)
|
|
1637
|
+
try {
|
|
1638
|
+
const wm = new WorktreeManager({ cwd })
|
|
1639
|
+
const meta = wm.getMeta(changeName)
|
|
1640
|
+
if (meta) {
|
|
1641
|
+
const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
|
|
1642
|
+
? meta.worktreePath
|
|
1643
|
+
: cwd
|
|
1644
|
+
const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
|
|
1645
|
+
const wtFiles = parsePorcelainFiles(wtStatus)
|
|
1646
|
+
.map(p => String(p).replace(/\\/g, '/'))
|
|
1647
|
+
.filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
|
|
1648
|
+
if (wtFiles.length > 0) diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
|
|
1649
|
+
}
|
|
1650
|
+
} catch { /* 退回 commit diff 口径 */ }
|
|
1651
|
+
const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
|
|
1652
|
+
changedFiles = allowedPaths.length > 0
|
|
1653
|
+
? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
1654
|
+
: []
|
|
1655
|
+
if (changedFiles.length === 0) {
|
|
1656
|
+
return {
|
|
1657
|
+
ok: false,
|
|
1658
|
+
errors: [
|
|
1659
|
+
`归属切片为空:task 卡 allowed_paths ${JSON.stringify(allowedPaths)} 未命中本次 diff 任何文件。` +
|
|
1660
|
+
`本变更 diff 共 ${mainDiff.length} 个文件:${mainDiff.slice(0, 10).join('、')}${mainDiff.length > 10 ? '…' : ''}。` +
|
|
1661
|
+
`确认改动确属本 task → 用 --changed-files <a,b> 显式给出;纯验证任务(无代码 diff 是本质属性)用 --changed-files ""(空覆盖)+ --evidence 披露验证区间`,
|
|
1662
|
+
],
|
|
1663
|
+
warnings,
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// ── 组装 + schema 自检 + 落盘 ──
|
|
1669
|
+
const review = {
|
|
1670
|
+
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
1671
|
+
task: taskId,
|
|
1672
|
+
base,
|
|
1673
|
+
head,
|
|
1674
|
+
changedFiles,
|
|
1675
|
+
...(allowedPaths.length > 0 ? { diffPaths: allowedPaths } : {}),
|
|
1676
|
+
specVerdict,
|
|
1677
|
+
qualityVerdict,
|
|
1678
|
+
...(requiredEvidence.filter(Boolean).length > 0 ? { requiredEvidence: requiredEvidence.filter(Boolean) } : {}),
|
|
1679
|
+
reviewerNotes: reviewerNotes || `written by sillyspec review write (${base.slice(0, 8)}..${head.slice(0, 8)})`,
|
|
1680
|
+
}
|
|
1681
|
+
const schemaResult = validateReviewSchema(review)
|
|
1682
|
+
if (!schemaResult.ok) {
|
|
1683
|
+
return { ok: false, errors: schemaResult.errors, warnings }
|
|
1684
|
+
}
|
|
1685
|
+
try {
|
|
1686
|
+
mkdirSync(reviewDir, { recursive: true })
|
|
1687
|
+
writeFileSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
return { ok: false, errors: [`review.json 落盘失败:${e.message}`], warnings }
|
|
1690
|
+
}
|
|
1691
|
+
return { ok: true, reviewPath, executeRunId, errors: [], warnings }
|
|
1692
|
+
}
|
|
1693
|
+
// 注:跨仓 task(repo≠main)在上方 mechanics 段前置守卫拒绝——review.repo 恒为 main,不再单独赋值
|
|
1694
|
+
|
|
1471
1695
|
/**
|
|
1472
1696
|
* 获取当前(或最新)execute run id
|
|
1473
1697
|
* 从 runtime 目录下查找 execute-runs/ 子目录
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-facts-schema.js — verify-facts.json v2 schema 单点(2026-09-08-ir-verify-facts task-01 / D-005@v2)。
|
|
3
|
+
*
|
|
4
|
+
* facts v2 的字段/枚举/豁免形态/路径分类/槽段解析集中于此,builder(verify-probes.buildVerifyFacts)、
|
|
5
|
+
* 对账引擎(verify-postcheck.runVerifyRequiredEvidenceCheck)、集成证据(change-risk-profile 经
|
|
6
|
+
* stage-contract)、骨架渲染(generateVerifyResultSkeleton)四方 import 同源,禁复制。
|
|
7
|
+
*
|
|
8
|
+
* 双层写入模型(D-001@v2):机器数据(probes/tests/factsConsistency)CLI 直写;判断层
|
|
9
|
+
* (结论枚举/证据状态/回执声明)经 verify-result.md 受控槽段录入、--done 时 CLI 解析固化
|
|
10
|
+
* (slot-backfill),单向渲染约束对象是机器数据。
|
|
11
|
+
*/
|
|
12
|
+
export const FACTS_SCHEMA_VERSION = 2
|
|
13
|
+
|
|
14
|
+
/** 证据状态枚举单源(「## 证据账」槽行 + facts.requiredEvidence[].status 共用) */
|
|
15
|
+
export const EVIDENCE_STATUS = ['satisfied', 'missing', 'partial']
|
|
16
|
+
|
|
17
|
+
/** 豁免后缀形态:missing + (豁免:<理由>)——机器可解析,无括注的 missing 不算豁免(模块私有:parseEvidenceSlots 内部消费,外部读解析结果的 exempt 字段) */
|
|
18
|
+
const EXEMPTION_RE = /(豁免:[^)]+)/
|
|
19
|
+
|
|
20
|
+
/** 证据账槽段标题(与 generateVerifyResultSkeleton 渲染逐字一致——避开既有「## Runtime Evidence」人工判断章) */
|
|
21
|
+
export const EVIDENCE_SLOT_HEADING = '## 证据账(cannot_verify 任务)'
|
|
22
|
+
|
|
23
|
+
/** 集成验证回执槽段标题 */
|
|
24
|
+
export const RECEIPT_SLOT_HEADING = '## 集成验证回执'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* verifiedFiles 路径分类:决定核验口径(design Phase 2)。
|
|
28
|
+
* artifact 类(.runtime/ 日志 / 文档——resolveVerifyChangedFiles 天然不覆盖)核 存在×mtime,
|
|
29
|
+
* diff 交集豁免;其余(code)核 存在×mtime×diff 交集。
|
|
30
|
+
* @param {string} path 仓根相对路径(posix)
|
|
31
|
+
* @returns {'code'|'artifact'}
|
|
32
|
+
*/
|
|
33
|
+
export function classifyVerifiedFile(path) {
|
|
34
|
+
const p = String(path || '').replace(/\\/g, '/')
|
|
35
|
+
if (p.startsWith('.runtime/') || p.startsWith('.sillyspec/.runtime/')) return 'artifact'
|
|
36
|
+
if (/\.(log|txt|out)$/i.test(p)) return 'artifact'
|
|
37
|
+
if (p.startsWith('.sillyspec/docs/') || p.startsWith('docs/')) return 'artifact'
|
|
38
|
+
if (p.endsWith('.md') || p.endsWith('.mdx')) return 'artifact'
|
|
39
|
+
return 'code'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 证据账 task 行:`- task-NN: <status> | verifiedFiles: a, b(豁免:…)`——占位 <待填:*> 不命中(fail-closed) */
|
|
43
|
+
const EVIDENCE_LINE_RE = /^- (task-\d+):\s*(satisfied|missing|partial)\b([^\n]*)$/
|
|
44
|
+
|
|
45
|
+
/** 回执行:`- claim: … | command: … | exit: 0 | log: path`——四字段行首锚定,占位不命中 */
|
|
46
|
+
const RECEIPT_LINE_RE = /^- claim:\s*([^|]+)\|\s*command:\s*([^|]+)\|\s*exit:\s*(\d+)\s*\|\s*log:\s*([^\s|]+)/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析 verify-result.md 的两个受控槽段(D-001@v2 slot-backfill 录入界面)。
|
|
50
|
+
* 占位形态(<待填:三选一> / <待填:0 或非 0>)不含枚举词,行首锚定 + 枚举词双保险 fail-closed。
|
|
51
|
+
* @param {string} mdText verify-result.md 全文(LF 归一由调用方 readFileSync 层负责或此处容错)
|
|
52
|
+
* @returns {{ requiredEvidence: Array<{task,status,exempt:boolean,exemptionReason:string|null,verifiedFiles:string[]}>, runtimeEvidence: Array<{claim,command,exitCode,logPath}>, hasEvidenceSlot: boolean, hasReceiptSlot: boolean }}
|
|
53
|
+
*/
|
|
54
|
+
export function parseEvidenceSlots(mdText) {
|
|
55
|
+
const text = String(mdText || '').replace(/\r\n/g, '\n')
|
|
56
|
+
const lines = text.split('\n')
|
|
57
|
+
|
|
58
|
+
// 槽段定位:标题行到下一个同级/更高级标题或文末
|
|
59
|
+
const sectionOf = (heading) => {
|
|
60
|
+
const start = lines.findIndex(l => l.startsWith(heading))
|
|
61
|
+
if (start === -1) return null
|
|
62
|
+
const body = []
|
|
63
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
64
|
+
if (/^#{1,2}\s/.test(lines[i])) break
|
|
65
|
+
body.push(lines[i])
|
|
66
|
+
}
|
|
67
|
+
return body
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const evidenceBody = sectionOf(EVIDENCE_SLOT_HEADING)
|
|
71
|
+
const receiptBody = sectionOf(RECEIPT_SLOT_HEADING)
|
|
72
|
+
|
|
73
|
+
const requiredEvidence = []
|
|
74
|
+
if (evidenceBody) {
|
|
75
|
+
for (const line of evidenceBody) {
|
|
76
|
+
const m = line.match(EVIDENCE_LINE_RE)
|
|
77
|
+
if (!m) continue
|
|
78
|
+
const [, task, status, rest] = m
|
|
79
|
+
const exemptM = rest.match(EXEMPTION_RE)
|
|
80
|
+
const vfMatch = rest.match(/verifiedFiles:\s*([^\n(]*)/)
|
|
81
|
+
const verifiedFiles = (vfMatch ? vfMatch[1] : '')
|
|
82
|
+
.split(',')
|
|
83
|
+
.map(s => s.trim().replace(/^`|`$/g, ''))
|
|
84
|
+
.filter(s => s.length > 0 && !s.startsWith('<'))
|
|
85
|
+
requiredEvidence.push({
|
|
86
|
+
task,
|
|
87
|
+
status,
|
|
88
|
+
exempt: status === 'missing' && !!exemptM,
|
|
89
|
+
exemptionReason: exemptM ? exemptM[0] : null,
|
|
90
|
+
verifiedFiles,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const runtimeEvidence = []
|
|
96
|
+
if (receiptBody) {
|
|
97
|
+
for (const line of receiptBody) {
|
|
98
|
+
const m = line.match(RECEIPT_LINE_RE)
|
|
99
|
+
if (!m) continue
|
|
100
|
+
runtimeEvidence.push({
|
|
101
|
+
claim: m[1].trim(),
|
|
102
|
+
command: m[2].trim(),
|
|
103
|
+
exitCode: Number(m[3]),
|
|
104
|
+
logPath: m[4].trim().replace(/^`|`$/g, ''),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
requiredEvidence,
|
|
111
|
+
runtimeEvidence,
|
|
112
|
+
hasEvidenceSlot: evidenceBody !== null,
|
|
113
|
+
hasReceiptSlot: receiptBody !== null,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* facts v2 结构校验(写入前/消费侧防线)。
|
|
119
|
+
* @param {object} facts
|
|
120
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
121
|
+
*/
|
|
122
|
+
export function validateFactsV2(facts) {
|
|
123
|
+
const errors = []
|
|
124
|
+
if (!facts || typeof facts !== 'object') return { ok: false, errors: ['facts 非对象'] }
|
|
125
|
+
if (facts.schemaVersion !== FACTS_SCHEMA_VERSION) {
|
|
126
|
+
errors.push(`schemaVersion 应为 ${FACTS_SCHEMA_VERSION},实际 ${facts.schemaVersion}`)
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(facts.probes ? Object.keys(facts.probes) : null)) {
|
|
129
|
+
errors.push('probes 段缺失或非对象')
|
|
130
|
+
}
|
|
131
|
+
if (facts.conclusion != null && !['PASS', 'PASS WITH NOTES', 'FAIL'].includes(facts.conclusion)) {
|
|
132
|
+
errors.push(`conclusion 非法枚举值:${facts.conclusion}`)
|
|
133
|
+
}
|
|
134
|
+
for (const item of facts.requiredEvidence || []) {
|
|
135
|
+
if (!EVIDENCE_STATUS.includes(item.status)) errors.push(`requiredEvidence[${item.task}] status 非法:${item.status}`)
|
|
136
|
+
if (item.status === 'satisfied' && (!Array.isArray(item.verifiedFiles) || item.verifiedFiles.length === 0)) {
|
|
137
|
+
errors.push(`requiredEvidence[${item.task}] satisfied 但 verifiedFiles 为空`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { ok: errors.length === 0, errors }
|
|
141
|
+
}
|