sillyspec 3.28.8 → 3.28.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/full.json +1 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/change-list.js +73 -20
- package/src/change-risk-profile.js +20 -0
- package/src/config-schema.js +62 -0
- package/src/contract-matrix.js +84 -5
- package/src/cross-repo-reconcile.js +134 -0
- package/src/db.js +11 -3
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +89 -20
- package/src/docs-gate.js +105 -6
- package/src/doctor-diagnostics.js +224 -2
- package/src/friction-ledger.js +171 -0
- package/src/hooks/worktree-guard.js +13 -1
- package/src/index.js +206 -18
- package/src/knowledge-classify.js +382 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/probe7-anchor-check.js +69 -0
- package/src/progress/change-registry.js +183 -8
- package/src/progress/shared.js +3 -2
- package/src/progress.js +100 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +164 -18
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +131 -13
- package/src/run/complete-handlers.js +511 -21
- package/src/run/complete.js +123 -23
- package/src/run/concurrent-detect.js +98 -0
- package/src/run/gate-snapshot.js +110 -2
- package/src/run/gates.js +141 -16
- package/src/run/prompt.js +221 -11
- package/src/run/quick-audit.js +111 -13
- package/src/run/shared.js +147 -11
- package/src/run/stage.js +6 -0
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +456 -56
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +23 -7
- package/src/stages/brainstorm.js +6 -0
- package/src/stages/cmd-existence.js +9 -3
- package/src/stages/execute.js +85 -3
- package/src/stages/knowledge.js +76 -6
- package/src/stages/plan-postcheck.js +32 -5
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +170 -6
- package/src/sync.js +141 -9
- package/src/task-review.js +366 -46
- package/src/verify-postcheck.js +212 -55
- package/src/verify-probes.js +727 -16
- package/src/worktree-apply.js +685 -48
- package/src/worktree-deps.js +124 -3
- package/src/worktree.js +391 -15
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/.patch16-tmp.mjs +0 -169
- package/sa-rename-probe/src/new.js +0 -4
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* probe7-anchor-check.js — verify-result.md 探针7 矩阵 covered 行证据锚点校验(零环新模块)。
|
|
3
|
+
*
|
|
4
|
+
* 坑 probe7-covered-anchor-missing(2026-09-15 复盘实证:探针7 矩阵第一轮 7 行 covered 判定的
|
|
5
|
+
* 证据列缺 file:line 锚点被审查打回——预填说明写着「证据列给首命中 file:line 锚点」但模板要求
|
|
6
|
+
* 没细读就交,人工往返一轮)。本模块把该口径机器化:verify --done 时点解析正文探针7 段,
|
|
7
|
+
* 判定列=covered 的行必须含 `path:line` 形态锚点,缺则 advisory 提示回补。
|
|
8
|
+
*
|
|
9
|
+
* 定位与边界:
|
|
10
|
+
* - **advisory 不阻断**:covered 行缺锚点只 warn(证据可以是合法的人工核验形态,但 covered=
|
|
11
|
+
* 有归属测试命中,锚点理应存在——缺锚点大概率是 agent 改写预填时丢了,回补成本低)。
|
|
12
|
+
* - 只查 covered:partial/uncovered/non-testable 的证据形态多样(人工核验提示/理由),不做
|
|
13
|
+
* 锚点要求(误报面大于收益)。
|
|
14
|
+
* - 独立模块而非长在 verify-probes.js:后者是多会话高频冲突面(探针3/5 家族改动多发),
|
|
15
|
+
* 本校验只消费 verify-result.md 文本,零依赖单文件最稳。
|
|
16
|
+
* - 表格列序与 renderProbe7Lines 骨架同源锚定:| acceptance | 归属测试 | 关键词命中 | 判定 | 证据 |
|
|
17
|
+
* → split('|') 后 cells[4]=判定、cells[5]=证据(首尾空串偏移)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// file:line 锚点:冒号后跟数字(`test/x.test.mjs:42`、`src/a.py:12-18` 均命中)
|
|
21
|
+
const ANCHOR_RE = /:\d+\b/
|
|
22
|
+
|
|
23
|
+
// 判定枚举纯值(骨架四枚举;容忍反引号包裹——门禁层 extractAcceptanceMatrixSlots 另有纯值校验)
|
|
24
|
+
function normalizeVerdict(cell) {
|
|
25
|
+
return String(cell || '').trim().replace(/^`|`$/g, '').toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 校验探针7 矩阵 covered 行的证据锚点。
|
|
30
|
+
* @param {string} reportText verify-result.md 全文
|
|
31
|
+
* @returns {{applicable:boolean, rowsChecked:number, coveredRows:number,
|
|
32
|
+
* missingAnchors:Array<{task:string, acceptance:string, evidence:string}>}}
|
|
33
|
+
* applicable=false:正文无「#### 探针 7」段(quick/存量报告)——调用方零输出。
|
|
34
|
+
*/
|
|
35
|
+
export function checkProbe7AnchorCoverage(reportText) {
|
|
36
|
+
const out = { applicable: false, rowsChecked: 0, coveredRows: 0, missingAnchors: [] }
|
|
37
|
+
if (!reportText) return out
|
|
38
|
+
const lines = String(reportText).replace(/\r\n/g, '\n').split('\n')
|
|
39
|
+
const start = lines.findIndex(l => /^####\s*探针\s*7/.test(l))
|
|
40
|
+
if (start === -1) return out
|
|
41
|
+
let end = lines.length
|
|
42
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
43
|
+
if (/^####\s/.test(lines[i])) { end = i; break }
|
|
44
|
+
}
|
|
45
|
+
out.applicable = true
|
|
46
|
+
let task = null
|
|
47
|
+
for (let i = start + 1; i < end; i++) {
|
|
48
|
+
const line = lines[i]
|
|
49
|
+
const tm = line.match(/^\*\*(.+?)\*\*\s*$/)
|
|
50
|
+
if (tm) { task = tm[1].trim(); continue }
|
|
51
|
+
if (!line.startsWith('|')) continue
|
|
52
|
+
const cells = line.split('|').map(c => c.trim())
|
|
53
|
+
if (cells.length < 6) continue // 表头/分隔行之外的畸形行不参与(表头 6 列含首尾空串)
|
|
54
|
+
const verdict = normalizeVerdict(cells[4])
|
|
55
|
+
if (!['covered', 'partial', 'uncovered', 'non-testable'].includes(verdict)) continue
|
|
56
|
+
out.rowsChecked++
|
|
57
|
+
if (verdict !== 'covered') continue
|
|
58
|
+
out.coveredRows++
|
|
59
|
+
const evidence = cells[5] || ''
|
|
60
|
+
if (!ANCHOR_RE.test(evidence)) {
|
|
61
|
+
out.missingAnchors.push({
|
|
62
|
+
task: task || '(未知 task)',
|
|
63
|
+
acceptance: (cells[1] || '').slice(0, 80),
|
|
64
|
+
evidence: evidence.slice(0, 80),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
@@ -1,7 +1,45 @@
|
|
|
1
1
|
// W6 Step9b: 变更注册表(changes 表生命周期)—— 注册/注销/重命名/隔离状态/平台同步戳/审批状态。
|
|
2
2
|
// 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm._ensureDB /
|
|
3
3
|
// pm._changePath(persistence-core 留 facade 本体)。无共享常量依赖。
|
|
4
|
-
import { existsSync, mkdirSync, renameSync } from 'fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
|
|
7
|
+
// 所有权活跃心跳窗缺省(2026-09-14-change-ownership-guards task-02 / FR-01):owner 非本会话
|
|
8
|
+
// 且 now-last_active < 窗口 → 拒绝接管;窗口外 → 自动接管。local.yaml change-ownership.
|
|
9
|
+
// heartbeat_minutes 可调(resolveHeartbeatMs),缺省 15 分钟(config-schema 已登记 live 读者=本文件)。
|
|
10
|
+
const DEFAULT_HEARTBEAT_MINUTES = 15;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 读 local.yaml 的 change-ownership.heartbeat_minutes → 毫秒窗(2026-09-14-change-ownership-guards
|
|
14
|
+
* task-02)。路径口径与 sync.js readLocalYaml / worktree-cross.js 一致:cwd/.sillyspec/local.yaml。
|
|
15
|
+
* 定向行扫描(只找 change-ownership: 顶层段下的 heartbeat_minutes),不引 YAML 解析依赖、
|
|
16
|
+
* 字节级容错(CRLF 去 \r、注释行天然不匹配)。缺文件/缺段/缺键/值非正整数(含 0——误配把
|
|
17
|
+
* 活跃窗清零会让护栏形同虚设)→ 缺省 15 分钟。纯读不抛。模块私有(唯一消费者=
|
|
18
|
+
* assertChangeOwnership 心跳窗缺省解析;config-schema 登记的读者即本文件)。
|
|
19
|
+
* @param {string} cwd - 项目根目录
|
|
20
|
+
* @returns {number} 心跳窗口毫秒
|
|
21
|
+
*/
|
|
22
|
+
function resolveHeartbeatMs(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
const p = join(cwd || process.cwd(), '.sillyspec', 'local.yaml');
|
|
25
|
+
if (!existsSync(p)) return DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
|
|
26
|
+
let inSection = false;
|
|
27
|
+
for (const rawLine of readFileSync(p, 'utf8').split('\n')) {
|
|
28
|
+
const line = rawLine.replace(/\r$/, ''); // CRLF 兼容(CLAUDE.md #13)
|
|
29
|
+
if (/^\S/.test(line)) { // 顶层 key 行:进/出 change-ownership 段
|
|
30
|
+
inSection = line.startsWith('change-ownership:');
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!inSection) continue;
|
|
34
|
+
const m = line.match(/^\s*heartbeat_minutes\s*:\s*(\d+)\s*$/);
|
|
35
|
+
if (m) {
|
|
36
|
+
const minutes = parseInt(m[1], 10);
|
|
37
|
+
return (Number.isFinite(minutes) && minutes >= 1) ? minutes * 60 * 1000 : DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch { /* 读失败 → 缺省(缺省是安全下界,不是语义分支) */ }
|
|
41
|
+
return DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
|
|
42
|
+
}
|
|
5
43
|
|
|
6
44
|
export class ChangeRegistry {
|
|
7
45
|
constructor(pm) {
|
|
@@ -138,8 +176,13 @@ export class ChangeRegistry {
|
|
|
138
176
|
/**
|
|
139
177
|
* 注册变更到活跃列表
|
|
140
178
|
* SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
|
|
179
|
+
* @param {string} cwd
|
|
180
|
+
* @param {string} changeName
|
|
181
|
+
* @param {{ ownerSession?: string|null }} [opts] - owner_session(2026-09-14-change-ownership-guards
|
|
182
|
+
* task-01:首建者获得所有权;INSERT OR IGNORE 语义天然「已有行不覆盖」——已存在行(含他人
|
|
183
|
+
* 活跃 change)owner 不动。会话标识三级方案的接线在 task-02,缺省 undefined=不写(列默认 NULL=无主))
|
|
141
184
|
*/
|
|
142
|
-
registerChange(cwd, changeName) {
|
|
185
|
+
registerChange(cwd, changeName, opts = {}) {
|
|
143
186
|
if (!changeName) {
|
|
144
187
|
console.warn('⚠️ registerChange: changeName 为空,跳过');
|
|
145
188
|
return;
|
|
@@ -148,11 +191,11 @@ export class ChangeRegistry {
|
|
|
148
191
|
db.transaction(() => {
|
|
149
192
|
const sqlDb = db.getDb();
|
|
150
193
|
const now = new Date().toISOString();
|
|
151
|
-
//
|
|
194
|
+
// 尝试插入新行(带 owner_session——首次创建者获得所有权,已有值不覆盖)
|
|
152
195
|
const ins = sqlDb.prepare(
|
|
153
|
-
`INSERT OR IGNORE INTO changes (name, created_at, last_active)
|
|
154
|
-
VALUES (?, ?, ?)`
|
|
155
|
-
).run(changeName, now, now);
|
|
196
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
|
|
197
|
+
VALUES (?, ?, ?, ?)`
|
|
198
|
+
).run(changeName, now, now, opts.ownerSession || null);
|
|
156
199
|
// 只在真正创建 change 时标脏(D-013/task-04);已存在行不标(读路径/重复调用不误判脏)
|
|
157
200
|
if (ins.changes > 0) this.pm._touchLocalModified(cwd, changeName, now);
|
|
158
201
|
// 注意:不复活已归档的变更——归档是不可逆操作
|
|
@@ -160,6 +203,131 @@ export class ChangeRegistry {
|
|
|
160
203
|
});
|
|
161
204
|
}
|
|
162
205
|
|
|
206
|
+
/**
|
|
207
|
+
* 读变更所有者会话标识(2026-09-14-change-ownership-guards task-01 数据载体,D-001/D-005@v1)。
|
|
208
|
+
* 只读不抛(与 getQuicklogId 同族容错):行缺失/未登记/读取失败 → null。
|
|
209
|
+
* NULL=无主(存量行 v6 迁移后即此态,任何会话可接管——判定/接管语义在 assertChangeOwnership,task-02)。
|
|
210
|
+
* @param {string} cwd
|
|
211
|
+
* @param {string} changeName
|
|
212
|
+
* @returns {string|null} owner 会话标识,无主/无行/读失败返回 null
|
|
213
|
+
*/
|
|
214
|
+
getChangeOwner(cwd, changeName) {
|
|
215
|
+
if (!changeName) return null;
|
|
216
|
+
try {
|
|
217
|
+
const db = this.pm._ensureDB(cwd);
|
|
218
|
+
const row = db.getDb().prepare('SELECT owner_session FROM changes WHERE name = ?').get(changeName);
|
|
219
|
+
return (row && typeof row.owner_session === 'string' && row.owner_session) || null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 认领变更所有权(2026-09-14-change-ownership-guards task-01 数据载体):
|
|
227
|
+
* - 行不存在 → 创建并写 owner=session(首建者获得);
|
|
228
|
+
* - 行存在且 owner 非空 → 不覆盖,返回既有 owner(调用方对比可知认领未成功);
|
|
229
|
+
* - 行存在且 owner 为 NULL(存量无主行)→ 认领写入。
|
|
230
|
+
* 单事务内「INSERT OR IGNORE + 守卫 UPDATE + 读回」,并发两个会话同时首认领时靠
|
|
231
|
+
* SQLite 单写者串行化——后者命中守卫(owner 已非 NULL)不覆盖,返回先到者。
|
|
232
|
+
* 实际变更(首建/认领)标本地脏度(owner 随同步 payload 上行,D-013 同族)。
|
|
233
|
+
* @param {string} cwd
|
|
234
|
+
* @param {string} changeName
|
|
235
|
+
* @param {string|null} session - 本会话标识(空值视为无主认领,只建行不写 owner)
|
|
236
|
+
* @returns {string|null} 实际 owner(首建/认领成功=session;他人已持有=其值;行刚建未写=null)
|
|
237
|
+
*/
|
|
238
|
+
claimChangeOwner(cwd, changeName, session) {
|
|
239
|
+
if (!changeName) return null;
|
|
240
|
+
const db = this.pm._ensureDB(cwd);
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
return db.transaction(() => {
|
|
243
|
+
const sqlDb = db.getDb();
|
|
244
|
+
// 1) 首建:INSERT OR IGNORE 写 own(行已存在则零行、既有 owner 不动)
|
|
245
|
+
const ins = sqlDb.prepare(
|
|
246
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
|
|
247
|
+
VALUES (?, ?, ?, ?)`
|
|
248
|
+
).run(changeName, now, now, session || null);
|
|
249
|
+
// 2) 存量无主行(owner NULL/空串)补认领;WHERE 守卫保证已有值(他人)绝不覆盖
|
|
250
|
+
let claimed = 0;
|
|
251
|
+
if (session) {
|
|
252
|
+
claimed = sqlDb.prepare(
|
|
253
|
+
`UPDATE changes SET owner_session = ? WHERE name = ? AND (owner_session IS NULL OR owner_session = '')`
|
|
254
|
+
).run(session, changeName).changes;
|
|
255
|
+
}
|
|
256
|
+
// 3) 实际变更(首建/认领命中)才标脏;纯读回(他人持有)不扰动同步脏度
|
|
257
|
+
if (ins.changes > 0 || claimed > 0) this.pm._touchLocalModified(cwd, changeName, now);
|
|
258
|
+
// 4) 读回实际 owner
|
|
259
|
+
const row = sqlDb.prepare('SELECT owner_session FROM changes WHERE name = ?').get(changeName);
|
|
260
|
+
return (row && row.owner_session) || null;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 所有权判定(2026-09-14-change-ownership-guards task-02,FR-01 / D-001@v1):
|
|
266
|
+
* **读 changes 行不写库**——接管写库归调用方(setChangeOwner / claimChangeOwner),
|
|
267
|
+
* 判定与写分离让接线点可在锁内先判后写(判定-执行无 TOCTOU 由调用方锁保证)。
|
|
268
|
+
* 分支(self 判定 = 精确字符串等值):
|
|
269
|
+
* - opts.forced(--takeover) → takeover-forced 放行(无条件接管,调用方重写 owner 留痕)
|
|
270
|
+
* - owner 为空(无行/NULL) → no-owner 放行(存量无主行,任何会话可接管)
|
|
271
|
+
* - owner === selfSession → self 放行(本会话自有 change 零路径变化)
|
|
272
|
+
* - owner 他人 且 now-last_active ≥ 窗口 → takeover-stale 放行(窗口外自动接管重写)
|
|
273
|
+
* - owner 他人 且活跃窗内 → blocked-active-owner 拒绝(allowed:false,调用方打结构化错误)
|
|
274
|
+
* last_active 缺失/不可解析按陈旧处理:无法证明新鲜即不拦——护栏拦「可证活跃」,不因时间戳
|
|
275
|
+
* 损坏把 change 锁死到无人能接管(逃生通道优先,与 getLatestActivityAt 族 fail-open 同哲学)。
|
|
276
|
+
* 心跳即 last_active:每次 CLI 写操作既有刷新(updateChangeIsolation/registerChange 等族)。
|
|
277
|
+
* @param {string} cwd
|
|
278
|
+
* @param {string} changeName
|
|
279
|
+
* @param {{ selfSession?: string|null, nowMs?: number, heartbeatMs?: number, forced?: boolean }} [opts]
|
|
280
|
+
* @returns {{allowed: boolean, action: 'self'|'takeover-stale'|'takeover-forced'|'no-owner'|'blocked-active-owner', owner: string|null, lastActive: string|null, heartbeatMs: number}}
|
|
281
|
+
*/
|
|
282
|
+
assertChangeOwnership(cwd, changeName, opts = {}) {
|
|
283
|
+
const heartbeatMs = (Number.isFinite(opts.heartbeatMs) && opts.heartbeatMs > 0)
|
|
284
|
+
? opts.heartbeatMs
|
|
285
|
+
: resolveHeartbeatMs(cwd);
|
|
286
|
+
const deny = (action, owner, lastActive) => ({ allowed: false, action, owner, lastActive, heartbeatMs });
|
|
287
|
+
if (!changeName) return { allowed: true, action: 'no-owner', owner: null, lastActive: null, heartbeatMs };
|
|
288
|
+
const row = this.pm._ensureDB(cwd).getDb()
|
|
289
|
+
.prepare('SELECT owner_session, last_active FROM changes WHERE name = ?').get(changeName);
|
|
290
|
+
const owner = (row && typeof row.owner_session === 'string' && row.owner_session) || null;
|
|
291
|
+
const lastActive = (row && typeof row.last_active === 'string' && row.last_active) || null;
|
|
292
|
+
if (opts.forced) return { allowed: true, action: 'takeover-forced', owner, lastActive, heartbeatMs };
|
|
293
|
+
if (owner === null) return { allowed: true, action: 'no-owner', owner: null, lastActive, heartbeatMs };
|
|
294
|
+
if (owner === opts.selfSession) return { allowed: true, action: 'self', owner, lastActive, heartbeatMs };
|
|
295
|
+
// owner 他人:新鲜度决定拒绝 / 自动接管
|
|
296
|
+
const lastMs = lastActive ? new Date(lastActive).getTime() : NaN;
|
|
297
|
+
const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
|
|
298
|
+
const stale = !Number.isFinite(lastMs) || (nowMs - lastMs) >= heartbeatMs;
|
|
299
|
+
return stale
|
|
300
|
+
? { allowed: true, action: 'takeover-stale', owner, lastActive, heartbeatMs }
|
|
301
|
+
: deny('blocked-active-owner', owner, lastActive);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 强制重写所有者(task-02 接管写库侧;claimChangeOwner 的对照——那把守卫「已有值不覆盖」,
|
|
306
|
+
* 本方法无条件写):assertChangeOwnership 放行 no-owner/takeover-stale/takeover-forced 后由
|
|
307
|
+
* 接线调用。同事务刷新 last_active——新 owner 的心跳从接管时刻起算,否则下次校验仍读到
|
|
308
|
+
* 旧 owner 的陈旧时间戳,接管形同没发生(立刻又可被他人 takeover-stale 抢走)。行缺失先建
|
|
309
|
+
* (INSERT OR IGNORE)。实际变更标本地脏度(owner_session 随同步 payload 上行,D-013 同族)。
|
|
310
|
+
* @param {string} cwd
|
|
311
|
+
* @param {string} changeName
|
|
312
|
+
* @param {string|null} session - 接管者的会话标识
|
|
313
|
+
*/
|
|
314
|
+
setChangeOwner(cwd, changeName, session) {
|
|
315
|
+
if (!changeName) return;
|
|
316
|
+
const db = this.pm._ensureDB(cwd);
|
|
317
|
+
const now = new Date().toISOString();
|
|
318
|
+
db.transaction(() => {
|
|
319
|
+
const sqlDb = db.getDb();
|
|
320
|
+
const ins = sqlDb.prepare(
|
|
321
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
|
|
322
|
+
VALUES (?, ?, ?, ?)`
|
|
323
|
+
).run(changeName, now, now, session || null);
|
|
324
|
+
const upd = sqlDb.prepare(
|
|
325
|
+
'UPDATE changes SET owner_session = ?, last_active = ? WHERE name = ?'
|
|
326
|
+
).run(session || null, now, changeName);
|
|
327
|
+
if (ins.changes > 0 || upd.changes > 0) this.pm._touchLocalModified(cwd, changeName, now);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
163
331
|
/**
|
|
164
332
|
* 更新变更的隔离状态
|
|
165
333
|
* @param {string} cwd - 项目根目录
|
|
@@ -263,9 +431,16 @@ export class ChangeRegistry {
|
|
|
263
431
|
// 永不携带(乐观锁失效)、本地脏度恒 false、platform status behind 恒跳过。值优先
|
|
264
432
|
// 平台回执 last_pushed_at,缺省回退本次 X-SillySpec-Pushed-At(后端 _apply 存的就是
|
|
265
433
|
// 该 header 原值,回写与服务器精确一致)。COALESCE 保旧值:无 syncedTs 只推进展示列。
|
|
434
|
+
// ql-20260914-001:COALESCE 直写改 MAX 单调推进(坑 sync-base-ts-out-of-order-backfill)——
|
|
435
|
+
// 同机多进程(CLI 会话 ×N + 步进链)并发推送时,A(t1)/B(t2) 乱序回填会让迟到的旧回执
|
|
436
|
+
// 覆盖已推进的 base(multi-agent-platform 实证:6 个假冲突的 base 全部停在上一轮值),
|
|
437
|
+
// 回声窗重开 → 下次 push 409/pull 假冲突。MAX 只升不降:base ≥ 回执恒安全(平台 409
|
|
438
|
+
// 判据是 stored > base,base 偏高只会让推送被接受,绝不产生假 409);仅当回执比现存
|
|
439
|
+
// base 旧(乱序/平台侧回退)时改变行为——正是要防的覆写。与 resolve/自愈路径的
|
|
440
|
+
// MAX 写法(sync.js 四处)对齐,消除本仓最后一个直写点。
|
|
266
441
|
sqlDb.prepare(
|
|
267
|
-
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1, last_synced_platform_ts = COALESCE(?, last_synced_platform_ts) WHERE name = ?'
|
|
268
|
-
).run(new Date().toISOString(), syncedTs, changeName);
|
|
442
|
+
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1, last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
|
|
443
|
+
).run(new Date().toISOString(), syncedTs, syncedTs, changeName);
|
|
269
444
|
});
|
|
270
445
|
}
|
|
271
446
|
|
package/src/progress/shared.js
CHANGED
|
@@ -26,8 +26,9 @@ export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', '
|
|
|
26
26
|
// (consistency-doctor 坑 + prompt-control-debt plan-c 同根因)。
|
|
27
27
|
export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
|
|
28
28
|
|
|
29
|
-
// progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8
|
|
30
|
-
|
|
29
|
+
// progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8;
|
|
30
|
+
// v6 = changes 加 owner_session 所有权列,2026-09-14-change-ownership-guards D-005@v1)
|
|
31
|
+
export const CURRENT_VERSION = 6;
|
|
31
32
|
|
|
32
33
|
// 滞留提示阈值(天,2026-08-30 用户反馈②:7 个「代码全落地但流程没收口」的变更挂 38 天
|
|
33
34
|
// 无人发现):活跃变更 last_active 距今超该天数且流程未收口(archive 未完成)时,progress
|
package/src/progress.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
14
14
|
import { join, basename, dirname, resolve, sep } from 'path';
|
|
15
|
-
import { tmpdir } from 'os';
|
|
15
|
+
import { tmpdir, hostname } from 'os';
|
|
16
16
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
17
17
|
import { DB } from './db.js';
|
|
18
18
|
import { checkExecuteCodeEvidence } from './stage-contract.js';
|
|
@@ -81,6 +81,61 @@ export class PlatformManagedError extends PointerUnreachableError {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// ── 会话标识三级解析(2026-09-14-change-ownership-guards task-02,FR-01 / D-001@v1)──
|
|
85
|
+
// 优先级:--session flag > env SILLYSPEC_SESSION_ID > quick 场景 changeName(quick-<8hex>,
|
|
86
|
+
// 既有 sessionId 机制——guard 按 change 名落盘天然跨进程)> anon@<hostname> 机器级降级。
|
|
87
|
+
// 降级时打一次性教学 warning:指引 export SILLYSPEC_SESSION_ID=<唯一标识> 或 --session,
|
|
88
|
+
// 明示无标识时同机并行不设防(R-01 明示局限,防线=显式标识铁律+归档收口+--takeover 摩擦)。
|
|
89
|
+
// 「一次性」双层降频:进程内 memo(同进程多次解析只报一次)+ 跨进程 marker 文件窗口
|
|
90
|
+
// (warnSelfRefPointerOnce 同款模式;CLI 每次调用即新进程,纯进程 memo 会退化成每命令必报)。
|
|
91
|
+
const ANON_SESSION_WARN_MARKER = 'anon-session-warn.json';
|
|
92
|
+
const ANON_SESSION_WARN_WINDOW_MS = 24 * 60 * 60 * 1000; // 教学指引 24h 窗口(非告警,不设短窗)
|
|
93
|
+
const QUICK_SID_PATTERN_FOR_IDENTITY = /^quick-[0-9a-f]{8}$/;
|
|
94
|
+
let _anonSessionWarnedThisProcess = false;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解析本会话的所有权标识(纯函数 + 降级侧的一次性教学 warn)。
|
|
98
|
+
* @param {{ flagSession?: string|null, quickChangeName?: string|null, cwd?: string|null, warn?: boolean }} [opts]
|
|
99
|
+
* - flagSession:--session flag 值(最高优先级;空串/空白视为未提供)
|
|
100
|
+
* - quickChangeName:quick 场景的 changeName(匹配 quick-<8hex> 才启用第三级)
|
|
101
|
+
* - cwd:教学 warning marker 落盘根(不提供则跳过跨进程降频,仅进程内 memo)
|
|
102
|
+
* - warn:false 关闭教学 warning(机器消费路径)
|
|
103
|
+
* @returns {{session: string, source: 'flag'|'env'|'quick-session'|'anon-host'}}
|
|
104
|
+
*/
|
|
105
|
+
export function resolveSessionIdentity({ flagSession = null, quickChangeName = null, cwd = null, warn = true } = {}) {
|
|
106
|
+
const flag = typeof flagSession === 'string' ? flagSession.trim() : '';
|
|
107
|
+
if (flag) return { session: flag, source: 'flag' };
|
|
108
|
+
const env = typeof process.env.SILLYSPEC_SESSION_ID === 'string' ? process.env.SILLYSPEC_SESSION_ID.trim() : '';
|
|
109
|
+
if (env) return { session: env, source: 'env' };
|
|
110
|
+
if (quickChangeName && QUICK_SID_PATTERN_FOR_IDENTITY.test(quickChangeName)) {
|
|
111
|
+
return { session: quickChangeName, source: 'quick-session' };
|
|
112
|
+
}
|
|
113
|
+
const session = `anon@${hostname()}`;
|
|
114
|
+
if (warn && !_anonSessionWarnedThisProcess) {
|
|
115
|
+
let suppressed = false;
|
|
116
|
+
if (cwd) {
|
|
117
|
+
try {
|
|
118
|
+
const markerDir = join(cwd, SPEC_DIR_NAME, RUNTIME_SUBDIR);
|
|
119
|
+
mkdirSync(markerDir, { recursive: true });
|
|
120
|
+
const markerPath = join(markerDir, ANON_SESSION_WARN_MARKER);
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
try {
|
|
123
|
+
const m = JSON.parse(readFileSync(markerPath, 'utf8'));
|
|
124
|
+
if (m && Number.isFinite(m.at) && now - m.at < ANON_SESSION_WARN_WINDOW_MS) suppressed = true;
|
|
125
|
+
} catch { /* 无 marker / 损坏 → 首报 */ }
|
|
126
|
+
if (!suppressed) writeFileSync(markerPath, JSON.stringify({ at: now }) + '\n');
|
|
127
|
+
} catch { /* marker 落盘失败 → 照常 warn(降频是优化不是语义) */ }
|
|
128
|
+
}
|
|
129
|
+
if (!suppressed) {
|
|
130
|
+
_anonSessionWarnedThisProcess = true;
|
|
131
|
+
console.warn(`⚠️ 未显式配置会话标识,所有权护栏已降级为机器级标识 ${session}(只拦他机,同机并行会话不设防)`);
|
|
132
|
+
console.warn(` 指引:会话启动时 export SILLYSPEC_SESSION_ID=<唯一标识>(如 agent 名+任务名),或在接管类命令上带 --session <唯一标识>(三级解析最高优先级)`);
|
|
133
|
+
console.warn(` (24 小时内不重复提示)`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { session, source: 'anon-host' };
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
/**
|
|
85
140
|
* 平台感知的 specDir 解析(fail-closed,所有 CLI 子命令统一入口)。
|
|
86
141
|
*
|
|
@@ -435,7 +490,7 @@ export class ProgressManager {
|
|
|
435
490
|
}
|
|
436
491
|
|
|
437
492
|
const result = {
|
|
438
|
-
_version:
|
|
493
|
+
_version: 6,
|
|
439
494
|
project: projectName,
|
|
440
495
|
currentChange: cName,
|
|
441
496
|
currentStage: currentStage || '',
|
|
@@ -460,7 +515,9 @@ export class ProgressManager {
|
|
|
460
515
|
* 输出契约(字段名 = DB 列名 snake_case,import 直接对列写回,round-trip 无映射损耗):
|
|
461
516
|
* project { name, schema_version } —— 全局单行稳定字段(id/created_at/updated_at 本地元数据不同步)
|
|
462
517
|
* changes [{ name, current_stage, status, last_active,
|
|
463
|
-
* last_synced_platform_ts, last_local_modified_ts }]
|
|
518
|
+
* last_synced_platform_ts, last_local_modified_ts, owner_session }] —— 只投影流程进度列
|
|
519
|
+
* + 所有权列 owner_session(v6,2026-09-14-change-ownership-guards D-005@v1;
|
|
520
|
+
* NULL=无主随 payload 带出,平台消费端不强制);
|
|
464
521
|
* 排除 isolation_* 系列 与 platform_* 系列(platform_change_id/workspace_id/last_sync/sync_enabled)
|
|
465
522
|
* 以及 created_at(本地强相关,不同步,B2)
|
|
466
523
|
* stages [{ change_name, stage, status, started_at, completed_at,
|
|
@@ -490,9 +547,9 @@ export class ProgressManager {
|
|
|
490
547
|
const db = this._ensureDB(cwd);
|
|
491
548
|
const sqlDb = db.getDb();
|
|
492
549
|
|
|
493
|
-
// 1. changes 行(投影流程进度列 + name
|
|
550
|
+
// 1. changes 行(投影流程进度列 + name 标识 + 所有权列 owner_session)
|
|
494
551
|
const changeRow = sqlDb.prepare(
|
|
495
|
-
`SELECT id, name, current_stage, status, last_active, last_synced_platform_ts, last_local_modified_ts
|
|
552
|
+
`SELECT id, name, current_stage, status, last_active, last_synced_platform_ts, last_local_modified_ts, owner_session
|
|
496
553
|
FROM changes WHERE name = ?`
|
|
497
554
|
).get(changeName);
|
|
498
555
|
if (changeRow === undefined) return null;
|
|
@@ -504,6 +561,7 @@ export class ProgressManager {
|
|
|
504
561
|
last_active: changeRow.last_active,
|
|
505
562
|
last_synced_platform_ts: changeRow.last_synced_platform_ts ?? null,
|
|
506
563
|
last_local_modified_ts: changeRow.last_local_modified_ts ?? null,
|
|
564
|
+
owner_session: changeRow.owner_session ?? null,
|
|
507
565
|
};
|
|
508
566
|
|
|
509
567
|
// 2. project 全局单行(只投影稳定字段 name/schema_version;created_at/updated_at 是本地库元数据,
|
|
@@ -600,7 +658,8 @@ export class ProgressManager {
|
|
|
600
658
|
*
|
|
601
659
|
* 语义:
|
|
602
660
|
* - 单个 DB.transaction() 包裹,原子重建 stages/steps/batch_progress/approvals 四表(任一失败整体回滚)
|
|
603
|
-
* - changes 行用 UPDATE 选择投影列(current_stage/status/last_active/last_synced_platform_ts/last_local_modified_ts
|
|
661
|
+
* - changes 行用 UPDATE 选择投影列(current_stage/status/last_active/last_synced_platform_ts/last_local_modified_ts
|
|
662
|
+
* + owner_session 容错回写——payload 含该列才写),
|
|
604
663
|
* 保留 isolation_* / platform_* / created_at(本地强相关状态不被覆盖,B2;change 不存在时 INSERT 兜底平台新增)
|
|
605
664
|
* - import 后 last_synced_platform_ts 与 last_local_modified_ts 均置为 progressObj.pushed_at(D-013 例外:
|
|
606
665
|
* 不更新 now()——否则 now()>base_ts 下次 pull 误判冲突;pushed_at 由 sync.js pull() 从响应 header attach)
|
|
@@ -673,6 +732,12 @@ export class ProgressManager {
|
|
|
673
732
|
ch && ch.last_active != null ? ch.last_active : now,
|
|
674
733
|
pushedAt, pushedAt, cn
|
|
675
734
|
);
|
|
735
|
+
// owner_session 回写容错(v6,2026-09-14-change-ownership-guards task-01):payload 含
|
|
736
|
+
// 该列(非 null)才写——他端旧版本快照无此列 → 跳过不报错,且本地已有 owner 不被置空
|
|
737
|
+
// (平台权威态随 payload 往返;serializeForSync↔import 互逆在非 null 值上成立)。
|
|
738
|
+
if (ch && ch.owner_session != null) {
|
|
739
|
+
sqlDb.prepare('UPDATE changes SET owner_session = ? WHERE name = ?').run(ch.owner_session, cn);
|
|
740
|
+
}
|
|
676
741
|
const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
|
|
677
742
|
const changeId = changeRow.id;
|
|
678
743
|
|
|
@@ -938,7 +1003,7 @@ export class ProgressManager {
|
|
|
938
1003
|
// 或 verify 两窗口,锚「完成时刻」会把 execute 期间产的证据判旧(时序两难);无行 null 走 completed_at → R-05
|
|
939
1004
|
getStageStartedAt(cwd, changeName, stage) { return this._changeRegistry.getStageStartedAt(cwd, changeName, stage); }
|
|
940
1005
|
|
|
941
|
-
registerChange(cwd, changeName) { return this._changeRegistry.registerChange(cwd, changeName); }
|
|
1006
|
+
registerChange(cwd, changeName, opts) { return this._changeRegistry.registerChange(cwd, changeName, opts); }
|
|
942
1007
|
|
|
943
1008
|
updateChangeIsolation(cwd, changeName, isolation) { return this._changeRegistry.updateChangeIsolation(cwd, changeName, isolation); }
|
|
944
1009
|
|
|
@@ -972,6 +1037,28 @@ export class ProgressManager {
|
|
|
972
1037
|
// quick --done 兜底复用启动 ql-ID(坑 platform-takeover-phantom-progress-db 同日变体)
|
|
973
1038
|
getQuicklogId(cwd, changeName) { return this._changeRegistry.getQuicklogId(cwd, changeName); }
|
|
974
1039
|
|
|
1040
|
+
// ── change 所有权读写 API(2026-09-14-change-ownership-guards task-01 数据载体,D-001/D-005@v1)──
|
|
1041
|
+
// 只铺数据读写;所有权判定/心跳窗口/接管语义(assertChangeOwnership)归 task-02 接线。
|
|
1042
|
+
|
|
1043
|
+
/** 读变更所有者会话标识;无行/未登记/无主 → null */
|
|
1044
|
+
getChangeOwner(cwd, changeName) { return this._changeRegistry.getChangeOwner(cwd, changeName); }
|
|
1045
|
+
|
|
1046
|
+
/** 认领所有权:首建写 own、已有值不覆盖;返回实际 owner(string|null) */
|
|
1047
|
+
claimChangeOwner(cwd, changeName, session) { return this._changeRegistry.claimChangeOwner(cwd, changeName, session); }
|
|
1048
|
+
|
|
1049
|
+
// task-02 接线(FR-01 / D-001@v1):判定/接管/心跳窗——会话标识三级解析见模块级
|
|
1050
|
+
// resolveSessionIdentity;接线点=index.js apply/cleanup/assess 自动 apply(锁内、实际动作前)。
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* 所有权判定(纯读不写库,详见 change-registry.assertChangeOwnership):
|
|
1054
|
+
* 四分支 self/no-owner/takeover-stale/takeover-forced 放行;他人且活跃窗内 →
|
|
1055
|
+
* { allowed:false, action:'blocked-active-owner' }(owner+lastActive+heartbeatMs 供结构化错误)。
|
|
1056
|
+
*/
|
|
1057
|
+
assertChangeOwnership(cwd, changeName, opts) { return this._changeRegistry.assertChangeOwnership(cwd, changeName, opts); }
|
|
1058
|
+
|
|
1059
|
+
/** 强制重写所有者+心跳起算(接管放行后的写库侧;claimChangeOwner 是「已有值不覆盖」守卫版) */
|
|
1060
|
+
setChangeOwner(cwd, changeName, session) { return this._changeRegistry.setChangeOwner(cwd, changeName, session); }
|
|
1061
|
+
|
|
975
1062
|
// ── CLI 命令 ──
|
|
976
1063
|
|
|
977
1064
|
init(cwd) {
|
|
@@ -1018,10 +1105,15 @@ export class ProgressManager {
|
|
|
1018
1105
|
}
|
|
1019
1106
|
// quick 会话 id(quick-<uuid8>,见 run.js QUICK_SID_RE)只作 progress 的跨进程 session key,
|
|
1020
1107
|
// 进度存 SQL 不需要实体 change 目录——跳过避免 changes/quick-<uuid>/ 空目录残留。
|
|
1108
|
+
// default 兜底 key 同款待遇(坑 default-empty-dir-materialized,2026-09-14 用户反馈):
|
|
1109
|
+
// default 行是辅助阶段(explore 等)无实体产物的进度容器,物化 changes/default/ 空目录
|
|
1110
|
+
// 纯污染——next.js 会报「变更目录为空→清理该空目录」误导、resolveChangeNameAuto 的目录
|
|
1111
|
+
// 计数被搅动、spec 树上行空目录(本仓实证 changes/default/ 建于 09-11 零文件)。进度真相
|
|
1112
|
+
// 在 DB 行;辅助阶段续跑锚定改由 run/command.js 的 default 行阶段亲和解析承担。
|
|
1021
1113
|
// 注:日期前缀门禁(assertDatedChangeName)只在 CLI 边界强制(run --change / change-rename,
|
|
1022
1114
|
// 见 run/command.js + index.js)——initChange 是库函数,测试/平台工具合法用任意名建 fixture,
|
|
1023
1115
|
// 不在此设门(2026-09-11 决策,避免 ~30 处测试 fixture 非日期名被拦)。
|
|
1024
|
-
if (!/^quick-[0-9a-f]{8}$/.test(changeName)) {
|
|
1116
|
+
if (!/^quick-[0-9a-f]{8}$/.test(changeName) && changeName !== 'default') {
|
|
1025
1117
|
this._ensureChangeDir(cwd, changeName);
|
|
1026
1118
|
}
|
|
1027
1119
|
|