sillyspec 3.32.7 → 3.32.9
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 +4 -3
- package/assets/command-cards/flow.md +8 -3
- package/package.json +1 -1
- package/src/change-list.js +43 -7
- package/src/commit-guard.js +70 -2
- package/src/cross-repo-reconcile.js +54 -1
- package/src/deleted-ledger.js +75 -0
- package/src/design-facts.js +5 -3
- package/src/dispatch/backends/local-agent.js +1 -1
- package/src/dispatch/backends/sillyhub-mcp.js +1 -1
- package/src/flow-draft.js +90 -6
- package/src/flow-parity.js +14 -0
- package/src/flow-state.js +21 -0
- package/src/flow.js +503 -86
- package/src/fr-index.js +10 -1
- package/src/index.js +62 -5
- package/src/progress/change-registry.js +7 -0
- package/src/progress.js +83 -0
- package/src/redomain.js +27 -0
- package/src/route-hindsight.js +34 -2
- package/src/run/command.js +37 -41
- package/src/run/complete.js +16 -19
- package/src/run/gates.js +1 -1
- package/src/run/prompt.js +10 -0
- package/src/run/shared.js +19 -0
- package/src/scope-audit.js +2 -2
- package/src/sentinel-assertions.js +13 -7
- package/src/stage-review.js +3 -0
- package/src/stages/brainstorm.js +19 -11
- package/src/stages/execute.js +1 -1
- package/src/stages/explore.js +3 -5
- package/src/stages/knowledge.js +22 -0
- package/src/stages/plan-postcheck.js +42 -7
- package/src/sync.js +70 -0
- package/src/verify-postcheck.js +34 -5
- package/templates/agents-instruction.md +4 -3
- package/templates/prompts/testcase-design.md +5 -0
- package/src/stages/brainstorm-auto.js +0 -286
package/AGENTS.md
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
## 选道(动手前先对号入座)
|
|
8
8
|
| 需求形态 | 走法 |
|
|
9
9
|
|---|---|
|
|
10
|
-
|
|
|
11
|
-
|
|
|
10
|
+
| 需求已含决策(改什么、成功标准说得清——成功标准须来自**用户原话或既有文档转写**;要靠你推断拟出的标准=需求不清晰) | **轻量变更(默认快道)**:`flow start` → 直接干活 → `flow done`(2 次协议调用;操作细节见 `/sillyspec:flow` skill 与 flow start 输出) |
|
|
11
|
+
| 用户显式要求极速(「极速/别验证/只要 spec 和代码」类原话)且需求已含决策 | **轻量变更极速版**:同上行加 `--turbo`(`flow start --change <名> --input "<...>" --turbo`)——**仅用户显式触发,禁止你自行启用**;收口跳过实测与重仪式门(hunk/dirty/ui 探针/评审),spec 结构校验/tasks 全勾/ledger/patch 冻结/distill/归档照常(跳的是验证不是证据);升厚即失效恢复全量仪式;收口输出显式标注「不含测试客观核验」。预段组合极速:头脑风暴预段走轻量跑判定+单轮合并问询+burst 一次 --done,收编时按用户要求带 --turbo |
|
|
12
|
+
| 需求不清晰 / 需要方案探索(含「成功标准要你替用户拟」的形态——自己拟标准再自答是把默认共识伪装成共识) | 头脑风暴预段:`run brainstorm --change <名>` → 完成后 `flow start --change <名>` 收编续跑(design 以头脑风暴版为准)。上个轻量变更被 route-hindsight 标记时全新 start 会被同意门拦下要求显式选道:确认已含决策带 `--no-prestage` 声明留痕,否则先走本行预段 |
|
|
12
13
|
| 大改动:跨模块取舍 / Wave 计划编排 / 多阶段治理 / 设计期人机对抗 | 完整流程五阶段:`run brainstorm → plan → execute → verify → archive`(每阶段一次渲染 + 一次 --done 收口;细节见各阶段 skill 与 CLI 输出) |
|
|
13
14
|
| 代码已先写好(倒推收尾) | 不回头补流程装样子:`flow start --change <YYYY-MM-DD-名> --input` 按下方过门格式写(动机=已做改动的描述,成功标准=已验证达标的行为)→ `flow done` 一步收口 |
|
|
14
15
|
|
|
@@ -31,7 +32,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
|
|
|
31
32
|
## 核心规则
|
|
32
33
|
1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
|
|
33
34
|
2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md(如有))或现有代码依据,无依据不改。
|
|
34
|
-
3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 →
|
|
35
|
+
3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。测试按层适用:纯逻辑先写测试,行为契约断言用户可见行为,视觉样式与纯配置/文档不写测试(记「不适用:理由」,验收靠规范与人工)。
|
|
35
36
|
4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`。以落盘文件与测试结果为准,不信口头"已完成"。
|
|
36
37
|
5. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
|
|
37
38
|
6. **git hook 拦截提交时禁止跳过**(如 `.husky/pre-push`),修复问题后再提交。
|
|
@@ -4,7 +4,7 @@ description: 轻量变更(默认快道):需求明确的小/中改动两步
|
|
|
4
4
|
---
|
|
5
5
|
# 何时用
|
|
6
6
|
|
|
7
|
-
需求已含决策的改动:改什么、成功标准说得清(能列 1-3
|
|
7
|
+
需求已含决策的改动:改什么、成功标准说得清(能列 1-3 条可验证标准),且成功标准来自用户原话/既有文档转写——要靠你替用户拟标准=需求不清晰,先走 brainstorm(完成后 flow start 收编续跑);跨模块取舍/Wave 编排/设计期人机对抗走完整五阶段(run-brainstorm 等卡)。
|
|
8
8
|
|
|
9
9
|
# 生命周期速查
|
|
10
10
|
|
|
@@ -15,12 +15,15 @@ sillyspec flow start --change <变更名> --input "<动机与背景>
|
|
|
15
15
|
|
|
16
16
|
成功标准:
|
|
17
17
|
- <可验证标准>"
|
|
18
|
-
#
|
|
18
|
+
# 极速变体:用户显式说「极速/别验证/只要 spec 和代码」类原话时才加 --turbo(仅用户显式触发,
|
|
19
|
+
# 禁止你自行启用)——flow-state 留痕,收口跳过实测与重仪式门(hunk/dirty/ui 探针/评审),
|
|
20
|
+
# spec 结构校验、tasks 全勾、ledger、patch 冻结、distill、归档照常;升厚即失效恢复全量仪式。
|
|
21
|
+
# ② 直接干活:改代码写测试(按层适用——纯逻辑先写、行为契约断用户所见、样式/纯配置不写);填 design.md 四节(空节 flow done 拒收;列改动文件用独立「## 文件变更清单」节);
|
|
19
22
|
# tasks.md 是你的工作队列——机器种子改写为实现步骤(每行做什么+怎么验证,保持 `- [ ] task-NN:` 行形态),
|
|
20
23
|
# 执行循环:Working on task N/M → 做一件 → 测试绿后当场勾一格(sillyspec task tick --change <名> --task task-NN
|
|
21
24
|
# 即时回显进度与下一任务)→ 下一行;每格勾选要有对应提交(消息带 task-NN),一把勾收口被拒。
|
|
22
25
|
# 交付代码显式 pathspec 提交(patch 冻结面=baseline..HEAD 提交面,未提交不进审计件)
|
|
23
|
-
# ③ 收口(CLI 亲自实测 test/lint + 独立评审定档 + patch
|
|
26
|
+
# ③ 收口(CLI 亲自实测 test/lint + 独立评审定档 + patch 留档;--turbo 变更自动跳过实测与重仪式门):
|
|
24
27
|
sillyspec flow done --change <变更名>
|
|
25
28
|
# 断点恢复 / 随时查进度:
|
|
26
29
|
sillyspec flow status --change <变更名>
|
|
@@ -29,8 +32,10 @@ sillyspec flow status --change <变更名>
|
|
|
29
32
|
# 防坑清单
|
|
30
33
|
|
|
31
34
|
- --input 的「成功标准:」必须独立成行,其后每行一条 `- <可验证标准>`——格式不对 CLI 会拦下给两选一(头脑风暴预段 / 确认输入已含决策),照提示走即可。
|
|
35
|
+
- 预段同意门:上个轻量变更被 route-hindsight 标记「疑似该走预段未走」时,全新 start 会被拦下要求显式选道——确认需求已含决策(成功标准来自用户原话/文档转写)加 `--no-prestage` 声明留痕;要靠你拟标准就走 brainstorm 预段,完成后收编(标记自动消费)。
|
|
32
36
|
- 触及 src/test 的改动 flow done 会亲自实测 local.yaml 的 test/lint(实测失败阻断收口,修好重跑即可)——想省一轮就先自跑。
|
|
33
37
|
- 轻量→完整转道是用户决策:征得同意带 `--upgrade-thick` 重启;实测失败自动升厚。
|
|
38
|
+
- 极速模式(--turbo)触发判据:仅用户显式极速原话(「极速/别验证/只要 spec 和代码」类)才携带——你不得自行启用(实证核验是默认价值,fast 是用户让渡的例外);跳过面只覆盖验证仪式,spec 面与证据面(patch/distill)全保留;收口输出会显式标注「本次收口不含测试客观核验」。预段组合极速:头脑风暴预段走轻量跑判定(需求已收敛直书结论)+ 单轮合并问询 + burst 一次 --done,收编时若用户要求极速则 start 带 --turbo。
|
|
34
39
|
- 显式 pathspec 提交(`git commit -m "..." -- 文件…`),禁 git add -A。
|
|
35
40
|
|
|
36
41
|
# 边界声明
|
package/package.json
CHANGED
package/src/change-list.js
CHANGED
|
@@ -13,6 +13,9 @@ export function normalizePath(raw) {
|
|
|
13
13
|
.replace(/`/g, '')
|
|
14
14
|
.replace(/\s*([^)]*)\s*$/, '')
|
|
15
15
|
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
16
|
+
// 未闭合尾括号残片兜底剥除(2026-10-13-declared-paren-strip:冒号剥等上游截断产生的
|
|
17
|
+
// 「路径黏连(交付」形态——闭合剥救不回,缺陷档案 flow-diff-declared-face-paren-pollution)
|
|
18
|
+
.replace(/\s*[((][^))]*$/, '')
|
|
16
19
|
.replace(/\\/g, '/')
|
|
17
20
|
.replace(/\/+$/, '')
|
|
18
21
|
.trim()
|
|
@@ -168,6 +171,17 @@ function looksLikePath(p) {
|
|
|
168
171
|
*/
|
|
169
172
|
const INCIDENTAL_RE = /顺带修复|附带修复|顺带|drive-?by|incidental/i
|
|
170
173
|
|
|
174
|
+
/**
|
|
175
|
+
* 「<repo-key> 仓变更」段头识别(单一源——2026-10-13-h2-repo-heading-parse 起定义在
|
|
176
|
+
* change-list.js 并导出;原 stages/plan-postcheck.js 模块内常量上移,plan-postcheck 改
|
|
177
|
+
* import 同一符号)。段头为 h2/h3:h3 子段形态由本文件消费(currentRepo 设置器),
|
|
178
|
+
* h2 形态在生态中是既定约定(brainstorm 厚道模板教学、plan-postcheck 覆盖对账
|
|
179
|
+
* parseDesignCoverageByRepo、design-facts/docs-check 存在性核验、评审清单四处按此解析)。
|
|
180
|
+
* 容忍可选编号前缀、(…)备注后缀(全/半角括号)与尾随空白/冒号;捕获组 1 = repo-key
|
|
181
|
+
* (限字母数字._-);key 是否在注册表由调用方 fail-closed 校验。
|
|
182
|
+
*/
|
|
183
|
+
export const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更(?:\s*[((][^()()]*[))])?\s*[::]?\s*$/
|
|
184
|
+
|
|
171
185
|
/**
|
|
172
186
|
* 从清单子段标题 / 路径前缀解析跨仓 repoKey(scope-audit-cross-repo-blindness 改进点 2,
|
|
173
187
|
* 2026-09-15):多仓变更 design 清单按「main 仓 / sub-xxx / spdemo」分段书写,主仓跑
|
|
@@ -222,14 +236,16 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoK
|
|
|
222
236
|
const sectionMatch = content.match(FILE_LIST_SECTION_RE)
|
|
223
237
|
if (!sectionMatch) return []
|
|
224
238
|
|
|
225
|
-
// 从标题后开始,截取到下一个 ##
|
|
239
|
+
// 从标题后开始,截取到下一个 ## 标题或文件末尾——「<注册key> 仓变更」段头(h2)是本节
|
|
240
|
+
// 子分段不截断(2026-10-13-h2-repo-heading-parse:段头是生态既定约定——brainstorm 模板/
|
|
241
|
+
// plan-postcheck 覆盖对账/design-facts 核验四处消费;此前 h2 段头在此终结「文件变更清单」
|
|
242
|
+
// 节,其后条目整段静默丢失,agent 照告警①书写即丢对账与冻结)。
|
|
226
243
|
const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length)
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const lines = relevantContent.split('\n')
|
|
244
|
+
const lines = []
|
|
245
|
+
for (const line of afterSection.split('\n')) {
|
|
246
|
+
if (/^##\s/.test(line) && !REPO_SECTION_HEADER_RE.test(line)) break
|
|
247
|
+
lines.push(line)
|
|
248
|
+
}
|
|
233
249
|
let headerSkipped = false
|
|
234
250
|
let pathColIdx = 1 // 默认第 2 列;解析表头后可重定位
|
|
235
251
|
let opColIdx = -1 // 操作列下标(表头扫描后定位;-1 = 无操作列 → operation=null)
|
|
@@ -239,6 +255,16 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoK
|
|
|
239
255
|
const entries = new Map() // path -> { path, operation, incidental, repo }(exclude 子段 delete)
|
|
240
256
|
|
|
241
257
|
for (const line of lines) {
|
|
258
|
+
// 「<key> 仓变更」段头(h2/h3,REPO_SECTION_HEADER_RE):仓分段切换——key 须在注册表内
|
|
259
|
+
// (fail-closed:未注册含 main → 主仓 repo=null,与 detectRepoFromSubsection 同口径);
|
|
260
|
+
// 段头重置分类子段态(新分段从 include 起、操作类别清空)。
|
|
261
|
+
const repoHdr = line.match(REPO_SECTION_HEADER_RE)
|
|
262
|
+
if (repoHdr) {
|
|
263
|
+
listMode = 'include'
|
|
264
|
+
currentOp = null
|
|
265
|
+
currentRepo = (Array.isArray(repoKeys) && repoKeys.includes(repoHdr[1])) ? repoHdr[1] : null
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
242
268
|
// 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件 / ### sub-grid-security(跨仓段)
|
|
243
269
|
const subHeader = line.match(/^###\s+(.+?)\s*$/)
|
|
244
270
|
if (subHeader) {
|
|
@@ -307,6 +333,16 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoK
|
|
|
307
333
|
const preCross = splitCrossRepoPrefix(normalizePath(listItem[1]), repoKeys)
|
|
308
334
|
let rawItem = listItem[1]
|
|
309
335
|
if (!preCross) {
|
|
336
|
+
// 括号注记剥除前置于冒号剥(2026-10-13-declared-paren-strip,缺陷档案实证:注记
|
|
337
|
+
// 「(交付:说明)」的冒号在括号内,冒号剥先跑把行砍成「路径(交付」未闭合残片——
|
|
338
|
+
// 循环剥行尾完整括号组(嵌套/多组)+ 未闭合残片兜底,路径中段括号不动)。
|
|
339
|
+
let _prev
|
|
340
|
+
do {
|
|
341
|
+
_prev = rawItem
|
|
342
|
+
rawItem = rawItem
|
|
343
|
+
.replace(/\s*[((][^))]*[))]\s*$/, '')
|
|
344
|
+
.replace(/\s*[((][^))]*$/, '')
|
|
345
|
+
} while (rawItem !== _prev)
|
|
310
346
|
// 坑 brainstorm-gate-agent-unavailable-and-list-path-parse 坑2:列表项「路径:描述」
|
|
311
347
|
// 整行当路径 existsSync 假阴性(文件存在却报幻觉路径)——剥首个中/英冒号及之后描述。
|
|
312
348
|
// 仅列表分支剥、且冒号前段须 looksLikePath:normalizePath 全局剥会毁 Windows 绝对路径
|
package/src/commit-guard.js
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* 即警告「疑似扫入他者文件」。
|
|
14
14
|
* S2 跨变更目录:staged 里 .sillyspec/changes/<name>/(含 archive/)命中 ≥2 个不同
|
|
15
15
|
* name → 警告「正常提交只携带一个变更的面」。
|
|
16
|
+
* S3 pathspec 提交暂存区旁路(2026-10-13-commit-guard-s3,055080e4 三十八行夹带实证):
|
|
17
|
+
* `git commit -- <路径>` 取工作树内容构造临时索引(GIT_INDEX_FILE 指给钩子),
|
|
18
|
+
* 暂存区净版被绕过——两索引同路径 blob 不一致 = 提交将携带未暂存工作树内容
|
|
19
|
+
* (共享仓多会话时即并行编辑夹带)。普通全量提交零开销跳过。
|
|
16
20
|
* 命中即 stderr 警告 + appendWriteAudit 留痕(via: 'commit-guard')。
|
|
17
21
|
*
|
|
18
22
|
* fail-open 硬约束(钩子挂在所有会话共享的 .husky/ 上,守卫自身绝不拦任何人的提交):
|
|
@@ -20,7 +24,7 @@
|
|
|
20
24
|
*/
|
|
21
25
|
import { execFileSync } from 'child_process'
|
|
22
26
|
import { readFileSync, existsSync } from 'fs'
|
|
23
|
-
import { join } from 'path'
|
|
27
|
+
import { join, resolve } from 'path'
|
|
24
28
|
import { pathToFileURL } from 'url'
|
|
25
29
|
import { appendWriteAudit } from './write-audit.js'
|
|
26
30
|
|
|
@@ -75,6 +79,43 @@ export function analyzeStagedFace(stagedFiles, qlPatches) {
|
|
|
75
79
|
return warnings
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/**
|
|
83
|
+
* S3 纯函数①:解析 `git ls-files -s` 输出为条目数组(CRLF/噪声行容错)。
|
|
84
|
+
* 行形态:`<mode> <sha> <stage>\t<path>`。
|
|
85
|
+
*/
|
|
86
|
+
export function parseLsFiles(output) {
|
|
87
|
+
const out = []
|
|
88
|
+
for (const line of String(output || '').split(/\r?\n/)) {
|
|
89
|
+
const m = line.match(/^(\d+) ([0-9a-f]{40}) (\d+)\t(.+)$/)
|
|
90
|
+
if (m) out.push({ mode: m[1], sha: m[2], stage: Number(m[3]), path: m[4] })
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* S3 纯函数②:两索引逐路径比对(2026-10-13-commit-guard-s3)。temp=钩子 GIT_INDEX_FILE
|
|
97
|
+
* 指向的临时索引(pathspec 提交将入库的内容),real=真实暂存区(用户 add 过的净版)。
|
|
98
|
+
* 不一致(异 sha / 仅临时在场)= 提交将携带未暂存工作树内容——暂存净版被 pathspec 语义
|
|
99
|
+
* 绕过(055080e4 实证形态)。仅真实在场(staged 但不在 pathspec)不告:该文件不会被
|
|
100
|
+
* 本提交带走。冲突态(stage>0)不判。
|
|
101
|
+
*/
|
|
102
|
+
export function analyzeIndexDivergence(tempEntries, realEntries) {
|
|
103
|
+
const real = new Map()
|
|
104
|
+
for (const e of realEntries) if (e && e.stage === 0 && e.path) real.set(e.path, e.sha)
|
|
105
|
+
const files = []
|
|
106
|
+
for (const e of tempEntries) {
|
|
107
|
+
if (!e || e.stage !== 0 || !e.path) continue
|
|
108
|
+
const rs = real.get(e.path)
|
|
109
|
+
if (rs === undefined || rs !== e.sha) files.push(e.path)
|
|
110
|
+
}
|
|
111
|
+
if (files.length === 0) return []
|
|
112
|
+
return [{
|
|
113
|
+
signal: 'index_divergence_on_pathspec_commit',
|
|
114
|
+
files,
|
|
115
|
+
message: `pathspec 提交将携带未暂存的工作树内容(暂存区净版被绕过——git commit -- <路径> 取工作树内容):${files.slice(0, 5).join('、')}${files.length > 5 ? ' 等' : ''}——共享仓此形态会夹带并行会话在途编辑;核对 git diff -- <路径> 后 add 最新净版再提交,或等并行会话先提交该文件`,
|
|
116
|
+
}]
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
function main() {
|
|
79
120
|
const cwd = process.cwd()
|
|
80
121
|
let staged = []
|
|
@@ -102,7 +143,34 @@ function main() {
|
|
|
102
143
|
qlPatches[f] = JSON.parse(readFileSync(join(cwd, f), 'utf8'))
|
|
103
144
|
} catch { /* 单个 json 损坏 → 跳过该信号源,不连坐 */ }
|
|
104
145
|
}
|
|
105
|
-
|
|
146
|
+
let warnings = analyzeStagedFace(staged, qlPatches)
|
|
147
|
+
// S3:pathspec 提交的暂存区旁路检测(fail-open——任何异常静默跳过该信号,绝不拦提交)。
|
|
148
|
+
// ⚠ 两个实证语义:①rev-parse --git-path index 跟随 GIT_INDEX_FILE(钩子内返回临时索引
|
|
149
|
+
// 路径自身)——真索引解析必须剥掉该环境变量;②worktree 内该命令返回**绝对路径**
|
|
150
|
+
// (.git/worktrees/<n>/index)——拼接必须用 resolve(绝对段重置),join(cwd, 绝对) 会拼出
|
|
151
|
+
// 损坏路径 → ls-files 指向不存在索引 exit 0 输出空 → 全文件误判偏差(评审 P1 实证)。
|
|
152
|
+
// 未设或等于真索引 → 跳过(普通全量提交零开销)。
|
|
153
|
+
try {
|
|
154
|
+
const envIdx = process.env.GIT_INDEX_FILE || ''
|
|
155
|
+
if (envIdx) {
|
|
156
|
+
const { GIT_INDEX_FILE: _drop, ...envNoIdx } = process.env
|
|
157
|
+
const realIdxRaw = execFileSync('git', ['rev-parse', '--git-path', 'index'], {
|
|
158
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: envNoIdx,
|
|
159
|
+
}).trim()
|
|
160
|
+
const realIdx = resolve(cwd, realIdxRaw) // 绝对段重置语义(worktree 形态关键)
|
|
161
|
+
const norm = (p) => {
|
|
162
|
+
try { return resolve(p).replace(/\\/g, '/').toLowerCase() } catch { return String(p).replace(/\\/g, '/').toLowerCase() }
|
|
163
|
+
}
|
|
164
|
+
if (norm(envIdx) !== norm(realIdx)) {
|
|
165
|
+
const tempLs = execFileSync('git', ['ls-files', '-s'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
166
|
+
const realLs = execFileSync('git', ['ls-files', '-s'], {
|
|
167
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
168
|
+
env: { ...process.env, GIT_INDEX_FILE: realIdx },
|
|
169
|
+
})
|
|
170
|
+
warnings = [...warnings, ...analyzeIndexDivergence(parseLsFiles(tempLs), parseLsFiles(realLs))]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch { /* S3 fail-open:解析/比对异常静默跳过 */ }
|
|
106
174
|
if (warnings.length === 0) return 0
|
|
107
175
|
for (const w of warnings) process.stderr.write(`⚠️ [commit-guard] ${w.message}\n`)
|
|
108
176
|
const specDir = join(cwd, '.sillyspec')
|
|
@@ -198,7 +198,7 @@ function collectStatusInto(repoPath, union) {
|
|
|
198
198
|
* 纯读 fail-soft:任何异常 → degraded 形态返回(degradedReason=`内核采集异常: <msg首行>`),
|
|
199
199
|
* 绝不 throw。
|
|
200
200
|
*/
|
|
201
|
-
export function collectRepoActual({ repoKey, specBase, cwd, runtimeRoot = null, changeName = null }) {
|
|
201
|
+
export function collectRepoActual({ repoKey, specBase, cwd, runtimeRoot = null, changeName = null, crossBaselines = null }) {
|
|
202
202
|
try {
|
|
203
203
|
// 1) 仓注册解析(与 reconcile 旧口径同源:local.yaml repos 段,读失败 → 空注册表落「未注册」)
|
|
204
204
|
let registry = new Map()
|
|
@@ -264,6 +264,59 @@ export function collectRepoActual({ repoKey, specBase, cwd, runtimeRoot = null,
|
|
|
264
264
|
}
|
|
265
265
|
} catch { /* meta 不可读/键缺失 → 回退 B 档 */ }
|
|
266
266
|
|
|
267
|
+
// 3c) flow-start-baseline 窗口(2026-10-13-cross-repo-baseline-anchor):thin 手工交付的
|
|
268
|
+
// 跨仓基线锚(flow start 首写者胜记录各注册仓 HEAD)——窗口=基线..工作树,根治 B 档
|
|
269
|
+
// HEAD~1 单笔窗口的多提交盲区(fire-ledger 两笔交付实证:实现+评审修复,首笔整笔
|
|
270
|
+
// 漏窗)。主面=token 归因(窗口内提交消息含变更名的提交文件并集——AGENTS 规则 11
|
|
271
|
+
// 的消息约定跨仓同样适用)∪ 未提交态;零 token 命中回退全窗口(并行工作区噪音
|
|
272
|
+
// 可见不静默——unplanned 行承载,与主仓夹带嫌疑 advisory 同语义)。基线 sha 在该仓
|
|
273
|
+
// 不可达(浅克隆/被 gc)→ 回退 B 档(不回退语义)。
|
|
274
|
+
const _blSha = crossBaselines && /^[0-9a-f]{7,40}$/i.test(String(crossBaselines[repoKey] || '')) ? String(crossBaselines[repoKey]) : null
|
|
275
|
+
if (_blSha) {
|
|
276
|
+
const _ok = gitQuiet(repoPath, ['rev-parse', '--verify', '--quiet', `${_blSha}^{commit}`], { timeout: GIT_TIMEOUT })
|
|
277
|
+
if (_ok !== null && String(_ok).trim()) {
|
|
278
|
+
const _mine = new Set()
|
|
279
|
+
let _tokenHit = false
|
|
280
|
+
const _log = changeName
|
|
281
|
+
? gitQuiet(repoPath, ['log', '--format=%H', `--grep=${changeName}`, `${_blSha}..HEAD`], { timeout: GIT_TIMEOUT })
|
|
282
|
+
: null
|
|
283
|
+
if (_log !== null) {
|
|
284
|
+
for (const c of String(_log).split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
285
|
+
// -m --first-parent(评审 P2 清偿):merge 提交不带 -m 时 diff-tree 输出**空**——消息
|
|
286
|
+
// 带变更名的 merge 会假命中 token 却收零文件、并抑制全窗口回退(窄形态劣于 B 档)。
|
|
287
|
+
// --first-parent 取 merge 带入本线的文件面(非 merge 提交单亲,-m 为 no-op)。
|
|
288
|
+
const dt = gitQuiet(repoPath, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', '--first-parent', c], { timeout: GIT_TIMEOUT })
|
|
289
|
+
if (dt === null) continue
|
|
290
|
+
let got = false
|
|
291
|
+
for (const f of String(dt).split('\n')) {
|
|
292
|
+
const n = normalizeRepoPath(f)
|
|
293
|
+
if (n) { _mine.add(n); got = true }
|
|
294
|
+
}
|
|
295
|
+
// 空文件列表不计 tokenHit(belt:全空命中回退全窗口而非静默漏面)
|
|
296
|
+
if (got) _tokenHit = true
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const union3 = new Set(_mine)
|
|
300
|
+
if (!_tokenHit) {
|
|
301
|
+
const d1 = gitQuiet(repoPath, ['diff', '--name-only', _blSha], { timeout: GIT_TIMEOUT })
|
|
302
|
+
for (const f of String(d1 || '').split('\n')) {
|
|
303
|
+
const n = normalizeRepoPath(f)
|
|
304
|
+
if (n) union3.add(n)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const d2 = gitQuiet(repoPath, ['ls-files', '--others', '--exclude-standard'], { timeout: GIT_TIMEOUT })
|
|
308
|
+
for (const f of String(d2 || '').split('\n')) {
|
|
309
|
+
const n = normalizeRepoPath(f)
|
|
310
|
+
if (n && n !== 'meta.json' && !n.startsWith('.sillyspec/')) union3.add(n)
|
|
311
|
+
}
|
|
312
|
+
collectStatusInto(repoPath, union3)
|
|
313
|
+
return { repo: repoKey, repoPath,
|
|
314
|
+
anchor: { source: 'flow-start-baseline', base: _blSha, head: null,
|
|
315
|
+
label: `flow-start 跨仓基线窗口(base=${_blSha.slice(0, 8)}…HEAD+工作树${_tokenHit ? ';token 归因命中(窗口内含变更名提交为主面)' : ';零 token 命中回退全窗口(并行噪音可见)'})` },
|
|
316
|
+
files: [...union3].sort(), degradedReason: null }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
267
320
|
// 4) B/C 档:diff HEAD~1..HEAD(现行 reconcile 口径)∪ status;diff 失败仅 status → C 档
|
|
268
321
|
const union = new Set()
|
|
269
322
|
const diffOut = gitQuiet(repoPath, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: GIT_TIMEOUT })
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 删除台账(deleted-changes.jsonl)——终态转换的追加式账本。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-10-13-new-change-e5a27813 幽灵变更事故,multi-agent-platform 仓实证):
|
|
5
|
+
* X1 墓碑链(sync.js 对 DB status='deleted'/'archived' 行追加墓碑 POST)依赖 DB 行存活;
|
|
6
|
+
* DB 行被外部手段物理清除后(rm + 裸 SQL,任何 sillyspec 官方路径都不物理删行),后续
|
|
7
|
+
* platform sync --change 一律「变更无进度数据」空转,平台行永挂 in_progress/active。
|
|
8
|
+
* 台账让「存在过且已达终态」这一事实脱离 DB 行存活而可查:deleteChange/unregisterChange
|
|
9
|
+
* 在事务提交后追加一行;sync() 的行缺失分支据此合成墓碑补推(见 sync.js 台账分支)。
|
|
10
|
+
*
|
|
11
|
+
* 形态契约:`.sillyspec/.runtime/deleted-changes.jsonl`,每行一个 JSON 对象
|
|
12
|
+
* { name, status: 'deleted'|'archived', at: ISO };追加-only(读取侧 latest-wins),
|
|
13
|
+
* 不旋转不清空——每行百余字节、终态转换频度=归档/删除频度,增长可忽略。
|
|
14
|
+
* 坏行容错:单行 JSON 解析失败跳过该行(外部编辑/半写截断不拖垮读取)。
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const LEDGER_FILENAME = 'deleted-changes.jsonl'
|
|
20
|
+
|
|
21
|
+
function ledgerPath(specDir) {
|
|
22
|
+
return join(specDir, '.runtime', LEDGER_FILENAME)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 追加一条终态台账。fail-open:任何写入失败只 warn 不抛——台账是 DB 行存活时的
|
|
27
|
+
* 冗余信息(X1 链已闭环),它的缺失最多让「行被外部清除」这一非官方场景退回平台
|
|
28
|
+
* 7 天 reparse 兜底,不值得阻断删除/归档主流程。
|
|
29
|
+
* @param {string} specDir 已解析的 .sillyspec 根(与 sillyspec.db 同根)
|
|
30
|
+
* @param {string} changeName 变更名
|
|
31
|
+
* @param {'deleted'|'archived'} status 终态
|
|
32
|
+
* @param {string} [at] ISO 时间戳(缺省当前时间)
|
|
33
|
+
*/
|
|
34
|
+
export function appendDeletedLedger(specDir, changeName, status, at = new Date().toISOString()) {
|
|
35
|
+
if (!specDir || !changeName || (status !== 'deleted' && status !== 'archived')) {
|
|
36
|
+
console.warn(`⚠️ appendDeletedLedger: 参数不完整(name=${changeName} status=${status}),跳过台账追加`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
appendFileSync(ledgerPath(specDir), JSON.stringify({ name: changeName, status, at }) + '\n', 'utf8')
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn(`⚠️ 删除台账追加失败(fail-open 不阻断主流程): ${changeName}: ${(err && err.message) || err}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 查询变更的最新终态台账条目(同名多条取最后一条——latest-wins)。
|
|
48
|
+
* 容错:文件不存在 → null;坏行跳过不抛。
|
|
49
|
+
* @param {string} specDir 已解析的 .sillyspec 根
|
|
50
|
+
* @param {string} changeName 变更名
|
|
51
|
+
* @returns {{name: string, status: string, at: string} | null}
|
|
52
|
+
*/
|
|
53
|
+
export function lookupDeletedLedger(specDir, changeName) {
|
|
54
|
+
if (!specDir || !changeName) return null
|
|
55
|
+
const p = ledgerPath(specDir)
|
|
56
|
+
if (!existsSync(p)) return null
|
|
57
|
+
let text
|
|
58
|
+
try {
|
|
59
|
+
text = readFileSync(p, 'utf8')
|
|
60
|
+
} catch {
|
|
61
|
+
return null // 读失败按无台账处理(消费侧退回普通「无进度数据」路径)
|
|
62
|
+
}
|
|
63
|
+
let hit = null
|
|
64
|
+
for (const line of text.split('\n')) {
|
|
65
|
+
const trimmed = line.trim()
|
|
66
|
+
if (!trimmed) continue
|
|
67
|
+
try {
|
|
68
|
+
const obj = JSON.parse(trimmed)
|
|
69
|
+
if (obj && obj.name === changeName && (obj.status === 'deleted' || obj.status === 'archived')) {
|
|
70
|
+
hit = { name: obj.name, status: obj.status, at: obj.at }
|
|
71
|
+
}
|
|
72
|
+
} catch { /* 坏行跳过:半写截断/外部编辑不拖垮整本台账 */ }
|
|
73
|
+
}
|
|
74
|
+
return hit
|
|
75
|
+
}
|
package/src/design-facts.js
CHANGED
|
@@ -296,8 +296,10 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
296
296
|
//「骨架优先,仅手写补文档才手填元数据」的依据(validateMetadata 未来可据此只认 CLI 戳)
|
|
297
297
|
lines.push(`generated_by: sillyspec-design-init`)
|
|
298
298
|
// scale 不预填(2026-09-29-brainstorm-exit-thin-default):此前预填 large 是静默厚默认——
|
|
299
|
-
// 不主动改就是五阶段;现由 brainstorm Step 8 规模评估按复杂度/上下文轴落值,拿不准默认 small
|
|
300
|
-
|
|
299
|
+
// 不主动改就是五阶段;现由 brainstorm Step 8 规模评估按复杂度/上下文轴落值,拿不准默认 small。
|
|
300
|
+
// 反判据口径(2026-10-14-scale-large-evidence):跨端/多仓/顺序依赖不算 Wave 证据,判 large
|
|
301
|
+
// 须写明「可真并行任务簇+单上下文吃不下」两条同时成立——deploy-release-notes 镜像误判实证
|
|
302
|
+
lines.push('scale: "" # TODO:Step 8 规模评估落值——单上下文可吞吐=small(flow start 收编);需 Wave 真并行编排/上下文分片/多阶段治理=large(run plan,须写明 Wave 证据:可真并行任务簇+单上下文吃不下;跨端/多仓/顺序依赖不算);拿不准 small')
|
|
301
303
|
lines.push('---')
|
|
302
304
|
lines.push('')
|
|
303
305
|
lines.push(`# 设计文档(Design)— ${change}`)
|
|
@@ -385,7 +387,7 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
385
387
|
lines.push('- [ ] frontmatter 字段齐全(author/created_at/scale)')
|
|
386
388
|
lines.push('- [ ] 引用所有当前版本 D-xxx@vN')
|
|
387
389
|
lines.push('- [ ] 涉及生命周期关键词时含「生命周期契约表」或紧邻豁免短语')
|
|
388
|
-
lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html
|
|
390
|
+
lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记)')
|
|
389
391
|
lines.push('- [ ] 不确定的问题标注「⚠️ 自审存疑」')
|
|
390
392
|
lines.push('')
|
|
391
393
|
|
|
@@ -95,7 +95,7 @@ export function renderLocalInstruction(contract) {
|
|
|
95
95
|
|
|
96
96
|
const modeLine = readOnly
|
|
97
97
|
? '5. **只读模式(readOnly=true)**:本子代理只读 / 审查 / 写 review.json,**不改源码**'
|
|
98
|
-
: '5. **写模式(readOnly=false)**:按蓝图实现源码(TDD:先读后写 → 写测试 →
|
|
98
|
+
: '5. **写模式(readOnly=false)**:按蓝图实现源码(TDD:先读后写 → 写测试 → 写实现,按层适用——纯逻辑严格先行、行为契约断用户所见、样式/纯配置不写,判据见「测试用例设计」)'
|
|
99
99
|
|
|
100
100
|
return `### 派发后端:Local(本机 Agent tool)— 默认与降级路径
|
|
101
101
|
|
|
@@ -218,7 +218,7 @@ dispatch_worker 的 \`worker_prompt\` 参数必须传以下覆写文本(路径
|
|
|
218
218
|
|
|
219
219
|
\`\`\`
|
|
220
220
|
你是 SillySpec execute 的 worker,在指定 worktree 干活。铁律:
|
|
221
|
-
1. 按 objective + 蓝图(tasks/task-XX.md / design.md)实现任务(TDD:先读后写 → 写测试 →
|
|
221
|
+
1. 按 objective + 蓝图(tasks/task-XX.md / design.md)实现任务(TDD:先读后写 → 写测试 → 写实现,按层适用——纯逻辑严格先行、行为契约断用户所见、样式/纯配置不写,判据见「测试用例设计」)
|
|
222
222
|
2. **绝不执行 git add / git commit / git push**:改动留在工作区(staged 或 unstaged 都行),
|
|
223
223
|
交 SillySpec 主体 git diff 回收(D-004)
|
|
224
224
|
3. 不超出 allowedPaths,不碰其他 task 的文件
|
package/src/flow-draft.js
CHANGED
|
@@ -65,6 +65,27 @@ export const DESIGN_QUESTIONS = {
|
|
|
65
65
|
* 占位句自带词表字样的误放行由 pending 检查((待撰写 前缀)独立拦截,不依赖本判据)。 */
|
|
66
66
|
const FR_STRENGTH_RE = /\b(SHALL|MUST|SHOULD)\b|必须|禁止/
|
|
67
67
|
|
|
68
|
+
/** 去 whitespace 字符 bigram Jaccard 相似度(design 问题行回填定位——2026-10-13-design-question-autorepair)。
|
|
69
|
+
* 改述行由锚问题衍生、共享绝大多数字符对,天然高于任何答案行;≥0.5 视为可原位替换。 */
|
|
70
|
+
function charBigramSimilarity(a, b) {
|
|
71
|
+
const grams = (s) => {
|
|
72
|
+
const t = String(s || '').replace(/\s+/g, '')
|
|
73
|
+
const set = new Set()
|
|
74
|
+
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2))
|
|
75
|
+
return set
|
|
76
|
+
}
|
|
77
|
+
const A = grams(a), B = grams(b)
|
|
78
|
+
let inter = 0
|
|
79
|
+
for (const g of A) if (B.has(g)) inter++
|
|
80
|
+
const union = A.size + B.size - inter
|
|
81
|
+
return union === 0 ? 0 : inter / union
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** design 问题行回填阈值:bigram 相似度 ≥ 此值定位到改述行(插入点=其上方),低于则节首插入。
|
|
85
|
+
* 回填只插入永不删除(评审 P1 清偿:引用问题原文的作答行相似度可胜过真改述行,任何替换式
|
|
86
|
+
* 回填都有静默销毁 agent 内容的面——插入式把「绝不删除」做成结构不变量,不再依赖阈值甄别)。 */
|
|
87
|
+
const QUESTION_REPAIR_SIM_THRESHOLD = 0.5
|
|
88
|
+
|
|
68
89
|
/** sidecar 台账路径(.runtime 下,verify-draft sidecar 同族;命名空间=文件:段键)。 */
|
|
69
90
|
export function draftLedgerPath(runtimeRoot, changeName) {
|
|
70
91
|
return join(runtimeRoot, `draft-ledger-${changeName}.json`)
|
|
@@ -135,6 +156,11 @@ export function extractSuccessCriteria(input, opts = {}) {
|
|
|
135
156
|
const lines = []
|
|
136
157
|
for (const l of rawLines) {
|
|
137
158
|
const prev = lines[lines.length - 1]
|
|
159
|
+
// 节标题合并守卫(2026-10-13-extract-head-guard 实证:动机行尾未闭合左括号 → 续行合并把
|
|
160
|
+
// 「成功标准:」并入动机、合并行以冒号收尾继续触发合并雪崩吞并全部条目行、提取 0 条。
|
|
161
|
+
// 节标题是结构边界——即使 prev 残缺也无条件独立成行。守卫只判当前行属性,不改
|
|
162
|
+
// needsContinuationMerge 本体(其括号判定是 R17 括号跨行并回的承重面)。
|
|
163
|
+
if (SECTION_HEAD_RE.test(l)) { lines.push(l); continue }
|
|
138
164
|
if (prev != null && needsContinuationMerge(prev)) lines[lines.length - 1] = prev + l
|
|
139
165
|
else lines.push(l)
|
|
140
166
|
}
|
|
@@ -303,25 +329,83 @@ function draftTasksV2({ change, criteria, withTasks }) {
|
|
|
303
329
|
export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
|
|
304
330
|
const violations = []
|
|
305
331
|
const advisories = []
|
|
332
|
+
const repairs = []
|
|
306
333
|
const anchorCriteria = Array.isArray(ledger?.anchor?.criteria) ? ledger.anchor.criteria : []
|
|
307
334
|
const read = (f) => {
|
|
308
335
|
try { return readFileSync(join(changeDir, f), 'utf8').replace(/\r\n/g, '\n') } catch { return null }
|
|
309
336
|
}
|
|
310
337
|
|
|
311
|
-
// design 四节:问题在场(对锚逐字)+
|
|
338
|
+
// design 四节:问题在场(对锚逐字)+ 实质作答。问题行漂移(节标题在场)→ 机器按锚回填
|
|
339
|
+
// (2026-10-13-design-question-autorepair:sess_dcb57f25 实证 Write 整文件重写滑误——原文
|
|
340
|
+
// 3 秒前刚 Read 过仍被整段改述,事前提示是概率性防护;确定防线=锚对比,修被拦后的恢复面:
|
|
341
|
+
// 高相似改述原位替换、低相似/删除节首插入,agent 作答零丢失,repairs 留痕交 flow.js 渲染)。
|
|
312
342
|
if (!skipDesign) {
|
|
313
343
|
const dText = read('design.md')
|
|
314
344
|
if (dText === null) violations.push('design.md 缺失(v2 起草件在案)——删除后重入 flow start 补生成')
|
|
315
345
|
else {
|
|
316
346
|
const anchorSections = ledger?.anchor?.designQuestions?.sections || DESIGN_QUESTIONS.sections
|
|
317
|
-
|
|
347
|
+
let dLines = dText.split('\n')
|
|
348
|
+
// 回填通道:改写先到不了归档(回填后进归档的就是锚文本,与人工恢复等价),写盘失败降级
|
|
349
|
+
// 回带全文的拒收文案(不抛异常阻断 CLI)。
|
|
350
|
+
// 问句在场判定(坑 flow-done-design-question-line-injection,2026-10-13 实证):回答行
|
|
351
|
+
// 前缀形态「N. <问句>:<答>」/「- <问句>:…」——问句锚保留在行内时视为在场,不注入
|
|
352
|
+
// 不拒收。此前只认整行相等:锚折进回答行 → 相似度低于阈值 → 四条全走节首兜底插入,
|
|
353
|
+
// 同位反复 splice 产出倒序重复问句块。改写形态(行内无锚原文)仍走既有回填/拒收链。
|
|
354
|
+
const questionPresent = (lines, q) => lines.some((raw) => {
|
|
355
|
+
const t = String(raw || '').trim()
|
|
356
|
+
if (!t || t === q) return t === q
|
|
357
|
+
// 包含判定只对自带编号的锚生效(四问节「N. 问句」形态——回答行「N. 问句:答」折叠
|
|
358
|
+
// 是惯例书写面,注入即倒序重复块,坑 flow-done-design-question-line-injection)。
|
|
359
|
+
// 无编号锚(做法/契约/风险节单行问句)不做包含判定:⑦c 契约要求引用原文的作答行
|
|
360
|
+
// 仍注入锚在引用行上方(锚独立成行是审计面)——包含跳过会吃掉该注入。
|
|
361
|
+
if (!/^\d+[.、)]\s/.test(q)) return false
|
|
362
|
+
// 只剥一层编号/列表前缀(锚自带「N.」编号,多层剥会把锚的编号也吃掉致包含
|
|
363
|
+
// 判定假阴性——① 用例实证)
|
|
364
|
+
const stripped = t.replace(/^(?:\d+[.、)]\s*|[-*•]\s*)/, '')
|
|
365
|
+
return stripped.includes(q)
|
|
366
|
+
})
|
|
367
|
+
const allQuestionLines = new Set(anchorSections.flatMap((s) => s.lines))
|
|
368
|
+
const repairedLines = [...dLines]
|
|
369
|
+
const repairEntries = []
|
|
370
|
+
for (const sec of anchorSections) {
|
|
371
|
+
for (const q of sec.lines) {
|
|
372
|
+
if (questionPresent(repairedLines, q)) continue
|
|
373
|
+
const hIdx = repairedLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
|
|
374
|
+
if (hIdx === -1) continue // 节整删 → 下方校验环拒收(文案带全文)
|
|
375
|
+
let end = repairedLines.findIndex((l, i) => i > hIdx && /^##\s/.test(l))
|
|
376
|
+
if (end === -1) end = repairedLines.length
|
|
377
|
+
let bestIdx = -1, bestSim = 0
|
|
378
|
+
for (let i = hIdx + 1; i < end; i++) {
|
|
379
|
+
const t = repairedLines[i].trim()
|
|
380
|
+
if (!t || allQuestionLines.has(t)) continue
|
|
381
|
+
const sim = charBigramSimilarity(t, q)
|
|
382
|
+
if (sim > bestSim) { bestSim = sim; bestIdx = i }
|
|
383
|
+
}
|
|
384
|
+
// 只插入不替换(评审 P1 清偿):高相似定位 → 插在改述行上方(原文与改述相邻、可读性
|
|
385
|
+
// 最好);未定位 → 节首。任何路径都不改动既有行——误定位的最坏结果是插入位置欠佳。
|
|
386
|
+
const insertAt = bestIdx >= 0 && bestSim >= QUESTION_REPAIR_SIM_THRESHOLD
|
|
387
|
+
? bestIdx
|
|
388
|
+
: (repairedLines[hIdx + 1] === '' ? hIdx + 2 : hIdx + 1)
|
|
389
|
+
repairedLines.splice(insertAt, 0, q, '')
|
|
390
|
+
repairEntries.push({ heading: sec.heading, q })
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (repairEntries.length > 0) {
|
|
394
|
+
try {
|
|
395
|
+
writeAtomicSync(join(changeDir, 'design.md'), repairedLines.join('\n'))
|
|
396
|
+
dLines = repairedLines
|
|
397
|
+
for (const e of repairEntries) repairs.push(`design「${e.heading}」问题行曾被改写——已按锚回填原文(agent 作答保留):${e.q}`)
|
|
398
|
+
} catch {
|
|
399
|
+
for (const e of repairEntries) violations.push(`design「${e.heading}」问题文本被改写——原文:${e.q}(机器回填写盘失败,请手工恢复此行,答案写在下方)`)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
318
402
|
for (const sec of anchorSections) {
|
|
319
403
|
if (!dLines.some((l) => l.trim() === `## ${sec.heading}`)) {
|
|
320
|
-
violations.push(`design「${sec.heading}
|
|
404
|
+
violations.push(`design「${sec.heading}」节被删(标题缺失)——恢复小节与四问原文后作答;问题原文:${sec.lines.join('/')}`)
|
|
321
405
|
continue
|
|
322
406
|
}
|
|
323
407
|
for (const q of sec.lines) {
|
|
324
|
-
if (!dLines
|
|
408
|
+
if (!questionPresent(dLines, q)) violations.push(`design「${sec.heading}」问题文本被改写——原文:${q}(问题钉死,答案写在下方;折进回答行前缀亦算在场)`)
|
|
325
409
|
}
|
|
326
410
|
// 实质作答:节内非空行 - 标题行 - 问题行 - 引导行(> 开头)≥1
|
|
327
411
|
const start = dLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
|
|
@@ -332,7 +416,7 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
|
|
|
332
416
|
const t = l.trim()
|
|
333
417
|
return t && !qSet.has(t) && !t.startsWith('>') && !t.startsWith('##')
|
|
334
418
|
})
|
|
335
|
-
if (!answered) violations.push(`design「${sec.heading}
|
|
419
|
+
if (!answered) violations.push(`design「${sec.heading}」未作答——每节至少一行(小改动可写「不适用:<理由>」);问题原文:${sec.lines.join('/')}`)
|
|
336
420
|
}
|
|
337
421
|
}
|
|
338
422
|
}
|
|
@@ -399,7 +483,7 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
|
|
|
399
483
|
const inFr = rText !== null && rText.includes(c)
|
|
400
484
|
if (!inFr) advisories.push(`成功标准第 ${i + 1} 条原文未在 requirements 见到(FR 标题锚漂移嫌疑——若属有意改写请核对语义未失真):${c.slice(0, 40)}${c.length > 40 ? '…' : ''}`)
|
|
401
485
|
}
|
|
402
|
-
return { violations, advisories }
|
|
486
|
+
return { violations, advisories, repairs }
|
|
403
487
|
}
|
|
404
488
|
|
|
405
489
|
|
package/src/flow-parity.js
CHANGED
|
@@ -26,6 +26,7 @@ import { splitOwnVsForeignDiffFiles, commitAttributionForChange, isForeignByComm
|
|
|
26
26
|
import { collectModuleMaps, normalizeMapPath } from './module-resolve.js'
|
|
27
27
|
import { computeDocsDebt } from './docs-debt.js'
|
|
28
28
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
29
|
+
import { normalizePath } from './change-list.js'
|
|
29
30
|
import { readV2SectionAnswer } from './flow-review.js'
|
|
30
31
|
import { DESIGN_QUESTIONS } from './flow-draft.js'
|
|
31
32
|
import { pathMatches } from './change-list.js'
|
|
@@ -563,6 +564,19 @@ export function buildThinSnapshotRows({ ownFiles, stats, planEntries, crossRepoR
|
|
|
563
564
|
// + pathMatches 双判——对账行是真实三态或 degraded ⊘,覆盖即不再补恒 ⊘ 行);对账行缺失
|
|
564
565
|
// (fail-soft 退路/降级仓无行)→ 照旧补 ⊘ 行
|
|
565
566
|
if (e.repo && crossRows.some((r) => r.crossRepo === e.repo && pathMatches(String(r.path || ''), e.path))) continue
|
|
567
|
+
// 兜底合并(2026-10-13-declared-paren-strip):declared 路径剥括号残片(normalizePath,
|
|
568
|
+
// 与解析端同源)后与实际行精确等值 → 不补占位行;实际行归因升 planned(取更准一行),
|
|
569
|
+
// totals 按唯一路径计。防他源污染形态致同文件双行/文件数虚高(缺陷档案实证形态的兜底层)。
|
|
570
|
+
const clean = normalizePath(String(e.path || ''))
|
|
571
|
+
const hitRow = clean ? rows.find((r) => r.path === clean && !r.crossRepo) : null
|
|
572
|
+
if (hitRow) {
|
|
573
|
+
matched.add(e.path)
|
|
574
|
+
if (hitRow.verdict !== 'planned') {
|
|
575
|
+
hitRow.planned = e.operation || null
|
|
576
|
+
hitRow.verdict = 'planned'
|
|
577
|
+
}
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
566
580
|
rows.push({
|
|
567
581
|
path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
|
|
568
582
|
kind: 'modified', verdict: 'untouched',
|