sillyspec 3.32.8 → 3.32.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-execute/SKILL.md +2 -2
- package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +2 -2
- package/AGENTS.md +5 -4
- package/assets/command-cards/flow.md +9 -4
- package/package.json +1 -1
- package/src/change-list.js +13 -0
- package/src/commit-guard.js +70 -2
- package/src/commit-suggest.js +45 -4
- package/src/config-schema.js +14 -0
- package/src/cross-repo-reconcile.js +54 -1
- package/src/decision-distill.js +77 -21
- package/src/decision-renumber.js +157 -0
- 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/docs-check.js +30 -15
- package/src/docs-debt.js +3 -2
- package/src/doctor-diagnostics.js +3 -1
- package/src/flow-draft.js +24 -2
- package/src/flow-parity.js +32 -2
- package/src/flow.js +427 -69
- package/src/fr-index.js +15 -1
- package/src/hooks/worktree-guard.js +3 -3
- package/src/index.js +22 -10
- package/src/knowledge-digest.js +23 -6
- package/src/knowledge-graph.js +92 -14
- package/src/knowledge-match.js +34 -9
- package/src/knowledge-stats.js +12 -13
- package/src/knowledge-vector.js +98 -7
- package/src/machine-interface.js +26 -0
- package/src/progress/change-registry.js +7 -0
- package/src/progress.js +67 -0
- package/src/quicklog.js +6 -1
- package/src/redomain.js +27 -0
- package/src/route-hindsight.js +34 -2
- package/src/run/archive-distill.js +3 -0
- package/src/run/command.js +40 -44
- package/src/run/complete-handlers.js +8 -3
- package/src/run/complete.js +129 -63
- package/src/run/gates.js +94 -90
- package/src/run/prompt.js +13 -3
- package/src/run/shared.js +19 -0
- package/src/run/stage.js +1 -1
- package/src/scope-audit.js +2 -2
- package/src/sentinel-assertions.js +43 -1
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +44 -11
- package/src/stage-templates.js +2 -0
- package/src/stages/brainstorm.js +24 -12
- package/src/stages/execute.js +1 -1
- package/src/stages/explore.js +3 -5
- package/src/stages/knowledge.js +56 -5
- package/src/sync.js +98 -2
- package/src/task-tick.js +12 -0
- package/src/timeline.js +60 -19
- package/src/verify-postcheck.js +83 -6
- package/src/verify-probes.js +61 -18
- package/templates/agents-instruction.md +5 -4
- package/templates/prompts/testcase-design.md +5 -0
- package/src/stages/brainstorm-auto.js +0 -286
|
@@ -80,8 +80,8 @@ brainstorm 完成时按 design.md frontmatter 的 `scale` 分叉:
|
|
|
80
80
|
|
|
81
81
|
brainstorm 在 `tier=independent` 规模下除 design.md 等四件套外,还需产出一个 stage 级 `review.json`,CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
|
|
82
82
|
|
|
83
|
-
- 路径:`.sillyspec/.runtime/stage-reviews/brainstorm
|
|
84
|
-
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id
|
|
83
|
+
- 路径:`.sillyspec/.runtime/stage-reviews/brainstorm-<run-id>/review.json`——**目录名 = `brainstorm-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `brainstorm-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-brainstorm-<变更名>`。
|
|
84
|
+
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage brainstorm` 一步生成。
|
|
85
85
|
- 字段(`schemaVersion:1`,`reviewType=design` —— 区别于 execute 的 `acceptance`):
|
|
86
86
|
|
|
87
87
|
```json
|
|
@@ -106,8 +106,8 @@ execute 完成时,每个 task 必须有 `review.json` 且 verdict 通过,否
|
|
|
106
106
|
|
|
107
107
|
execute 还有**第二道**独立的 stage 级审查:除逐 task review.json 外,整个 execute 阶段完成还需一个 stage 级 `review.json`(在 "完成确认"/acceptance 步骤产出)。CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
|
|
108
108
|
|
|
109
|
-
- 路径:`.sillyspec/.runtime/stage-reviews/execute
|
|
110
|
-
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id
|
|
109
|
+
- 路径:`.sillyspec/.runtime/stage-reviews/execute-<run-id>/review.json`——**目录名 = `execute-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `execute-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-execute-<变更名>`。
|
|
110
|
+
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage execute` 一步生成。
|
|
111
111
|
- 字段(`schemaVersion:1`,`reviewType=acceptance` —— 区别于 brainstorm/plan 的 `"design"`):
|
|
112
112
|
|
|
113
113
|
```json
|
|
@@ -49,8 +49,8 @@ plan 的步骤是动态的:`generate_plan`(生成分级计划)→ `review_
|
|
|
49
49
|
|
|
50
50
|
`review_plan` 步骤在 `tier=independent` 时产出一个 stage 级 `review.json`,CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
|
|
51
51
|
|
|
52
|
-
- 路径:`.sillyspec/.runtime/stage-reviews/plan
|
|
53
|
-
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id
|
|
52
|
+
- 路径:`.sillyspec/.runtime/stage-reviews/plan-<run-id>/review.json`——**目录名 = `plan-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `plan-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-plan-<变更名>`。
|
|
53
|
+
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage plan` 一步生成。
|
|
54
54
|
- 字段(`schemaVersion:1`,`reviewType=plan` —— plan 阶段主审查文档是 `plan.md`;execute 阶段才是 `acceptance`):
|
|
55
55
|
|
|
56
56
|
```json
|
package/AGENTS.md
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
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
|
|
|
15
16
|
`--input` 过门格式(轻量道首次调用即过,勿靠报错学;引号内换行合法,下例可照抄):
|
|
16
17
|
|
|
17
18
|
```bash
|
|
18
|
-
sillyspec flow start --change <YYYY-MM-DD-名> --input "
|
|
19
|
+
sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景
|
|
19
20
|
|
|
20
21
|
成功标准:
|
|
21
22
|
- <可验证标准>"
|
|
@@ -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,23 +4,26 @@ 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
|
|
|
11
11
|
```bash
|
|
12
12
|
export SILLYSPEC_SESSION_ID=<agent名+任务名> # 每条命令都带(shell 状态不持久;多会话并行的所有权判定依赖它)
|
|
13
13
|
# ① 立项 + 锁基线(--input 引号内换行合法,可照抄;缺「成功标准」条目会被清晰度门拦下):
|
|
14
|
-
sillyspec flow start --change <变更名> --input "
|
|
14
|
+
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()
|
|
@@ -330,6 +333,16 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoK
|
|
|
330
333
|
const preCross = splitCrossRepoPrefix(normalizePath(listItem[1]), repoKeys)
|
|
331
334
|
let rawItem = listItem[1]
|
|
332
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)
|
|
333
346
|
// 坑 brainstorm-gate-agent-unavailable-and-list-path-parse 坑2:列表项「路径:描述」
|
|
334
347
|
// 整行当路径 existsSync 假阴性(文件存在却报幻觉路径)——剥首个中/英冒号及之后描述。
|
|
335
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')
|
package/src/commit-suggest.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* C. 阶段产出:changed paths 按路径模式归 scan/brainstorm/plan/execute/archive/knowledge
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
20
|
-
import { join } from 'path'
|
|
20
|
+
import { join, relative } from 'path'
|
|
21
21
|
import { DatabaseSync } from 'node:sqlite'
|
|
22
22
|
import { gitQuiet, safeGit, stageArchiveArtifacts } from './git-helper.js'
|
|
23
23
|
import { resolveSpecDir } from './run/shared.js'
|
|
@@ -117,6 +117,12 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
117
117
|
const stat = gitQuiet(cwd, ['diff', 'HEAD', '--stat']) || (changedPaths.length > 0 ? `(${changedPaths.length} 个文件待首提交)` : '')
|
|
118
118
|
|
|
119
119
|
// ── 来源 A:QUICKLOG(主 quicklog + 各活跃变更归属 quicklog)──
|
|
120
|
+
// 并行会话噪声锚定(坑 probe-noise-and-commit-suggest-quality 坑B,2026-10-15 消费仓实证:
|
|
121
|
+
// 归档提交时他并行 quick 会话的 QUICKLOG 新条目(时间窗过滤粗筛通过)把 feat 建议劫持成
|
|
122
|
+
// 「fix: quick 修复 + 阶段产出」——type 误判 + 凭空 quick 字样。quick 来源现加锚「quicklog
|
|
123
|
+
// 文件属于本次提交变更集」(porcelain 含 staged/unstaged/untracked):不在变更集的 quicklog
|
|
124
|
+
// 条目不参与建议(时间窗保留作首道粗筛,零条目时无建议行为不变)。
|
|
125
|
+
const changedSet = new Set(changedPaths.map(p => String(p).replace(/\\/g, '/')))
|
|
120
126
|
const quickFiles = []
|
|
121
127
|
try {
|
|
122
128
|
for (const f of readdirSync(join(specBase, 'quicklog'))) {
|
|
@@ -133,6 +139,7 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
133
139
|
const quickEntries = quickFiles
|
|
134
140
|
.flatMap(file => parseQuicklogEntries(file).map(e => ({ ...e, file })))
|
|
135
141
|
.filter(e => !lastCommitKey || e.time > lastCommitKey)
|
|
142
|
+
.filter(e => changedSet.has(relative(cwd, e.file).replace(/\\/g, '/')))
|
|
136
143
|
.sort((a, b) => (a.time < b.time ? -1 : 1))
|
|
137
144
|
const doneQuick = quickEntries.filter(e => e.status === '已完成')
|
|
138
145
|
|
|
@@ -155,6 +162,27 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
155
162
|
}
|
|
156
163
|
}
|
|
157
164
|
|
|
165
|
+
// ── 来源 B2:归档产物任务面(坑 probe-noise-and-commit-suggest-quality 坑B 连带)──
|
|
166
|
+
// apply 后 tasks.md 移到 changes/archive/<变更>/——来源 B 的 mtime 锚对已提交面恒失效
|
|
167
|
+
// (任务勾选随 execute 提交走,mtime 早于上次 commit → hasTasks 恒 false → 归档提交被判
|
|
168
|
+
// fix)。归档目录下 tasks.md 在本次变更集时解析其 [x] 行进 task 面(归档提交的 feat 语义正源)。
|
|
169
|
+
try {
|
|
170
|
+
const archDir = join(specBase, 'changes', 'archive')
|
|
171
|
+
if (existsSync(archDir)) {
|
|
172
|
+
for (const c of readdirSync(archDir)) {
|
|
173
|
+
const tp = join(archDir, c, 'tasks.md')
|
|
174
|
+
try {
|
|
175
|
+
if (!statSync(tp).isFile()) continue
|
|
176
|
+
} catch { continue }
|
|
177
|
+
if (!changedSet.has(relative(cwd, tp).replace(/\\/g, '/'))) continue
|
|
178
|
+
for (const line of readFileSync(tp, 'utf8').split('\n')) {
|
|
179
|
+
const m = line.match(/^[-*]\s*\[[xX]\]\s*(task-\d+)\s*[::]?\s*(.*)/)
|
|
180
|
+
if (m) checkedTasks.push({ change: c, item: `${m[1]}: ${m[2].trim()}` })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch { /* 归档扫描失败静默(来源面缺省,行为回退修复前) */ }
|
|
185
|
+
|
|
158
186
|
// ── 来源 C:阶段产出(changed paths 按模式归类去重)──
|
|
159
187
|
const stageSet = new Set()
|
|
160
188
|
for (const p of changedPaths) {
|
|
@@ -195,11 +223,24 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
195
223
|
type = 'feat'
|
|
196
224
|
subject = `feat: 变更交付 + quick 修复 ${doneQuick.length} 项`
|
|
197
225
|
} else {
|
|
198
|
-
|
|
199
|
-
|
|
226
|
+
// 纯阶段产出(坑 probe-noise-and-commit-suggest-quality 坑B,2026-10-15 实证:原
|
|
227
|
+
// 「fix: quick 修复 + 阶段产出」在无任何 quick 证据时凭空捏造 quick 语义且误判 fix——
|
|
228
|
+
// 无 task 无 quick 的归档/知识面提交按 chore 归档语义建议)
|
|
229
|
+
type = 'chore'
|
|
230
|
+
subject = `chore: 阶段产出归档(${stageArtifacts.join('、')})`
|
|
200
231
|
}
|
|
201
232
|
if (doneQuick.length >= 2 || hasTasks || hasStages) {
|
|
202
|
-
|
|
233
|
+
// body 与 subject 去重(坑同上:quick ≤2 条时标题已全列 subject,body 原样重复无信息量)
|
|
234
|
+
const subjectListed = doneQuick.length <= 2 ? new Set(doneQuick.map(q => q.title)) : null
|
|
235
|
+
for (const q of doneQuick) {
|
|
236
|
+
// 标题已列 subject 时 body 仍保留 qlId 行(去重不丢审计锚——标题可省,qlId 是
|
|
237
|
+
// QUICKLOG 对账锚点,既有测试契约钉住 body 含 qlId)
|
|
238
|
+
if (subjectListed && subjectListed.has(q.title)) {
|
|
239
|
+
bodyLines.push(`- quick ${q.qlId}(标题见上)`)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
bodyLines.push(`- quick ${q.qlId}: ${q.title}`)
|
|
243
|
+
}
|
|
203
244
|
}
|
|
204
245
|
for (const t of checkedTasks) bodyLines.push(`- ${t.change} ${t.item}`)
|
|
205
246
|
for (const s of stageArtifacts) bodyLines.push(`- 阶段产出: ${s}`)
|
package/src/config-schema.js
CHANGED
|
@@ -77,6 +77,14 @@ export const LOCAL_YAML_SCHEMA = {
|
|
|
77
77
|
{ path: 'commands.knowledge-gate', type: 'boolean', optional: true, status: 'live', readers: ['completeStep (src/run/complete.js)'], desc: '【2026-09-28-unclear-req-to-brainstorm FR-03/D-004】brainstorm 方案步(提出 2-3 种方案)--done 知识检索命中回显开关——缺省 true=开(对 --output 与 decisions.md 条目跑既有检索匹配器,命中 warn 回显「须 evidence 回应或说明不复潮」不阻断);false(或 off)关回显,无命中时输出与现状一致。', example: 'false' },
|
|
78
78
|
],
|
|
79
79
|
},
|
|
80
|
+
{
|
|
81
|
+
id: 'knowledge',
|
|
82
|
+
title: '知识检索(平台向量语义召回)',
|
|
83
|
+
note: '可选段——缺省(未写整段/auto)跟随平台能力自动探测:端点在场自动启用、未部署缓存 1h 内跳过(部署后自动启用,零配置;2026-10-15-platform-vector-search)。off=显式逃生阀强制关,on=强制开跳过探测。能力缓存落 .runtime/vector-capability.json(gitignored);提示触点:knowledge search hint 键、flow 注入零命中行(auto 未就绪时信息性提示)。',
|
|
84
|
+
keys: [
|
|
85
|
+
{ path: 'knowledge.vector_search', type: 'string', optional: true, status: 'live', readers: ['vectorSwitchFromLocalYaml (src/knowledge-vector.js)'], desc: '【2026-10-15-vector-capability-autodetect 终态】平台向量语义召回开关——缺省(未写键/auto)跟随平台能力自动探测:端点在场自动启用、未部署缓存 1h 内跳过(部署后自动启用,零配置);off=显式逃生阀强制关、on=强制开跳过探测。env SILLYSPEC_KNOWLEDGE_VECTOR=on|auto/off 同语义强制。四层检索第二层:路由 tag 零命中后平台向量召回(语料=knowledge/decisions 条目,词面不相交条目可语义召回)。', example: 'off' },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
80
88
|
{
|
|
81
89
|
id: 'mcp',
|
|
82
90
|
title: 'SillyHub MCP 客户端凭据(派发用)',
|
|
@@ -387,6 +395,12 @@ commands:
|
|
|
387
395
|
# lint_timeout_sec: 600 # commands.lint 超时秒数(缺省 180)
|
|
388
396
|
# knowledge-gate: false # 方案步 --done 知识检索命中回显开关(缺省 true 开;false 关回显)
|
|
389
397
|
|
|
398
|
+
# ── 知识检索(平台向量语义召回)──
|
|
399
|
+
# 缺省(未写整段)跟随平台能力自动探测:端点在场自动启用、未部署自动跳过(部署后自动
|
|
400
|
+
# 启用,零配置)。仅显式逃生阀时才需要写:
|
|
401
|
+
# knowledge:
|
|
402
|
+
# vector_search: off # 强制关(on=强制开跳过探测)
|
|
403
|
+
|
|
390
404
|
# ── SillyHub MCP 客户端凭据(派发到 worker 用)──
|
|
391
405
|
# agent 手填,或 sillyspec platform connect 在 mcp 段缺失时同源自动填。
|
|
392
406
|
# 两键齐全才生效;缺则回退环境变量 SILLYHUB_MCP_URL / SILLYHUB_MCP_TOKEN。
|
|
@@ -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 })
|
package/src/decision-distill.js
CHANGED
|
@@ -313,10 +313,13 @@ function newDomainPreamble(domain) {
|
|
|
313
313
|
* 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
|
|
314
314
|
* 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
|
|
315
315
|
* docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
|
|
316
|
-
function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
317
|
-
const lines = [`## ${entry.
|
|
316
|
+
function renderBlockLines(entry, headHash, supersedesNote, changeName, sectionNumber, originId) {
|
|
317
|
+
const lines = [`## ${sectionNumber}@v${entry.version} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
|
|
318
318
|
lines.push(`状态:${entry.selected}`)
|
|
319
319
|
if (changeName) lines.push(`变更:${oneLine(changeName)}`)
|
|
320
|
+
// 原编号(2026-10-14-decision-global-id-epoch):epoch 形态溯源字段——变更行后/锚点行前
|
|
321
|
+
// (不破「文件:紧跟锚点」行序红线);legacy 复用段(originId=null)不渲染,形态零变化
|
|
322
|
+
if (originId) lines.push(`原编号:${oneLine(originId)}`)
|
|
320
323
|
lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
|
|
321
324
|
// 文件行(FR-01/D-001@v1):仅非空渲染——存量条目零迁移,幂等重归档不添空行
|
|
322
325
|
if (entry.files && entry.files.length > 0) lines.push(`文件:${entry.files.join(', ')}`)
|
|
@@ -336,12 +339,14 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
336
339
|
}
|
|
337
340
|
|
|
338
341
|
/** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
|
|
339
|
-
* 段的变更归属取段内 `变更:<name>`
|
|
340
|
-
*
|
|
342
|
+
* 段的变更归属取段内 `变更:<name>` 行,「来源:<name>` 兜底(2026-10-14-decision-id-p2-hardening:
|
|
343
|
+
* 08-24 首修形态残留——读出后带来源的 legacy 段参与同变更同号匹配/supersede,重蒸馏在位更新收敛);
|
|
344
|
+
* 无任何限定行的历史条目 → change=null(legacy:升级前落库的条目,不参与新条目的同号匹配——只共存不误删)。
|
|
341
345
|
* 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
|
|
342
346
|
* 正则与恒等 id),decisions 调用点零改动。 */
|
|
343
347
|
export function splitKnowledgeSections(content, opts = {}) {
|
|
344
|
-
|
|
348
|
+
// 节头号双形态(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1 并存
|
|
349
|
+
const sectionRegex = opts.sectionRegex || /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
|
|
345
350
|
const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
|
|
346
351
|
const sections = []
|
|
347
352
|
const preamble = []
|
|
@@ -359,8 +364,15 @@ export function splitKnowledgeSections(content, opts = {}) {
|
|
|
359
364
|
if (cur) sections.push(cur)
|
|
360
365
|
for (const s of sections) {
|
|
361
366
|
s.id = buildId(s.number, s.version)
|
|
362
|
-
|
|
363
|
-
|
|
367
|
+
// 「变更:」优先、「来源:」兜底(行内前缀按命中行剥除;无任何限定行 → null 不互认)
|
|
368
|
+
const cm = s.lines.find(l => /^变更[::]/.test(l)) || s.lines.find(l => /^来源[::]/.test(l))
|
|
369
|
+
s.change = cm ? cm.replace(/^(变更|来源)[::]\s*/, '').trim() || null : null
|
|
370
|
+
// 原编号(2026-10-14-decision-global-id-epoch 溯源字段):「原编号:D-011@v1」→ origin/originNumber;
|
|
371
|
+
// legacy 段无该行 → null(幂等匹配谓词按号兜底)
|
|
372
|
+
const oi = s.lines.find(l => /^原编号[::]/.test(l))
|
|
373
|
+
s.origin = oi ? oi.replace(/^原编号[::]\s*/, '').trim() || null : null
|
|
374
|
+
const on = s.origin ? /^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v?\d+/.exec(s.origin) : null
|
|
375
|
+
s.originNumber = on ? on[1] : null
|
|
364
376
|
}
|
|
365
377
|
return { preamble, sections }
|
|
366
378
|
}
|
|
@@ -385,14 +397,23 @@ export function joinKnowledgeFile(preamble, sections) {
|
|
|
385
397
|
* 升级前落库)不与任何新条目互认,只共存不误删。
|
|
386
398
|
* @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
|
|
387
399
|
*/
|
|
388
|
-
|
|
400
|
+
/** 同变更同段匹配谓词(2026-10-14-decision-global-id-epoch 扩展):变更一致 且
|
|
401
|
+
* (段号==条目局部号 或 段.原编号号段==条目局部号)——legacy 段按号匹配、epoch 段按原编号匹配。
|
|
402
|
+
* @returns {{action: 'append'|'update'|'supersede'}|null} null = 同变更同段高版本已落库,迟到低版本不降级。 */
|
|
403
|
+
function sameDecisionNumber(s, entry) {
|
|
404
|
+
return (s.change || null) === (entry.change || null) && (s.number === entry.number || s.originNumber === entry.number)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function applyEntryToSections(sections, entry, headHash, sectionNumber, originId) {
|
|
389
408
|
const change = entry.change || null
|
|
390
|
-
const sameNumber = sections.filter(s => s
|
|
409
|
+
const sameNumber = sections.filter(s => sameDecisionNumber(s, entry))
|
|
391
410
|
if (sameNumber.some(s => s.version > entry.version)) return null
|
|
392
411
|
let action
|
|
393
412
|
let supersedesNote = oneLine(entry.supersedes) || null
|
|
413
|
+
const sectionId = `${sectionNumber}@v${entry.version}`
|
|
414
|
+
const originNumber = originId ? (/^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)/.exec(originId) || [])[1] || null : null
|
|
394
415
|
if (sameNumber.length === 0) {
|
|
395
|
-
sections.push({ number:
|
|
416
|
+
sections.push({ number: sectionNumber, version: entry.version, id: sectionId, title: entry.title, change, origin: originId, originNumber, lines: renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId) })
|
|
396
417
|
action = 'append'
|
|
397
418
|
} else {
|
|
398
419
|
const same = sameNumber.find(s => s.version === entry.version)
|
|
@@ -400,10 +421,14 @@ function applyEntryToSections(sections, entry, headHash) {
|
|
|
400
421
|
if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
|
|
401
422
|
slot.version = entry.version
|
|
402
423
|
slot.title = entry.title
|
|
403
|
-
slot.
|
|
404
|
-
|
|
424
|
+
slot.number = sectionNumber
|
|
425
|
+
slot.id = sectionId
|
|
426
|
+
slot.origin = originId
|
|
427
|
+
slot.originNumber = originNumber
|
|
428
|
+
slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId)
|
|
429
|
+
// 同变更同段(号或原编号匹配)旧版本就地清除——同文件内同变更同段只留一个段
|
|
405
430
|
for (let i = sections.length - 1; i >= 0; i--) {
|
|
406
|
-
if (sections[i]
|
|
431
|
+
if (sameDecisionNumber(sections[i], entry) && sections[i] !== slot) sections.splice(i, 1)
|
|
407
432
|
}
|
|
408
433
|
action = same ? 'update' : 'supersede'
|
|
409
434
|
}
|
|
@@ -577,31 +602,62 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
577
602
|
}
|
|
578
603
|
|
|
579
604
|
const written = []
|
|
605
|
+
// 域全集(跨文件清理与复用扫描共用;磁盘 ∪ 本批已加载)
|
|
606
|
+
let onDisk = []
|
|
607
|
+
try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
|
|
608
|
+
let allDomains = [...new Set([...onDisk, ...store.keys()])]
|
|
609
|
+
// 发号器(2026-10-14-decision-global-id-epoch):域前缀全域 max+1 只增不复用;mintMax 批内会话
|
|
610
|
+
// 计数防同批撞号(磁盘扫描只做初值);并发蒸馏撞号窗口与 FR 侧发号同病(接受,doctor 可见)。
|
|
611
|
+
const mintMax = new Map()
|
|
612
|
+
const mintFor = (domain) => {
|
|
613
|
+
if (!mintMax.has(domain)) {
|
|
614
|
+
let max = 0
|
|
615
|
+
for (const f of onDisk) {
|
|
616
|
+
let c; try { c = readFileSync(join(decisionsDir, f.endsWith('.md') ? f : `${f}.md`), 'utf8') } catch { continue }
|
|
617
|
+
const re = /^## D-([A-Za-z0-9_\-\u4e00-\u9fff]+?)-(\d+)@v\d+/gm
|
|
618
|
+
let m
|
|
619
|
+
while ((m = re.exec(c)) !== null) if (m[1] === domain) max = Math.max(max, parseInt(m[2], 10))
|
|
620
|
+
}
|
|
621
|
+
mintMax.set(domain, max)
|
|
622
|
+
}
|
|
623
|
+
const next = mintMax.get(domain) + 1
|
|
624
|
+
mintMax.set(domain, next)
|
|
625
|
+
return `D-${domain}-${String(next).padStart(3, '0')}`
|
|
626
|
+
}
|
|
580
627
|
const targetSets = new Map() // entry → Set(域)(跨文件清理时排除目标文件)
|
|
581
628
|
for (const entry of batch) {
|
|
582
629
|
const domains = resolveDomains(entry, idx)
|
|
583
630
|
targetSets.set(entry, new Set(domains))
|
|
631
|
+
// 号决策(2026-10-14-decision-global-id-epoch):同变更同段存量(全域文件扫描,号或原编号匹配)
|
|
632
|
+
// 复用其号——legacy 段保持局部形态(不补原编号不改前缀,存量零触碰);epoch 段原编号随版本更新;
|
|
633
|
+
// 无匹配 → 发号域=首域(resolveDomains 已 sanitize)批内 max+1,扇出多文件同一号(单一身份)。
|
|
634
|
+
let reuse = null
|
|
635
|
+
for (const d of allDomains) {
|
|
636
|
+
const st = loadDomain(d)
|
|
637
|
+
reuse = st.sections.find(s => sameDecisionNumber(s, entry)) || null
|
|
638
|
+
if (reuse) break
|
|
639
|
+
}
|
|
640
|
+
const sectionNumber = reuse ? reuse.number : mintFor(domains[0])
|
|
641
|
+
const originId = reuse && !reuse.origin ? null : `${entry.number}@v${entry.version}`
|
|
584
642
|
for (const d of domains) {
|
|
585
643
|
const st = loadDomain(d)
|
|
586
|
-
const res = applyEntryToSections(st.sections, entry, headHash)
|
|
644
|
+
const res = applyEntryToSections(st.sections, entry, headHash, sectionNumber, originId)
|
|
587
645
|
if (res) {
|
|
588
646
|
st.dirty = true
|
|
589
|
-
written.push({ file: `decisions/${d}.md`, id: entry.
|
|
647
|
+
written.push({ file: `decisions/${d}.md`, id: `${sectionNumber}@v${entry.version}`, action: res.action })
|
|
590
648
|
}
|
|
591
649
|
}
|
|
650
|
+
allDomains = [...new Set([...allDomains, ...domains])]
|
|
592
651
|
}
|
|
593
652
|
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
let onDisk = []
|
|
597
|
-
try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
|
|
598
|
-
const allDomains = [...new Set([...onDisk, ...store.keys()])]
|
|
653
|
+
// 跨文件清理:同变更同段(号或原编号匹配)≤ 新版本的旧段散落其他域文件时移除(同变更同段
|
|
654
|
+
// 全局只留最高版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
|
|
599
655
|
for (const entry of batch) {
|
|
600
656
|
const targets = targetSets.get(entry)
|
|
601
657
|
for (const d of allDomains) {
|
|
602
658
|
if (targets.has(d)) continue
|
|
603
659
|
const st = loadDomain(d)
|
|
604
|
-
const removed = st.sections.filter(s => s.
|
|
660
|
+
const removed = st.sections.filter(s => s.version <= entry.version && sameDecisionNumber(s, entry))
|
|
605
661
|
if (removed.length === 0) continue
|
|
606
662
|
st.sections = st.sections.filter(s => !removed.includes(s))
|
|
607
663
|
st.dirty = true
|