@mingmaple/dsh-handover 0.9.13 → 0.9.22
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 +46 -19
- package/docs/migrate.md +6 -6
- package/docs/releases/0.9.14.md +18 -0
- package/docs/releases/0.9.22.md +34 -0
- package/docs/troubleshoot.md +34 -10
- package/package.json +71 -71
- package/scripts/blank-dsh.mjs +29 -23
- package/scripts/blank-smoke.mjs +261 -78
- package/scripts/e2e-migration.mjs +2 -2
- package/scripts/office.mjs +3 -3
- package/src/client.js +159 -120
- package/src/index.mjs +65 -58
- package/src/lib/api.mjs +9 -7
- package/src/lib/brief.mjs +2 -2
- package/src/lib/config.mjs +3 -3
- package/src/lib/consent.mjs +54 -1
- package/src/lib/docmap.mjs +143 -0
- package/src/lib/gates.mjs +25 -1
- package/src/lib/guide.mjs +35 -44
- package/src/lib/lint.mjs +3 -2
- package/src/lib/manual.mjs +48 -9
- package/src/lib/mover.mjs +10 -10
- package/src/lib/names.mjs +14 -19
- package/src/lib/office.mjs +22 -14
- package/src/lib/pack-ops.mjs +2 -2
- package/src/lib/peer.mjs +4 -4
- package/src/lib/role.mjs +8 -5
- package/src/lib/scaffold.mjs +42 -20
- package/src/lib/schedule.mjs +1 -2
- package/src/lib/session-create.mjs +1 -1
- package/src/lib/session-ops.mjs +49 -20
- package/src/lib/sessions.mjs +1 -1
- package/src/lib/status.mjs +205 -45
- package/src/lib/succession.mjs +27 -11
- package/src/lib/zstd-log.mjs +1 -1
- package/src/selftest.mjs +6 -6
- package/src/templates/Prompt_Templates.md +13 -13
- package/src/templates/Runtime_Manual.md +56 -30
- package/src/templates/rules/Handover_Rules.md +2 -1
- package/src/templates/rules/Migration_Rules.md +1 -1
- package/src/templates/standards/README.md +25 -0
- package/src/tools/ho_backup.mjs +135 -0
- package/src/tools/ho_checkpoint.mjs +12 -11
- package/src/tools/ho_docmap.mjs +153 -0
- package/src/tools/ho_gate.mjs +2 -1
- package/src/tools/ho_office.mjs +1 -1
- package/src/tools/ho_sessions.mjs +3 -4
- package/src/tools/ho_status.mjs +246 -0
- package/src/tools/ho_succeed.mjs +1 -1
- package/src/tools/ho_workspace.mjs +86 -14
- package/src/tools/peer.mjs +205 -0
- package/src/tools/ho_brief.mjs +0 -57
- package/src/tools/ho_diag.mjs +0 -87
- package/src/tools/ho_drift.mjs +0 -41
- package/src/tools/ho_init.mjs +0 -82
- package/src/tools/ho_pack.mjs +0 -38
- package/src/tools/ho_phase.mjs +0 -37
- package/src/tools/ho_snapshot.mjs +0 -104
- package/src/tools/ho_tokens.mjs +0 -57
- package/src/tools/peer_list.mjs +0 -37
- package/src/tools/peer_new.mjs +0 -75
- package/src/tools/peer_peek.mjs +0 -41
- package/src/tools/peer_rename.mjs +0 -31
- package/src/tools/peer_send.mjs +0 -52
- package/src/tools/peer_status.mjs +0 -46
|
@@ -18,17 +18,17 @@ export function pmCheckpointTool(env) {
|
|
|
18
18
|
properties: {
|
|
19
19
|
handover_text: { type: 'string', description: '要写入的交接正文;不给则要求磁盘上已有合格交接' },
|
|
20
20
|
handover_path: { type: 'string', description: '覆盖交接路径(演练用)' },
|
|
21
|
-
resume: { type: 'string', enum: ['continue', 'wait'], description: '压缩后的去向:continue(默认)按交接 §2 继续;wait
|
|
21
|
+
resume: { type: 'string', enum: ['continue', 'wait'], description: '压缩后的去向:continue(默认)按交接 §2 继续;wait 表示本轮任务已收尾,压缩后不唤醒,等当前用户派新任务' },
|
|
22
22
|
at_risk: { type: 'boolean', description: '当前处在关键步骤:为 true 时,水位在提醒线~交接线之间只提醒、不压缩(过交接线仍会压)' },
|
|
23
23
|
force: { type: 'boolean', description: '忽略关键步骤判定,直接排压缩' },
|
|
24
|
-
confirm: { type: 'boolean', description: '
|
|
24
|
+
confirm: { type: 'boolean', description: '判定为「可压缩」后须经当前用户同意才传 true;还需当前用户在设置页点击「授权压缩」(5 分钟一次性授权),否则不排压缩' },
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
27
|
output: textOutput(),
|
|
28
28
|
async execute(args, exec) {
|
|
29
29
|
const ws = env.resolveWs(exec);
|
|
30
30
|
const meId = env.selfId(exec);
|
|
31
|
-
if (!meId) throw new Error("
|
|
31
|
+
if (!meId) throw new Error("无法获取本会话 id,无法调度压缩");
|
|
32
32
|
const info = env.roleOf(exec);
|
|
33
33
|
const target = args.handover_path
|
|
34
34
|
? { path: args.handover_path, kind: info.role === 'project' ? 'project' : info.role === 'misc' ? 'misc' : 'global' }
|
|
@@ -46,6 +46,7 @@ export function pmCheckpointTool(env) {
|
|
|
46
46
|
const self = env.selfPct(meId);
|
|
47
47
|
const ratio = self === null ? null : self / 100;
|
|
48
48
|
const out = [];
|
|
49
|
+
const ratioText = ratio === null ? '读不到' : (Math.round(ratio * 1000) / 10).toFixed(1) + '%';
|
|
49
50
|
const recentSec = st && st.lastSummaryMs ? (Date.now() - st.lastSummaryMs) / 1000 : null;
|
|
50
51
|
if (recentSec !== null && recentSec < 120 && args.force !== true) {
|
|
51
52
|
out.push("拒绝排压缩:" + Math.round(recentSec) + " 秒前刚完成一次压缩(日志里有 compaction/summary),避免重复排程。");
|
|
@@ -54,7 +55,7 @@ export function pmCheckpointTool(env) {
|
|
|
54
55
|
}
|
|
55
56
|
if (count.lock === "open") {
|
|
56
57
|
out.push("拒绝排压缩:检测到未闭合的压缩锁(compaction/start 无配对 end)。");
|
|
57
|
-
out.push("处理:等当前压缩结束后用
|
|
58
|
+
out.push("处理:等当前压缩结束后用 ho_status 复查;若长时间不动,说明上次压缩崩在中间。");
|
|
58
59
|
return { text: out.join(NL) };
|
|
59
60
|
}
|
|
60
61
|
const adv = decide({ ratio, compactionsUsed: count.used, cfg: env.cfg, lockState: count.lock });
|
|
@@ -69,15 +70,15 @@ export function pmCheckpointTool(env) {
|
|
|
69
70
|
return { text: out.join(NL) };
|
|
70
71
|
}
|
|
71
72
|
if (adv.action === "continue" && args.force !== true) {
|
|
72
|
-
out.push("水位未到线(" +
|
|
73
|
+
out.push("水位未到线(" + ratioText + "):不压。");
|
|
73
74
|
out.push("需要强制演练可加 force=true。");
|
|
74
75
|
return { text: out.join(NL) };
|
|
75
76
|
}
|
|
76
77
|
// 判断逻辑不动;只有判定可以压缩之后,才停下来问用户。
|
|
77
78
|
if (args.confirm !== true) {
|
|
78
79
|
out.push('判定:可以压缩(' + adv.reason + ')。');
|
|
79
|
-
out.push('水位 ' +
|
|
80
|
-
out.push('压缩会丢历史并自动续跑 ——
|
|
80
|
+
out.push('水位 ' + ratioText + ',已压缩 ' + count.used + '/3。');
|
|
81
|
+
out.push('压缩会丢历史并自动续跑 —— 先问当前用户「要压缩吗?」,回答同意后再调 ho_checkpoint(confirm=true);不同意就保持原样。');
|
|
81
82
|
return { text: out.join(NL) };
|
|
82
83
|
}
|
|
83
84
|
let wrote = null;
|
|
@@ -105,8 +106,8 @@ export function pmCheckpointTool(env) {
|
|
|
105
106
|
const cs = checkConsent(meId);
|
|
106
107
|
if (!cs.ok) {
|
|
107
108
|
out.push('交接已写好,但**没有排压缩**:' + cs.reason + '。');
|
|
108
|
-
out.push('授权方式(任选其一):① 在聊天框发送 /allow-compact(最快,仅授权本会话);② 在设置页的 dsh-handover
|
|
109
|
-
out.push('如此设限的原因:压缩会丢弃历史,而参数由 AI
|
|
109
|
+
out.push('授权方式(任选其一):① 在聊天框发送 /allow-compact(最快,仅授权本会话);② 在设置页的 dsh-handover 中点击「授权压缩」一次。授权有效期 5 分钟,其后须再调用 ho_checkpoint(confirm=true)。');
|
|
110
|
+
out.push('如此设限的原因:压缩会丢弃历史,而参数由 AI 自行填写——惟有当前用户在设置页当面点击的那一次,方视为同意。');
|
|
110
111
|
return { text: out.join(NL) };
|
|
111
112
|
}
|
|
112
113
|
// 成本提示:低于交接线强行压缩会白付一次摘要,且本轮前缀缓存失效。
|
|
@@ -118,12 +119,12 @@ export function pmCheckpointTool(env) {
|
|
|
118
119
|
const n = count.used + 1;
|
|
119
120
|
const resume = args.resume === 'wait' ? 'wait' : 'continue';
|
|
120
121
|
env.schedule(meId, n, { resume });
|
|
121
|
-
out.push('
|
|
122
|
+
out.push('当前用户已授权(' + cs.atText + ' 点击,剩 ' + Math.round(cs.leftMs / 1000) + ' 秒)。');
|
|
122
123
|
out.push("已登记压缩(第 " + n + "/" + env.cfg.maxCompactions + " 次)。本回合结束后执行。");
|
|
123
124
|
if (resume === 'wait') {
|
|
124
125
|
out.push("去向:wait —— 压缩完成后不会唤醒本会话,等你派新任务。");
|
|
125
126
|
} else {
|
|
126
|
-
out.push("去向:continue —— 压缩完成后自动收到续跑消息;若 60 秒内没有续跑消息,用
|
|
127
|
+
out.push("去向:continue —— 压缩完成后自动收到续跑消息;若 60 秒内没有续跑消息,用 ho_status 看状态。");
|
|
127
128
|
}
|
|
128
129
|
out.push("压缩期间不要结束会话。");
|
|
129
130
|
return { text: out.join(NL) };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ho_docmap —— 本会话的文档定位 map(只指路,不规定读多少)。
|
|
3
|
+
*
|
|
4
|
+
* 写入范围由会话已登记的角色决定,不接受传入项目名或任务名:全局只写 global.md,
|
|
5
|
+
* 项目只写 project-<本项目>.md,杂项只写 misc-<本任务>.md。
|
|
6
|
+
* 写入者必须是在任会话(以 Office.json 为准);已交出的旧会话只读。
|
|
7
|
+
* 不设字数上限;写盘前逐条校验路径存在,缺项整条拒绝并列出。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { textOutput } from '../lib/output.mjs';
|
|
12
|
+
import { nameOf } from '../lib/names.mjs';
|
|
13
|
+
import { NL } from '../lib/text.mjs';
|
|
14
|
+
import { loadOffice, lineageKeyOf } from '../lib/office.mjs';
|
|
15
|
+
import {
|
|
16
|
+
lineageOf, lineageLabel, docmapRelPath, docmapAbsPath, readDocmap,
|
|
17
|
+
renderDocmap, validateEntries,
|
|
18
|
+
} from '../lib/docmap.mjs';
|
|
19
|
+
|
|
20
|
+
export const ACTIONS = ['read', 'write', 'check'];
|
|
21
|
+
|
|
22
|
+
function ownLineage(env, exec) {
|
|
23
|
+
let info = null;
|
|
24
|
+
try { info = env.roleOf(exec); } catch { info = null; }
|
|
25
|
+
const lin = lineageOf(info);
|
|
26
|
+
return { info, lin };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function holderOf(ws, lin) {
|
|
30
|
+
try {
|
|
31
|
+
const store = loadOffice(ws);
|
|
32
|
+
const key = lineageKeyOf(lin.kind, lin.project, lin.task);
|
|
33
|
+
const row = store.lineages ? store.lineages[key] : null;
|
|
34
|
+
return { key, current: row && row.current ? row.current : null };
|
|
35
|
+
} catch {
|
|
36
|
+
return { key: '', current: null };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function entriesText(entries) {
|
|
41
|
+
if (!entries.length) return '(空 map:不注入提示段)';
|
|
42
|
+
return entries.map((e) => '- ' + e.path + (e.about ? ' —— ' + e.about : '') + (e.when ? '(' + e.when + ')' : '')).join(NL);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function pmDocmapTool(env) {
|
|
46
|
+
return {
|
|
47
|
+
name: nameOf('docmap'),
|
|
48
|
+
description:
|
|
49
|
+
'本会话的文档定位:action=read 读、write 写(逐条校验路径存在)、check 查自己的 map;只指路,不规定读多少。',
|
|
50
|
+
parameters: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
properties: {
|
|
54
|
+
action: { type: 'string', enum: ACTIONS, description: '缺省 read' },
|
|
55
|
+
entries: {
|
|
56
|
+
type: 'array',
|
|
57
|
+
description: 'write:整份替换。每项 { path: 工作区相对路径, about: 一句话介绍, when: 何时读 }',
|
|
58
|
+
items: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
required: ['path', 'about'],
|
|
62
|
+
properties: {
|
|
63
|
+
path: { type: 'string', description: '工作区相对路径,必须存在' },
|
|
64
|
+
about: { type: 'string', description: '一句话介绍' },
|
|
65
|
+
when: { type: 'string', description: '什么时候该读(可选,建议写)' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
output: textOutput(),
|
|
72
|
+
async execute(args, exec) {
|
|
73
|
+
const ws = env.resolveWs(exec);
|
|
74
|
+
const { info, lin } = ownLineage(env, exec);
|
|
75
|
+
if (!lin) throw new Error('本会话没有角色:文档定位只对全局、项目、杂项会话开放');
|
|
76
|
+
const action = typeof args.action === 'string' && args.action ? args.action : 'read';
|
|
77
|
+
if (ACTIONS.indexOf(action) === -1) throw new Error('action 只能是 ' + ACTIONS.join(' | ') + '(收到 ' + String(action) + ')');
|
|
78
|
+
const rel = docmapRelPath(info);
|
|
79
|
+
const abs = docmapAbsPath(ws, info);
|
|
80
|
+
const label = lineageLabel(info);
|
|
81
|
+
|
|
82
|
+
if (action === 'read') {
|
|
83
|
+
const map = readDocmap(ws, info);
|
|
84
|
+
const out = [];
|
|
85
|
+
out.push('文档定位(' + label + ') ' + rel);
|
|
86
|
+
out.push(map.exists ? '状态 已设置,' + map.entries.length + ' 条' : '状态 未设置(空 map 不注入提示段)');
|
|
87
|
+
if (map.entries.length) out.push('', entriesText(map.entries));
|
|
88
|
+
return { text: out.join(NL) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (action === 'check') {
|
|
92
|
+
const map = readDocmap(ws, info);
|
|
93
|
+
const bad = validateEntries(ws, map.entries);
|
|
94
|
+
const out = [];
|
|
95
|
+
out.push('文档定位体检(' + label + ') ' + rel);
|
|
96
|
+
out.push(map.exists ? '条目 ' + map.entries.length + ' 条' : '未设置(空 map 不注入)');
|
|
97
|
+
for (const b of bad) out.push(' 失效:' + b.path + ' —— ' + b.problem);
|
|
98
|
+
out.push(bad.length ? '判定:' + bad.length + ' 条失效,请更新 map。' : '判定:路径全部存在。');
|
|
99
|
+
return { text: out.join(NL) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// action=write
|
|
103
|
+
const holder = holderOf(ws, lin);
|
|
104
|
+
const meId = String(env.selfId(exec) || '');
|
|
105
|
+
if (!holder.current) {
|
|
106
|
+
throw new Error('登记表里没有「' + (holder.key || label) + '」的在任记录:以在任记录为写入依据,先由全局完成登记');
|
|
107
|
+
}
|
|
108
|
+
if (!meId || String(holder.current.sessionId || '') !== meId) {
|
|
109
|
+
throw new Error('只有在任会话可以写:本条登记的在任会话为 ' + (holder.current.name || '?') + '(' + holder.current.sessionId + '),本会话为只读');
|
|
110
|
+
}
|
|
111
|
+
const list = Array.isArray(args.entries) ? args.entries : null;
|
|
112
|
+
if (!list) throw new Error('write 需要 entries 数组(每项 path、about、when)');
|
|
113
|
+
const entries = [];
|
|
114
|
+
for (const raw of list) {
|
|
115
|
+
const e = raw || {};
|
|
116
|
+
const p = String(e.path === undefined || e.path === null ? '' : e.path).trim();
|
|
117
|
+
const about = String(e.about === undefined || e.about === null ? '' : e.about).trim();
|
|
118
|
+
const when = String(e.when === undefined || e.when === null ? '' : e.when).trim();
|
|
119
|
+
if (!p) throw new Error('entries 里有空路径');
|
|
120
|
+
for (const [field, val] of [['path', p], ['about', about], ['when', when]]) {
|
|
121
|
+
if (val.indexOf('|') !== -1) throw new Error(field + ' 里不能出现竖线「|」(表格分隔符):' + val);
|
|
122
|
+
if (val.indexOf(String.fromCharCode(10)) !== -1) throw new Error(field + ' 里不能换行');
|
|
123
|
+
}
|
|
124
|
+
entries.push({ path: p, about, when });
|
|
125
|
+
}
|
|
126
|
+
const bad = validateEntries(ws, entries);
|
|
127
|
+
if (bad.length) {
|
|
128
|
+
const lines = ['未写入:下列路径不存在或不合法,逐条修正后重试。'];
|
|
129
|
+
for (const b of bad) lines.push(' - ' + b.path + ':' + b.problem);
|
|
130
|
+
lines.push('路径一律写工作区相对路径(例:Projects/<项目>/SKILL.md)。');
|
|
131
|
+
throw new Error(lines.join(NL));
|
|
132
|
+
}
|
|
133
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
134
|
+
let backup = '';
|
|
135
|
+
if (fs.existsSync(abs)) {
|
|
136
|
+
backup = abs + '.bak';
|
|
137
|
+
try { fs.copyFileSync(abs, backup); } catch { backup = ''; }
|
|
138
|
+
}
|
|
139
|
+
const body = renderDocmap(info, entries);
|
|
140
|
+
const tmp = abs + '.tmp-' + process.pid;
|
|
141
|
+
fs.writeFileSync(tmp, body, 'utf8');
|
|
142
|
+
fs.renameSync(tmp, abs);
|
|
143
|
+
let refreshed = false;
|
|
144
|
+
try { if (typeof env.refreshPromptFor === 'function') refreshed = env.refreshPromptFor(meId) === true; } catch { refreshed = false; }
|
|
145
|
+
const out = [];
|
|
146
|
+
out.push('已写入 ' + rel + '(' + entries.length + ' 条)' + (backup ? ';原文件备份为 ' + path.basename(backup) : ''));
|
|
147
|
+
out.push(refreshed ? '提示段已重装:下一轮即按新 map 注入。' : '提示段未能在本进程重装:重启 DSH 或让会话重建后生效。');
|
|
148
|
+
out.push('建议精炼:每条一句介绍、一句时机即可;不设字数上限,但太长的介绍会挤占提示段。');
|
|
149
|
+
if (!entries.length) out.push('空 map:不注入提示段(相当于撤销)。');
|
|
150
|
+
return { text: out.join(NL) };
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/tools/ho_gate.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { nameOf } from '../lib/names.mjs';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import { textOutput } from '../lib/output.mjs';
|
|
6
6
|
import { NL } from '../lib/text.mjs';
|
|
7
|
-
import { syncCheck, renderSyncCheck } from '../lib/gates.mjs';
|
|
7
|
+
import { syncCheck, renderSyncCheck, docmapReport, renderDocmapCheck } from '../lib/gates.mjs';
|
|
8
8
|
import { lintGlobal, lintProject, renderLintGlobal, renderLintProject } from '../lib/lint.mjs';
|
|
9
9
|
import { SETUP_STEPS } from '../lib/guide.mjs';
|
|
10
10
|
|
|
@@ -53,6 +53,7 @@ export function pmGateTool(env) {
|
|
|
53
53
|
}
|
|
54
54
|
out.push(renderSyncCheck(g).join(NL));
|
|
55
55
|
out.push(renderLintProject(null, lintAllProjects(ws)).join(NL));
|
|
56
|
+
out.push(renderDocmapCheck(docmapReport(ws)).join(NL));
|
|
56
57
|
} else if (scope === "project") {
|
|
57
58
|
const name = args.project;
|
|
58
59
|
if (!name) throw new Error("scope=project 时必须给 project 参数");
|
package/src/tools/ho_office.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export function pmOfficeTool(env) {
|
|
|
13
13
|
type: 'object',
|
|
14
14
|
additionalProperties: false,
|
|
15
15
|
properties: {
|
|
16
|
-
query: { type: 'string', description: '要查的名字,如「全局」「全局3」「
|
|
16
|
+
query: { type: 'string', description: '要查的名字,如「全局」「全局3」「Demo1」「示例任务」;多个用空格或逗号分隔;留空=列出全部在任' },
|
|
17
17
|
json: { type: 'boolean', description: 'true 时输出机器可读 JSON(供脚本解析)' },
|
|
18
18
|
},
|
|
19
19
|
},
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { textOutput } from '../lib/output.mjs';
|
|
3
3
|
import { nameOf } from '../lib/names.mjs';
|
|
4
4
|
import { NL } from '../lib/text.mjs';
|
|
5
|
-
import path from 'node:path';
|
|
6
5
|
import { scanSessions } from '../lib/sessions.mjs';
|
|
7
6
|
import { syncCheck } from '../lib/gates.mjs';
|
|
8
7
|
import { stamp } from '../lib/brief.mjs';
|
|
@@ -37,9 +36,9 @@ function list(env, args, exec) {
|
|
|
37
36
|
}
|
|
38
37
|
const notifProjects = new Set(scan.notify.map((r) => r.project));
|
|
39
38
|
const skipProjects = new Set(scan.skip.map((r) => r.project));
|
|
40
|
-
out.push("通知判定:活跃项目 " + notifProjects.size + " 个,需要通知 " + scan.notify.length + " 个会话(仅各项目在任)/ 沉默项目 " + skipProjects.size + " 个,跳过 " + scan.skip.length + "
|
|
39
|
+
out.push("通知判定:活跃项目 " + notifProjects.size + " 个,需要通知 " + scan.notify.length + " 个会话(仅各项目在任)/ 沉默项目 " + skipProjects.size + " 个,跳过 " + scan.skip.length + " 个会话(沉默项目全部+已交出的旧会话)");
|
|
41
40
|
for (const r of scan.notify) out.push(" 要通知【在任】" + r.project + "(" + r.sid + ") 最后活动 " + stamp(r.mtime));
|
|
42
|
-
if (scan.skip.length) out.push("
|
|
41
|
+
if (scan.skip.length) out.push(" 跳过(沉默项目,含其已交出的旧会话):" + scan.skip.map((x) => x.project + "(" + x.sid.slice(-8) + ")").join("、"));
|
|
43
42
|
const gate = syncCheck(env.resolveWs(exec));
|
|
44
43
|
if (gate.error) out.push("通用约束条闸门(Charter §7.2.3):读不到 §8.1.1.4 正文 —— 请人工核 " + gate.path);
|
|
45
44
|
else if (!gate.ok) {
|
|
@@ -85,7 +84,7 @@ export function pmSessionsTool(env) {
|
|
|
85
84
|
sessionId: { type: 'string', description: 'prompt:新会话自己的 id' },
|
|
86
85
|
globalSessionId: { type: 'string', description: 'prompt:现任全局 id' },
|
|
87
86
|
extra: { type: 'string', description: 'prompt 附加一句;order 时作为「全局附加」拼进指令' },
|
|
88
|
-
confirm: { type: 'boolean', description: 'new、order
|
|
87
|
+
confirm: { type: 'boolean', description: 'new、order 需要当前用户明确同意后传 true;否则只报错、不做任何事' },
|
|
89
88
|
},
|
|
90
89
|
},
|
|
91
90
|
output: textOutput(),
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ho_status —— 会话状态:一个入口,按 view 分流。
|
|
3
|
+
* phase(默认):水位、压缩次数、锁、建议动作
|
|
4
|
+
* brief:水位、闸门、会话清点、交接漂移、阶段建议
|
|
5
|
+
* diag:服务可解析性、调度器、初始化错误、角色与工具集
|
|
6
|
+
* drift:交接漂移体检
|
|
7
|
+
* tokens:按会话 id 统计轮数、步数、每步 token
|
|
8
|
+
* 全部只读;原 ho_phase、ho_brief、ho_diag、ho_drift、ho_tokens 合并于此。
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { textOutput } from '../lib/output.mjs';
|
|
13
|
+
import { nameOf } from '../lib/names.mjs';
|
|
14
|
+
import { NL, pick } from '../lib/text.mjs';
|
|
15
|
+
import { syncCheck } from '../lib/gates.mjs';
|
|
16
|
+
import { scanSessions, readSelfPressure } from '../lib/sessions.mjs';
|
|
17
|
+
import { composeBrief } from '../lib/brief.mjs';
|
|
18
|
+
import { decide } from '../lib/phase.mjs';
|
|
19
|
+
import { computeDrift, driftVerdict } from '../lib/drift.mjs';
|
|
20
|
+
import { watermark } from '../lib/facts.mjs';
|
|
21
|
+
import { eventsOf, countCompactions } from '../lib/events.mjs';
|
|
22
|
+
import { findLog, decodeLog, turnStarts } from '../lib/zstd-log.mjs';
|
|
23
|
+
import { activeConsentsFor, formatConsentLine } from '../lib/consent.mjs';
|
|
24
|
+
|
|
25
|
+
export const VIEWS = ['phase', 'brief', 'diag', 'drift', 'tokens'];
|
|
26
|
+
|
|
27
|
+
function analyzeLog(records) {
|
|
28
|
+
const turns = turnStarts(records).length;
|
|
29
|
+
let steps = 0, outTok = 0, maxResult = 0, toolResults = 0;
|
|
30
|
+
const perTurn = new Map();
|
|
31
|
+
for (const r of records) {
|
|
32
|
+
if (!r || !r.type) continue;
|
|
33
|
+
if (r.type === 'step/start') {
|
|
34
|
+
steps++;
|
|
35
|
+
const t = pick(r, 'data', 'turn');
|
|
36
|
+
if (typeof t === 'number') perTurn.set(t, (perTurn.get(t) || 0) + 1);
|
|
37
|
+
} else if (r.type === 'assistant/message') {
|
|
38
|
+
const u = pick(r, 'data', 'usage');
|
|
39
|
+
if (u && typeof u.outputTokens === 'number') outTok += u.outputTokens;
|
|
40
|
+
} else if (r.type === 'tool/result') {
|
|
41
|
+
toolResults++;
|
|
42
|
+
try { const len = JSON.stringify(pick(r, 'data') || '').length; if (len > maxResult) maxResult = len; } catch { /* ignore */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const heavyTurns = Array.from(perTurn.values()).filter((n) => n > 20).length;
|
|
46
|
+
return { turns, steps, outTok, maxResult, toolResults, heavyTurns, perStep: steps ? Math.round(outTok / steps) : 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 授权状态行:压缩与换任各一句,未授权写「无授权」。 */
|
|
50
|
+
function consentLine(meId) {
|
|
51
|
+
try { return formatConsentLine(activeConsentsFor(meId)); } catch { return ''; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function phaseText(env, args, exec) {
|
|
55
|
+
const meId = env.selfId(exec);
|
|
56
|
+
const real = env.selfPct(meId);
|
|
57
|
+
const ratio = typeof args.simulateRatio === 'number' ? args.simulateRatio : (real === null ? null : real / 100);
|
|
58
|
+
const count = env.compactionCount(exec);
|
|
59
|
+
const lock = env.lockState(exec);
|
|
60
|
+
const adv = decide({ ratio, compactionsUsed: count.used, cfg: env.cfg, lockState: lock });
|
|
61
|
+
const out = [];
|
|
62
|
+
out.push('会话 ' + (meId || '未知'));
|
|
63
|
+
out.push('水位 ' + (ratio === null ? '读不到' : (Math.round(ratio * 1000) / 10).toFixed(1) + '%')
|
|
64
|
+
+ (typeof args.simulateRatio === 'number' ? ' (演练模拟值)' : '')
|
|
65
|
+
+ (typeof env.staleNote === 'function' ? env.staleNote(exec) : ''));
|
|
66
|
+
out.push('压缩 ' + count.used + '/' + env.cfg.maxCompactions + ' 次' + (count.note ? ' ' + count.note : ''));
|
|
67
|
+
out.push('锁 ' + lock);
|
|
68
|
+
out.push('建议 ' + adv.action + ' —— ' + adv.reason);
|
|
69
|
+
const c = consentLine(meId);
|
|
70
|
+
if (c) out.push('授权 ' + c);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function briefText(env, args, exec) {
|
|
75
|
+
const ws = env.resolveWs(exec);
|
|
76
|
+
const w = watermark(env.cfg);
|
|
77
|
+
const warn = typeof args.threshold === 'number' ? args.threshold : w.warnValue;
|
|
78
|
+
const gate = syncCheck(ws);
|
|
79
|
+
const meId = env.selfId(exec);
|
|
80
|
+
const self = readSelfPressure(env.cacheDir(), meId);
|
|
81
|
+
const scan = scanSessions({
|
|
82
|
+
dir: env.cacheDir(),
|
|
83
|
+
meId,
|
|
84
|
+
meCwd: env.selfCwd(exec),
|
|
85
|
+
warn,
|
|
86
|
+
silentHours: env.cfg.sessionScanHours,
|
|
87
|
+
});
|
|
88
|
+
const drift = computeDrift({ ws, cacheDir: env.cacheDir(), slack: env.cfg.driftSlack, all: false });
|
|
89
|
+
const ratio = self.used === null || self.win === null ? null : self.used / self.win;
|
|
90
|
+
const adv = decide({ ratio, compactionsUsed: 0, cfg: env.cfg, lockState: 'unknown' });
|
|
91
|
+
const selfPct = self.used === null || self.win === null ? null : (self.used / self.win) * 100;
|
|
92
|
+
const ok = gate.ok && (selfPct === null || selfPct < warn);
|
|
93
|
+
const lines = composeBrief({
|
|
94
|
+
cfg: env.cfg,
|
|
95
|
+
selfUsed: self.used,
|
|
96
|
+
selfWin: self.win,
|
|
97
|
+
gate,
|
|
98
|
+
scan,
|
|
99
|
+
drift,
|
|
100
|
+
advice: { text: adv.action + ' —— ' + adv.reason },
|
|
101
|
+
ok,
|
|
102
|
+
});
|
|
103
|
+
const c = consentLine(meId);
|
|
104
|
+
if (c) lines.push('授权 ' + c);
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function diagText(env, state, exec) {
|
|
109
|
+
const out = [];
|
|
110
|
+
const meId = env.selfId(exec);
|
|
111
|
+
out.push('会话 ' + (meId || '?'));
|
|
112
|
+
if (typeof env.version === 'function') out.push('版本 ' + env.version());
|
|
113
|
+
out.push('调度器 ' + (state.schedulerReady() ? '就绪' : '未就绪' + (state.schedulerError() ? ':' + state.schedulerError() : '(稍后重试或重启)')));
|
|
114
|
+
if (typeof env.injectList === 'function') out.push('inject ' + env.injectList().join(' / '));
|
|
115
|
+
if (typeof env.initErrors === 'function') {
|
|
116
|
+
const errs = env.initErrors();
|
|
117
|
+
out.push('初始化 ' + (errs.length ? '有 ' + errs.length + ' 处失败:' + errs.join(';') : '无错误'));
|
|
118
|
+
}
|
|
119
|
+
if (typeof env.probeServices === 'function') {
|
|
120
|
+
const svc = env.probeServices();
|
|
121
|
+
out.push('服务 ' + Object.keys(svc).map((k) => k + '=' + (svc[k] ? '可用' : '—')).join(' '));
|
|
122
|
+
}
|
|
123
|
+
if (typeof env.guardLast === 'function') {
|
|
124
|
+
const g = env.guardLast();
|
|
125
|
+
out.push('通信判定 ' + (g
|
|
126
|
+
? (g.allow ? '放行' : '拦截') + ' · 依据 ' + g.source + (g.title ? ' · 标题 ' + g.title : '') + ' · 会话 ' + g.sid
|
|
127
|
+
: '本进程还没有 peer(action="send"、"new")调用'));
|
|
128
|
+
}
|
|
129
|
+
if (typeof env.probeAgentScope === 'function') {
|
|
130
|
+
const a = env.probeAgentScope(exec);
|
|
131
|
+
const show = ['compaction', 'compactNow', 'tokenMeter', 'sessions', 'llm', 'agents']
|
|
132
|
+
.map((k) => k + '=' + (a[k] ? '可用' : '—')).join(' ');
|
|
133
|
+
out.push('会话作用域 ' + (a.ctx ? show : '无法获取 exec.agent.ctx'));
|
|
134
|
+
}
|
|
135
|
+
const st = typeof env.compactionState === 'function'
|
|
136
|
+
? env.compactionState(exec)
|
|
137
|
+
: (() => { const c = countCompactions(eventsOf(exec)); return { used: c.used, lock: c.lock, source: 'events' }; })();
|
|
138
|
+
out.push('压缩 ' + st.used + '/' + env.cfg.maxCompactions + ' 次;锁 ' + st.lock + ';来源 ' + st.source);
|
|
139
|
+
const pct = env.selfPct(meId);
|
|
140
|
+
out.push('水位 ' + (pct === null ? '读不到' : pct.toFixed(1) + '%') + (typeof env.staleNote === 'function' ? env.staleNote(exec) : ''));
|
|
141
|
+
const ws = env.resolveWs(exec);
|
|
142
|
+
let wsOk = false;
|
|
143
|
+
try { wsOk = fs.existsSync(path.join(ws, '_Control_Center')); } catch { wsOk = false; }
|
|
144
|
+
if (typeof env.roleOf === 'function') {
|
|
145
|
+
try {
|
|
146
|
+
const info = env.roleOf(exec);
|
|
147
|
+
const ts = typeof env.toolsetOf === 'function' ? env.toolsetOf(info.role) : null;
|
|
148
|
+
out.push('角色 ' + info.role + (info.project ? '(项目 ' + info.project + ')' : info.task ? '(任务 ' + info.task + ')' : '') + ' · 依据 ' + info.source + (ts ? ' · 可见工具 ' + ts.allow.length + '/' + (ts.allow.length + ts.deny.length) : ''));
|
|
149
|
+
if (typeof env.roleState === 'function') {
|
|
150
|
+
const rs = env.roleState();
|
|
151
|
+
const cnt = typeof rs.count === 'number' ? ' · 已装配 ' + rs.count + ' 个会话' : '';
|
|
152
|
+
out.push('角色装配 ' + (rs.mode || '未知') + cnt + (rs.last ? ' · 最近:' + rs.last.sid + ' → ' + rs.last.role + '(' + rs.last.at + ')' : ''));
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
out.push('角色 判定失败:' + (e && e.message ? e.message : e));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
out.push('工作区 ' + ws + (wsOk
|
|
159
|
+
? ' 已接入'
|
|
160
|
+
: ' — 未接入(未找到 _Control_Center):让当前用户走设置页「首次安装」,或本会话直接 ho_workspace(action="init", parent="<目录>", register=true)'));
|
|
161
|
+
try {
|
|
162
|
+
const mv = typeof env.moveInfo === 'function' ? env.moveInfo() : null;
|
|
163
|
+
if (mv) out.push('迁移 ' + mv.phase + ' · ' + mv.src + (mv.dst ? ' → ' + mv.dst : ''));
|
|
164
|
+
} catch { /* ignore */ }
|
|
165
|
+
out.push('配置 warn=' + env.cfg.warnRatio + ' handover=' + env.cfg.handoverRatio + ' hard=' + env.cfg.hardRatio + ' max=' + env.cfg.maxCompactions);
|
|
166
|
+
if (typeof env.handoverFor === 'function') {
|
|
167
|
+
try {
|
|
168
|
+
const hp = env.handoverFor(exec);
|
|
169
|
+
out.push('交接路径 ' + (hp && hp.path ? hp.path + '(' + hp.kind + ')' : '无 —— ' + ((hp && hp.reason) || '角色未定')));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
out.push('交接路径 解析失败:' + (e && e.message ? e.message : e));
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
out.push('交接路径 ' + (env.cfg.handoverPath || '(按角色解析)'));
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function driftText(env, args, exec) {
|
|
180
|
+
const ws = env.resolveWs(exec);
|
|
181
|
+
const res = computeDrift({
|
|
182
|
+
ws,
|
|
183
|
+
cacheDir: env.cacheDir(),
|
|
184
|
+
slack: typeof args.slack === 'number' ? args.slack : env.cfg.driftSlack,
|
|
185
|
+
all: args.all === true,
|
|
186
|
+
});
|
|
187
|
+
const out = [];
|
|
188
|
+
out.push('交接匹配检查(跟着交接走;冗余 ' + res.slack + ' 轮) 工作区根 ' + ws);
|
|
189
|
+
out.push(' 项目 会话类型 会话 当前轮 交接轮 Δ 判定');
|
|
190
|
+
for (const r of res.rows) {
|
|
191
|
+
const cells = [r.project, r.kind, r.sid.slice(-8), String(r.turns), r.handoverTurn === null ? '?' : String(r.handoverTurn), r.delta === null ? '?' : String(r.delta), r.verdict];
|
|
192
|
+
out.push(' ' + cells.join(' '));
|
|
193
|
+
}
|
|
194
|
+
if (!res.rows.length) out.push(' 读不到任何项目会话(Projects/ 下对应库不存在时属正常)');
|
|
195
|
+
out.push(driftVerdict(res));
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function tokensText(args) {
|
|
200
|
+
const out = [];
|
|
201
|
+
const list = Array.isArray(args.sessions) ? args.sessions.filter((s) => typeof s === 'string' && s.trim() !== '') : [];
|
|
202
|
+
if (!list.length) throw new Error('view=tokens 时 sessions 不能为空(给会话 id 数组)');
|
|
203
|
+
out.push('会话 轮 步 输出tok 每步tok 最贵返回 超20步轮 判定');
|
|
204
|
+
for (const sid of list) {
|
|
205
|
+
const full = sid.indexOf('session-') === 0 ? sid : 'session-' + sid;
|
|
206
|
+
const log = findLog(full);
|
|
207
|
+
if (!log) { out.push(sid + ' ERR 日志找不到'); continue; }
|
|
208
|
+
const recs = decodeLog(log);
|
|
209
|
+
if (!recs) { out.push(sid + ' ERR 日志解不出'); continue; }
|
|
210
|
+
const m = analyzeLog(recs);
|
|
211
|
+
const verdict = m.perStep > 2000 ? '偏贵(每步 >2k)' : m.heavyTurns > 0 ? '有超 20 步的轮' : '正常';
|
|
212
|
+
out.push([sid.slice(-8), m.turns, m.steps, m.outTok, m.perStep, Math.round(m.maxResult / 1024) + 'KB', m.heavyTurns, verdict].join(' '));
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function pmStatusTool(env, state) {
|
|
218
|
+
const w = watermark(env.cfg);
|
|
219
|
+
return {
|
|
220
|
+
name: nameOf('status'),
|
|
221
|
+
description:
|
|
222
|
+
'会话状态(只读):view=phase 水位与建议(默认)、brief 起始简报、diag 插件自检、drift 交接漂移、tokens token 体检。',
|
|
223
|
+
parameters: {
|
|
224
|
+
type: 'object',
|
|
225
|
+
additionalProperties: false,
|
|
226
|
+
properties: {
|
|
227
|
+
view: { type: 'string', enum: VIEWS, description: '缺省 phase' },
|
|
228
|
+
threshold: { type: 'number', description: 'view=brief:预警阈值百分比(默认 ' + w.warnValue + ')' },
|
|
229
|
+
simulateRatio: { type: 'number', description: 'view=phase:演练用比例替代真实水位(0-1),不写任何状态' },
|
|
230
|
+
sessions: { type: 'array', items: { type: 'string' }, description: 'view=tokens:会话 id 列表' },
|
|
231
|
+
slack: { type: 'number', description: 'view=drift:容忍轮数(默认 3)' },
|
|
232
|
+
all: { type: 'boolean', description: 'view=drift:列全部会话,而不只是每个项目的在任会话' },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
output: textOutput(),
|
|
236
|
+
async execute(args, exec) {
|
|
237
|
+
const view = typeof args.view === 'string' && args.view ? args.view : 'phase';
|
|
238
|
+
if (VIEWS.indexOf(view) === -1) throw new Error('view 只能是 ' + VIEWS.join(' | ') + '(收到 ' + String(view) + ')');
|
|
239
|
+
if (view === 'phase') return { text: phaseText(env, args, exec).join(NL) };
|
|
240
|
+
if (view === 'brief') return { text: briefText(env, args, exec).join(NL) };
|
|
241
|
+
if (view === 'diag') return { text: diagText(env, state, exec).join(NL) };
|
|
242
|
+
if (view === 'drift') return { text: driftText(env, args, exec).join(NL) };
|
|
243
|
+
return { text: tokensText(args).join(NL) };
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/tools/ho_succeed.mjs
CHANGED