@shgroup/dsh-serenity-hooks 1.29.2 → 1.30.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/README.md +6 -4
- package/dsh.plugin.json +9 -12
- package/experiments/autopilot-trajectory/SKILL.md +10 -10
- package/lib/client.js +2 -2
- package/lib/fs-ops.d.ts +1 -1
- package/lib/git-ops.d.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1740 -2374
- package/lib/invariant.d.ts +1 -1
- package/lib/invariant.js +7 -10
- package/lib/kit-ops.d.ts +4 -4
- package/lib/localstore-ops.d.ts +3 -3
- package/lib/msm-ops.d.ts +7 -7
- package/lib/output-guard-seam.d.ts +1 -1
- package/lib/rebuild.d.ts +2 -1
- package/lib/seams/guards.d.ts +7 -7
- package/lib/seams/keeper.d.ts +1 -1
- package/lib/seams/system-prompt.d.ts +1 -1
- package/lib/session-bound-p9oR9dj9.js +725 -0
- package/lib/session-bound.d.ts +1 -1
- package/lib/session-ops.d.ts +1 -1
- package/lib/skiff-core.d.ts +1 -1
- package/lib/{skiff-debug-CBU6T2F_.js → skiff-debug-BA8CDeJl.js} +2 -2
- package/lib/{skiff-role-BTdBHyOQ.js → skiff-role-LW4tjF9L.js} +4 -4
- package/lib/skiff-role.d.ts +2 -2
- package/lib/tools/cc-fs.d.ts +1 -1
- package/lib/tools/container-admin.d.ts +17 -0
- package/lib/tools/git.d.ts +1 -1
- package/lib/tools/kit.d.ts +1 -1
- package/lib/tools/msm.d.ts +19 -1
- package/lib/tools/praxis.d.ts +14 -0
- package/lib/tools/skiff-admin.d.ts +4 -4
- package/lib/{weixin-route-DFkRf9ou.js → weixin-route-jA0qENOf.js} +5 -5
- package/package.json +1 -1
- package/lib/tools/rebuild.d.ts +0 -19
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
//#region src/session-ops.ts
|
|
4
|
+
/**
|
|
5
|
+
* session-ops.ts — session 工具纯操作层(零 DSH 依赖,可独立单测)
|
|
6
|
+
*
|
|
7
|
+
* 行为对齐 osp(opencode-serenity-plugin/src/session/lib.ts)——osp 是 ACC 工具 spec:
|
|
8
|
+
* - create:--desc / --issue 二选一(互斥、缺省报错);issue 模式目录 YYYY-MM-DD--<issue>
|
|
9
|
+
* (无 S###,sessionId=issue);desc 模式 YYYY-MM-DD--S###--<desc>;goal 写入目标段;dry-run 预览
|
|
10
|
+
* - close:需 name + confirm=true;标记 [x] 已完成+已关闭 + 进度记录"关闭"
|
|
11
|
+
* - archive:name 缺省 → 批量归档(completed + ≥7 天 → 移动 _archived/);单会话需 completed + grace
|
|
12
|
+
* - list/show/health/qa/summary:文本输出格式与 osp 一致
|
|
13
|
+
* 保留 dsp S134 活跃会话机制(内存 Map + events 恢复,不落盘)——osp 同为内存 active-state。
|
|
14
|
+
*/
|
|
15
|
+
const SESSION_ACTIONS = [
|
|
16
|
+
"list",
|
|
17
|
+
"show",
|
|
18
|
+
"create",
|
|
19
|
+
"use",
|
|
20
|
+
"close",
|
|
21
|
+
"health",
|
|
22
|
+
"qa",
|
|
23
|
+
"archive",
|
|
24
|
+
"summary",
|
|
25
|
+
"hook-develop-guide",
|
|
26
|
+
"rebuild"
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* 读取 Session 事件序列(v1.28.1 适配 0.1.2-rc.1 补齐):rc.1 起官方 Session 类
|
|
30
|
+
* 移除 `.events` 属性 → `snapshotEvents()` 方法(dsh-session/src/session.ts:
|
|
31
|
+
* `snapshotEvents(fromSeq, toSeqExclusive)`)。插件早期代码多处裸读 `.events`
|
|
32
|
+
* (经 `as unknown as { events? }` 断言绕过 typecheck),运行时静默 undefined——
|
|
33
|
+
* 造成 first-anchor 每轮重插 / SESSION 激活恢复失效 / rebuild 定位错乱。
|
|
34
|
+
* 统一收敛到本 helper:snapshotEvents() 优先(rc.1 真实形态),`.events` 兜底
|
|
35
|
+
* (测试替身/旧运行时)。所有消费方一律经此读取,禁止再裸读 `.events`。
|
|
36
|
+
* 泛型 T:调用方按需声明事件形状(如 `SessionEvent`),unknown 默认。
|
|
37
|
+
*/
|
|
38
|
+
function sessionEvents(session) {
|
|
39
|
+
const s = session;
|
|
40
|
+
if (!s) return [];
|
|
41
|
+
if (typeof s.snapshotEvents === "function") try {
|
|
42
|
+
return s.snapshotEvents() ?? [];
|
|
43
|
+
} catch {}
|
|
44
|
+
return s.events ?? [];
|
|
45
|
+
}
|
|
46
|
+
const SESSION_MD = "SESSION.md";
|
|
47
|
+
const ARCHIVE_DIR_NAME = "_archived";
|
|
48
|
+
const HEALTH_STALE_DAYS = 7;
|
|
49
|
+
const HEALTH_STALLED_PCT = 30;
|
|
50
|
+
const HEALTH_STALLED_DAYS = 3;
|
|
51
|
+
const HEALTH_GHOST_DAYS = 2;
|
|
52
|
+
const DAY = 864e5;
|
|
53
|
+
function sessionsRoot(root) {
|
|
54
|
+
return join(root, "AGENT_SESSIONS");
|
|
55
|
+
}
|
|
56
|
+
/** 解析 SESSION.md 状态元数据(对齐 osp parseSessionMd) */
|
|
57
|
+
function parseSessionMd(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
const content = readFileSync(filePath, "utf-8");
|
|
60
|
+
return {
|
|
61
|
+
hasSessionMd: true,
|
|
62
|
+
completed: /\[\s*x\s*\]/i.test(content),
|
|
63
|
+
completedCount: (content.match(/\[\s*x\s*\]/gi) ?? []).length,
|
|
64
|
+
pendingCount: (content.match(/\[\s*[ \t]\s*\]/g) ?? []).length,
|
|
65
|
+
unresolvedCount: (content.match(/(未解决|open|question|TODO)/gi) ?? []).length
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
return {
|
|
69
|
+
hasSessionMd: false,
|
|
70
|
+
completed: false,
|
|
71
|
+
completedCount: 0,
|
|
72
|
+
pendingCount: 0,
|
|
73
|
+
unresolvedCount: 0
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function readSessionEntry(dirPath) {
|
|
78
|
+
try {
|
|
79
|
+
const st = statSync(dirPath);
|
|
80
|
+
if (!st.isDirectory()) return null;
|
|
81
|
+
const dirName = basename(dirPath);
|
|
82
|
+
const mdPath = join(dirPath, SESSION_MD);
|
|
83
|
+
const status = existsSync(mdPath) ? parseSessionMd(mdPath) : {
|
|
84
|
+
hasSessionMd: false,
|
|
85
|
+
completed: false,
|
|
86
|
+
completedCount: 0,
|
|
87
|
+
pendingCount: 0,
|
|
88
|
+
unresolvedCount: 0
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
dirName,
|
|
92
|
+
path: dirPath,
|
|
93
|
+
mtime: st.mtime,
|
|
94
|
+
status
|
|
95
|
+
};
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** 读取 AGENT_SESSIONS 中所有会话,活跃(未完成)排前(对齐 osp readAllSessions) */
|
|
101
|
+
function readAllSessions(sessionsDir) {
|
|
102
|
+
try {
|
|
103
|
+
return readdirSync(sessionsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => readSessionEntry(join(sessionsDir, e.name))).filter((s) => s !== null).sort((a, b) => {
|
|
104
|
+
if (!a.status.completed && b.status.completed) return -1;
|
|
105
|
+
if (a.status.completed && !b.status.completed) return 1;
|
|
106
|
+
return b.mtime.getTime() - a.mtime.getTime();
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** 提取目录名中的会话 ID(S### 或 issue 名);无匹配返回 '' */
|
|
113
|
+
function extractSessionId(dirName) {
|
|
114
|
+
const m = dirName.match(/--S(\d{3,})--/);
|
|
115
|
+
return m ? `S${m[1]}` : "";
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 根据 key 查找会话(对齐 osp findSession):
|
|
119
|
+
* 精确目录名 → S### ID(允许 S31→031)→ 唯一模糊子串匹配(多个则报错)
|
|
120
|
+
*/
|
|
121
|
+
function findSession(sessionsDir, key) {
|
|
122
|
+
const all = readAllSessions(sessionsDir);
|
|
123
|
+
const byName = all.find((s) => s.dirName === key);
|
|
124
|
+
if (byName) return byName;
|
|
125
|
+
const searchId = key.replace(/^S/, "").padStart(3, "0");
|
|
126
|
+
const byId = all.find((s) => {
|
|
127
|
+
const m = s.dirName.match(/--S(\d{3,})--/);
|
|
128
|
+
return m && m[1] === searchId;
|
|
129
|
+
});
|
|
130
|
+
if (byId) return byId;
|
|
131
|
+
const lower = key.toLowerCase();
|
|
132
|
+
const fuzzy = all.filter((s) => s.dirName.toLowerCase().includes(lower));
|
|
133
|
+
if (fuzzy.length === 1) return fuzzy[0] ?? null;
|
|
134
|
+
if (fuzzy.length > 1) throw new Error(`Found ${fuzzy.length} sessions matching "${key}": ` + fuzzy.map((s) => s.dirName).join(", ") + ". Use a more specific query.");
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/** list 子命令(对齐 osp listSessions 文本格式 + active 标记) */
|
|
138
|
+
function listSessions(root, activeId) {
|
|
139
|
+
const sessions = readAllSessions(sessionsRoot(root));
|
|
140
|
+
if (sessions.length === 0) return "(no sessions in AGENT_SESSIONS/)";
|
|
141
|
+
const lines = sessions.map((s) => {
|
|
142
|
+
const age = Math.floor((Date.now() - s.mtime.getTime()) / DAY);
|
|
143
|
+
const sessionId = extractSessionId(s.dirName);
|
|
144
|
+
return `${activeId !== void 0 && sessionId !== "" && sessionId === activeId ? "●" : s.status.completed ? "✓" : "○"} ${s.dirName} (${age}d ago)`;
|
|
145
|
+
});
|
|
146
|
+
return `AGENT_SESSIONS/ (${sessions.length} sessions)\n` + lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
/** show 子命令(对齐 osp showSession:`# dirName\n\n` + SESSION.md 内容) */
|
|
149
|
+
function showSession(root, key) {
|
|
150
|
+
const session = findSession(sessionsRoot(root), key);
|
|
151
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
152
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
153
|
+
if (!existsSync(mdPath)) return `Session ${session.dirName} (no SESSION.md — directory exists but is empty)`;
|
|
154
|
+
const content = readFileSync(mdPath, "utf-8");
|
|
155
|
+
return `# ${session.dirName}\n\n${content}`;
|
|
156
|
+
}
|
|
157
|
+
/** 生成 SESSION.md 模板(对齐 osp:goal 写入目标段,时间戳 YYYY-MM-DD HH:mm) */
|
|
158
|
+
function sessionMdTemplate(title, id, goal, now) {
|
|
159
|
+
const ts = now.toISOString().slice(0, 16).replace("T", " ");
|
|
160
|
+
return `# SESSION: ${title}\n- ID: ${id}\n\n## 目标\n${goal ?? "(待补充)"}\n\n## 状态\n- [ ] 进行中\n\n## 关键决策\n| # | 决策 | 理由 |\n|---|------|------|\n| 1 | | |\n\n## 进度记录\n- ${ts} — 创建\n\n## 产出物\n- \n\n## 未解决的问题\n- \n`;
|
|
161
|
+
}
|
|
162
|
+
/** create 子命令(对齐 osp createSession:--desc/--issue 二选一 + dry-run + 长度限制) */
|
|
163
|
+
/** 目录名脱敏(Windows 审计问题 10):非法字符 → '-', 去尾点/空格, 保留名(CON/NUL 等)加前缀 */
|
|
164
|
+
function sanitizeDirName(s) {
|
|
165
|
+
const cleaned = s.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-").replace(/[ .]+$/g, "");
|
|
166
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(cleaned)) return `_${cleaned}`;
|
|
167
|
+
return cleaned;
|
|
168
|
+
}
|
|
169
|
+
function createSession(opts) {
|
|
170
|
+
const { root, desc, issue, goal, dryRun } = opts;
|
|
171
|
+
const sessionsDir = sessionsRoot(root);
|
|
172
|
+
const now = /* @__PURE__ */ new Date();
|
|
173
|
+
const datePrefix = now.toISOString().slice(0, 10);
|
|
174
|
+
if (!desc && !issue) throw new Error("create requires either --desc or --issue");
|
|
175
|
+
if (desc && issue) throw new Error("--desc and --issue are mutually exclusive");
|
|
176
|
+
if (issue) {
|
|
177
|
+
if (issue.length > 100) throw new Error(`issue too long: ${issue.length} chars (max 100)`);
|
|
178
|
+
const dirName = `${datePrefix}--${sanitizeDirName(issue)}`;
|
|
179
|
+
const sessionPath = join(sessionsDir, dirName);
|
|
180
|
+
if (!dryRun && existsSync(sessionPath)) throw new Error(`Session directory already exists: "${dirName}"`);
|
|
181
|
+
if (dryRun) return {
|
|
182
|
+
message: `[dry-run] Would create: ${dirName}/`,
|
|
183
|
+
dirName,
|
|
184
|
+
sessionPath,
|
|
185
|
+
sessionId: issue
|
|
186
|
+
};
|
|
187
|
+
mkdirSync(sessionPath, { recursive: true });
|
|
188
|
+
writeFileSync(join(sessionPath, SESSION_MD), sessionMdTemplate(issue, issue, goal, now), "utf-8");
|
|
189
|
+
return {
|
|
190
|
+
message: `Created: ${dirName}/`,
|
|
191
|
+
dirName,
|
|
192
|
+
sessionPath,
|
|
193
|
+
sessionId: issue
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (!desc || desc.length === 0) throw new Error("description cannot be empty");
|
|
197
|
+
if (desc.length > 200) throw new Error(`description too long: ${desc.length} chars (max 200)`);
|
|
198
|
+
const sessions = readAllSessions(sessionsDir);
|
|
199
|
+
let maxId = 0;
|
|
200
|
+
for (const s of sessions) {
|
|
201
|
+
const m = s.dirName.match(/--S(\d{3,})--/);
|
|
202
|
+
if (m) {
|
|
203
|
+
const num = parseInt(m[1], 10);
|
|
204
|
+
if (num > maxId) maxId = num;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const nextId = String(maxId + 1).padStart(3, "0");
|
|
208
|
+
const dirName = `${datePrefix}--S${nextId}--${sanitizeDirName(desc)}`;
|
|
209
|
+
const sessionPath = join(sessionsDir, dirName);
|
|
210
|
+
if (!dryRun && existsSync(sessionPath)) throw new Error(`Session directory already exists: "${dirName}"`);
|
|
211
|
+
if (dryRun) return {
|
|
212
|
+
message: `[dry-run] Would create: ${dirName}/\n goal=${goal ?? "(none)"}`,
|
|
213
|
+
dirName,
|
|
214
|
+
sessionPath,
|
|
215
|
+
sessionId: `S${nextId}`
|
|
216
|
+
};
|
|
217
|
+
mkdirSync(sessionPath, { recursive: true });
|
|
218
|
+
writeFileSync(join(sessionPath, SESSION_MD), sessionMdTemplate(desc, `S${nextId}`, goal, now), "utf-8");
|
|
219
|
+
return {
|
|
220
|
+
message: `Created: ${dirName}/ (S${nextId})`,
|
|
221
|
+
dirName,
|
|
222
|
+
sessionPath,
|
|
223
|
+
sessionId: `S${nextId}`
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 活动会话跟踪(S134 v1.16.14 内存化,对齐 osp active-state):
|
|
228
|
+
* **不落盘**——活跃会话状态在内存 Map(key = scope = dsh 会话 id),避免落盘标记
|
|
229
|
+
* 文件累积与跨会话串台(落盘版 `.dsh/active-sessions/<scope>` 已被此方案取代)。
|
|
230
|
+
* 进程重启恢复:从**当前会话历史(events)**解析 `[SESSION CONTEXT]` 标记(use 时注入),
|
|
231
|
+
* 只扫自己会话——无全局扫描、无跨会话污染。
|
|
232
|
+
*/
|
|
233
|
+
const DEFAULT_SESSION_SCOPE = "default";
|
|
234
|
+
/** [SESSION CONTEXT] 恢复标记(use 时注入 events 历史;进程重启后从 events 解析) */
|
|
235
|
+
const SESSION_CONTEXT_MARKER = "[SESSION CONTEXT] Activated:";
|
|
236
|
+
/** 内存活跃会话:scope(dsh 会话 id)→ 会话信息(不落盘;并行多会话各自 key 隔离) */
|
|
237
|
+
const activeStore = /* @__PURE__ */ new Map();
|
|
238
|
+
/** 全局最近活跃(对齐 osp lastActive;供无 scope 上下文使用) */
|
|
239
|
+
let lastActive = null;
|
|
240
|
+
function getActiveSessionInfo(scope) {
|
|
241
|
+
return activeStore.get(scope) ?? null;
|
|
242
|
+
}
|
|
243
|
+
function setActiveSessionInfo(scope, info) {
|
|
244
|
+
activeStore.set(scope, info);
|
|
245
|
+
lastActive = info;
|
|
246
|
+
}
|
|
247
|
+
function clearActiveSessionInfo(scope) {
|
|
248
|
+
activeStore.delete(scope);
|
|
249
|
+
if (lastActive && ![...activeStore.values()].some((v) => v === lastActive)) lastActive = null;
|
|
250
|
+
}
|
|
251
|
+
/** 当前 scope 的活跃会话 SESSION.md 绝对路径;无激活返回 null(读内存,不落盘) */
|
|
252
|
+
function readActiveSessionMd(_root, scope = DEFAULT_SESSION_SCOPE) {
|
|
253
|
+
return getActiveSessionInfo(scope)?.mdPath ?? null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* use 子命令:激活会话(写内存 Map)+ 返回对齐 osp 的输出文本
|
|
257
|
+
* (含 [SESSION CONTEXT] 标记 + todowrite 指令;标记随工具结果进 events 历史,
|
|
258
|
+
* 进程重启后从当前会话 events 解析恢复)。
|
|
259
|
+
*/
|
|
260
|
+
function useSession(root, key, scope = DEFAULT_SESSION_SCOPE) {
|
|
261
|
+
const session = findSession(sessionsRoot(root), key);
|
|
262
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
263
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
264
|
+
if (!existsSync(mdPath)) throw new Error(`Session "${session.dirName}" has no SESSION.md — nothing to load.`);
|
|
265
|
+
const sessionId = extractSessionId(session.dirName) || basename(session.dirName);
|
|
266
|
+
const dirName = session.dirName;
|
|
267
|
+
const shortName = dirName.replace(/^\d{4}-\d{2}-\d{2}--/, "");
|
|
268
|
+
setActiveSessionInfo(scope, {
|
|
269
|
+
sessionId,
|
|
270
|
+
dirName,
|
|
271
|
+
mdPath
|
|
272
|
+
});
|
|
273
|
+
return {
|
|
274
|
+
dir: dirName,
|
|
275
|
+
mdPath,
|
|
276
|
+
context: [
|
|
277
|
+
`───────────────────────────────────────────────────────────────`,
|
|
278
|
+
`${SESSION_CONTEXT_MARKER} ${dirName}`,
|
|
279
|
+
`───────────────────────────────────────────────────────────────`,
|
|
280
|
+
`Use "session show ${sessionId}" to view session details.`,
|
|
281
|
+
`SESSION.md path: ${mdPath}`,
|
|
282
|
+
``,
|
|
283
|
+
`→ All subsequent work should refer back to this session.`,
|
|
284
|
+
` Use "session show ${sessionId}" to check current progress.`,
|
|
285
|
+
` After advancing work, update the "进度记录" (progress) section in SESSION.md.`,
|
|
286
|
+
``,
|
|
287
|
+
`→ BEFORE responding to the user, you MUST call todowrite immediately`,
|
|
288
|
+
` with the session todo list. The first item MUST be:`,
|
|
289
|
+
` content: "SESSION: ${sessionId} — ${shortName}"`,
|
|
290
|
+
` status: "completed", priority: "low"`,
|
|
291
|
+
` Follow with any tasks parsed from SESSION.md.`,
|
|
292
|
+
`───────────────────────────────────────────────────────────────`
|
|
293
|
+
].join("\n")
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* close 子命令(对齐 osp closeSession):需 name + confirm=true;
|
|
298
|
+
* 标记 SESSION.md 为 [x] 已完成 + [x] 已关闭 + 进度记录"关闭";清除该会话的活跃状态。
|
|
299
|
+
*/
|
|
300
|
+
function closeSession(root, key, confirm, scope = DEFAULT_SESSION_SCOPE) {
|
|
301
|
+
if (!confirm) return "⚠ Close requires explicit confirmation.\n Re-run with --confirm to confirm closing this session.";
|
|
302
|
+
const session = findSession(sessionsRoot(root), key);
|
|
303
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
304
|
+
if (session.status.completed) return `Session "${session.dirName}" is already completed.`;
|
|
305
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
306
|
+
if (!existsSync(mdPath)) throw new Error(`Session "${session.dirName}" has no SESSION.md — nothing to close.`);
|
|
307
|
+
let content = readFileSync(mdPath, "utf-8");
|
|
308
|
+
content = content.replace(/\r\n/g, "\n");
|
|
309
|
+
content = content.replace(/## 状态\n\n?- \[ \] 进行中/, "## 状态\n- [x] 已完成\n- [x] 已关闭");
|
|
310
|
+
const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
|
|
311
|
+
if (!content.includes("-- 关闭")) content = content.replace(/(## 进度记录\n)/, `$1- ${now} — 关闭\n`);
|
|
312
|
+
writeFileSync(mdPath, content, "utf-8");
|
|
313
|
+
clearActiveSessionInfo(scope);
|
|
314
|
+
return `Session "${session.dirName}" closed and marked as completed.`;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* 从会话历史(events)解析会话身份(进程重启恢复;只扫**当前会话**自己的历史——无跨会话串台)。
|
|
318
|
+
*
|
|
319
|
+
* v1.24.11 稳固化(S142 用户需求:重建后新会话必须准确知道从哪个 SESSION 恢复):
|
|
320
|
+
* **路径规范行即可,不再要求 [SESSION CONTEXT] 标记**——`session use` 上下文与重建锚点
|
|
321
|
+
* (buildRebuildAnchor 的 `- Persistent trajectory — SESSION.md path: <rel>` 行)同格式,
|
|
322
|
+
* 因此**仅靠重建锚点的会话(从未显式 use)同样可恢复**。从**尾到头**扫描,最后一条
|
|
323
|
+
* 合法路径胜出(时间序最新);会话目录名/ID 从路径本身派生(单一真相源,不猜)。
|
|
324
|
+
* 路径可为相对(重建锚点存 rel)——绝对化与存在性校验在调用方(知道 root)执行。
|
|
325
|
+
*/
|
|
326
|
+
function parseSessionContextFromEvents(events) {
|
|
327
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
328
|
+
const strs = [];
|
|
329
|
+
collectStrings(events[i], strs);
|
|
330
|
+
for (const s of strs) {
|
|
331
|
+
const md = extractSessionMdPathFromText(s);
|
|
332
|
+
if (!md) continue;
|
|
333
|
+
let dirName;
|
|
334
|
+
let filePath;
|
|
335
|
+
if (basename(md) === SESSION_MD) {
|
|
336
|
+
dirName = basename(dirname(md));
|
|
337
|
+
filePath = md;
|
|
338
|
+
} else if (isSessionDirName(basename(md))) {
|
|
339
|
+
dirName = basename(md);
|
|
340
|
+
filePath = join(md, SESSION_MD);
|
|
341
|
+
} else continue;
|
|
342
|
+
if (!isSessionDirName(dirName)) continue;
|
|
343
|
+
const idMatch = dirName.match(/--S(\d{3,})--/);
|
|
344
|
+
return {
|
|
345
|
+
sessionId: idMatch ? `S${idMatch[1]}` : dirName,
|
|
346
|
+
dirName,
|
|
347
|
+
mdPath: filePath
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
/** 递归收集对象/数组/字符串中的全部字符串(保留原文,无 JSON 转义) */
|
|
354
|
+
function collectStrings(v, out) {
|
|
355
|
+
if (typeof v === "string") {
|
|
356
|
+
out.push(v);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (v && typeof v === "object") for (const val of Object.values(v)) collectStrings(val, out);
|
|
360
|
+
}
|
|
361
|
+
/** 规范行正则:`SESSION.md path: <路径>`(路径可含空格 → [^\r\n]+ 整行匹配) */
|
|
362
|
+
const SESSION_MD_PATH_RE = /SESSION\.md path:\s*([^\r\n]+)/;
|
|
363
|
+
/** 从文本提取 SESSION.md 路径(use 上下文 / 重建锚点规范行通用;无匹配返回 null)。
|
|
364
|
+
* 同行已知尾注(如系统提示词 Session 块的 persistent-body 注释)剥除——规范行只取路径本体。 */
|
|
365
|
+
function extractSessionMdPathFromText(text) {
|
|
366
|
+
const m = text.match(SESSION_MD_PATH_RE);
|
|
367
|
+
if (!m) return null;
|
|
368
|
+
let p = m[1].trim();
|
|
369
|
+
const suffix = p.search(/ \(the trajectory's persistent body/);
|
|
370
|
+
if (suffix > 0) p = p.slice(0, suffix).trim();
|
|
371
|
+
return p;
|
|
372
|
+
}
|
|
373
|
+
/** 会话目录名形态校验(createSession 恒带日期前缀 `YYYY-MM-DD--`) */
|
|
374
|
+
function isSessionDirName(dirName) {
|
|
375
|
+
return /^\d{4}-\d{2}-\d{2}--/.test(dirName) && dirName.length > 11;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* 从 dsh 会话标题解析 SESSION 目录(编码无关 best-match,U3/U4)——
|
|
379
|
+
* 标题不假设 S### 前缀(CCC 可自定义编码:apaas-xxx / P### / 完整目录名等)。
|
|
380
|
+
* 匹配优先级(全部对 AGENT_SESSIONS 现有目录 best-match,不猜):
|
|
381
|
+
* ① 标题即完整目录名(含日期前缀 `YYYY-MM-DD--`)→ 精确命中
|
|
382
|
+
* ② 标题含 `--<code>--` 段(如完整目录名被截断为 `<code>-日期-概括` 前段)→
|
|
383
|
+
* 按 code 段匹配:取标题首 token(`-` 前),与各目录 `--<code>--`/`--<code>` 尾段比对
|
|
384
|
+
* ③ 唯一模糊子串匹配(多个则返回 null 防误猜)
|
|
385
|
+
* @param title dsh 会话标题(如 `S142-2026-08-24-概括` / `apaas-26116-…` / 完整目录名)
|
|
386
|
+
* @param sessionsDir AGENT_SESSIONS 绝对路径
|
|
387
|
+
* @returns 命中目录的绝对路径(SESSION.md);无/歧义返回 null
|
|
388
|
+
*/
|
|
389
|
+
function resolveSessionByTitle(title, sessionsDir) {
|
|
390
|
+
const t = (title ?? "").trim();
|
|
391
|
+
if (!t) return null;
|
|
392
|
+
const all = readAllSessions(sessionsDir);
|
|
393
|
+
if (all.length === 0) return null;
|
|
394
|
+
if (isSessionDirName(t)) {
|
|
395
|
+
const exact = all.find((s) => s.dirName === t);
|
|
396
|
+
if (exact) return join(exact.path, SESSION_MD);
|
|
397
|
+
}
|
|
398
|
+
const codeToken = t.split("-")[0]?.trim() ?? "";
|
|
399
|
+
if (codeToken) {
|
|
400
|
+
const byCode = all.filter((s) => {
|
|
401
|
+
const m = s.dirName.match(/--([^--]+)--/);
|
|
402
|
+
const code = m ? m[1] : null;
|
|
403
|
+
const tailMatch = s.dirName.match(/--([^--]+)$/);
|
|
404
|
+
const tailCode = tailMatch && !s.dirName.includes("--", s.dirName.lastIndexOf("--") + 3) ? tailMatch[1] : null;
|
|
405
|
+
return code === codeToken || tailCode === codeToken || s.dirName === codeToken || s.dirName.includes(`--${codeToken}`);
|
|
406
|
+
});
|
|
407
|
+
if (byCode.length === 1) return join(byCode[0].path, SESSION_MD);
|
|
408
|
+
if (byCode.length > 1) return null;
|
|
409
|
+
}
|
|
410
|
+
const fuzzy = all.filter((s) => s.dirName.toLowerCase().includes(t.toLowerCase()));
|
|
411
|
+
if (fuzzy.length === 1) return join(fuzzy[0].path, SESSION_MD);
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* 约定回退(v1.24.11):AGENT_SESSIONS 下最新修改的**未完成**会话的 SESSION.md。
|
|
416
|
+
* readAllSessions 已按「未完成优先 + mtime 降序」排序 → 首个未完成且含 SESSION.md 即最新活动。
|
|
417
|
+
* 只作最后手段(内存/events/锚点全缺时),保证重建锚点至少指向一个真实存在的轨迹。
|
|
418
|
+
*/
|
|
419
|
+
function findLatestActiveSessionMd(root) {
|
|
420
|
+
for (const s of readAllSessions(sessionsRoot(root))) {
|
|
421
|
+
if (s.status.completed) continue;
|
|
422
|
+
const md = join(s.path, SESSION_MD);
|
|
423
|
+
if (existsSync(md)) return md;
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
/** health 子命令(对齐 osp healthCheck:stale/stalled/ghost/drift 四类检查,文本输出) */
|
|
428
|
+
function healthCheck(root) {
|
|
429
|
+
const sessions = readAllSessions(sessionsRoot(root));
|
|
430
|
+
if (sessions.length === 0) return "No sessions found — nothing to check.";
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
const issues = [];
|
|
433
|
+
for (const s of sessions) {
|
|
434
|
+
const ageDays = (now - s.mtime.getTime()) / DAY;
|
|
435
|
+
const st = s.status;
|
|
436
|
+
if (ageDays > HEALTH_STALE_DAYS && !st.completed) issues.push({
|
|
437
|
+
dirName: s.dirName,
|
|
438
|
+
issue: `No activity for ${Math.floor(ageDays)}d`,
|
|
439
|
+
severity: "stale"
|
|
440
|
+
});
|
|
441
|
+
const totalTasks = st.completedCount + st.pendingCount;
|
|
442
|
+
if (totalTasks > 0) {
|
|
443
|
+
const pct = Math.round(st.completedCount / totalTasks * 100);
|
|
444
|
+
if (pct < HEALTH_STALLED_PCT && ageDays > HEALTH_STALLED_DAYS && !st.completed) issues.push({
|
|
445
|
+
dirName: s.dirName,
|
|
446
|
+
issue: `Only ${pct}% done after ${Math.floor(ageDays)}d`,
|
|
447
|
+
severity: "stalled"
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if (!st.hasSessionMd && ageDays > HEALTH_GHOST_DAYS) issues.push({
|
|
451
|
+
dirName: s.dirName,
|
|
452
|
+
issue: "No SESSION.md (ghost directory)",
|
|
453
|
+
severity: "ghost"
|
|
454
|
+
});
|
|
455
|
+
if (st.unresolvedCount > 3 && !st.completed) issues.push({
|
|
456
|
+
dirName: s.dirName,
|
|
457
|
+
issue: `${st.unresolvedCount} unresolved items`,
|
|
458
|
+
severity: "drift"
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (issues.length === 0) return "All sessions healthy — no issues found.";
|
|
462
|
+
const lines = issues.map((i) => `[${i.severity.toUpperCase()}] ${i.dirName}: ${i.issue}`);
|
|
463
|
+
return `${issues.length} issue(s) found:\n` + lines.join("\n");
|
|
464
|
+
}
|
|
465
|
+
/** archive 子命令(对齐 osp archiveSessions:移动 _archived/;name 缺省批量) */
|
|
466
|
+
function archiveSessions(root, opts) {
|
|
467
|
+
const { name, dryRun } = opts;
|
|
468
|
+
const sessionsDir = sessionsRoot(root);
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
const archiveDir = join(sessionsDir, ARCHIVE_DIR_NAME);
|
|
471
|
+
if (name) {
|
|
472
|
+
const session = findSession(sessionsDir, name);
|
|
473
|
+
if (!session) throw new Error(`Session not found: "${name}"`);
|
|
474
|
+
if (!session.status.completed) return `Session "${session.dirName}" is not completed — skipping.`;
|
|
475
|
+
const ageDays = (now - session.mtime.getTime()) / DAY;
|
|
476
|
+
if (ageDays < 7) return `Session "${session.dirName}" completed ${Math.floor(ageDays)}d ago — needs ${7 - Math.floor(ageDays)} more days before archiving.`;
|
|
477
|
+
if (dryRun) return `[dry-run] Would archive: ${session.dirName} → ${ARCHIVE_DIR_NAME}/`;
|
|
478
|
+
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
479
|
+
renameSync(session.path, join(archiveDir, session.dirName));
|
|
480
|
+
return `Archived: ${session.dirName} → _archived/`;
|
|
481
|
+
}
|
|
482
|
+
const toArchive = readAllSessions(sessionsDir).filter((s) => {
|
|
483
|
+
if (!s.status.completed) return false;
|
|
484
|
+
return (now - s.mtime.getTime()) / DAY >= 7;
|
|
485
|
+
});
|
|
486
|
+
if (toArchive.length === 0) return "No sessions eligible for archiving.";
|
|
487
|
+
if (dryRun) return `[dry-run] Would archive ${toArchive.length} session(s):\n` + toArchive.map((s) => ` ${s.dirName}`).join("\n");
|
|
488
|
+
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
489
|
+
let count = 0;
|
|
490
|
+
for (const s of toArchive) {
|
|
491
|
+
renameSync(s.path, join(archiveDir, s.dirName));
|
|
492
|
+
count++;
|
|
493
|
+
}
|
|
494
|
+
return `Archived ${count} session(s) → _archived/`;
|
|
495
|
+
}
|
|
496
|
+
/** summary 子命令(对齐 osp sessionSummary 文本仪表盘) */
|
|
497
|
+
function summarize(root) {
|
|
498
|
+
const sessions = readAllSessions(sessionsRoot(root));
|
|
499
|
+
if (sessions.length === 0) return "AGENT_SESSIONS/ is empty.";
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
const completed = sessions.filter((s) => s.status.completed).length;
|
|
502
|
+
const active = sessions.length - completed;
|
|
503
|
+
const stale = sessions.filter((s) => !s.status.completed && (now - s.mtime.getTime()) / DAY > HEALTH_STALE_DAYS).length;
|
|
504
|
+
const ghost = sessions.filter((s) => !s.status.hasSessionMd).length;
|
|
505
|
+
const recent = sessions.slice(0, 5);
|
|
506
|
+
const lines = [
|
|
507
|
+
`AGENT_SESSIONS Summary`,
|
|
508
|
+
`────────────────────────`,
|
|
509
|
+
`Total: ${sessions.length}`,
|
|
510
|
+
`Active: ${active}`,
|
|
511
|
+
`Completed: ${completed}`,
|
|
512
|
+
`Stale: ${stale}`,
|
|
513
|
+
`Ghost: ${ghost}`,
|
|
514
|
+
``,
|
|
515
|
+
`Recent activity (top 5):`,
|
|
516
|
+
...recent.map((s) => {
|
|
517
|
+
const age = Math.floor((now - s.mtime.getTime()) / DAY);
|
|
518
|
+
return ` ${s.status.completed ? "✓" : "○"} ${s.dirName} (${age}d ago)`;
|
|
519
|
+
})
|
|
520
|
+
];
|
|
521
|
+
if (stale > 0) lines.push("", "⚠ Warning: Stale sessions found — run \"session health\" for details.");
|
|
522
|
+
return lines.join("\n");
|
|
523
|
+
}
|
|
524
|
+
/** 事实核对:SESSION.md 声明 vs 实际情况(结构/一致性/新鲜度/决策质量/产出物) */
|
|
525
|
+
function qaCheck(root, key) {
|
|
526
|
+
const session = findSession(sessionsRoot(root), key);
|
|
527
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
528
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
529
|
+
if (!existsSync(mdPath)) return `[ERROR] Session "${session.dirName}" has no SESSION.md — nothing to verify.`;
|
|
530
|
+
const content = readFileSync(mdPath, "utf-8");
|
|
531
|
+
const issues = [];
|
|
532
|
+
for (const section of [
|
|
533
|
+
{
|
|
534
|
+
heading: "目标",
|
|
535
|
+
label: "目标 (goal)"
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
heading: "状态",
|
|
539
|
+
label: "状态 (status)"
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
heading: "关键决策",
|
|
543
|
+
label: "关键决策 (key decisions)"
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
heading: "进度记录",
|
|
547
|
+
label: "进度记录 (progress)"
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
heading: "产出物",
|
|
551
|
+
label: "产出物 (outputs)"
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
heading: "未解决的问题",
|
|
555
|
+
label: "未解决的问题 (unresolved)"
|
|
556
|
+
}
|
|
557
|
+
]) {
|
|
558
|
+
const headingRegex = new RegExp(`^##\\s*${section.heading}[\\s\\S]*?(?=^##|(?![\\s\\S]))`, "m");
|
|
559
|
+
const match = content.match(headingRegex);
|
|
560
|
+
if (!match) {
|
|
561
|
+
issues.push({
|
|
562
|
+
severity: "warning",
|
|
563
|
+
category: "structure",
|
|
564
|
+
message: `Missing section: ${section.label}`
|
|
565
|
+
});
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const headingLineRegex = new RegExp(`^##\\s*${section.heading}\\s*$`, "m");
|
|
569
|
+
const body = match[0].replace(headingLineRegex, "").trim();
|
|
570
|
+
if (!body || /^[-*]\s*$/.test(body)) issues.push({
|
|
571
|
+
severity: "warning",
|
|
572
|
+
category: "structure",
|
|
573
|
+
message: `Section "${section.label}" is empty (only placeholder)`
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
const completedTasks = (content.match(/\[\s*x\s*\]/gi) ?? []).length;
|
|
577
|
+
const pendingTasks = (content.match(/\[\s*[ \t]\s*\]/gi) ?? []).length;
|
|
578
|
+
const statusSection = content.match(/^##\s*状态[\s\S]*?(?=^##|(?![^]))/im);
|
|
579
|
+
const statusBody = statusSection ? statusSection[0].replace(/^##\s*状态.*$/m, "").trim() : "";
|
|
580
|
+
const hasCompletionMark = statusBody ? /#+\s*(?:完成|done|completed|closed)\b/i.test(statusBody) || /(?:全部完成|已全部完成|所有.*任务.*完成|任务.*全部完成|已完成.*所有)/i.test(statusBody) : false;
|
|
581
|
+
const unresolvedSection = content.match(/^##\s*未解决的问题[\s\S]*?(?=^##|(?![^]))/im);
|
|
582
|
+
const unresolvedBody = unresolvedSection ? unresolvedSection[0].replace(/^##\s*未解决的问题.*$/m, "").trim() : "";
|
|
583
|
+
const unresolvedCount = unresolvedBody ? (unresolvedBody.match(/(?:未解决|open|question|TODO)/gi) ?? []).length : 0;
|
|
584
|
+
if (hasCompletionMark && pendingTasks > 0) issues.push({
|
|
585
|
+
severity: "error",
|
|
586
|
+
category: "consistency",
|
|
587
|
+
message: `Session marked as completed but has ${pendingTasks} pending task(s)`
|
|
588
|
+
});
|
|
589
|
+
if (hasCompletionMark && unresolvedCount > 0) issues.push({
|
|
590
|
+
severity: "warning",
|
|
591
|
+
category: "consistency",
|
|
592
|
+
message: `Session marked as completed but has ${unresolvedCount} unresolved item(s)`
|
|
593
|
+
});
|
|
594
|
+
if (completedTasks > 0 && pendingTasks === 0 && !hasCompletionMark) issues.push({
|
|
595
|
+
severity: "info",
|
|
596
|
+
category: "consistency",
|
|
597
|
+
message: `All ${completedTasks} task(s) completed but session not marked complete`
|
|
598
|
+
});
|
|
599
|
+
const progressSection = content.match(/##\s*进度记录[\s\S]*?(?=^##|\z)/m);
|
|
600
|
+
if (progressSection) {
|
|
601
|
+
const dateMatches = progressSection[0].match(/\b(\d{4}-\d{2}-\d{2})\b/g);
|
|
602
|
+
if (dateMatches && dateMatches.length > 0) {
|
|
603
|
+
const lastDateStr = dateMatches[dateMatches.length - 1];
|
|
604
|
+
const lastDate = new Date(lastDateStr);
|
|
605
|
+
const daysSince = Math.floor((Date.now() - lastDate.getTime()) / DAY);
|
|
606
|
+
if (daysSince > HEALTH_STALE_DAYS && pendingTasks > 0) issues.push({
|
|
607
|
+
severity: "warning",
|
|
608
|
+
category: "stale",
|
|
609
|
+
message: `No progress entry for ${daysSince} days (last: ${lastDateStr}), session still has ${pendingTasks} pending task(s)`
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const decisionSection = content.match(/##\s*关键决策[\s\S]*?(?=^##|\z)/m);
|
|
614
|
+
if (decisionSection) {
|
|
615
|
+
const decisionLines = decisionSection[0].split("\n").filter((l) => /^\|\s*\d+\s*\|/.test(l));
|
|
616
|
+
if (decisionLines.length > 0) {
|
|
617
|
+
const emptyDecisions = decisionLines.filter((l) => {
|
|
618
|
+
const cells = l.split("|").map((c) => c.trim());
|
|
619
|
+
return cells.length >= 4 && (!cells[2] || !cells[3] || cells[2] === "-" || cells[3] === "-");
|
|
620
|
+
});
|
|
621
|
+
if (emptyDecisions.length > 0) issues.push({
|
|
622
|
+
severity: "info",
|
|
623
|
+
category: "quality",
|
|
624
|
+
message: `${emptyDecisions.length} decision(s) have empty reason — consider filling gaps`
|
|
625
|
+
});
|
|
626
|
+
} else if (!hasCompletionMark) issues.push({
|
|
627
|
+
severity: "info",
|
|
628
|
+
category: "quality",
|
|
629
|
+
message: "No decisions recorded yet — add key decisions as the session progresses"
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const outputSection = content.match(/##\s*产出物[\s\S]*?(?=^##|\z)/m);
|
|
633
|
+
if (outputSection) {
|
|
634
|
+
const outputLines = outputSection[0].split("\n").filter((l) => /^\s*[-*]\s/.test(l));
|
|
635
|
+
const fileRefs = [];
|
|
636
|
+
for (const line of outputLines) {
|
|
637
|
+
const refs = line.match(/`[^`]+`/g) ?? [];
|
|
638
|
+
fileRefs.push(...refs.map((r) => r.replace(/`/g, "")));
|
|
639
|
+
const inlineRefs = line.match(/\b[\w./-]+\.[a-zA-Z]{1,5}\b/g) ?? [];
|
|
640
|
+
fileRefs.push(...inlineRefs.filter((r) => r.includes("/") || r.includes(".")));
|
|
641
|
+
}
|
|
642
|
+
if (fileRefs.length > 0) {
|
|
643
|
+
const missing = fileRefs.filter((ref) => !existsSync(join(root, ref)));
|
|
644
|
+
if (missing.length > 0 && completedTasks > 0) issues.push({
|
|
645
|
+
severity: "warning",
|
|
646
|
+
category: "outputs",
|
|
647
|
+
message: `${missing.length} referenced file(s) not found: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? `... (+${missing.length - 3} more)` : ""}`
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
652
|
+
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
653
|
+
const infoCount = issues.filter((i) => i.severity === "info").length;
|
|
654
|
+
const verified = errorCount === 0 && warningCount === 0;
|
|
655
|
+
const lines = [
|
|
656
|
+
`QA Report: ${session.dirName}`,
|
|
657
|
+
`────────────────${"─".repeat(session.dirName.length)}`,
|
|
658
|
+
`Summary: ${issues.length} issue(s) found (${errorCount} error, ${warningCount} warning, ${infoCount} info)`,
|
|
659
|
+
`Status: ${verified ? "✓ Verified" : "⚠ Issues found"}`
|
|
660
|
+
];
|
|
661
|
+
if (issues.length > 0) {
|
|
662
|
+
lines.push("");
|
|
663
|
+
for (const issue of issues) {
|
|
664
|
+
const tag = issue.severity === "error" ? "ERR" : issue.severity === "warning" ? "WRN" : "INF";
|
|
665
|
+
lines.push(` [${tag}:${issue.category}] ${issue.message}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
lines.push("", "Recommendations:");
|
|
669
|
+
if (errorCount > 0) lines.push(" • Fix errors before closing the session (status vs content mismatch)");
|
|
670
|
+
if (warningCount > 0) lines.push(" • Review warnings — they may indicate incomplete or outdated information");
|
|
671
|
+
if (verified) lines.push(" • Session looks clean — no issues detected");
|
|
672
|
+
return lines.join("\n");
|
|
673
|
+
}
|
|
674
|
+
//#endregion
|
|
675
|
+
//#region src/session-bound.ts
|
|
676
|
+
/** 事件 type 常量(声明 + 读取共用) */
|
|
677
|
+
const SESSION_BOUND_EVENT = "serenity/bound";
|
|
678
|
+
/** 事件形状归一(运行时数据经 session.append 深冻结,此处仅类型收窄) */
|
|
679
|
+
function asBoundEvent(e) {
|
|
680
|
+
const ev = e;
|
|
681
|
+
if (!ev || ev.type !== "serenity/bound") return null;
|
|
682
|
+
const d = ev.data;
|
|
683
|
+
if (!d || typeof d.dirName !== "string" || typeof d.mdPath !== "string") return null;
|
|
684
|
+
return d;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* 读取会话日志中**最后一条** `serenity/bound`(权威绑定,latest-wins)。
|
|
688
|
+
* 尾到头扫描(时间序最新在后);无 bound 事件返回 null。
|
|
689
|
+
* 纯逻辑零 DSH 依赖(经 sessionEvents helper 读 snapshotEvents/events 兜底)。
|
|
690
|
+
*/
|
|
691
|
+
function readLastBound(session) {
|
|
692
|
+
const events = sessionEvents(session);
|
|
693
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
694
|
+
const b = asBoundEvent(events[i]);
|
|
695
|
+
if (b) return b;
|
|
696
|
+
}
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* append 一条 `serenity/bound` 绑定事件(log-only,无 surfaceOp——元数据不进模型可见面)。
|
|
701
|
+
* @param session 目标 dsh 会话(真实 Session;append 泛型经内部断言兼容——类型由
|
|
702
|
+
* `serenity/bound` 声明面保证,append 接受已声明事件)
|
|
703
|
+
* @param action 绑定动作
|
|
704
|
+
* @param rec 绑定记录(dirName + mdPath 必填;sessionId 可选展示码)
|
|
705
|
+
* @returns 是否成功(append 抛错/会话不可用时 false——绑定失败不阻断主流程)
|
|
706
|
+
*/
|
|
707
|
+
function appendBound(session, action, rec) {
|
|
708
|
+
if (!session || typeof session.append !== "function") return false;
|
|
709
|
+
try {
|
|
710
|
+
const append = session.append;
|
|
711
|
+
append(SESSION_BOUND_EVENT, {
|
|
712
|
+
dirName: rec.dirName,
|
|
713
|
+
mdPath: rec.mdPath,
|
|
714
|
+
...rec.sessionId ? { sessionId: rec.sessionId } : {},
|
|
715
|
+
action,
|
|
716
|
+
at: Date.now(),
|
|
717
|
+
...rec.note ? { note: rec.note } : {}
|
|
718
|
+
});
|
|
719
|
+
return true;
|
|
720
|
+
} catch {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
export { useSession as C, summarize as S, resolveSessionByTitle as _, archiveSessions as a, setActiveSessionInfo as b, extractSessionMdPathFromText as c, getActiveSessionInfo as d, healthCheck as f, readActiveSessionMd as g, qaCheck as h, SESSION_ACTIONS as i, findLatestActiveSessionMd as l, parseSessionContextFromEvents as m, readLastBound as n, closeSession as o, listSessions as p, DEFAULT_SESSION_SCOPE as r, createSession as s, appendBound as t, findSession as u, sessionEvents as v, showSession as x, sessionsRoot as y };
|