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.
Files changed (77) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +25 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/results-review-tier.md +18 -0
  15. package/src/agent-session-log.js +4 -3
  16. package/src/archive-delta.js +82 -0
  17. package/src/change-delete.js +2 -1
  18. package/src/change-list.js +18 -3
  19. package/src/change-risk-profile.js +77 -6
  20. package/src/commit-suggest.js +52 -1
  21. package/src/config-cat.js +17 -1
  22. package/src/constants.js +3 -1
  23. package/src/decision-distill.js +45 -5
  24. package/src/decisions-io.js +96 -0
  25. package/src/design-facts.js +3 -0
  26. package/src/dispatch/probe.js +31 -8
  27. package/src/docs-check.js +102 -3
  28. package/src/doctor-diagnostics.js +441 -5
  29. package/src/foreign-declared.js +53 -2
  30. package/src/index.js +606 -22
  31. package/src/mcp-server.js +144 -0
  32. package/src/module-impact.js +84 -12
  33. package/src/modules.js +46 -34
  34. package/src/plan-adopt-waves.js +13 -5
  35. package/src/progress/change-registry.js +34 -0
  36. package/src/progress.js +75 -2
  37. package/src/quick-recommend.js +6 -1
  38. package/src/quicklog.js +70 -8
  39. package/src/review-dispatch.js +377 -0
  40. package/src/run/archive-distill.js +48 -0
  41. package/src/run/command.js +48 -13
  42. package/src/run/complete-handlers.js +215 -9
  43. package/src/run/complete.js +184 -2
  44. package/src/run/gate-snapshot.js +88 -0
  45. package/src/run/gates.js +206 -58
  46. package/src/run/prompt.js +150 -14
  47. package/src/run/quick-audit.js +38 -13
  48. package/src/run/shared.js +183 -12
  49. package/src/run/stage.js +52 -2
  50. package/src/run/verify-quality-scan.js +336 -0
  51. package/src/runtime-hygiene.js +84 -0
  52. package/src/scope-audit.js +735 -0
  53. package/src/sillyhub-mcp/client.js +58 -3
  54. package/src/spec-sync.js +63 -8
  55. package/src/stage-contract.js +83 -3
  56. package/src/stage-review-checklist.js +44 -0
  57. package/src/stage-review.js +125 -4
  58. package/src/stages/archive.js +60 -167
  59. package/src/stages/brainstorm.js +7 -17
  60. package/src/stages/doctor.js +41 -515
  61. package/src/stages/execute.js +13 -10
  62. package/src/stages/plan-postcheck.js +191 -7
  63. package/src/stages/plan.js +7 -35
  64. package/src/stages/quick.js +11 -11
  65. package/src/stages/verify.js +24 -39
  66. package/src/sync-noise.js +158 -0
  67. package/src/sync.js +93 -27
  68. package/src/task-review.js +133 -17
  69. package/src/taskcard.js +46 -3
  70. package/src/validate-artifacts.js +163 -0
  71. package/src/verify-facts-schema.js +141 -0
  72. package/src/verify-postcheck.js +347 -29
  73. package/src/verify-probes.js +275 -24
  74. package/src/workflow.js +6 -0
  75. package/src/worktree-apply.js +167 -24
  76. package/src/worktree.js +5 -1
  77. package/templates/agents-instruction.md +1 -0
@@ -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 }
@@ -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
  *
@@ -74,8 +74,13 @@ export function globMatch(str, pattern) {
74
74
  * @returns {boolean}
75
75
  */
76
76
  export function pathMatches(a, b) {
77
- a = normalizePath(a)
78
- b = normalizePath(b)
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
- const filePath = normalizePath(listItem[1])
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
- // 字面证据正则从 VERIFICATION_NEEDS[k].literals 派生——与报错描述、prompt 事前契约严格同源,
296
- // 杜绝历史上"描述说 A、正则查 B"的分叉(checkIntegrationEvidence 正则曾比 VERIFICATION_NEEDS
297
- // 描述多匹配 集成测试/backend.*daemon/real.*integration 等)。literals 为空的 need
298
- // (unit_tests/contract_tests)无字面校验,视为满足。
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
+ }
@@ -18,7 +18,8 @@
18
18
  */
19
19
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
20
20
  import { join } from 'path'
21
- import { gitQuiet } from './git-helper.js'
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
- push(join(d, '.sillyspec', 'local.yaml'), 'cwd 祖先链(worktree 内向上命中主仓)')
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
- 'doctor',
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)──
@@ -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
- const f = line.match(/^-\s+([^\s::]+)\s*[::]\s*(.*)$/)
124
- if (f) applyField(cur, f[1].toLowerCase(), f[2].trim())
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
- return { entries, missing: false }
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
+ }
@@ -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('')
@@ -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
- * @returns {Promise<{ available: boolean, reason?: string }>}
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(process.cwd())) {
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(process.cwd());
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: process.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, process.cwd());
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
- return { available: true };
246
+ // 5. 全通过:不缓存正面结果,下次重新探测(让 daemon 恢复/退化及时反映);
247
+ // daemonOnline 透传(true/null——调用方可区分「探明在线」与「未知」)
248
+ return { available: true, daemonOnline };
226
249
  })();
227
250
 
228
251
  let timeoutId = null;