sillyspec 3.28.3 → 3.28.5
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 +54 -54
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
- package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
- package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
- package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
- package/.claude/skills/sillyspec-state/SKILL.md +25 -25
- package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
- package/AGENTS.md +25 -0
- package/INSTRUCTIONS.md +12 -0
- package/package.json +1 -1
- package/results-review-tier.md +18 -0
- package/src/agent-session-log.js +4 -3
- package/src/archive-delta.js +82 -0
- package/src/change-delete.js +2 -1
- package/src/change-list.js +18 -3
- package/src/change-risk-profile.js +77 -6
- package/src/commit-suggest.js +52 -1
- package/src/config-cat.js +17 -1
- package/src/constants.js +3 -1
- package/src/decision-distill.js +45 -5
- package/src/decisions-io.js +96 -0
- package/src/design-facts.js +3 -0
- package/src/dispatch/probe.js +31 -8
- package/src/docs-check.js +102 -3
- package/src/doctor-diagnostics.js +441 -5
- package/src/foreign-declared.js +53 -2
- package/src/index.js +606 -22
- package/src/mcp-server.js +144 -0
- package/src/module-impact.js +84 -12
- package/src/modules.js +46 -34
- package/src/plan-adopt-waves.js +13 -5
- package/src/progress/change-registry.js +34 -0
- package/src/progress.js +75 -2
- package/src/quick-recommend.js +6 -1
- package/src/quicklog.js +70 -8
- package/src/review-dispatch.js +377 -0
- package/src/run/archive-distill.js +48 -0
- package/src/run/command.js +48 -13
- package/src/run/complete-handlers.js +215 -9
- package/src/run/complete.js +184 -2
- package/src/run/gate-snapshot.js +88 -0
- package/src/run/gates.js +206 -58
- package/src/run/prompt.js +150 -14
- package/src/run/quick-audit.js +38 -13
- package/src/run/shared.js +183 -12
- package/src/run/stage.js +52 -2
- package/src/run/verify-quality-scan.js +336 -0
- package/src/runtime-hygiene.js +84 -0
- package/src/scope-audit.js +735 -0
- package/src/sillyhub-mcp/client.js +58 -3
- package/src/spec-sync.js +63 -8
- package/src/stage-contract.js +83 -3
- package/src/stage-review-checklist.js +44 -0
- package/src/stage-review.js +125 -4
- package/src/stages/archive.js +60 -167
- package/src/stages/brainstorm.js +7 -17
- package/src/stages/doctor.js +41 -515
- package/src/stages/execute.js +13 -10
- package/src/stages/plan-postcheck.js +191 -7
- package/src/stages/plan.js +7 -35
- package/src/stages/quick.js +11 -11
- package/src/stages/verify.js +24 -39
- package/src/sync-noise.js +158 -0
- package/src/sync.js +93 -27
- package/src/task-review.js +133 -17
- package/src/taskcard.js +46 -3
- package/src/validate-artifacts.js +163 -0
- package/src/verify-facts-schema.js +141 -0
- package/src/verify-postcheck.js +347 -29
- package/src/verify-probes.js +275 -24
- package/src/workflow.js +6 -0
- package/src/worktree-apply.js +167 -24
- package/src/worktree.js +5 -1
- package/templates/agents-instruction.md +1 -0
package/src/archive-delta.js
CHANGED
|
@@ -34,6 +34,7 @@ import { parseDecisionDomains, loadModuleMap, deriveActualModules } from './desi
|
|
|
34
34
|
import { parseModuleMapSimple } from './modules.js'
|
|
35
35
|
import { scanBackendEndpoints } from './endpoint-extractor.js'
|
|
36
36
|
import { diffEndpointSets } from './endpoint-baseline.js'
|
|
37
|
+
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
37
38
|
|
|
38
39
|
// ---------------------------------------------------------------------------
|
|
39
40
|
// 小工具(全部 fail-soft:读不到/解析失败 → null / [],不抛出)
|
|
@@ -460,3 +461,84 @@ export function writeLastDeltaSidecar(runtimeRoot, summary) {
|
|
|
460
461
|
return { ok: false, reason: e && e.message ? e.message : String(e) }
|
|
461
462
|
}
|
|
462
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* archive 三重核对(2026-09-09 ql-20260909-004,轮次经济学 §3.1 archive 收口机械化):
|
|
466
|
+
* module-impact.md 矩阵记录 vs 真实变更文件 vs module-map 归属——机械比对 CLI 代算,
|
|
467
|
+
* agent 只裁决不一致项。只读,供 archive extract-module-impact 步 prompt 注入/命令消费。
|
|
468
|
+
* @returns {{ ok: boolean, mismatches: string[], impactFiles: string[], actualFiles: string[], summary: string }}
|
|
469
|
+
*/
|
|
470
|
+
export function auditModuleImpactAgainstDiff({ cwd, changeName, specDir = null }) {
|
|
471
|
+
const mismatches = []
|
|
472
|
+
const specBase = specDir || join(cwd, '.sillyspec')
|
|
473
|
+
const impactPath = join(specBase, 'changes', changeName, 'module-impact.md')
|
|
474
|
+
let impactText = null
|
|
475
|
+
try { impactText = readFileSync(impactPath, 'utf8') } catch { /* 缺失由降级路径处理 */ }
|
|
476
|
+
const actualFiles = (resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase: specDir || join(cwd, '.sillyspec') }) || [])
|
|
477
|
+
.map(f => String(f).replace(/\\/g, '/'))
|
|
478
|
+
.filter(f => !f.startsWith('.sillyspec/changes/'))
|
|
479
|
+
if (!impactText) {
|
|
480
|
+
return { ok: false, mismatches: ['module-impact.md 不存在(走 --init 降级路径)'], impactFiles: [], actualFiles, summary: `真实变更 ${actualFiles.length} 文件;无 module-impact 可核` }
|
|
481
|
+
}
|
|
482
|
+
// 矩阵反引号路径收集(启发式——advisory 注入用,非硬门真相源:无反引号表格格会漏,外部审核 P3-3)
|
|
483
|
+
const impactFiles = [...impactText.matchAll(/`([^`]+[.](?:js|mjs|ts|py|java|md|yaml))`/g)].map(m => m[1].split(String.fromCharCode(92)).join('/'))
|
|
484
|
+
const impactSet = new Set(impactFiles)
|
|
485
|
+
const actualSet = new Set(actualFiles)
|
|
486
|
+
const inDiffNotDoc = [...actualSet].filter(f => !impactSet.has(f))
|
|
487
|
+
const inDocNotDiff = [...impactSet].filter(f => !actualSet.has(f))
|
|
488
|
+
if (inDiffNotDoc.length > 0) mismatches.push(`diff 有而 module-impact 未列(${inDiffNotDoc.length}):${inDiffNotDoc.slice(0, 5).join('、')}${inDiffNotDoc.length > 5 ? ' …' : ''}`)
|
|
489
|
+
if (inDocNotDiff.length > 0) mismatches.push(`module-impact 列而 diff 无(${inDocNotDiff.length}):${inDocNotDiff.slice(0, 5).join('、')}${inDocNotDiff.length > 5 ? ' …' : ''}`)
|
|
490
|
+
// 第三重:module-map 归属一致性(2026-09-09 外部审核 P2-1——「三重核对」此前名实不符:
|
|
491
|
+
// 矩阵行的模块列 vs map 前缀推导——误标模块点名;未命中 map 的归 unmatched 提示不判红)
|
|
492
|
+
const moduleMisattrib = auditImpactModuleAttribution(impactText, specBase)
|
|
493
|
+
mismatches.push(...moduleMisattrib)
|
|
494
|
+
return {
|
|
495
|
+
ok: mismatches.length === 0,
|
|
496
|
+
mismatches,
|
|
497
|
+
impactFiles,
|
|
498
|
+
actualFiles,
|
|
499
|
+
summary: `module-impact ${impactFiles.length} 文件 × 真实 diff ${actualFiles.length} 文件 × map 归属:${mismatches.length === 0 ? '一致 ✓' : mismatches.length + ' 类不一致'}`,
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 第三重核对(P2-1):矩阵行「| <模块> | `文件` |」的模块列 vs _module-map.yaml 前缀推导。
|
|
505
|
+
* 只读 fail-soft:map 不可得 → 返回 [](前两重仍有效,注入文案会带 map 跳过注记)。
|
|
506
|
+
*/
|
|
507
|
+
function auditImpactModuleAttribution(impactText, specBase) {
|
|
508
|
+
try {
|
|
509
|
+
// 找 _module-map.yaml(docs/<p>/modules/ 扫描——detectModuleDocHealth 同款)
|
|
510
|
+
let mapText = null
|
|
511
|
+
const docsDir = join(specBase, 'docs')
|
|
512
|
+
if (existsSync(docsDir)) {
|
|
513
|
+
for (const d of readdirSync(docsDir)) {
|
|
514
|
+
const cand = join(docsDir, d, 'modules', '_module-map.yaml')
|
|
515
|
+
if (existsSync(cand)) { mapText = readFileSync(cand, 'utf8'); break }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (!mapText) return []
|
|
519
|
+
const { parseModuleMapPaths } = require_module_impact()
|
|
520
|
+
const map = parseModuleMapPaths(mapText)
|
|
521
|
+
const deriveMod = (posixPath) => {
|
|
522
|
+
for (const [id, paths] of map) {
|
|
523
|
+
for (const pp of paths) {
|
|
524
|
+
if (pp.endsWith('/') ? posixPath.startsWith(pp) : (posixPath === pp || posixPath.startsWith(pp + '/'))) return id
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return null
|
|
528
|
+
}
|
|
529
|
+
const out = []
|
|
530
|
+
for (const m of impactText.matchAll(/^\|\s*([^|]+?)\s*\|[^|]*`([^`]+)`/gm)) {
|
|
531
|
+
const recorded = m[1].trim()
|
|
532
|
+
const file = m[2].split(String.fromCharCode(92)).join('/')
|
|
533
|
+
if (recorded.startsWith('---') || recorded.startsWith('模块')) continue
|
|
534
|
+
const derived = deriveMod(file)
|
|
535
|
+
if (derived && recorded !== derived && recorded !== 'unmapped') {
|
|
536
|
+
out.push(`map 归属不一致:${file} 矩阵记「${recorded}」但 map 推导为「${derived}」——修正矩阵模块列或跑 modules rebuild`)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return out
|
|
540
|
+
} catch { return [] }
|
|
541
|
+
}
|
|
542
|
+
// 延迟引用(防环:module-impact 不反向依赖本模块)
|
|
543
|
+
import * as _moduleImpactMod from './module-impact.js'
|
|
544
|
+
function require_module_impact() { return _moduleImpactMod }
|
package/src/change-delete.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - DB:status='deleted'(与 archived 语义分离,DB 可直接审计区分,行保留不物理删)
|
|
10
10
|
* - 目录:changes/<name>/ 物理移除(git tracked 文件删除后照常 commit,历史仍可回溯)
|
|
11
11
|
* - worktree:复用 archiveWorktreeCleanup(有未 apply 变更时保留 worktree 只警告——
|
|
12
|
-
* 不因删变更丢用户唯一副本的代码;顺带清 execute/stage-review runId marker
|
|
12
|
+
* 不因删变更丢用户唯一副本的代码;顺带清 execute/stage-review runId marker
|
|
13
|
+
* + 该变更的 runtime 取证(pruneArchivedChangeRuntime))
|
|
13
14
|
* - git:safeGit add -A 暂存目录删除(best-effort,对齐 archive CLI 下沉 git add)
|
|
14
15
|
* - 平台:triggerSync 推终态(sync.js 对 status='deleted' 上行墓碑,平台收敛软删)
|
|
15
16
|
*
|
package/src/change-list.js
CHANGED
|
@@ -74,8 +74,13 @@ export function globMatch(str, pattern) {
|
|
|
74
74
|
* @returns {boolean}
|
|
75
75
|
*/
|
|
76
76
|
export function pathMatches(a, b) {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// 坑 platform-sync-progress-rollback-and-db-corruption 坑3:NEW: 前缀(待建文件标记)在
|
|
78
|
+
// 「比对」语义下剥除对齐——design 清单带 NEW: 的待建文件 vs task allowed_paths 裸路径 /
|
|
79
|
+
// 主仓脏文件,比的是目标文件而非「新建意图」,剥后两道门禁(brainstorm 幻觉核验要求加
|
|
80
|
+
// NEW: / plan 覆盖对账按裸路径匹配)不再互斥。仅在比对处剥:存在性核验(design-facts
|
|
81
|
+
// 的 NEW: 豁免)不走本函数、仍见原文前缀;target_files 解析器(plan-postcheck)本就剥。
|
|
82
|
+
a = normalizePath(a).replace(/^NEW:/, '')
|
|
83
|
+
b = normalizePath(b).replace(/^NEW:/, '')
|
|
79
84
|
if (!a || !b) return false
|
|
80
85
|
if (a === b) return true
|
|
81
86
|
if (a.startsWith(b + '/') || b.startsWith(a + '/')) return true
|
|
@@ -254,7 +259,17 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
|
|
|
254
259
|
// 分类列表项:`- path` / `- \`path\``
|
|
255
260
|
const listItem = line.match(/^\s*-\s+(.+)/)
|
|
256
261
|
if (listItem) {
|
|
257
|
-
|
|
262
|
+
// 坑 brainstorm-gate-agent-unavailable-and-list-path-parse 坑2:列表项「路径:描述」
|
|
263
|
+
// 整行当路径 existsSync 假阴性(文件存在却报幻觉路径)——剥首个中/英冒号及之后描述。
|
|
264
|
+
// 仅列表分支剥、且冒号前段须 looksLikePath:normalizePath 全局剥会毁 Windows 绝对路径
|
|
265
|
+
// (C:\...),列表项是仓根相对路径无盘符,安全。
|
|
266
|
+
let rawItem = listItem[1]
|
|
267
|
+
const colonIdx = rawItem.search(/[::]/)
|
|
268
|
+
if (colonIdx > 0) {
|
|
269
|
+
const head = rawItem.slice(0, colonIdx).trim()
|
|
270
|
+
if (looksLikePath(head)) rawItem = head
|
|
271
|
+
}
|
|
272
|
+
const filePath = normalizePath(rawItem)
|
|
258
273
|
if (isPlaceholder(filePath) || (filePath.startsWith('.sillyspec/') && !(keepSillyspecDocs && filePath.startsWith('.sillyspec/docs/')))) continue
|
|
259
274
|
if (!looksLikePath(filePath)) continue // 脏描述兜底
|
|
260
275
|
const incidental = INCIDENTAL_RE.test(listItem[1])
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* 判定 5 级风险(doc-only / unit-sufficient / contract-required /
|
|
6
6
|
* integration-critical / deployment-critical),产出门控验证需求。
|
|
7
7
|
*/
|
|
8
|
+
import { readFileSync, statSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
8
10
|
|
|
9
11
|
// ============ 向后兼容:旧的 INTEGRATION_CRITICAL_PATTERNS ============
|
|
10
12
|
|
|
@@ -292,10 +294,40 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
|
|
|
292
294
|
const warnings = []
|
|
293
295
|
const lower = (String(verifyContent || '') + '\n' + String(opts.extraEvidenceText || '')).toLowerCase()
|
|
294
296
|
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
297
|
+
// ── v2 回执槽优先(2026-09-08-ir-verify-facts FR-04,D-003@v1):「集成验证回执」槽在场时
|
|
298
|
+
// 走结构化一致性校验(绿判据:logPath 存在 × mtime ∈ verify 窗口 × 日志尾 200 行失败签名
|
|
299
|
+
// 扫描(噪声剔除)× exitCode===0),literals 降 legacy 回退(存量兼容)。
|
|
300
|
+
// extraEvidenceText(verify-services 回执注入)保留合并为补充候选文本。CLI 不代跑集成进程。──
|
|
301
|
+
if (Array.isArray(opts.runtimeEvidence) && opts.runtimeEvidence.length > 0) {
|
|
302
|
+
const greenReceipts = []
|
|
303
|
+
const receiptAudit = []
|
|
304
|
+
for (const r of opts.runtimeEvidence) {
|
|
305
|
+
const audit = auditRuntimeReceipt(r, opts)
|
|
306
|
+
receiptAudit.push(audit)
|
|
307
|
+
if (audit.green) greenReceipts.push(r)
|
|
308
|
+
}
|
|
309
|
+
const needsIntegration = requiredVerification.includes('real_daemon_backend_integration')
|
|
310
|
+
const needsLogEvidence = requiredVerification.includes('runtime_log_evidence')
|
|
311
|
+
const needsRealStartup = requiredVerification.includes('real_startup_once')
|
|
312
|
+
// 任一绿回执 = 结构化在场证据(claim 语义由 agent 声明,一致性由 CLI 校验)
|
|
313
|
+
const hasGreen = greenReceipts.length > 0
|
|
314
|
+
if (needsIntegration && !hasGreen) {
|
|
315
|
+
const bad = receiptAudit.filter(a => !a.green)
|
|
316
|
+
errors.push(`integration-critical 变更无绿回执(${bad.length} 条回执校验不过:${bad.map(a => a.reason).join(';') || '无有效回执'})——绿判据:log 存在 × mtime ∈ verify 窗口 × 日志无失败签名 × exit 0`)
|
|
317
|
+
}
|
|
318
|
+
if (needsLogEvidence && !hasGreen) {
|
|
319
|
+
errors.push('integration-critical 变更的回执未通过一致性校验(Runtime Evidence 等价物)')
|
|
320
|
+
}
|
|
321
|
+
if (needsRealStartup && !hasGreen) {
|
|
322
|
+
errors.push('deployment-critical 变更无绿回执(真实启动验证)')
|
|
323
|
+
}
|
|
324
|
+
if (receiptAudit.some(a => !a.green && a.failSignatures > 0)) {
|
|
325
|
+
warnings.push('回执日志含失败签名(error/exception/traceback/fatal 行首命中,已剔除「0 errors」类良性行)——确认是否预期内失败')
|
|
326
|
+
}
|
|
327
|
+
return { ok: errors.length === 0, errors, warnings, receiptAudit, structured: true }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── legacy literals 回退(无槽存量格式)──
|
|
299
331
|
const hasEvidence = (k) => {
|
|
300
332
|
const n = VERIFICATION_NEEDS[k]
|
|
301
333
|
if (!n || !n.literals || n.literals.length === 0) return true
|
|
@@ -335,5 +367,44 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
|
|
|
335
367
|
}
|
|
336
368
|
}
|
|
337
369
|
|
|
338
|
-
return { ok: errors.length === 0, errors, warnings }
|
|
339
|
-
}
|
|
370
|
+
return { ok: errors.length === 0, errors, warnings, structured: false }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 单条回执一致性校验(FR-04 绿判据)。噪声剔除:签名行首匹配 + 剔除「0 errors」类良性行
|
|
375
|
+
* (本仓 verify-postcheck.js 测试输出解析先例同款)。fail-soft:校验依赖缺失(opts 无
|
|
376
|
+
* cwd/verifyStartAt)时只核 exitCode 与日志可读性,不假装跑了文件校验。
|
|
377
|
+
*/
|
|
378
|
+
function auditRuntimeReceipt(r, opts) {
|
|
379
|
+
const out = { claim: r && r.claim, logPath: r && r.logPath, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
|
|
380
|
+
if (!r || !r.logPath) { out.reason = '回执缺 logPath'; return out }
|
|
381
|
+
let content = null
|
|
382
|
+
try {
|
|
383
|
+
const abs = opts.cwd ? join(opts.cwd, r.logPath) : r.logPath
|
|
384
|
+
content = readFileSync(abs, 'utf8')
|
|
385
|
+
out.logExists = true
|
|
386
|
+
} catch {
|
|
387
|
+
out.logExists = false
|
|
388
|
+
out.reason = `日志不存在:${r.logPath}`
|
|
389
|
+
return out
|
|
390
|
+
}
|
|
391
|
+
if (opts.verifyStartAt) {
|
|
392
|
+
try {
|
|
393
|
+
const startAt = new Date(opts.verifyStartAt).getTime()
|
|
394
|
+
out.mtimeInWindow = statSync(opts.cwd ? join(opts.cwd, r.logPath) : r.logPath).mtimeMs >= startAt - 60_000
|
|
395
|
+
if (!out.mtimeInWindow) { out.reason = `日志 mtime 早于 verifyStartAt(${r.logPath})`; return out }
|
|
396
|
+
} catch { out.mtimeInWindow = null }
|
|
397
|
+
}
|
|
398
|
+
// 失败签名扫描(日志尾 200 行;行首匹配 + 良性计数行剔除)
|
|
399
|
+
const tail = content.split('\n').slice(-200)
|
|
400
|
+
const SIGNATURE_RE = /^(?:error|exception|traceback|fatal)\b/i
|
|
401
|
+
const BENIGN_RE = /\b0\s+(?:errors?|exceptions?|failures?)\b|no\s+(?:errors?|exceptions?)/i
|
|
402
|
+
for (const line of tail) {
|
|
403
|
+
if (SIGNATURE_RE.test(line.trim()) && !BENIGN_RE.test(line)) out.failSignatures++
|
|
404
|
+
}
|
|
405
|
+
if (out.failSignatures > 0) { out.reason = `日志含 ${out.failSignatures} 行失败签名(行首 error/exception/traceback/fatal,已剔除良性计数行)`; return out }
|
|
406
|
+
if (out.exitCode !== 0) { out.reason = `exitCode=${out.exitCode}(非 0 不可用作在场证据)`; return out }
|
|
407
|
+
out.green = true
|
|
408
|
+
out.reason = '绿回执(log 存在 × mtime 窗口 × 无失败签名 × exit 0)'
|
|
409
|
+
return out
|
|
410
|
+
}
|
package/src/commit-suggest.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
20
20
|
import { join } from 'path'
|
|
21
|
-
import {
|
|
21
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
22
|
+
import { gitQuiet, safeGit, stageArchiveArtifacts } from './git-helper.js'
|
|
22
23
|
import { resolveSpecDir } from './run/shared.js'
|
|
23
24
|
|
|
24
25
|
function listActiveChanges(specBase) {
|
|
@@ -222,3 +223,53 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
222
223
|
suggestion,
|
|
223
224
|
}
|
|
224
225
|
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* archive 语境判定(P1-6,noai-ir-roadmap §4):commit --apply 只在归档阶段内放行
|
|
230
|
+
* (changes.status=archived 或 current_stage=archive)——那里文件已验收、pathspec 显式
|
|
231
|
+
* (stageArchiveArtifacts 三路径);其余阶段一律拒绝(agent 上下文「需人确认」是伪约束,
|
|
232
|
+
* 通用自动 git 在多会话仓是历史重灾区)。DB 直查避免 read 对注销变更的视图差异。
|
|
233
|
+
*/
|
|
234
|
+
export function resolveArchiveCommitScope({ cwd, specDir = null, changeName }) {
|
|
235
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
236
|
+
let status = null
|
|
237
|
+
let currentStage = null
|
|
238
|
+
try {
|
|
239
|
+
const db = new DatabaseSync(join(specBase, '.runtime', 'sillyspec.db'))
|
|
240
|
+
const row = db.prepare('SELECT status, current_stage FROM changes WHERE name = ?').get(changeName)
|
|
241
|
+
db.close()
|
|
242
|
+
if (row) { status = row.status; currentStage = row.current_stage }
|
|
243
|
+
} catch { /* DB 缺失/损坏 → 视为不可判定(拒绝) */ }
|
|
244
|
+
return { specBase, status, currentStage, inArchiveScope: status === 'archived' || currentStage === 'archive' }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 执行归档提交(P1-6):语义来源(QUICKLOG/勾选 task → conventional message)× 显式
|
|
249
|
+
* pathspec(归档产物既有清单)。共享 index 安全:git add 用显式 pathspec(规则 18),
|
|
250
|
+
* git commit 带 `-- <paths>` pathspec 限定——同 main 并行会话暂存的其他文件不会被扫入
|
|
251
|
+
* (pathspec commit 只提交指定路径的内容,与暂存区其余部分无关)。
|
|
252
|
+
*/
|
|
253
|
+
export function applyArchiveCommit({ cwd, specDir = null, changeName }) {
|
|
254
|
+
const scope = resolveArchiveCommitScope({ cwd, specDir, changeName })
|
|
255
|
+
if (!scope.inArchiveScope) {
|
|
256
|
+
return { ok: false, reason: `变更 ${changeName} 不在归档语境(status=${scope.status}, current_stage=${scope.currentStage})——commit --apply 只限归档阶段内使用(文件已验收 + pathspec 显式);其余阶段请手写 message 手动提交` }
|
|
257
|
+
}
|
|
258
|
+
const cc = collectCommitContext({ cwd, specDir })
|
|
259
|
+
if (!cc.suggestion || !cc.suggestion.subject) {
|
|
260
|
+
return { ok: false, reason: '无语义来源(QUICKLOG/勾选 task 均无新条目)——请结合 git status 手写 message 后手动提交' }
|
|
261
|
+
}
|
|
262
|
+
const art = stageArchiveArtifacts(cwd)
|
|
263
|
+
const paths = (art.staged || []).filter((p) => existsSync(p))
|
|
264
|
+
if (paths.length === 0) {
|
|
265
|
+
return { ok: false, reason: '无归档产物路径可提交(stageArchiveArtifacts 候选全部不存在)' }
|
|
266
|
+
}
|
|
267
|
+
const add = safeGit(cwd, ['add', '--', ...paths])
|
|
268
|
+
if (add.error) return { ok: false, reason: `git add 失败: ${add.error}` }
|
|
269
|
+
const commitArgs = ['commit', '-m', cc.suggestion.subject]
|
|
270
|
+
if (cc.suggestion.body) commitArgs.push('-m', cc.suggestion.body)
|
|
271
|
+
commitArgs.push('--', ...paths)
|
|
272
|
+
const commit = safeGit(cwd, commitArgs)
|
|
273
|
+
if (commit.error) return { ok: false, reason: `git commit 失败: ${commit.error}`, paths }
|
|
274
|
+
return { ok: true, subject: cc.suggestion.subject, paths, output: commit.value }
|
|
275
|
+
}
|
package/src/config-cat.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { homedir } from 'os'
|
|
2
|
+
import { sep } from 'path'
|
|
1
3
|
/**
|
|
2
4
|
* config-cat.js — local.yaml 实际值读取的权威路径解析器(`sillyspec config cat` 数据源)。
|
|
3
5
|
*
|
|
@@ -39,9 +41,23 @@ export function resolveLocalYaml(dir, opts = {}) {
|
|
|
39
41
|
candidates.push({ path: key, source })
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
// home 拒绝守卫(e4f2855 resolveSpecDir 同款,2026-09-09 补到 local.yaml 候选链):
|
|
45
|
+
// 起点在 home 子树内(如 Temp 下的测试 fixture)时,祖先链走到 home 层不得命中
|
|
46
|
+
// ~/.sillyspec/local.yaml——那是用户全局配置,不是本项目配置;register-repo 写侧
|
|
47
|
+
// 经 resolveLocalYamlWriteTarget 同链解析,误命中会把项目注册静默写进全局配置
|
|
48
|
+
// (local-register.test.mjs 实证)。
|
|
49
|
+
const home = homedir()
|
|
50
|
+
const originBelowHome = start !== home && start.startsWith(home + sep)
|
|
42
51
|
if (opts.specBase) push(join(opts.specBase, 'local.yaml'), 'spec 根(--spec-dir/平台 pointer/最近 .sillyspec)')
|
|
52
|
+
let passedHome = false
|
|
43
53
|
for (let d = start; ; d = dirname(d)) {
|
|
44
|
-
|
|
54
|
+
// passedHome:经过 home 后不再入候选(resolveSpecDir e4f2855 同款语义)——套件隔离下
|
|
55
|
+
// HOME 与 fixture 可能同根(HOME=suiteTmp、fixture=suiteTmp/x),遍历越过 home 层后
|
|
56
|
+
// 仍会撞真实 home 的 ~/.sillyspec/local.yaml,须整段截断。
|
|
57
|
+
if (!(passedHome || (originBelowHome && d === home))) {
|
|
58
|
+
push(join(d, '.sillyspec', 'local.yaml'), 'cwd 祖先链(worktree 内向上命中主仓;home 层受守卫跳过)')
|
|
59
|
+
}
|
|
60
|
+
if (d === home) passedHome = true
|
|
45
61
|
const parent = dirname(d)
|
|
46
62
|
if (parent === d) break
|
|
47
63
|
}
|
package/src/constants.js
CHANGED
|
@@ -93,7 +93,9 @@ export const AUXILIARY_STAGES = Object.freeze([
|
|
|
93
93
|
// 供 command.js 判断 status/doctor 等查询型辅助阶段执行只读短路(FR-04 / D-005@v2)。
|
|
94
94
|
export const READONLY_AUXILIARY_STAGES = Object.freeze([
|
|
95
95
|
'status',
|
|
96
|
-
|
|
96
|
+
// doctor 移出(2026-09-09-doctor-noai D-001@v1):阶段形态走状态机(noAI 诊断步 +
|
|
97
|
+
// agent 修复步——只读短路使 _cliAction 不可达);顶层 sillyspec doctor 命令的只读
|
|
98
|
+
// 语义由 index.js case 头部拦截保真(--json/--status 先行分流)。
|
|
97
99
|
])
|
|
98
100
|
|
|
99
101
|
// ── IR 严格模式闸门(change: 2026-09-07-ir-hardening,D-001@v1)──
|
package/src/decision-distill.js
CHANGED
|
@@ -69,8 +69,8 @@ function dispositionOf(entry) {
|
|
|
69
69
|
/** decisions.md 列表字段标签 → entry 字段(兼容中英标签与中英冒号;其余字段仅留在 raw) */
|
|
70
70
|
function applyField(entry, label, value) {
|
|
71
71
|
switch (label) {
|
|
72
|
-
case 'type': entry.type = firstToken(value); break
|
|
73
|
-
case 'status': entry.status = firstToken(value); break
|
|
72
|
+
case 'type': case '类型': entry.type = firstToken(value); break
|
|
73
|
+
case 'status': case '状态': entry.status = firstToken(value); break
|
|
74
74
|
case 'question': case '问题': entry.question = value; break
|
|
75
75
|
case 'answer': case '答案': entry.answer = value; break
|
|
76
76
|
case '锚点': case 'anchor': entry.anchor = value; break
|
|
@@ -111,6 +111,11 @@ export function parseDecisions(changeDir) {
|
|
|
111
111
|
entries.push(cur)
|
|
112
112
|
}
|
|
113
113
|
for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
|
|
114
|
+
// 条目开头两形态(坑 decision-flat-list-silent-zero,2026-09-10 驾驭小结第四批②,用户实证:
|
|
115
|
+
// 扁平列表格式 decisions.md 解析 0 条静默放行):
|
|
116
|
+
// ①标题式(模板正统):`## D-001@v2 标题`,正文行 `- 字段:值`
|
|
117
|
+
// ②扁平列表式(agent 实际产出):`- D-001@v2:标题` 或 `- D-001@v2 | 状态:implemented | 模块域:x`
|
|
118
|
+
// ——行内 | 分段即字段;后续缩进子项 ` - 字段:值` 同样进字段
|
|
114
119
|
const h = line.match(/^##\s+(D-\d+)(?:@v(\d+))?\s*(.*)$/)
|
|
115
120
|
if (h) {
|
|
116
121
|
flush()
|
|
@@ -118,15 +123,44 @@ export function parseDecisions(changeDir) {
|
|
|
118
123
|
cur = { id: `${h[1]}@v${version}`, number: h[1], version, title: h[3].trim(), rawLines: [line] }
|
|
119
124
|
continue
|
|
120
125
|
}
|
|
126
|
+
const b = line.match(/^[-*]\s+(D-\d+)(?:@v(\d+))?\s*(?:[::]\s*|\s+—\s*|\s*——\s*|\s*\|\s*)?\s*(.*)$/)
|
|
127
|
+
if (b) {
|
|
128
|
+
flush()
|
|
129
|
+
const version = b[2] ? parseInt(b[2], 10) : 1
|
|
130
|
+
cur = { id: `${b[1]}@v${version}`, number: b[1], version, title: '', rawLines: [line] }
|
|
131
|
+
// 行内 | 分段字段:`- D-001@v1 标题 | 状态:implemented | 模块域:core`
|
|
132
|
+
// 首个非「字段:值」形态的分段为标题;每段独立过 applyField(未知标签忽略)。
|
|
133
|
+
const rest = (b[3] || '').trim()
|
|
134
|
+
const segs = rest.split(/[||]/).map(s => s.trim()).filter(Boolean)
|
|
135
|
+
const nonFieldSegs = []
|
|
136
|
+
for (const seg of segs) {
|
|
137
|
+
const inline = seg.match(/^([^::]{1,20})[::]\s*(.*)$/)
|
|
138
|
+
if (inline && FIELD_LABEL_RE.test(inline[1].trim())) {
|
|
139
|
+
applyField(cur, inline[1].trim().toLowerCase(), inline[2].trim())
|
|
140
|
+
} else {
|
|
141
|
+
nonFieldSegs.push(seg)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
cur.title = nonFieldSegs.join(' | ')
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
121
147
|
if (!cur) continue
|
|
122
148
|
cur.rawLines.push(line)
|
|
123
|
-
|
|
124
|
-
|
|
149
|
+
// 字段行:标题式条目的顶格 `- 字段:值` + 扁平式条目的缩进子项 ` - 字段:值`(缩进至多 4 空格,
|
|
150
|
+
// 不吞正文嵌套散文——字段标签白名单外的行不进字段,见 applyField default 忽略)
|
|
151
|
+
const f = line.match(/^(?: {1,4})?[-*]\s+([^\s::]{1,20})\s*[::]\s*(.*)$/)
|
|
152
|
+
if (f && FIELD_LABEL_RE.test(f[1])) applyField(cur, f[1].toLowerCase(), f[2].trim())
|
|
125
153
|
}
|
|
126
154
|
flush()
|
|
127
|
-
|
|
155
|
+
// 0 条告警素材(消费方 distillIntoKnowledge 据此显式 warn,不再静默放行):
|
|
156
|
+
// 文件非空、正文出现 D-xxx 形态却一条都没解析出 = 格式两种都不匹配,必须可见
|
|
157
|
+
const hasDecisionLike = /D-\d+/.test(content)
|
|
158
|
+
return { entries, missing: false, zeroWithContent: hasDecisionLike && entries.length === 0 }
|
|
128
159
|
}
|
|
129
160
|
|
|
161
|
+
/** applyField 认识的字段标签白名单(行内 | 分段与缩进子项只按白名单进字段,散文误伤为零) */
|
|
162
|
+
const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts|影响|normalized_requirement|状态|类型)$/i
|
|
163
|
+
|
|
130
164
|
// ---------------------------------------------------------------------------
|
|
131
165
|
// 域三级兜底(FR-03)
|
|
132
166
|
// ---------------------------------------------------------------------------
|
|
@@ -434,6 +468,12 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
434
468
|
if (parsed.missing) {
|
|
435
469
|
return { written: [], skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')}),零输出`, needsWait: null }
|
|
436
470
|
}
|
|
471
|
+
// 0 条告警(坑 decision-flat-list-silent-zero,2026-09-10 驾驭小结第四批②):文件有内容、
|
|
472
|
+
// 正文含 D-xxx 形态却 0 条可解析 = 标题式/扁平列表式两种格式都不匹配——此前静默放行
|
|
473
|
+
// (skipped 文案还误导成「解析 0 条,0 条入选」),决策知识凭空丢失不可见。显式 warn 留痕。
|
|
474
|
+
if (parsed.zeroWithContent) {
|
|
475
|
+
console.warn(`⚠️ decisions.md 存在且含 D-xxx 条目形态,但 0 条可解析(既非「## D-xxx」标题式、也非「- D-xxx@vN」扁平列表式)——格式不识别,本次提炼零输出;请核对格式(标题式 \`## D-001@v1 标题\` + 正文 \`- 字段:值\`,或扁平式 \`- D-001@v1:标题 | 状态:implemented\`)`)
|
|
476
|
+
}
|
|
437
477
|
const selected = parsed.entries.filter(e => e.selected)
|
|
438
478
|
if (selected.length === 0) {
|
|
439
479
|
return { written: [], skipped: `无入选条目(解析 ${parsed.entries.length} 条,0 条入选:scope/非五类 type/非 confirmed|accepted|rejected 状态均不入选),零输出`, needsWait: null }
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decisions-io.js(P1-5,noai-ir-roadmap §4):decisions.md 的 CLI 单一写入者。
|
|
3
|
+
*
|
|
4
|
+
* 背景:decisions.md 此前由 agent 手拼自由 markdown,13 处解析点里它占两处
|
|
5
|
+
* (decision-distill parseDecisions × design-facts 镜像),历史事故全在「手写格式走样」:
|
|
6
|
+
* 扁平列表静默 0 条(decision-flat-list-silent-zero)、CRLF 状态行恒失败、字段标签拼写漂移。
|
|
7
|
+
* 单一写入者 = canonical 格式只此一家:标题式条目(`## D-001@v2 标题` + `- 字段:值`,
|
|
8
|
+
* 字段标签用 applyField 白名单的中文正统名),读取侧 parseDecisions 零改动双向兼容。
|
|
9
|
+
*
|
|
10
|
+
* 语义:
|
|
11
|
+
* - upsertDecision:同 id@v 条目整块替换(幂等,重跑同输入零 diff);新条目追加文末。
|
|
12
|
+
* 不碰 frontmatter 与其他条目;EOL 跟随原文件;原子写。
|
|
13
|
+
* - 校验:number 形态 D-xxx、version 正整数、status/type 白名单外的值拒绝
|
|
14
|
+
* (fail-fast——写入者先于解析者挡格式错误,这正是单一写入者的意义)。
|
|
15
|
+
* - agent 仍可手写正文散文(决策动机等),但结构化字段一律走本写入者。
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
20
|
+
|
|
21
|
+
const STATUS_VALUES = new Set(['confirmed', 'accepted', 'implemented', 'rejected', 'proposed', 'superseded', 'draft'])
|
|
22
|
+
const TYPE_VALUES = new Set(['architecture', 'compatibility', 'boundary', 'definition', 'process', 'scope'])
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* canonical 条目渲染(正文散文行原样保留在 body 数组)。
|
|
26
|
+
* @param {{ number: string, version: number, title: string, body?: string[], status?: string, type?: string, question?: string, answer?: string, anchor?: string, domains?: string[], rejectReason?: string, revisitWhen?: string, supersedes?: string, impacts?: string, normalizedRequirement?: string }} d
|
|
27
|
+
* @returns {string} 条目 markdown 块(不含首尾空行)
|
|
28
|
+
*/
|
|
29
|
+
export function renderDecisionEntry(d) {
|
|
30
|
+
if (!d || !/^D-\d+$/.test(d.number || '')) throw new Error(`决策编号形态非法(期望 D-xxx,实得 ${JSON.stringify(d && d.number)})`)
|
|
31
|
+
const version = parseInt(d.version, 10)
|
|
32
|
+
if (!Number.isInteger(version) || version < 1) throw new Error(`决策版本非法(期望正整数,实得 ${JSON.stringify(d.version)})`)
|
|
33
|
+
if (d.status && !STATUS_VALUES.has(d.status)) throw new Error(`status 白名单外:${d.status}(合法:${[...STATUS_VALUES].join(' / ')})`)
|
|
34
|
+
if (d.type && !TYPE_VALUES.has(d.type)) throw new Error(`type 白名单外:${d.type}(合法:${[...TYPE_VALUES].join(' / ')})`)
|
|
35
|
+
const lines = [`## ${d.number}@v${version} ${String(d.title || '').trim()}`]
|
|
36
|
+
if (d.status) lines.push(`- 状态:${d.status}`)
|
|
37
|
+
if (d.type) lines.push(`- 类型:${d.type}`)
|
|
38
|
+
if (Array.isArray(d.domains) && d.domains.length > 0) lines.push(`- 模块域:${d.domains.join(', ')}`)
|
|
39
|
+
if (d.question) lines.push(`- 问题:${d.question}`)
|
|
40
|
+
if (d.answer) lines.push(`- 答案:${d.answer}`)
|
|
41
|
+
if (d.anchor) lines.push(`- 锚点:${d.anchor}`)
|
|
42
|
+
if (d.supersedes) lines.push(`- supersedes:${d.supersedes}`)
|
|
43
|
+
if (d.impacts) lines.push(`- 影响:${d.impacts}`)
|
|
44
|
+
if (d.rejectReason) lines.push(`- 否决理由:${d.rejectReason}`)
|
|
45
|
+
if (d.revisitWhen) lines.push(`- 复潮条件:${d.revisitWhen}`)
|
|
46
|
+
if (d.normalizedRequirement) lines.push(`- normalized_requirement:${d.normalizedRequirement}`)
|
|
47
|
+
for (const prose of (Array.isArray(d.body) ? d.body : [])) {
|
|
48
|
+
if (typeof prose === 'string' && prose.trim() !== '') lines.push(prose.trimEnd())
|
|
49
|
+
}
|
|
50
|
+
return lines.join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* upsert 一条决策:同 id@v 整块替换,否则文末追加。返回 { path, action: 'replaced'|'appended' }。
|
|
55
|
+
* 文件不存在 → 以新文件创建(含空 frontmatter 占位不写——decisions.md 可无 frontmatter,
|
|
56
|
+
* 但本仓惯例有 author/created_at;创建时写最小 frontmatter 由调用方 flag 补充)。
|
|
57
|
+
*/
|
|
58
|
+
export function upsertDecision(changeDir, decision) {
|
|
59
|
+
const filePath = join(changeDir, 'decisions.md')
|
|
60
|
+
const block = renderDecisionEntry(decision)
|
|
61
|
+
const id = `${decision.number}@v${parseInt(decision.version, 10)}`
|
|
62
|
+
if (!existsSync(filePath)) {
|
|
63
|
+
const content = `# 决策记录\n\n${block}\n`
|
|
64
|
+
writeAtomicSync(filePath, content)
|
|
65
|
+
return { path: filePath, action: 'appended' }
|
|
66
|
+
}
|
|
67
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
68
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n'
|
|
69
|
+
const normalized = raw.replace(/\r\n/g, '\n')
|
|
70
|
+
const lines = normalized.split('\n')
|
|
71
|
+
const header = `## ${id} `
|
|
72
|
+
const startIdx = lines.findIndex((l) => l.startsWith(`## ${id} `) || l.trim() === `## ${id}`)
|
|
73
|
+
if (startIdx === -1) {
|
|
74
|
+
const next = normalized.replace(/\n*$/, '\n') + '\n' + block + '\n'
|
|
75
|
+
writeAtomicSync(filePath, eol === '\n' ? next : next.replace(/\n/g, '\r\n'))
|
|
76
|
+
return { path: filePath, action: 'appended' }
|
|
77
|
+
}
|
|
78
|
+
let endIdx = lines.length
|
|
79
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
80
|
+
if (lines[i].startsWith('## ')) { endIdx = i; break }
|
|
81
|
+
}
|
|
82
|
+
// 保留原条目正文散文(非「- 字段:值」白名单行)随替换块回填——CLI 拥有字段,散文归作者
|
|
83
|
+
const preservedProse = []
|
|
84
|
+
for (const line of lines.slice(startIdx + 1, endIdx)) {
|
|
85
|
+
const t = line.trim()
|
|
86
|
+
if (t === '' ) continue
|
|
87
|
+
if (/^(?: {1,4})?[-*]\s+(状态|类型|模块域|问题|答案|锚点|否决理由|复潮条件|影响)\s*[::]/.test(t)) continue
|
|
88
|
+
if (/^(?: {1,4})?[-*]\s+(supersedes|impacts|normalized_requirement|status|type|domains|domain|question|answer|anchor|reject_reason|revisit_when)\s*[::]/i.test(t)) continue
|
|
89
|
+
preservedProse.push(line)
|
|
90
|
+
}
|
|
91
|
+
const merged = { ...decision, body: [...(decision.body || []), ...preservedProse] }
|
|
92
|
+
const mergedBlock = renderDecisionEntry(merged)
|
|
93
|
+
const next = [...lines.slice(0, startIdx), ...mergedBlock.split('\n'), '', ...lines.slice(endIdx)].join('\n')
|
|
94
|
+
writeAtomicSync(filePath, eol === '\n' ? next : next.replace(/\n/g, '\r\n'))
|
|
95
|
+
return { path: filePath, action: 'replaced' }
|
|
96
|
+
}
|
package/src/design-facts.js
CHANGED
|
@@ -261,6 +261,9 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
261
261
|
lines.push('---')
|
|
262
262
|
lines.push(`author: ${String(author || '').trim() || 'TODO(git 用户名)'}`)
|
|
263
263
|
lines.push(`created_at: ${String(now || '').trim() || 'TODO(ISO 时间)'}`)
|
|
264
|
+
// P2-e provenance 戳(noai-ir-roadmap §5):CLI 骨架出品的可审计标记——铁律 8 收窄为
|
|
265
|
+
//「骨架优先,仅手写补文档才手填元数据」的依据(validateMetadata 未来可据此只认 CLI 戳)
|
|
266
|
+
lines.push(`generated_by: sillyspec-design-init`)
|
|
264
267
|
lines.push('scale: large')
|
|
265
268
|
lines.push('---')
|
|
266
269
|
lines.push('')
|
package/src/dispatch/probe.js
CHANGED
|
@@ -128,26 +128,31 @@ export function detectPathAFromTools(tools) {
|
|
|
128
128
|
* 拿(task-12 #5),拿不到则跳过越界校验(不判 unavailable)
|
|
129
129
|
* @param {number} [opts.ttlMs] - 负面缓存 TTL 覆盖(优先于 local.yaml)
|
|
130
130
|
* @param {number} [opts.totalTimeoutMs] - 总超时覆盖(默认 25s,HUB-12a)
|
|
131
|
-
* @
|
|
131
|
+
* @param {string} [opts.cwd] - local.yaml/env 定位根(默认 process.cwd())。测试
|
|
132
|
+
* 注入干净 tmp 目录隔离「dev 仓自身已连平台」的环境
|
|
133
|
+
* (坑 probe-no-config-cwd-leak:本仓 local.yaml 带 mcp
|
|
134
|
+
* 段时 no-config 用例读到真配置,同步快速路径判定漂移)
|
|
135
|
+
* @returns {Promise<{ available: boolean, reason?: string, daemonOnline?: boolean|null }>}
|
|
132
136
|
*/
|
|
133
|
-
export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, totalTimeoutMs } = {}) {
|
|
137
|
+
export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, totalTimeoutMs, cwd } = {}) {
|
|
138
|
+
const probeCwd = cwd || process.cwd();
|
|
134
139
|
// 1. 同步快速路径:无 local.yaml mcp 段且 env 缺凭据即 unavailable(不发网络,零回归关键)。
|
|
135
140
|
// readMcpConfig 纯 fs + env 读(best-effort 不抛不发网络),返回 null = 两源都缺 → no-config。
|
|
136
|
-
if (!readMcpConfig(
|
|
141
|
+
if (!readMcpConfig(probeCwd)) {
|
|
137
142
|
return { available: false, reason: 'no-config' };
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
// 2. 负面缓存命中(未过 TTL)→ 直接返回,不发网络(R-06 抖动期免反复探测)
|
|
141
|
-
const fp = configFingerprint(
|
|
146
|
+
const fp = configFingerprint(probeCwd);
|
|
142
147
|
const cached = negativeCache.get(fp);
|
|
143
148
|
if (cached && cached.expiresAt > Date.now()) {
|
|
144
149
|
return cached.result;
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
const cli = client || new SillyHubMcpClient({ cwd:
|
|
152
|
+
const cli = client || new SillyHubMcpClient({ cwd: probeCwd });
|
|
148
153
|
const cacheNegative = (reason) => {
|
|
149
154
|
const result = { available: false, reason };
|
|
150
|
-
const ttl = resolveTtl(ttlMs,
|
|
155
|
+
const ttl = resolveTtl(ttlMs, probeCwd);
|
|
151
156
|
negativeCache.set(fp, { result, expiresAt: Date.now() + ttl });
|
|
152
157
|
return result;
|
|
153
158
|
};
|
|
@@ -202,6 +207,23 @@ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, tot
|
|
|
202
207
|
setPathAProbeResult(false);
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
// 3.6 daemon 在线层(2026-09-10 平台侧 P1-3 新工具 get_daemon_status):daemon_online
|
|
211
|
+
// =false 是确定性「派了也无人认领」信号(spike 实证:无 daemon 认领时 worker 0.2s
|
|
212
|
+
// 快速失败 no_online_daemon)——正面探明,替代「真派发一次才知道」的浪费。fail-open
|
|
213
|
+
// 三态:true/false 明确;null(无工具=旧 backend / token 缺 read scope / 调用异常)不判
|
|
214
|
+
// unavailable,回退旧口径(可用性未知按可用走)。不进负面缓存(活状态随 daemon 起停
|
|
215
|
+
// 变化,与 worktree-outside-root 同类,非网络派生)。
|
|
216
|
+
let daemonOnline = null;
|
|
217
|
+
try {
|
|
218
|
+
if (typeof cli.getDaemonStatus === 'function') {
|
|
219
|
+
const st = await cli.getDaemonStatus();
|
|
220
|
+
if (st && typeof st.online === 'boolean') daemonOnline = st.online;
|
|
221
|
+
}
|
|
222
|
+
} catch { daemonOnline = null; }
|
|
223
|
+
if (daemonOnline === false) {
|
|
224
|
+
return { available: false, reason: 'daemon-offline', daemonOnline };
|
|
225
|
+
}
|
|
226
|
+
|
|
205
227
|
if (worktreePath) {
|
|
206
228
|
let effectiveRoot = rootPath;
|
|
207
229
|
if (effectiveRoot === undefined) {
|
|
@@ -221,8 +243,9 @@ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs, tot
|
|
|
221
243
|
}
|
|
222
244
|
}
|
|
223
245
|
|
|
224
|
-
// 5. 全通过:不缓存正面结果,下次重新探测(让 daemon
|
|
225
|
-
|
|
246
|
+
// 5. 全通过:不缓存正面结果,下次重新探测(让 daemon 恢复/退化及时反映);
|
|
247
|
+
// daemonOnline 透传(true/null——调用方可区分「探明在线」与「未知」)
|
|
248
|
+
return { available: true, daemonOnline };
|
|
226
249
|
})();
|
|
227
250
|
|
|
228
251
|
let timeoutId = null;
|