sillyspec 3.28.13 → 3.29.0
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/package.json +1 -1
- package/src/blast-surface.js +168 -0
- package/src/ceremony-tier.js +359 -0
- package/src/change-risk-profile.js +72 -199
- package/src/config-schema.js +21 -0
- package/src/decisions-io.js +32 -0
- package/src/design-facts.js +1 -0
- package/src/doctor-diagnostics.js +119 -1
- package/src/index.js +92 -5
- package/src/knowledge-stats.js +25 -0
- package/src/modules.js +28 -0
- package/src/prefill.js +491 -0
- package/src/probe7-anchor-check.js +8 -6
- package/src/quick-gate-profile.js +8 -4
- package/src/quick-session-owner.js +63 -0
- package/src/review-dispatch.js +473 -1
- package/src/review-material-pack.js +265 -0
- package/src/review-tier.js +185 -41
- package/src/run/archive-distill.js +6 -0
- package/src/run/command.js +158 -87
- package/src/run/complete-handlers.js +82 -1
- package/src/run/complete.js +37 -1
- package/src/run/gates.js +301 -19
- package/src/run/prompt.js +423 -21
- package/src/run/shared.js +36 -0
- package/src/run/verify-quality-scan.js +14 -6
- package/src/scope-audit.js +8 -1
- package/src/span-risk-surface.js +127 -0
- package/src/stage-contract-spec.js +2 -2
- package/src/stage-contract.js +102 -81
- package/src/stage-review.js +26 -2
- package/src/stages/brainstorm.js +28 -15
- package/src/stages/execute.js +10 -2
- package/src/stages/plan.js +29 -12
- package/src/stages/verify.js +7 -7
- package/src/taskcard.js +3 -0
- package/src/verify-postcheck.js +375 -20
- package/src/verify-probes.js +117 -10
- package/src/worktree.js +1 -1
- package/templates/prompts/taskcard-rules.md +1 -0
- package/templates/prompts/verify-probes.md +1 -1
package/src/prefill.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prefill.js — 三槽预填引擎(change: 2026-09-18-artifact-prefill,task-01,FR-01/FR-02,
|
|
3
|
+
* D-001@v1 / D-003@v1 / D-005@v1)
|
|
4
|
+
*
|
|
5
|
+
* 背景:verify-result 已实证的「机械预填模式」(预填≠结论,agent 逐格核对改写)推广到
|
|
6
|
+
* design/tasks/taskcard 三产物——agent 从「从零写」变「核对改写」。本模块是预填的单点
|
|
7
|
+
* 推导引擎:三个纯函数 + 注检测 + refresh 重放,供生成器接线(design-init/taskcard,
|
|
8
|
+
* Phase 2)与 prefill-refresh 命令(Phase 3)复用,禁止两处各自重写推导漂移。
|
|
9
|
+
*
|
|
10
|
+
* 来源注协议(D-003):所有预填输出行尾附加 PREFILL_NOTE。注在场 = 未确认(--done 门
|
|
11
|
+
* advisory warning / refresh 可重放);删注 = 确认动作(人工内容,refresh 不覆盖——
|
|
12
|
+
* D-005 已确认跳过)。非白名单槽零触碰(D-001 红线):本模块只产出行/值,落槽范围由
|
|
13
|
+
* 调用方(生成器/refresh)限定在三槽内——①design.md 文件变更清单表、②design.md 决策
|
|
14
|
+
* 追踪表、③task 卡 frontmatter requirement_ids/decision_ids。
|
|
15
|
+
*
|
|
16
|
+
* 解析对齐(零外部依赖手写,不引 js-yaml——decisions-io/change-list 家族先例):
|
|
17
|
+
* - task 卡 target_files:语义对齐 src/stages/plan-postcheck.js parseTargetFiles——
|
|
18
|
+
* inline [] + 块列表双形态、NEW: 前缀拆剥、严格口径非法判定(glob/目录前缀/引号/
|
|
19
|
+
* 绝对路径),但为守「零依赖手写」不做跨模块 import(该模块顶层 import js-yaml);
|
|
20
|
+
* - decisions.md 标题:对齐 src/decisions-io.js 单一写入者的 canonical 形态
|
|
21
|
+
* (`## D-xxx@vN 标题` / 整行恰为 `## D-xxx@vN`,版本必在、无版本不收);
|
|
22
|
+
* - requirements.md FR:对齐 src/fr-index.js 的标题式形态(`### FR-NN: 标题`,
|
|
23
|
+
* 放宽标题级 #2~#6 容手写漂移);
|
|
24
|
+
* - design.md 章节定位:对齐 src/change-list.js FILE_LIST_SECTION_RE 同义词集
|
|
25
|
+
* (该常量未导出,此处带出处复制,改一处须同步另一处)。
|
|
26
|
+
*
|
|
27
|
+
* 纯度口径(task-01 constraints「三纯函数与 hasUnconfirmedPrefill 纯函数零 IO」):
|
|
28
|
+
* 签名即含目录/文件入参,读取是推导的固有部分——此四函数只读不写(无副作用、同输入
|
|
29
|
+
* 同输出);runPrefillRefresh 是唯一写入口(writeAtomicSync 原子落盘,fs-atomic 先例)。
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 来源注协议常量(D-003):所有预填值行尾附加的本注。
|
|
37
|
+
* hasUnconfirmedPrefill 按此字面检测;门禁(Phase 4 gates/verify-probes)同源引用。
|
|
38
|
+
* @type {string}
|
|
39
|
+
*/
|
|
40
|
+
export const PREFILL_NOTE = '(预填:核对后删本注)'
|
|
41
|
+
|
|
42
|
+
/** decisions.md canonical 决策标题(decisions-io 单一写入者唯一产出形态):`## D-xxx@vN 标题` 或整行恰为 `## D-xxx@vN`(后随标题须有空白分隔,与 hasDecisionId 的双形态匹配逐字同源)。 */
|
|
43
|
+
const DECISION_HEADING_RE = /^## (D-\d+)@v(\d+)(?:\s.*)?$/
|
|
44
|
+
|
|
45
|
+
/** requirements.md 功能需求标题(fr-index 先例 `### FR-NN: 标题`,放宽标题级 #2~#6;FR-<域>-NNN 全局 id 无「FR-紧邻数字」形态,天然不误收)。 */
|
|
46
|
+
const FR_HEADING_RE = /^#{2,6}\s+(FR-\d+)\s*[::]/
|
|
47
|
+
|
|
48
|
+
/** design.md「文件变更清单」章节标题同义词集——逐字复制自 change-list.js FILE_LIST_SECTION_RE(该常量未导出;两处须同步)。 */
|
|
49
|
+
const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变更文件清单|文件清单|File Changes|Files to Change)/
|
|
50
|
+
|
|
51
|
+
/** design.md「决策追踪」章节标题(design-facts 骨架形态,容忍可选编号前缀)。 */
|
|
52
|
+
const DECISION_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?决策追踪/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 读文件并归一 CRLF;文件不存在 / 不可读 / 入参非法 → null(调用方按空源处理:
|
|
56
|
+
* 预填留空、现状骨架行为不变——design 兼容策略)。所有只读路径共用此入口。
|
|
57
|
+
* @param {string} filePath
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
function readTextIfExists(filePath) {
|
|
61
|
+
if (typeof filePath !== 'string' || filePath === '') return null
|
|
62
|
+
if (!existsSync(filePath)) return null
|
|
63
|
+
try {
|
|
64
|
+
return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n')
|
|
65
|
+
} catch {
|
|
66
|
+
return null // 目录 / 权限等读失败按空源降级(fail-soft,预填是 advisory 能力)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 解析 task 卡 frontmatter target_files 条目(手写零依赖,语义对齐
|
|
72
|
+
* stages/plan-postcheck.js parseTargetFiles:inline [] + 块列表双形态、NEW: 前缀拆剥、
|
|
73
|
+
* 反斜杠与 ./ 前缀归一、严格口径非法判定)。
|
|
74
|
+
* @param {string} content task 卡全文(自提取 frontmatter)
|
|
75
|
+
* @returns {Array<{ raw: string, isNew: boolean, path: string, invalid: string|null }>}
|
|
76
|
+
*/
|
|
77
|
+
function parseTargetFileEntries(content) {
|
|
78
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
79
|
+
if (!fmMatch) return []
|
|
80
|
+
const fm = fmMatch[1]
|
|
81
|
+
let raws = null
|
|
82
|
+
const inlineMatch = fm.match(/target_files:[ \t]*\[([^\]]*)\]/)
|
|
83
|
+
if (inlineMatch) {
|
|
84
|
+
raws = inlineMatch[1].split(',').map((s) => s.trim()).filter(Boolean)
|
|
85
|
+
} else {
|
|
86
|
+
// 块列表:[ \t]*\n 不吃换行(parseTargetFiles 坑6① 家族教训——\s* 贪婪吞顶格列表静默判空)
|
|
87
|
+
const blockMatch = fm.match(/target_files:[ \t]*\n((?:[ \t]*-[ \t]+.+\n?)+)/)
|
|
88
|
+
if (blockMatch) {
|
|
89
|
+
raws = blockMatch[1].match(/[ \t]*-[ \t]+(.+)/g)
|
|
90
|
+
?.map((s) => s.replace(/^[ \t]*-[ \t]+/, '').trim()).filter(Boolean) || []
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (raws === null || raws.length === 0) return []
|
|
94
|
+
return raws.map((raw) => {
|
|
95
|
+
let path = raw
|
|
96
|
+
let isNew = false
|
|
97
|
+
if (path.startsWith('NEW:')) { isNew = true; path = path.slice('NEW:'.length).trim() }
|
|
98
|
+
path = path.replace(/\\/g, '/').replace(/^(\.\/)+/, '')
|
|
99
|
+
let invalid = null
|
|
100
|
+
if (path === '') invalid = '剥 NEW: / ./ 前缀后为空'
|
|
101
|
+
else if (/[*?]/.test(path)) invalid = '含通配符(禁 glob)'
|
|
102
|
+
else if (path.endsWith('/')) invalid = '目录前缀(须精确到文件)'
|
|
103
|
+
else if (/["'`]/.test(path)) invalid = '含引号/反引号(须裸路径书写)'
|
|
104
|
+
else if (path.startsWith('/') || /^[A-Za-z]:/.test(path)) invalid = '绝对路径(须仓根相对)'
|
|
105
|
+
return { raw, isNew, path, invalid }
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 槽一(纯函数):扫 tasksDir 下 task-*.md 的 frontmatter target_files,并集去重排序,
|
|
111
|
+
* 产出 design.md 文件变更清单表行。
|
|
112
|
+
*
|
|
113
|
+
* 语义:
|
|
114
|
+
* - 去重键 = 剥 NEW: 后的归一路径;任一卡声明 NEW: → 输出保形 NEW: 前缀(待建文件
|
|
115
|
+
* 标记不因他卡无前缀声明而丢失),操作列按 NEW: 推导(NEW:→新增,其余→修改——
|
|
116
|
+
* target_files 语义 = 本 task 计划改动的文件)。
|
|
117
|
+
* - 排序 = 归一路径码点升序(跨平台确定序,多 agent 并行下重放结果稳定)。
|
|
118
|
+
* - 非法条目(glob/目录前缀/引号/绝对路径——parseTargetFiles 同款严格口径)跳过
|
|
119
|
+
* 不进清单:预填行会被 agent 核对后转正,带非法形态只会把 gate 错误提前嫁接。
|
|
120
|
+
* - 无 tasksDir / 无 task 卡 / 全空声明 → [](槽留空,生成器侧给提示行,骨架行为不变)。
|
|
121
|
+
*
|
|
122
|
+
* @param {{ tasksDir?: string }} opts
|
|
123
|
+
* @returns {string[]} design 清单表行(形如 `| 新增 | NEW:src/foo.js | 预填自 task 卡 target_files 并集 (预填:核对后删本注) |`)
|
|
124
|
+
*/
|
|
125
|
+
export function prefillFileChangeList({ tasksDir } = {}) {
|
|
126
|
+
if (typeof tasksDir !== 'string' || tasksDir === '' || !existsSync(tasksDir)) return []
|
|
127
|
+
let files
|
|
128
|
+
try {
|
|
129
|
+
files = readdirSync(tasksDir).filter((f) => /^task-.+\.md$/.test(f)).sort()
|
|
130
|
+
} catch {
|
|
131
|
+
return []
|
|
132
|
+
}
|
|
133
|
+
// 归一路径 → { isNew };同路径多卡声明时 NEW: 取并(任一声明待建即待建)
|
|
134
|
+
const union = new Map()
|
|
135
|
+
for (const f of files) {
|
|
136
|
+
const text = readTextIfExists(join(tasksDir, f))
|
|
137
|
+
if (!text) continue
|
|
138
|
+
for (const e of parseTargetFileEntries(text)) {
|
|
139
|
+
if (e.invalid) continue
|
|
140
|
+
const prev = union.get(e.path)
|
|
141
|
+
if (!prev) union.set(e.path, { isNew: e.isNew })
|
|
142
|
+
else if (e.isNew) prev.isNew = true
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...union.keys()]
|
|
146
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
147
|
+
.map((p) => {
|
|
148
|
+
const isNew = union.get(p).isNew
|
|
149
|
+
return `| ${isNew ? '新增' : '修改'} | ${isNew ? 'NEW:' : ''}${p} | 预填自 task 卡 target_files 并集 ${PREFILL_NOTE} |`
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 槽二(纯函数):解析 changeDir/decisions.md 的 canonical 决策标题(## D-xxx@vN,
|
|
155
|
+
* decisions-io 单一写入者形态),产出 design.md 决策追踪表行(状态列「待确认」)。
|
|
156
|
+
*
|
|
157
|
+
* 排序 = D 编号数字升序、同号版本升序;重复标题去重。无 decisions.md / 无 canonical
|
|
158
|
+
* 标题(扁平列表、### 级手写漂移形态不收——对齐 hasDecisionId 的 fail-closed 口径)
|
|
159
|
+
* → []。
|
|
160
|
+
* @param {{ changeDir?: string }} opts
|
|
161
|
+
* @returns {string[]} 决策追踪表行(形如 `| D-001@v1 | (待填覆盖点) | 待确认 (预填:核对后删本注) |`——三列对齐 design-facts 骨架「决策|覆盖点|状态」,待确认落状态列)
|
|
162
|
+
*/
|
|
163
|
+
export function prefillDecisionTable({ changeDir } = {}) {
|
|
164
|
+
const text = readTextIfExists(typeof changeDir === 'string' ? join(changeDir, 'decisions.md') : null)
|
|
165
|
+
if (!text) return []
|
|
166
|
+
const seen = new Set()
|
|
167
|
+
const entries = []
|
|
168
|
+
for (const line of text.split('\n')) {
|
|
169
|
+
const m = line.match(DECISION_HEADING_RE)
|
|
170
|
+
if (!m) continue
|
|
171
|
+
const id = `${m[1]}@v${m[2]}`
|
|
172
|
+
if (seen.has(id)) continue
|
|
173
|
+
seen.add(id)
|
|
174
|
+
entries.push({ id, number: parseInt(m[1].slice(2), 10), version: parseInt(m[2], 10) })
|
|
175
|
+
}
|
|
176
|
+
entries.sort((a, b) => a.number - b.number || a.version - b.version || (a.id < b.id ? -1 : 1))
|
|
177
|
+
return entries.map((e) => `| ${e.id} | (待填覆盖点) | 待确认 ${PREFILL_NOTE} |`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 槽三(纯函数):从 changeDir 的 requirements.md(标题式 FR-NN)与 decisions.md
|
|
182
|
+
* (canonical D-xxx@vN)抽取 TaskCard frontmatter 的 requirement_ids / decision_ids。
|
|
183
|
+
*
|
|
184
|
+
* 各自去重排序(FR 按数字升序、D 按编号+版本升序);源文件缺失 → 对应数组留空
|
|
185
|
+
* (生成器侧按在场文件预填、缺则留空数组+提示行——design Phase 2 语义)。
|
|
186
|
+
* @param {{ changeDir?: string }} opts
|
|
187
|
+
* @returns {{ requirementIds: string[], decisionIds: string[] }}
|
|
188
|
+
*/
|
|
189
|
+
export function prefillCardIds({ changeDir } = {}) {
|
|
190
|
+
const requirementIds = []
|
|
191
|
+
const decisionIds = []
|
|
192
|
+
const base = typeof changeDir === 'string' ? changeDir : null
|
|
193
|
+
const reqText = readTextIfExists(base ? join(base, 'requirements.md') : null)
|
|
194
|
+
if (reqText) {
|
|
195
|
+
const seen = new Set()
|
|
196
|
+
for (const line of reqText.split('\n')) {
|
|
197
|
+
const m = line.match(FR_HEADING_RE)
|
|
198
|
+
if (m) seen.add(m[1])
|
|
199
|
+
}
|
|
200
|
+
requirementIds.push(...[...seen].sort((a, b) =>
|
|
201
|
+
parseInt(a.slice(3), 10) - parseInt(b.slice(3), 10) || (a < b ? -1 : 1)))
|
|
202
|
+
}
|
|
203
|
+
const decText = readTextIfExists(base ? join(base, 'decisions.md') : null)
|
|
204
|
+
if (decText) {
|
|
205
|
+
const seen = new Set()
|
|
206
|
+
for (const line of decText.split('\n')) {
|
|
207
|
+
const m = line.match(DECISION_HEADING_RE)
|
|
208
|
+
if (m) seen.add(`${m[1]}@v${m[2]}`)
|
|
209
|
+
}
|
|
210
|
+
decisionIds.push(...[...seen].sort((a, b) => {
|
|
211
|
+
const [an, av] = a.slice(2).split('@v')
|
|
212
|
+
const [bn, bv] = b.slice(2).split('@v')
|
|
213
|
+
return parseInt(an, 10) - parseInt(bn, 10) || parseInt(av, 10) - parseInt(bv, 10) || (a < b ? -1 : 1)
|
|
214
|
+
}))
|
|
215
|
+
}
|
|
216
|
+
return { requirementIds, decisionIds }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 预填注在场检测(门禁与 refresh 共用的确认判据,D-003/D-005):文件内容含 PREFILL_NOTE
|
|
221
|
+
* 即存在未确认预填。文件不存在 / 不可读 → false(无注可删即无未确认项,不误报)。
|
|
222
|
+
* @param {string} filePath
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
export function hasUnconfirmedPrefill(filePath) {
|
|
226
|
+
const text = readTextIfExists(filePath)
|
|
227
|
+
return text !== null && text.includes(PREFILL_NOTE)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ---------------------------------------------------------------------------
|
|
231
|
+
* runPrefillRefresh —— 三槽重放(唯一写入口;D-005:已确认跳过,注已删=人工内容不覆盖)
|
|
232
|
+
* ------------------------------------------------------------------------- */
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* design.md 表格槽重放:在章节表格的内容区尾部追加预填行(查重——已在位的行不重复追加)。
|
|
236
|
+
* 有人工内容(内容行含未带注行,或章节内有非表格非注释非空的正文/列表行——分类列表
|
|
237
|
+
* 写法的人类产出同样受保护)→ confirmed 跳过;无源可预填 → skipped;预填就位
|
|
238
|
+
* (本次写入或已在位)→ filled。
|
|
239
|
+
*
|
|
240
|
+
* 行坐标视角约定:章节内首个表格行视为表头(骨架与 brainstorm 模板的既定形态),
|
|
241
|
+
* 表头与分隔行不参与人工判定,内容行 = 表头之后的表格行。
|
|
242
|
+
* @param {string[]} lines 全文行(已归一 \n)
|
|
243
|
+
* @param {{ slotLabel: string, sectionRe: RegExp, headerLines: string[], prefillRows: string[] }} opts
|
|
244
|
+
* @param {{ filled: number, skipped: number, confirmed: number, lines: string[] }} result
|
|
245
|
+
* @returns {boolean} 是否发生写入(调用方统一落盘)
|
|
246
|
+
*/
|
|
247
|
+
function refreshDesignTableSlot(lines, opts, result) {
|
|
248
|
+
const { slotLabel, sectionRe, headerLines, prefillRows } = opts
|
|
249
|
+
const start = lines.findIndex((l) => sectionRe.test(l))
|
|
250
|
+
if (start === -1) {
|
|
251
|
+
result.skipped += 1
|
|
252
|
+
result.lines.push(`⏭️ ${slotLabel}:design.md 无该章节,跳过(现状骨架行为不变)`)
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
let end = lines.length
|
|
256
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
257
|
+
if (/^##\s/.test(lines[i])) { end = i; break }
|
|
258
|
+
}
|
|
259
|
+
// 表格行定位(含表头;分隔行排除——change-list 同款判定)
|
|
260
|
+
const tableRowIdxs = []
|
|
261
|
+
for (let i = start + 1; i < end; i++) {
|
|
262
|
+
if (lines[i].startsWith('|') && !/^\|[-:\s|]+\|$/.test(lines[i])) tableRowIdxs.push(i)
|
|
263
|
+
}
|
|
264
|
+
const contentRowIdxs = tableRowIdxs.slice(1) // 首表格行 = 表头
|
|
265
|
+
const hasHumanContent = contentRowIdxs.some((i) => !lines[i].includes(PREFILL_NOTE))
|
|
266
|
+
|| lines.slice(start + 1, end).some((l) => {
|
|
267
|
+
const t = l.trim()
|
|
268
|
+
return t !== '' && !t.startsWith('<!--') && !t.startsWith('|') // 散文/分类列表 = 人工产出
|
|
269
|
+
})
|
|
270
|
+
if (hasHumanContent) {
|
|
271
|
+
result.confirmed += 1
|
|
272
|
+
result.lines.push(`🔒 ${slotLabel}:已有人工内容(预填注已删/非预填行),确认态跳过不覆盖`)
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
if (prefillRows.length === 0) {
|
|
276
|
+
result.skipped += 1
|
|
277
|
+
result.lines.push(`⏭️ ${slotLabel}:无源可预填(推导为空),跳过`)
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
const existing = new Set(contentRowIdxs.map((i) => lines[i].trim()))
|
|
281
|
+
const toAdd = prefillRows.filter((r) => !existing.has(r.trim()))
|
|
282
|
+
if (toAdd.length === 0) {
|
|
283
|
+
result.filled += 1
|
|
284
|
+
result.lines.push(`✅ ${slotLabel}:预填 ${prefillRows.length} 行已在位(幂等,0 追加,注在场待核对)`)
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
if (tableRowIdxs.length > 0) {
|
|
288
|
+
// 追加位置:最后一个内容行后;无内容行(仅表头)则表头/分隔行后
|
|
289
|
+
let insertAt
|
|
290
|
+
if (contentRowIdxs.length > 0) {
|
|
291
|
+
insertAt = contentRowIdxs[contentRowIdxs.length - 1] + 1
|
|
292
|
+
} else {
|
|
293
|
+
insertAt = tableRowIdxs[0] + 1
|
|
294
|
+
if (insertAt < end && /^\|[-:\s|]+\|$/.test(lines[insertAt])) insertAt += 1
|
|
295
|
+
}
|
|
296
|
+
lines.splice(insertAt, 0, ...toAdd)
|
|
297
|
+
} else {
|
|
298
|
+
// 章节内全无表格(兜底):表头 + 预填行整体插在标题行后
|
|
299
|
+
lines.splice(start + 1, 0, '', ...headerLines, ...toAdd)
|
|
300
|
+
}
|
|
301
|
+
result.filled += 1
|
|
302
|
+
result.lines.push(`✅ ${slotLabel}:预填追加 ${toAdd.length} 行(未确认——注在场,核对后删注)`)
|
|
303
|
+
return true
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* task 卡 ids 槽重放(requirement_ids / decision_ids 各计一槽):
|
|
308
|
+
* - 值非空且注在场 → 未确认预填 → 按最新推导重放(源更新语义,R-01:requirements/
|
|
309
|
+
* decisions 变更后 refresh 刷新值;与在位值一致则零写入);
|
|
310
|
+
* - 值非空且无注 → 人工内容(确认态)→ confirmed 跳过不覆盖;
|
|
311
|
+
* - 值空/占位([] 或 taskcard 骨架 [FR-XX]/[D-XXX@vN])且推导非空 → 直填+注;
|
|
312
|
+
* - 值空且推导为空(源缺失)→ 留空跳过。
|
|
313
|
+
* @param {string[]} lines task 卡全文行(已归一 \n)
|
|
314
|
+
* @param {number} fmEnd frontmatter 闭合 --- 的行下标
|
|
315
|
+
* @param {string} field 字段名(requirement_ids / decision_ids)
|
|
316
|
+
* @param {string[]} derived 预填值(prefillCardIds 对应数组)
|
|
317
|
+
* @param {string} basename 文件名(advisory 行标注用)
|
|
318
|
+
* @param {{ filled: number, skipped: number, confirmed: number, lines: string[] }} result
|
|
319
|
+
* @returns {boolean} 是否发生写入
|
|
320
|
+
*/
|
|
321
|
+
function refreshTaskIdSlot(lines, fmEnd, field, derived, basename, result) {
|
|
322
|
+
const slotLabel = `${basename} ${field}`
|
|
323
|
+
const fieldRe = new RegExp(`^${field}:`)
|
|
324
|
+
let fi = -1
|
|
325
|
+
for (let i = 0; i < fmEnd; i++) {
|
|
326
|
+
if (fieldRe.test(lines[i])) { fi = i; break }
|
|
327
|
+
}
|
|
328
|
+
let fe = fi + 1
|
|
329
|
+
if (fi >= 0) {
|
|
330
|
+
while (fe < fmEnd && /^\s*-[ \t]/.test(lines[fe])) fe++ // 块列表形态占用区
|
|
331
|
+
}
|
|
332
|
+
const valueText = fi >= 0 ? lines.slice(fi, fe).join('\n') : ''
|
|
333
|
+
// 值条目收集(inline [] / 块列表 / 标量兜底;注释不入条目)
|
|
334
|
+
const entries = []
|
|
335
|
+
const firstLine = valueText.split('\n', 1)[0] || ''
|
|
336
|
+
const inline = firstLine.match(new RegExp(`^${field}:\\s*\\[([^\\]]*)\\]`))
|
|
337
|
+
if (inline) {
|
|
338
|
+
entries.push(...inline[1].split(',').map((s) => s.trim()).filter(Boolean))
|
|
339
|
+
} else {
|
|
340
|
+
const scalar = firstLine.slice(firstLine.indexOf(':') + 1).replace(/#.*$/, '').trim().replace(/^\[|\]$/g, '')
|
|
341
|
+
if (scalar) entries.push(scalar)
|
|
342
|
+
}
|
|
343
|
+
for (const l of valueText.split('\n').slice(1)) {
|
|
344
|
+
const m = l.match(/^\s*-\s+(.+)/)
|
|
345
|
+
if (m) entries.push(m[1].trim())
|
|
346
|
+
}
|
|
347
|
+
const placeholderRe = field === 'requirement_ids' ? /^FR-XX$/i : /^D-XXX@vN$/i
|
|
348
|
+
const isEmptyValue = entries.length === 0 || entries.every((e) => placeholderRe.test(e))
|
|
349
|
+
|
|
350
|
+
if (!isEmptyValue) {
|
|
351
|
+
if (!valueText.includes(PREFILL_NOTE)) {
|
|
352
|
+
result.confirmed += 1
|
|
353
|
+
result.lines.push(`🔒 ${slotLabel}:人工值在场(注已删),确认态跳过不覆盖`)
|
|
354
|
+
return false
|
|
355
|
+
}
|
|
356
|
+
if (derived.length === 0) {
|
|
357
|
+
result.skipped += 1
|
|
358
|
+
result.lines.push(`⏭️ ${slotLabel}:注在场但源缺失(无可推导值),保留原预填值跳过`)
|
|
359
|
+
return false
|
|
360
|
+
}
|
|
361
|
+
const newline = `${field}: [${derived.join(', ')}] # ${PREFILL_NOTE}`
|
|
362
|
+
if (fi >= 0 && fe - fi === 1 && lines[fi] === newline) {
|
|
363
|
+
result.filled += 1
|
|
364
|
+
result.lines.push(`✅ ${slotLabel}:预填值已在位(幂等,0 改写,注在场待核对)`)
|
|
365
|
+
return false
|
|
366
|
+
}
|
|
367
|
+
lines.splice(fi, fe - fi, newline)
|
|
368
|
+
result.filled += 1
|
|
369
|
+
result.lines.push(`✅ ${slotLabel}:未确认(注在场),按最新源重放预填`)
|
|
370
|
+
return true
|
|
371
|
+
}
|
|
372
|
+
if (derived.length === 0) {
|
|
373
|
+
result.skipped += 1
|
|
374
|
+
result.lines.push(`⏭️ ${slotLabel}:无可预填源(推导为空),留空跳过`)
|
|
375
|
+
return false
|
|
376
|
+
}
|
|
377
|
+
const newline = `${field}: [${derived.join(', ')}] # ${PREFILL_NOTE}`
|
|
378
|
+
if (fi >= 0) lines.splice(fi, fe - fi, newline)
|
|
379
|
+
else lines.splice(fmEnd, 0, newline) // 字段缺失(存量卡)→ 闭合 --- 前补写
|
|
380
|
+
result.filled += 1
|
|
381
|
+
result.lines.push(`✅ ${slotLabel}:空/占位 → 预填 ${derived.length} 项(注在场,核对后删注)`)
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 三槽重放(唯一 IO 写入口;D-005):对 changeDir 的 design.md 两表槽(文件变更清单 /
|
|
387
|
+
* 决策追踪——均「在表后追加预填行,有人工行=已确认跳过」)与 tasks/*.md ids 槽执行预填。
|
|
388
|
+
*
|
|
389
|
+
* 计数语义(advisory,每槽累计;lines 每槽一行结果供 CLI 透出):
|
|
390
|
+
* - filled:槽未确认(无人工内容)且预填内容就位——含幂等重放时「已在位零改写」的槽,
|
|
391
|
+
* 保证同输入二次重放 { filled, skipped, confirmed } 逐字段一致(幂等契约);
|
|
392
|
+
* - skipped:槽无源可预填(源文件/章节缺失、推导为空、注在场但源缺失保留原值);
|
|
393
|
+
* - confirmed:槽有人工内容(预填注已删 / 非预填行 / 人工值)→ 保护跳过不覆盖。
|
|
394
|
+
*
|
|
395
|
+
* 写入:writeAtomicSync 原子落盘(fs-atomic 先例);仅在内容变化时写;文件 EOL 跟随原文件。
|
|
396
|
+
* 变更目录不存在:三计数归零 + 一行告警(fail-soft,CLI 侧自行决定 exit code)。
|
|
397
|
+
*
|
|
398
|
+
* @param {{ cwd?: string, specBase?: string, changeName?: string }} opts
|
|
399
|
+
* cwd/specBase 同 change-delete/archive-delta 口径:specBase 缺省 = join(cwd, '.sillyspec')
|
|
400
|
+
* @returns {Promise<{ filled: number, skipped: number, confirmed: number, lines: string[] }>}
|
|
401
|
+
*/
|
|
402
|
+
export async function runPrefillRefresh({ cwd, specBase, changeName } = {}) {
|
|
403
|
+
const base = specBase || join(cwd || process.cwd(), '.sillyspec')
|
|
404
|
+
const changeDir = join(base, 'changes', String(changeName || ''))
|
|
405
|
+
const result = { filled: 0, skipped: 0, confirmed: 0, lines: [] }
|
|
406
|
+
if (!changeName || !existsSync(changeDir)) {
|
|
407
|
+
result.lines.push(`⚠️ 变更目录不存在: ${changeDir}(三槽均无法预填)`)
|
|
408
|
+
return result
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 槽一/槽二:design.md 文件变更清单 + 决策追踪(单次读盘,两槽共一份行数组后统一落盘)
|
|
412
|
+
const designPath = join(changeDir, 'design.md')
|
|
413
|
+
const designRaw = readTextIfExists(designPath)
|
|
414
|
+
if (designRaw === null) {
|
|
415
|
+
result.skipped += 2
|
|
416
|
+
result.lines.push('⏭️ design.md 文件变更清单:design.md 不存在,跳过')
|
|
417
|
+
result.lines.push('⏭️ design.md 决策追踪:design.md 不存在,跳过')
|
|
418
|
+
} else {
|
|
419
|
+
// EOL 探测回读原文(readTextIfExists 返回的是 CRLF 归一文本,探测不了原始 EOL——
|
|
420
|
+
// upsertDecision 同款「原文探测 + 归一编辑 + 原 EOL 回写」口径)
|
|
421
|
+
let eolProbe = '\n'
|
|
422
|
+
try { eolProbe = readFileSync(designPath, 'utf8').includes('\r\n') ? '\r\n' : '\n' } catch { /* 保持 \n */ }
|
|
423
|
+
const lines = designRaw.split('\n')
|
|
424
|
+
const listRows = prefillFileChangeList({ tasksDir: join(changeDir, 'tasks') })
|
|
425
|
+
const decisionRows = prefillDecisionTable({ changeDir })
|
|
426
|
+
let dirty = false
|
|
427
|
+
dirty = refreshDesignTableSlot(lines, {
|
|
428
|
+
slotLabel: 'design.md 文件变更清单',
|
|
429
|
+
sectionRe: FILE_LIST_SECTION_RE,
|
|
430
|
+
headerLines: ['| 操作 | 文件路径 | 说明 |', '|---|---|---|'],
|
|
431
|
+
prefillRows: listRows,
|
|
432
|
+
}, result) || dirty
|
|
433
|
+
dirty = refreshDesignTableSlot(lines, {
|
|
434
|
+
slotLabel: 'design.md 决策追踪',
|
|
435
|
+
sectionRe: DECISION_SECTION_RE,
|
|
436
|
+
headerLines: ['| 决策 | 覆盖点 | 状态 |', '|---|---|---|'],
|
|
437
|
+
prefillRows: decisionRows,
|
|
438
|
+
}, result) || dirty
|
|
439
|
+
if (dirty) writeAtomicSync(designPath, lines.join(eolProbe))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 槽三:tasks/*.md 的 requirement_ids / decision_ids
|
|
443
|
+
const tasksDir = join(changeDir, 'tasks')
|
|
444
|
+
if (!existsSync(tasksDir)) {
|
|
445
|
+
result.skipped += 1
|
|
446
|
+
result.lines.push('⏭️ tasks/*.md ids 槽:tasks 目录不存在,跳过')
|
|
447
|
+
return result
|
|
448
|
+
}
|
|
449
|
+
const { requirementIds, decisionIds } = prefillCardIds({ changeDir })
|
|
450
|
+
let taskFiles = []
|
|
451
|
+
try {
|
|
452
|
+
taskFiles = readdirSync(tasksDir).filter((f) => /^task-.+\.md$/.test(f)).sort()
|
|
453
|
+
} catch {
|
|
454
|
+
taskFiles = []
|
|
455
|
+
}
|
|
456
|
+
for (const f of taskFiles) {
|
|
457
|
+
const taskPath = join(tasksDir, f)
|
|
458
|
+
const raw = readTextIfExists(taskPath)
|
|
459
|
+
if (raw === null) continue
|
|
460
|
+
// frontmatter 边界:首行 --- 与其闭合 ---(无 frontmatter 的存量文件跳过——两槽各计 skipped)
|
|
461
|
+
const lines = raw.split('\n')
|
|
462
|
+
let fmEnd = -1
|
|
463
|
+
if (lines[0] !== undefined && lines[0].trim() === '---') {
|
|
464
|
+
for (let i = 1; i < lines.length; i++) {
|
|
465
|
+
if (lines[i].trim() === '---') { fmEnd = i; break }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (fmEnd === -1) {
|
|
469
|
+
result.skipped += 2
|
|
470
|
+
result.lines.push(`⏭️ ${f} requirement_ids:无 frontmatter,跳过`)
|
|
471
|
+
result.lines.push(`⏭️ ${f} decision_ids:无 frontmatter,跳过`)
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
let eolProbe = '\n'
|
|
475
|
+
try { eolProbe = readFileSync(taskPath, 'utf8').includes('\r\n') ? '\r\n' : '\n' } catch { /* 保持 \n */ }
|
|
476
|
+
let dirty = false
|
|
477
|
+
dirty = refreshTaskIdSlot(lines, fmEnd, 'requirement_ids', requirementIds, f, result) || dirty
|
|
478
|
+
// decision_ids 插行可能移动 fmEnd(字段缺失时在 fmEnd 处插入)——插入点在 fmEnd 本身,闭合 --- 后移,重定位
|
|
479
|
+
let fmEndNow = fmEnd
|
|
480
|
+
if (dirty) {
|
|
481
|
+
fmEndNow = -1
|
|
482
|
+
for (let i = 1; i < lines.length; i++) {
|
|
483
|
+
if (lines[i].trim() === '---') { fmEndNow = i; break }
|
|
484
|
+
}
|
|
485
|
+
if (fmEndNow === -1) fmEndNow = lines.length
|
|
486
|
+
}
|
|
487
|
+
dirty = refreshTaskIdSlot(lines, fmEndNow, 'decision_ids', decisionIds, f, result) || dirty
|
|
488
|
+
if (dirty) writeAtomicSync(taskPath, lines.join(eolProbe))
|
|
489
|
+
}
|
|
490
|
+
return result
|
|
491
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 坑 probe7-covered-anchor-missing(2026-09-15 复盘实证:探针7 矩阵第一轮 7 行 covered 判定的
|
|
5
5
|
* 证据列缺 file:line 锚点被审查打回——预填说明写着「证据列给首命中 file:line 锚点」但模板要求
|
|
6
6
|
* 没细读就交,人工往返一轮)。本模块把该口径机器化:verify --done 时点解析正文探针7 段,
|
|
7
|
-
* 判定列=covered 的行必须含 file:line / `.test.` 测试文件名 /
|
|
8
|
-
*
|
|
7
|
+
* 判定列=covered/covered-service 的行必须含 file:line / `.test.` 测试文件名 / 反引号包裹路径
|
|
8
|
+
* 或测试名三形态之一,缺则 advisory 提示回补。
|
|
9
9
|
*
|
|
10
10
|
* 口径对齐(2026-09-16-friction5-hardening FR-05 / D-005@v1,用户 2026-09-16 驾驭小结⑤):
|
|
11
11
|
* 锚点认 file:line 或 `.test.` 文件名两形态(对齐 stage-contract 硬门三形态中的两形态)——
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* 定位与边界:
|
|
21
21
|
* - **advisory 不阻断**:covered 行缺锚点只 warn(证据可以是合法的人工核验形态,但 covered=
|
|
22
22
|
* 有归属测试命中,锚点理应存在——缺锚点大概率是 agent 改写预填时丢了,回补成本低)。
|
|
23
|
-
* - 只查 covered
|
|
23
|
+
* - 只查 covered/covered-service(covered-service 同 covered 口径:service 层承接的判定同样
|
|
24
|
+
* 须测试锚点):partial/uncovered/non-testable 的证据形态多样(人工核验提示/理由),不做
|
|
24
25
|
* 锚点要求(误报面大于收益)。
|
|
25
26
|
* - 独立模块而非长在 verify-probes.js:后者是多会话高频冲突面(探针3/5 家族改动多发),
|
|
26
27
|
* 本校验只消费 verify-result.md 文本,零依赖单文件最稳。
|
|
@@ -35,7 +36,8 @@ const ANCHOR_RE = /:\d+\b/
|
|
|
35
36
|
// 与 stage-contract 硬门 matrixEvidenceHasAnchor 的 /`[^`]+`/ 同权)
|
|
36
37
|
const BACKTICK_RE = /`[^`]+`/
|
|
37
38
|
|
|
38
|
-
//
|
|
39
|
+
// 判定枚举纯值(骨架五枚举 covered/covered-service/partial/uncovered/non-testable;容忍反引号
|
|
40
|
+
// 包裹——门禁层 extractAcceptanceMatrixSlots 另有纯值校验)
|
|
39
41
|
function normalizeVerdict(cell) {
|
|
40
42
|
return String(cell || '').trim().replace(/^`|`$/g, '').toLowerCase()
|
|
41
43
|
}
|
|
@@ -67,9 +69,9 @@ export function checkProbe7AnchorCoverage(reportText) {
|
|
|
67
69
|
const cells = line.split('|').map(c => c.trim())
|
|
68
70
|
if (cells.length < 6) continue // 表头/分隔行之外的畸形行不参与(表头 6 列含首尾空串)
|
|
69
71
|
const verdict = normalizeVerdict(cells[4])
|
|
70
|
-
if (!['covered', 'partial', 'uncovered', 'non-testable'].includes(verdict)) continue
|
|
72
|
+
if (!['covered', 'covered-service', 'partial', 'uncovered', 'non-testable'].includes(verdict)) continue
|
|
71
73
|
out.rowsChecked++
|
|
72
|
-
if (verdict !== 'covered') continue
|
|
74
|
+
if (verdict !== 'covered' && verdict !== 'covered-service') continue
|
|
73
75
|
out.coveredRows++
|
|
74
76
|
const evidence = cells[5] || ''
|
|
75
77
|
// 三形态锚点(FR-05 / D-005@v2):file:line(ANCHOR_RE,本体不动)、`.test.` 测试文件名、
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* (_module-map.yaml 解析结果),输出 L0/L1/L2 画像与检查项,供 Wave 2 接线消费
|
|
7
7
|
* (run/shared.js 挂 review.gateProfile → complete-handlers 落账 / quick-audit 打印 /
|
|
8
8
|
* scope-audit 表格与 --json 双出口)。本模块零 IO、零子进程:moduleIndex、风险表、阈值
|
|
9
|
-
* 全部由参数或默认值提供(D-007 独立纯函数模块;R-04
|
|
9
|
+
* 全部由参数或默认值提供(D-007 独立纯函数模块;R-04 画像=纯字符串运算)。风险表自
|
|
10
|
+
* 2026-09-19-span-risk-pattern-migration D-003 起为声明面口径:默认空表=风险维度关闭,
|
|
11
|
+
* 项目声明表(_module-map.yaml 顶层 span_risk 段)经 opts.riskTable 注入
|
|
12
|
+
* (loadSpanRiskPatterns 产物装载),不再内嵌全宇宙表。
|
|
10
13
|
*
|
|
11
14
|
* 判级(阈值只引用 THRESHOLDS 常量,不散落字面量):
|
|
12
15
|
* 正常态(moduleIndex 可用):L2 = 跨 ≥L2_SPAN 模块 或 风险命中;L1 = 跨 ≥L1_SPAN 模块
|
|
@@ -25,7 +28,6 @@
|
|
|
25
28
|
* .sillyspec/docs/ 前缀——模块卡与 changelog sidecar 均 .md 天然覆盖),
|
|
26
29
|
* 文档文件不计入 testFileCount/codeFileCount,也不参与模块归属与风险命中。
|
|
27
30
|
*/
|
|
28
|
-
import { QUICK_RISK_PATH_PATTERNS } from './change-risk-profile.js'
|
|
29
31
|
|
|
30
32
|
/** 代码内默认阈值(task-05 真实图谱校准定稿,缺省行为的单一事实源;local.yaml quick-gate 段可覆写,D-009)。
|
|
31
33
|
* 定稿依据(2026-09-14,sillyhub 919 条 quicklog × 5 份真实 _module-map.yaml 重算交叉表,
|
|
@@ -138,14 +140,16 @@ function cardInFiles(doc, fileSet) {
|
|
|
138
140
|
* @param {string[]} changedFiles 变更文件(CLI 审计链 git 事实;反斜杠自动归一 POSIX)
|
|
139
141
|
* @param {object|null} moduleIndex _module-map.yaml 解析结果(null/undefined/空 → degraded)
|
|
140
142
|
* @param {object} [opts]
|
|
141
|
-
* @param {Array<{pattern: string, re: RegExp}>} [opts.riskTable]
|
|
143
|
+
* @param {Array<{pattern: string, re: RegExp}>} [opts.riskTable] 路径模式风险表(默认空表=
|
|
144
|
+
* 风险维度关闭;项目声明表经注入——loadSpanRiskPatterns 产物,
|
|
145
|
+
* 2026-09-19-span-risk-pattern-migration D-003)
|
|
142
146
|
* @param {object} [opts.thresholds] 已合并阈值(resolveGateThresholds 产物;缺省用 THRESHOLDS,D-009)
|
|
143
147
|
* @param {Array<{path: string}>} [opts.fileNotes] --file-notes 解析结果(perFileNotes 覆盖率判定)
|
|
144
148
|
* @param {boolean} [opts.noDocs] --no-docs 显式豁免(docClaim → exempt-no-docs)
|
|
145
149
|
* @returns 画像对象(字段与判级规则见 design.md 接口定义)
|
|
146
150
|
*/
|
|
147
151
|
export function computeGateProfile(changedFiles, moduleIndex, opts = {}) {
|
|
148
|
-
const riskTable = Array.isArray(opts.riskTable) ? opts.riskTable :
|
|
152
|
+
const riskTable = Array.isArray(opts.riskTable) ? opts.riskTable : [] // 默认空表=风险维度关闭(声明表经注入,D-003)
|
|
149
153
|
|
|
150
154
|
// 阈值:opts.thresholds 逐键取合法值,否则回 THRESHOLDS 默认(warn 责任在 resolveGateThresholds)
|
|
151
155
|
const th = {}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quick-session-owner.js — quick 会话归属隔离(多 agent 并行防互踩)
|
|
3
|
+
*
|
|
4
|
+
* 坑 quick-shared-pointer-stomp(2026-09-19 实证):quick 会话 id 经共享指针
|
|
5
|
+
* .runtime/current-quick-run-id(last-writer-wins)+ --done fallback 解析,并行 agent
|
|
6
|
+
* 会话的 --done 会静默步进/收尾他者会话(实证:他者会话 QUICKLOG 条目标题被覆写、
|
|
7
|
+
* 四字段嵌合)。既有护栏两处缺口:resolveSessionIdentity 的 quick 场景身份=会话 id
|
|
8
|
+
* 本身(selfSession==changeName 恒 self,不区分创建者);Q7 守卫只拦 fallback 命中
|
|
9
|
+
* 已完成/stale 会话,活动中的他者会话照踩。
|
|
10
|
+
*
|
|
11
|
+
* 修复语义(与 change 所有权护栏同三级:--session > SILLYSPEC_SESSION_ID >
|
|
12
|
+
* anon@<host>,缺省机器级只拦他机):quick 会话创建时把创建者 agent 身份落
|
|
13
|
+
* .runtime/quick-sessions/<sid>/owner.json(与 guard.json 同目录,随 --cancel
|
|
14
|
+
* 目录清理销毁);变更类操作(--done/--skip/--reset/--reopen/--cancel)前核验归属,
|
|
15
|
+
* 异主 exit 2(接管=显式 --session <owner>)。无 owner 文件的存量会话放行
|
|
16
|
+
* (brownfield 兼容)。核验身份刻意不走 quick 会话三级——那会把身份解析成会话 id
|
|
17
|
+
* 本身,恒 self 失去区分度。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, mkdirSync } from 'fs'
|
|
20
|
+
import { join, dirname } from 'path'
|
|
21
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
22
|
+
|
|
23
|
+
/** owner 文件路径(<runtimeRoot>/quick-sessions/<sid>/owner.json,与 guard.json 同目录) */
|
|
24
|
+
export function quickSessionOwnerPath(runtimeRoot, sessionId) {
|
|
25
|
+
return join(runtimeRoot, 'quick-sessions', sessionId, 'owner.json')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 读创建者身份:文件缺失 / 坏 JSON / owner 非法形态 → null(按无 owner 放行处理) */
|
|
29
|
+
export function readQuickSessionOwner(runtimeRoot, sessionId) {
|
|
30
|
+
if (!runtimeRoot || !sessionId) return null
|
|
31
|
+
try {
|
|
32
|
+
const doc = JSON.parse(readFileSync(quickSessionOwnerPath(runtimeRoot, sessionId), 'utf8'))
|
|
33
|
+
if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) return null
|
|
34
|
+
const owner = typeof doc.owner === 'string' ? doc.owner.trim() : ''
|
|
35
|
+
return owner ? owner : null
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 创建时落 owner(fail-open:调用方吞异常,落盘失败不阻断启动,核验侧缺失即放行) */
|
|
42
|
+
export function writeQuickSessionOwner(runtimeRoot, sessionId, owner) {
|
|
43
|
+
if (!runtimeRoot || !sessionId || !owner || !owner.trim()) return false
|
|
44
|
+
const p = quickSessionOwnerPath(runtimeRoot, sessionId)
|
|
45
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
46
|
+
writeAtomicSync(p, JSON.stringify({ owner: owner.trim(), at: new Date().toISOString() }, null, 2))
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 归属核验(纯判定,exit 语义归调用方):
|
|
52
|
+
* owner 文件缺失(存量会话/落盘失败)→ { ok: true, legacy: true } 放行;
|
|
53
|
+
* owner === actor → { ok: true };
|
|
54
|
+
* owner !== actor → { ok: false, owner, actor }(调用方 exit 2 + 接管指引);
|
|
55
|
+
* 入参不全(无法判定)→ { ok: true } 放行(fail-open,Q7 守卫同立场——宁可少拦不误伤单用户流)。
|
|
56
|
+
*/
|
|
57
|
+
export function checkQuickSessionOwner(runtimeRoot, sessionId, actor) {
|
|
58
|
+
if (!runtimeRoot || !sessionId || !actor) return { ok: true }
|
|
59
|
+
const owner = readQuickSessionOwner(runtimeRoot, sessionId)
|
|
60
|
+
if (owner == null) return { ok: true, legacy: true }
|
|
61
|
+
if (owner === actor) return { ok: true }
|
|
62
|
+
return { ok: false, owner, actor }
|
|
63
|
+
}
|