sillyspec 3.28.4 → 3.28.6
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/AGENTS.md +1 -0
- package/package.json +1 -1
- package/results-review-tier.md +18 -0
- package/sa-rename-probe/src/new.js +4 -0
- package/src/agent-session-log.js +4 -3
- package/src/archive-delta.js +2 -2
- package/src/change-list.js +11 -1
- package/src/commit-suggest.js +52 -1
- package/src/config-cat.js +6 -1
- package/src/config-schema.js +13 -0
- package/src/decision-distill.js +53 -7
- 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 +77 -8
- package/src/friction-tally.js +221 -0
- package/src/index.js +430 -18
- package/src/mcp-server.js +144 -0
- package/src/module-impact.js +8 -1
- package/src/modules.js +46 -34
- package/src/progress/change-registry.js +20 -0
- package/src/progress.js +16 -1
- package/src/quicklog.js +60 -6
- package/src/review-dispatch.js +377 -0
- package/src/run/archive-distill.js +48 -0
- package/src/run/command.js +35 -5
- package/src/run/complete-handlers.js +136 -3
- package/src/run/complete.js +170 -2
- package/src/run/gate-snapshot.js +159 -0
- package/src/run/gates.js +166 -69
- package/src/run/prompt.js +54 -9
- package/src/run/quick-audit.js +78 -16
- package/src/run/shared.js +138 -7
- package/src/run/stage.js +31 -2
- package/src/run/verify-quality-scan.js +383 -0
- package/src/scope-audit.js +941 -0
- package/src/sillyhub-mcp/client.js +58 -3
- package/src/stage-review-checklist.js +44 -0
- package/src/stage-review.js +122 -4
- package/src/stages/archive.js +57 -164
- package/src/stages/brainstorm.js +8 -12
- package/src/stages/execute.js +13 -10
- package/src/stages/plan.js +3 -10
- package/src/stages/verify.js +14 -33
- package/src/sync.js +51 -10
- package/src/task-review.js +108 -16
- package/src/taskcard.js +46 -3
- package/src/validate-artifacts.js +163 -0
- package/src/verify-postcheck.js +90 -10
- package/src/verify-probes.js +134 -12
- package/src/worktree-apply.js +119 -14
- package/templates/agents-instruction.md +1 -0
package/AGENTS.md
CHANGED
|
@@ -22,3 +22,4 @@
|
|
|
22
22
|
15. **quicklog 结构化落盘**:末步 `--done` 用四参数 `--req/--cause/--solution/--result`(CLI 合成结构化 output 并自动提取标题/四段分行);文件括注用 `--file-notes "path::注 || path2"`。骨架由 CLI 接管,`--done` 后按需核对即可,勿手拼模板。
|
|
23
23
|
16. **代码可能随时在修改**(多 agent 并行),Edit 前重跑 + 查最新态;破坏性 git op 前先备份。
|
|
24
24
|
17. **不奉承用户**,禁止"你说得对"类话术,直接给结论、依据、方案。
|
|
25
|
+
18. **禁止目录级 git add / git add -A**(多会话共享仓):.sillyspec/ 等目录里有并行会话的进行中文件与已提交文档,目录级暂存会夹带他者改动甚至误删已提交文件(2026-09-10 实证)。提交一律用显式 pathspec(git add -- 文件1 文件2),提交前 git status 核对暂存面只含本会话文件。
|
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Worker 审查报告:src/review-tier.js(77 行,只读审查)
|
|
2
|
+
|
|
3
|
+
## 三行审查结论
|
|
4
|
+
|
|
5
|
+
1. **职责**:classifyReviewTier 是审查分级器(CLI done 门禁与 prompt 注入两侧共用同一函数防判定漂移),按 plan_level 确定性映射(none/light→self,full→independent),无 plan_level 时退「design.md 变更文件数 ≤3」启发式,designPath 完全未传(fileCount=null)时 fail-safe 判 independent(宁严勿松)。
|
|
6
|
+
2. **风险**:tier 判定权完全下放 agent 自报的 plan_level——agent 在 plan step1 低估规模报 light 即可绕过独立审查(自审自批),CLI 无第二道防线;这是 2026-08-14 tier-plan-level 改造的已知设计取舍(消除两套标准打架),但意味着分级质量上限受制于 agent 自律。
|
|
7
|
+
3. **结论**:**pass** —— 纯函数、双侧共用判定、fail-safe 方向正确(无法证明规模→从严)、注释与实现一致(含 fileCount=0 按"真无变更=极小"的字面处理);未发现逻辑缺陷,无需改动。
|
|
8
|
+
|
|
9
|
+
## 结构化摘要
|
|
10
|
+
|
|
11
|
+
- **发现**:
|
|
12
|
+
- 分级四步短路判定顺序清晰:none→self、light→self、full→independent、无 plan_level→文件数启发式(≤SELF_REVIEW_FILE_THRESHOLD=3→self)。
|
|
13
|
+
- fail-safe 双分支处理完备:fileCount=0(design 有但清单空)按极小判 self;fileCount=null(designPath 未传)判 independent。
|
|
14
|
+
- 注释记录了改造动机(此前 light+7 文件被文件数强制 independent,两套标准打架),可追溯性好。
|
|
15
|
+
- 唯一依赖 parseFileChangeList(designPath).size(Set),无其他副作用,纯函数可测性好。
|
|
16
|
+
- **结论**:pass,无阻塞问题;风险项为设计取舍而非缺陷,如实登记。
|
|
17
|
+
- **产出文件路径**:results-review-tier.md(本文件)
|
|
18
|
+
- **风险**:见三行结论第 2 条(plan_level 自报低报可绕独立审查,无 CLI 二次防线)。
|
package/src/agent-session-log.js
CHANGED
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
* - cursor / opencode:loose 探测器(无 cwd/env 归属线索,仅 precise 全落空时启用)
|
|
31
31
|
* - 其他 CLI:env SILLYSPEC_AGENT_LOG 显式指定(绝对路径)
|
|
32
32
|
*
|
|
33
|
-
* 上报配置来源(优先级):env SILLYHUB_PLATFORM_URL + SILLYHUB_PLATFORM_TOKEN
|
|
34
|
-
*
|
|
33
|
+
* 上报配置来源(优先级):env SILLYHUB_PLATFORM_URL + SILLYHUB_PLATFORM_TOKEN(预留
|
|
34
|
+
* 通道,daemon 未实现注入、需显式 export)> local.yaml platform 段(与进度同步同源)。**不受平台模式 sentinel 限制**
|
|
35
35
|
* (链路 A 进度同步在平台模式跳过是因为 daemon 自有进度回传链路;agent 日志没有
|
|
36
36
|
* daemon 链路,本上报就是它的主通道)。env SILLYSPEC_AGENT_LOG_PUSH=0 可关。
|
|
37
37
|
*
|
|
@@ -559,7 +559,8 @@ export function detectAgentLogEntries({
|
|
|
559
559
|
* 不 import sync.js 防拖 ProgressManager 重链)。
|
|
560
560
|
*
|
|
561
561
|
* 优先级:env SILLYHUB_PLATFORM_URL + SILLYHUB_PLATFORM_TOKEN(两键齐全才生效——
|
|
562
|
-
* daemon
|
|
562
|
+
* 预留的外部注入通道,daemon 未实现注入、需显式 export;平台模式 specRoot 的
|
|
563
|
+
* local.yaml 无 platform 段时本就无凭据可上报,勿指望 env 兜底)>
|
|
563
564
|
* specBase/local.yaml platform 段(url + token,与链路 A 同源)。
|
|
564
565
|
* @returns {{url:string, token:string}|null} 未配置返回 null(合法状态,静默跳过上报)
|
|
565
566
|
*/
|
package/src/archive-delta.js
CHANGED
|
@@ -489,7 +489,7 @@ export function auditModuleImpactAgainstDiff({ cwd, changeName, specDir = null }
|
|
|
489
489
|
if (inDocNotDiff.length > 0) mismatches.push(`module-impact 列而 diff 无(${inDocNotDiff.length}):${inDocNotDiff.slice(0, 5).join('、')}${inDocNotDiff.length > 5 ? ' …' : ''}`)
|
|
490
490
|
// 第三重:module-map 归属一致性(2026-09-09 外部审核 P2-1——「三重核对」此前名实不符:
|
|
491
491
|
// 矩阵行的模块列 vs map 前缀推导——误标模块点名;未命中 map 的归 unmatched 提示不判红)
|
|
492
|
-
const moduleMisattrib = auditImpactModuleAttribution(impactText,
|
|
492
|
+
const moduleMisattrib = auditImpactModuleAttribution(impactText, specBase)
|
|
493
493
|
mismatches.push(...moduleMisattrib)
|
|
494
494
|
return {
|
|
495
495
|
ok: mismatches.length === 0,
|
|
@@ -504,7 +504,7 @@ export function auditModuleImpactAgainstDiff({ cwd, changeName, specDir = null }
|
|
|
504
504
|
* 第三重核对(P2-1):矩阵行「| <模块> | `文件` |」的模块列 vs _module-map.yaml 前缀推导。
|
|
505
505
|
* 只读 fail-soft:map 不可得 → 返回 [](前两重仍有效,注入文案会带 map 跳过注记)。
|
|
506
506
|
*/
|
|
507
|
-
function auditImpactModuleAttribution(impactText,
|
|
507
|
+
function auditImpactModuleAttribution(impactText, specBase) {
|
|
508
508
|
try {
|
|
509
509
|
// 找 _module-map.yaml(docs/<p>/modules/ 扫描——detectModuleDocHealth 同款)
|
|
510
510
|
let mapText = null
|
package/src/change-list.js
CHANGED
|
@@ -259,7 +259,17 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
|
|
|
259
259
|
// 分类列表项:`- path` / `- \`path\``
|
|
260
260
|
const listItem = line.match(/^\s*-\s+(.+)/)
|
|
261
261
|
if (listItem) {
|
|
262
|
-
|
|
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)
|
|
263
273
|
if (isPlaceholder(filePath) || (filePath.startsWith('.sillyspec/') && !(keepSillyspecDocs && filePath.startsWith('.sillyspec/docs/')))) continue
|
|
264
274
|
if (!looksLikePath(filePath)) continue // 脏描述兜底
|
|
265
275
|
const incidental = INCIDENTAL_RE.test(listItem[1])
|
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
|
@@ -49,10 +49,15 @@ export function resolveLocalYaml(dir, opts = {}) {
|
|
|
49
49
|
const home = homedir()
|
|
50
50
|
const originBelowHome = start !== home && start.startsWith(home + sep)
|
|
51
51
|
if (opts.specBase) push(join(opts.specBase, 'local.yaml'), 'spec 根(--spec-dir/平台 pointer/最近 .sillyspec)')
|
|
52
|
+
let passedHome = false
|
|
52
53
|
for (let d = start; ; d = dirname(d)) {
|
|
53
|
-
|
|
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))) {
|
|
54
58
|
push(join(d, '.sillyspec', 'local.yaml'), 'cwd 祖先链(worktree 内向上命中主仓;home 层受守卫跳过)')
|
|
55
59
|
}
|
|
60
|
+
if (d === home) passedHome = true
|
|
56
61
|
const parent = dirname(d)
|
|
57
62
|
if (parent === d) break
|
|
58
63
|
}
|
package/src/config-schema.js
CHANGED
|
@@ -165,6 +165,14 @@ export const LOCAL_YAML_SCHEMA = {
|
|
|
165
165
|
{ path: 'decisions.behind_threshold', type: 'integer', optional: true, status: 'live', readers: ['readDecisionRulesConfig (src/docs-check.js)'], desc: '决策 behind 复核阈值(锚定模块源码在「最近确认」commit 后的前进数),缺省 10;超阈报「决策待复核」提示。', example: '10' },
|
|
166
166
|
],
|
|
167
167
|
},
|
|
168
|
+
{
|
|
169
|
+
id: 'friction_hint',
|
|
170
|
+
title: '摩擦提示开关',
|
|
171
|
+
note: 'quick/verify 收尾的摩擦信号计数提示配置(friction-signal-hint)。读键按「存在则读、不存在用缺省」容错:读失败与未配置同兜底为默认开。',
|
|
172
|
+
keys: [
|
|
173
|
+
{ path: 'friction_hint.enabled', type: 'boolean', optional: true, status: 'live', readers: ['readFrictionHintEnabled (src/friction-tally.js)'], desc: '摩擦提示开关,默认 true;设 false 关闭摩擦提示(gate 回滚/验证失败/审查打回的收尾 advisory 与计数落盘全停,.runtime 零写入)。', example: 'true' },
|
|
174
|
+
],
|
|
175
|
+
},
|
|
168
176
|
],
|
|
169
177
|
};
|
|
170
178
|
|
|
@@ -294,6 +302,11 @@ test_strategy: full
|
|
|
294
302
|
decisions:
|
|
295
303
|
behind_threshold: 10
|
|
296
304
|
|
|
305
|
+
# ── 摩擦提示开关(quick/verify 收尾的摩擦信号一行 advisory:gate 回滚/验证失败/审查打回计数)──
|
|
306
|
+
# 默认 true 开启;false 一键全关(计数与提示双停,.runtime 零写入)
|
|
307
|
+
friction_hint:
|
|
308
|
+
enabled: true
|
|
309
|
+
|
|
297
310
|
# ── 预存失败豁免清单(变更前就失败的测试行;务必定期复核,防误豁免真实失败)──
|
|
298
311
|
known_failures:
|
|
299
312
|
- tests/some-old-stable-failure
|
package/src/decision-distill.js
CHANGED
|
@@ -25,7 +25,10 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
|
|
25
25
|
import { basename, dirname, join } from 'node:path'
|
|
26
26
|
|
|
27
27
|
/** FR-02:有实现影响的五类 type(scope 与未知 type 的 confirmed/accepted 不入选) */
|
|
28
|
-
|
|
28
|
+
// feasibility 补录(2026-09-11 驾驭第十二批,本仓归档 2026-08-08-concurrent-write-preflight 实证:
|
|
29
|
+
// 8 条 feasibility+accepted 决策因五类集漏选从未入知识库)——可行性裁决是设计决策(技术选型/
|
|
30
|
+
// 可行 vs 不可行),与五类同格;排除集本意只针对 scope(范围裁决不入库)。
|
|
31
|
+
const IMPLEMENTED_TYPES = new Set(['architecture', 'compatibility', 'boundary', 'definition', 'process', 'feasibility', 'consistency'])
|
|
29
32
|
/** FR-02:implemented 入选的 status(superseded 等其余状态不入选) */
|
|
30
33
|
const IMPLEMENTED_STATUSES = new Set(['confirmed', 'accepted'])
|
|
31
34
|
/** 三级兜底终点域(仍不中归此;域文件由本模块自管) */
|
|
@@ -69,8 +72,8 @@ function dispositionOf(entry) {
|
|
|
69
72
|
/** decisions.md 列表字段标签 → entry 字段(兼容中英标签与中英冒号;其余字段仅留在 raw) */
|
|
70
73
|
function applyField(entry, label, value) {
|
|
71
74
|
switch (label) {
|
|
72
|
-
case 'type': entry.type = firstToken(value); break
|
|
73
|
-
case 'status': entry.status = firstToken(value); break
|
|
75
|
+
case 'type': case '类型': entry.type = firstToken(value); break
|
|
76
|
+
case 'status': case '状态': entry.status = firstToken(value); break
|
|
74
77
|
case 'question': case '问题': entry.question = value; break
|
|
75
78
|
case 'answer': case '答案': entry.answer = value; break
|
|
76
79
|
case '锚点': case 'anchor': entry.anchor = value; break
|
|
@@ -111,22 +114,59 @@ export function parseDecisions(changeDir) {
|
|
|
111
114
|
entries.push(cur)
|
|
112
115
|
}
|
|
113
116
|
for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
|
|
114
|
-
|
|
117
|
+
// 条目开头两形态(坑 decision-flat-list-silent-zero,2026-09-10 驾驭小结第四批②,用户实证:
|
|
118
|
+
// 扁平列表格式 decisions.md 解析 0 条静默放行):
|
|
119
|
+
// ①标题式(模板正统):`## D-001@v2 标题`,正文行 `- 字段:值`
|
|
120
|
+
// ②扁平列表式(agent 实际产出):`- D-001@v2:标题` 或 `- D-001@v2 | 状态:implemented | 模块域:x`
|
|
121
|
+
// ——行内 | 分段即字段;后续缩进子项 ` - 字段:值` 同样进字段
|
|
122
|
+
// 标题级放宽 ^#{2,4}(坑 decision-heading-h3-colon-variant,2026-09-11 驾驭第十二批,
|
|
123
|
+
// 本仓 2026-08-08-concurrent-write-preflight 实证:### D-001@v1: 标题 形态 8 条 accepted
|
|
124
|
+
// 决策未入知识库)——agent 落盘的标题级漂移(##/###/####)与标题前导冒号均容收
|
|
125
|
+
const h = line.match(/^#{2,4}\s+(D-\d+)(?:@v(\d+))?\s*[::]?\s*(.*)$/)
|
|
115
126
|
if (h) {
|
|
116
127
|
flush()
|
|
117
128
|
const version = h[2] ? parseInt(h[2], 10) : 1
|
|
118
129
|
cur = { id: `${h[1]}@v${version}`, number: h[1], version, title: h[3].trim(), rawLines: [line] }
|
|
119
130
|
continue
|
|
120
131
|
}
|
|
132
|
+
const b = line.match(/^[-*]\s+(D-\d+)(?:@v(\d+))?\s*(?:[::]\s*|\s+—\s*|\s*——\s*|\s*\|\s*)?\s*(.*)$/)
|
|
133
|
+
if (b) {
|
|
134
|
+
flush()
|
|
135
|
+
const version = b[2] ? parseInt(b[2], 10) : 1
|
|
136
|
+
cur = { id: `${b[1]}@v${version}`, number: b[1], version, title: '', rawLines: [line] }
|
|
137
|
+
// 行内 | 分段字段:`- D-001@v1 标题 | 状态:implemented | 模块域:core`
|
|
138
|
+
// 首个非「字段:值」形态的分段为标题;每段独立过 applyField(未知标签忽略)。
|
|
139
|
+
const rest = (b[3] || '').trim()
|
|
140
|
+
const segs = rest.split(/[||]/).map(s => s.trim()).filter(Boolean)
|
|
141
|
+
const nonFieldSegs = []
|
|
142
|
+
for (const seg of segs) {
|
|
143
|
+
const inline = seg.match(/^([^::]{1,20})[::]\s*(.*)$/)
|
|
144
|
+
if (inline && FIELD_LABEL_RE.test(inline[1].trim())) {
|
|
145
|
+
applyField(cur, inline[1].trim().toLowerCase(), inline[2].trim())
|
|
146
|
+
} else {
|
|
147
|
+
nonFieldSegs.push(seg)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
cur.title = nonFieldSegs.join(' | ')
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
121
153
|
if (!cur) continue
|
|
122
154
|
cur.rawLines.push(line)
|
|
123
|
-
|
|
124
|
-
|
|
155
|
+
// 字段行:标题式条目的顶格 `- 字段:值` + 扁平式条目的缩进子项 ` - 字段:值`(缩进至多 4 空格,
|
|
156
|
+
// 不吞正文嵌套散文——字段标签白名单外的行不进字段,见 applyField default 忽略)
|
|
157
|
+
const f = line.match(/^(?: {1,4})?[-*]\s+([^\s::]{1,20})\s*[::]\s*(.*)$/)
|
|
158
|
+
if (f && FIELD_LABEL_RE.test(f[1])) applyField(cur, f[1].toLowerCase(), f[2].trim())
|
|
125
159
|
}
|
|
126
160
|
flush()
|
|
127
|
-
|
|
161
|
+
// 0 条告警素材(消费方 distillIntoKnowledge 据此显式 warn,不再静默放行):
|
|
162
|
+
// 文件非空、正文出现 D-xxx 形态却一条都没解析出 = 格式两种都不匹配,必须可见
|
|
163
|
+
const hasDecisionLike = /D-\d+/.test(content)
|
|
164
|
+
return { entries, missing: false, zeroWithContent: hasDecisionLike && entries.length === 0 }
|
|
128
165
|
}
|
|
129
166
|
|
|
167
|
+
/** applyField 认识的字段标签白名单(行内 | 分段与缩进子项只按白名单进字段,散文误伤为零) */
|
|
168
|
+
const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts|影响|normalized_requirement|状态|类型)$/i
|
|
169
|
+
|
|
130
170
|
// ---------------------------------------------------------------------------
|
|
131
171
|
// 域三级兜底(FR-03)
|
|
132
172
|
// ---------------------------------------------------------------------------
|
|
@@ -434,6 +474,12 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
434
474
|
if (parsed.missing) {
|
|
435
475
|
return { written: [], skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')}),零输出`, needsWait: null }
|
|
436
476
|
}
|
|
477
|
+
// 0 条告警(坑 decision-flat-list-silent-zero,2026-09-10 驾驭小结第四批②):文件有内容、
|
|
478
|
+
// 正文含 D-xxx 形态却 0 条可解析 = 标题式/扁平列表式两种格式都不匹配——此前静默放行
|
|
479
|
+
// (skipped 文案还误导成「解析 0 条,0 条入选」),决策知识凭空丢失不可见。显式 warn 留痕。
|
|
480
|
+
if (parsed.zeroWithContent) {
|
|
481
|
+
console.warn(`⚠️ decisions.md 存在且含 D-xxx 条目形态,但 0 条可解析(既非「## D-xxx」标题式、也非「- D-xxx@vN」扁平列表式)——格式不识别,本次提炼零输出;请核对格式(标题式 \`## D-001@v1 标题\` + 正文 \`- 字段:值\`,或扁平式 \`- D-001@v1:标题 | 状态:implemented\`)`)
|
|
482
|
+
}
|
|
437
483
|
const selected = parsed.entries.filter(e => e.selected)
|
|
438
484
|
if (selected.length === 0) {
|
|
439
485
|
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;
|