@mingmaple/dsh-handover 0.0.0-stage → 0.9.12
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/LICENSE +21 -0
- package/README.md +149 -2
- package/docs/migrate.md +61 -0
- package/docs/troubleshoot.md +77 -0
- package/package.json +70 -5
- package/scripts/blank-dsh.mjs +203 -0
- package/scripts/blank-smoke.mjs +981 -0
- package/scripts/build.mjs +61 -0
- package/scripts/check-docs.mjs +74 -0
- package/scripts/e2e-migration.mjs +127 -0
- package/scripts/lib/register.mjs +103 -0
- package/src/client.js +945 -0
- package/src/cordis.patch.yml +16 -0
- package/src/index.mjs +726 -0
- package/src/lib/absrefs.mjs +112 -0
- package/src/lib/api.mjs +291 -0
- package/src/lib/archive.mjs +198 -0
- package/src/lib/brief.mjs +74 -0
- package/src/lib/charter.mjs +28 -0
- package/src/lib/config.mjs +37 -0
- package/src/lib/consent.mjs +84 -0
- package/src/lib/drift.mjs +65 -0
- package/src/lib/events.mjs +89 -0
- package/src/lib/extra-prompt.mjs +41 -0
- package/src/lib/facts.mjs +70 -0
- package/src/lib/gates.mjs +148 -0
- package/src/lib/guide.mjs +103 -0
- package/src/lib/handover-io.mjs +69 -0
- package/src/lib/lint.mjs +152 -0
- package/src/lib/manual.mjs +124 -0
- package/src/lib/mover.mjs +663 -0
- package/src/lib/names.mjs +33 -0
- package/src/lib/output.mjs +11 -0
- package/src/lib/pack-ops.mjs +332 -0
- package/src/lib/peer.mjs +260 -0
- package/src/lib/phase.mjs +36 -0
- package/src/lib/profile.mjs +204 -0
- package/src/lib/prompt.mjs +18 -0
- package/src/lib/registry.mjs +53 -0
- package/src/lib/retarget-helper.tmpl +46 -0
- package/src/lib/role.mjs +251 -0
- package/src/lib/rpc-carrier.mjs +65 -0
- package/src/lib/scaffold.mjs +118 -0
- package/src/lib/schedule.mjs +223 -0
- package/src/lib/session-create.mjs +42 -0
- package/src/lib/session-ops.mjs +408 -0
- package/src/lib/sessions.mjs +143 -0
- package/src/lib/sevenzip.mjs +48 -0
- package/src/lib/status.mjs +177 -0
- package/src/lib/text.mjs +101 -0
- package/src/lib/webroute.mjs +130 -0
- package/src/lib/workspace.mjs +23 -0
- package/src/lib/zstd-log.mjs +65 -0
- package/src/selftest.mjs +84 -0
- package/src/templates/Changelog.skeleton.md +10 -0
- package/src/templates/Charter.skeleton.md +53 -0
- package/src/templates/Handover.global.skeleton.md +56 -0
- package/src/templates/Prompt_Templates.md +96 -0
- package/src/templates/Runtime_Manual.md +166 -0
- package/src/templates/rules/Handover_Rules.md +44 -0
- package/src/templates/rules/Migration_Rules.md +32 -0
- package/src/templates/rules/Misc_Task_Standard.md +23 -0
- package/src/templates/rules/Writing_Rules.md +24 -0
- package/src/tools/ho_brief.mjs +57 -0
- package/src/tools/ho_changelog.mjs +59 -0
- package/src/tools/ho_charter.mjs +89 -0
- package/src/tools/ho_checkpoint.mjs +132 -0
- package/src/tools/ho_diag.mjs +87 -0
- package/src/tools/ho_drift.mjs +41 -0
- package/src/tools/ho_gate.mjs +69 -0
- package/src/tools/ho_handover.mjs +84 -0
- package/src/tools/ho_init.mjs +82 -0
- package/src/tools/ho_pack.mjs +38 -0
- package/src/tools/ho_phase.mjs +37 -0
- package/src/tools/ho_profile.mjs +105 -0
- package/src/tools/ho_sessions.mjs +115 -0
- package/src/tools/ho_snapshot.mjs +104 -0
- package/src/tools/ho_tokens.mjs +57 -0
- package/src/tools/ho_workspace.mjs +217 -0
- package/src/tools/peer_list.mjs +37 -0
- package/src/tools/peer_new.mjs +75 -0
- package/src/tools/peer_peek.mjs +41 -0
- package/src/tools/peer_rename.mjs +31 -0
- package/src/tools/peer_send.mjs +52 -0
- package/src/tools/peer_status.mjs +46 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 绝对路径体检:扫「文件内容里写死的本机绝对路径」,只报告、不改写。
|
|
3
|
+
* 跨机器/跨平台迁移时,这些位置必须由人或项目会话自己处理——插件改不了用户文件里的路径语义。
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const SKIP_EXT = new Set([
|
|
9
|
+
'.7z', '.zip', '.gz', '.zst', '.tar', '.rar',
|
|
10
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg',
|
|
11
|
+
'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt',
|
|
12
|
+
'.mp3', '.mp4', '.mov', '.wav', '.flac', '.m4a',
|
|
13
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
14
|
+
'.exe', '.dll', '.so', '.dylib', '.node', '.pyc', '.class', '.jar',
|
|
15
|
+
'.db', '.sqlite', '.bin', '.dat',
|
|
16
|
+
]);
|
|
17
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
const MAX_PER_FILE = 3;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 识别模式:本机路径的典型写法。
|
|
22
|
+
* 盘符前必须是词边界、冒号后不能再跟斜杠——否则 https:// 、file:/// 的尾字母会被误判成盘符。
|
|
23
|
+
*/
|
|
24
|
+
const PATTERNS = [
|
|
25
|
+
['盘符路径', /(?<![A-Za-z0-9_])[A-Za-z]:[\\/](?![\\/])[^\s'"`<>|]*/g],
|
|
26
|
+
['UNC 网络路径', /(?<![A-Za-z0-9_\\])\\\\[A-Za-z0-9_.-]+\\[^\s'"`<>|]*/g],
|
|
27
|
+
['POSIX 家目录', /(?<![A-Za-z0-9_.-])\/(?:Users|home)\/[A-Za-z0-9_.-]+\/[^\s'"`<>|]*/g],
|
|
28
|
+
['file URL', /file:\/\/\/[^\s'"`<>|]*/g],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function walk(root, rel, out) {
|
|
32
|
+
let names = [];
|
|
33
|
+
try { names = fs.readdirSync(path.join(root, rel)); } catch { return out; }
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const r = rel ? rel + '/' + name : name;
|
|
36
|
+
const abs = path.join(root, rel, name);
|
|
37
|
+
let st;
|
|
38
|
+
try { st = fs.statSync(abs); } catch { continue; }
|
|
39
|
+
if (st.isDirectory()) walk(root, r, out);
|
|
40
|
+
else if (st.isFile()) out.push({ rel: r, abs, bytes: st.size });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 扫一个目录(或给定相对清单)里的绝对路径引用。
|
|
47
|
+
* @returns {{files:number, scanned:number, skipped:number, hits:Array, totalHits:number, crlf:number, lf:number, nonUtf8:Array, mixed:Array}}
|
|
48
|
+
*/
|
|
49
|
+
export function scanAbsoluteRefs(root, relPaths) {
|
|
50
|
+
const list = Array.isArray(relPaths) && relPaths.length
|
|
51
|
+
? relPaths.map((rel) => ({ rel, abs: path.join(root, rel.split('/').join(path.sep)) }))
|
|
52
|
+
: walk(root, '', []);
|
|
53
|
+
const res = { files: list.length, scanned: 0, skipped: 0, hits: [], totalHits: 0, crlf: 0, lf: 0, nonUtf8: [], mixed: [] };
|
|
54
|
+
for (const it of list) {
|
|
55
|
+
const ext = path.extname(it.rel).toLowerCase();
|
|
56
|
+
if (SKIP_EXT.has(ext)) { res.skipped++; continue; }
|
|
57
|
+
let buf;
|
|
58
|
+
try {
|
|
59
|
+
const st = fs.statSync(it.abs);
|
|
60
|
+
if (!st.isFile() || st.size > MAX_BYTES) { res.skipped++; continue; }
|
|
61
|
+
buf = fs.readFileSync(it.abs);
|
|
62
|
+
} catch { res.skipped++; continue; }
|
|
63
|
+
const text = buf.toString('utf8');
|
|
64
|
+
res.scanned++;
|
|
65
|
+
if (text.indexOf(String.fromCharCode(0xFFFD)) !== -1) res.nonUtf8.push(it.rel);
|
|
66
|
+
const hasCrlf = text.indexOf(String.fromCharCode(13, 10)) !== -1;
|
|
67
|
+
const hasLfOnly = /\n/.test(text) && !hasCrlf;
|
|
68
|
+
if (hasCrlf) res.crlf++;
|
|
69
|
+
if (hasLfOnly) res.lf++;
|
|
70
|
+
const lines = text.split(String.fromCharCode(10));
|
|
71
|
+
const items = [];
|
|
72
|
+
for (let i = 0; i < lines.length && items.length < MAX_PER_FILE; i++) {
|
|
73
|
+
const line = lines[i];
|
|
74
|
+
for (const [kind, re] of PATTERNS) {
|
|
75
|
+
re.lastIndex = 0;
|
|
76
|
+
const m = re.exec(line);
|
|
77
|
+
if (!m) continue;
|
|
78
|
+
const sample = String(m[0]).trim().slice(0, 90);
|
|
79
|
+
if (!sample) continue;
|
|
80
|
+
if (items.some((x) => x.kind === kind && x.sample === sample)) continue;
|
|
81
|
+
items.push({ kind, sample, line: i + 1 });
|
|
82
|
+
if (items.length >= MAX_PER_FILE) break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (items.length) {
|
|
86
|
+
res.hits.push({ path: it.rel, items });
|
|
87
|
+
res.totalHits += items.length;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 渲染成给人看的中文结论。 */
|
|
94
|
+
export function renderAbsoluteRefs(res, opts) {
|
|
95
|
+
const o = opts || {};
|
|
96
|
+
const out = [];
|
|
97
|
+
const title = o.title || '绝对路径体检';
|
|
98
|
+
out.push(title + ':扫描 ' + res.scanned + ' 个文本文件(跳过二进制/超大 ' + res.skipped + ' 个)');
|
|
99
|
+
if (!res.totalHits) {
|
|
100
|
+
out.push(' 未发现写死的本机绝对路径');
|
|
101
|
+
} else {
|
|
102
|
+
out.push(' 发现 ' + res.hits.length + ' 个文件里有 ' + res.totalHits + ' 处绝对路径(**插件不改写这些内容**):');
|
|
103
|
+
for (const h of res.hits.slice(0, o.max || 10)) {
|
|
104
|
+
out.push(' ' + h.path);
|
|
105
|
+
for (const it of h.items) out.push(' L' + it.line + ' [' + it.kind + '] ' + it.sample);
|
|
106
|
+
}
|
|
107
|
+
if (res.hits.length > (o.max || 10)) out.push(' …还有 ' + (res.hits.length - (o.max || 10)) + ' 个文件(见 manifest)');
|
|
108
|
+
}
|
|
109
|
+
if (res.nonUtf8.length) out.push(' 非 UTF-8(疑似 GBK/其他编码)' + res.nonUtf8.length + ' 个:' + res.nonUtf8.slice(0, 5).join('、'));
|
|
110
|
+
if (res.crlf && res.lf) out.push(' 换行混用:CRLF ' + res.crlf + ' 个 / LF ' + res.lf + ' 个(跨系统请项目会话自行统一)');
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/src/lib/api.mjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页数据与动作的唯一实现:HTTP 路由(旧通道)和 /api 载体(新通道)都调它。
|
|
3
|
+
* 返回统一信封:{ ok:true, value } | { ok:false, error:{code,message,details}, http }。
|
|
4
|
+
* http 只是给旧 HTTP 通道用的状态码提示,载体通道不用它。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { isDir } from './text.mjs';
|
|
9
|
+
import { buildStatus } from './status.mjs';
|
|
10
|
+
import { createGlobalWorkspace } from './scaffold.mjs';
|
|
11
|
+
import { probeMove, startMove, moveStatus, cleanupOld, stageSwitch, repairLink, stageRetarget, applyRetarget } from './mover.mjs';
|
|
12
|
+
import { routeWorkspace } from './workspace.mjs';
|
|
13
|
+
import { grantConsent, listConsent, consumedConsent } from './consent.mjs';
|
|
14
|
+
import { newSessionIn } from './session-create.mjs';
|
|
15
|
+
|
|
16
|
+
function msgOf(e) { return String(e && e.message ? e.message : e); }
|
|
17
|
+
|
|
18
|
+
/** 当前全局根:先按 cwd 推断,推不出来再查注册表里标题含「全局」的那个。 */
|
|
19
|
+
export function currentRoot(env) {
|
|
20
|
+
let ws = '';
|
|
21
|
+
try { ws = String(routeWorkspace(env) || ''); } catch { ws = ''; }
|
|
22
|
+
if (ws && isDir(path.join(ws, '_Control_Center'))) return ws;
|
|
23
|
+
try {
|
|
24
|
+
const reg = env.registry && env.registry();
|
|
25
|
+
const list = reg && typeof reg.list === 'function' ? reg.list() || [] : [];
|
|
26
|
+
for (const w of list) {
|
|
27
|
+
const p = String((w && w.path) || '');
|
|
28
|
+
if (!p) continue;
|
|
29
|
+
if (String((w && w.title) || '').indexOf('全局') === -1) continue;
|
|
30
|
+
if (isDir(path.join(p, '_Control_Center'))) return p;
|
|
31
|
+
}
|
|
32
|
+
} catch { /* ignore */ }
|
|
33
|
+
return ws || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 状态快照缓存:设置页只取现成结果,不让请求等待现算。 */
|
|
37
|
+
const statusCache = new WeakMap(); // env → { at, json, ms }
|
|
38
|
+
const STATUS_TTL_MS = 30000;
|
|
39
|
+
|
|
40
|
+
function invalidateStatus(env) { try { statusCache.delete(env); } catch { /* ignore */ } }
|
|
41
|
+
|
|
42
|
+
export function statusSnapshot(env, info, why) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const hit = statusCache.get(env);
|
|
45
|
+
if (hit && now - hit.at < STATUS_TTL_MS) {
|
|
46
|
+
env.log('状态快照:命中缓存(' + (why || 'call') + ',' + hit.ms + ' ms 前计算)');
|
|
47
|
+
return hit.json;
|
|
48
|
+
}
|
|
49
|
+
const t0 = Date.now();
|
|
50
|
+
const json = buildStatus(env, info);
|
|
51
|
+
const ms = Date.now() - t0;
|
|
52
|
+
statusCache.set(env, { at: Date.now(), json, ms });
|
|
53
|
+
env.log('状态快照:现算 ' + ms + ' ms(' + (why || 'call') + ')');
|
|
54
|
+
return json;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 允许在设置页与聊天命令里填短号:session-xxxx… 前 8 位即可,这里补全成完整 id。 */
|
|
58
|
+
export function resolveShortSid(env, input) {
|
|
59
|
+
const s = String(input || '').trim();
|
|
60
|
+
if (!s) return '';
|
|
61
|
+
if (s.indexOf('session-') === 0) return s;
|
|
62
|
+
try {
|
|
63
|
+
const dir = typeof env.cacheDir === 'function' ? env.cacheDir() : '';
|
|
64
|
+
if (dir) {
|
|
65
|
+
for (const name of fs.readdirSync(dir)) {
|
|
66
|
+
if (!name.endsWith('.json')) continue;
|
|
67
|
+
const stem = name.replace('.json', '');
|
|
68
|
+
if (stem.indexOf('session-' + s) === 0) return stem;
|
|
69
|
+
if (stem.slice(8, 8 + s.length) === s) return stem;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch { /* 找不到就按原样 */ }
|
|
73
|
+
return s;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 在已注册的工作区里建一个「全局」会话:先按工作区 id,抓不到再用 cwd。 */
|
|
77
|
+
async function createGlobalSession(env, rootPath, title) {
|
|
78
|
+
let workspaceId = '';
|
|
79
|
+
try {
|
|
80
|
+
const reg = env.registry && env.registry();
|
|
81
|
+
const list = reg && typeof reg.list === 'function' ? (reg.list() || []) : [];
|
|
82
|
+
const norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
83
|
+
const hit = list.find((w) => w && norm(w.path) === norm(rootPath));
|
|
84
|
+
if (hit) workspaceId = String(hit.id || '');
|
|
85
|
+
} catch { workspaceId = ''; }
|
|
86
|
+
const made = await newSessionIn(env, {
|
|
87
|
+
workspaceId,
|
|
88
|
+
cwd: workspaceId ? '' : rootPath,
|
|
89
|
+
title: title || '全局',
|
|
90
|
+
role: 'global',
|
|
91
|
+
});
|
|
92
|
+
return { sessionId: made.sessionId || '', error: made.error || '', workspaceId };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createApi(env, info) {
|
|
96
|
+
const ok = (value) => ({ ok: true, value });
|
|
97
|
+
const bad = (code, message, http) => ({ ok: false, error: { code, message, details: {} }, http: http || 400 });
|
|
98
|
+
const boom = (e, http) => bad('failed', msgOf(e), http || 400);
|
|
99
|
+
|
|
100
|
+
async function call(method, payload) {
|
|
101
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
102
|
+
try {
|
|
103
|
+
if (method === 'status') {
|
|
104
|
+
env.lastStatusCallAt = Date.now(); // 供预热判断「最近有人看过设置页」
|
|
105
|
+
return ok(statusSnapshot(env, info, 'call'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (method === 'setup') {
|
|
109
|
+
const r = createGlobalWorkspace(p.parent, { name: p.name });
|
|
110
|
+
invalidateStatus(env);
|
|
111
|
+
return r && r.ok ? ok(r) : { ok: false, error: { code: 'setup-failed', message: String((r && r.error) || '建立失败'), details: {} }, http: 400 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (method === 'deploy') {
|
|
115
|
+
// 一键部署:建文件体系 → 注册「全局」工作区 → 建「全局」会话(三件事一次做完)。
|
|
116
|
+
const scaffold = createGlobalWorkspace(p.parent, { name: p.name });
|
|
117
|
+
if (!scaffold || scaffold.ok !== true) {
|
|
118
|
+
return { ok: false, error: { code: 'setup-failed', message: String((scaffold && scaffold.error) || '建立失败'), details: {} }, http: 400 };
|
|
119
|
+
}
|
|
120
|
+
let ws = null;
|
|
121
|
+
let workspaceError = '';
|
|
122
|
+
try {
|
|
123
|
+
const reg = env.registry && env.registry();
|
|
124
|
+
if (reg && typeof reg.create === 'function') ws = await reg.create(scaffold.root, '全局');
|
|
125
|
+
else workspaceError = 'workspaceRegistry 不可用';
|
|
126
|
+
} catch (e) { workspaceError = '注册工作区失败:' + msgOf(e); }
|
|
127
|
+
const made = await createGlobalSession(env, scaffold.root, '全局');
|
|
128
|
+
invalidateStatus(env);
|
|
129
|
+
env.log('一键部署:体系 ' + scaffold.root + ';工作区 ' + (ws ? String(ws.id) : '失败') + ';会话 ' + (made.sessionId || '失败'));
|
|
130
|
+
return ok({
|
|
131
|
+
root: scaffold.root,
|
|
132
|
+
created: scaffold.created || [],
|
|
133
|
+
written: scaffold.written || [],
|
|
134
|
+
workspace: ws ? { id: String(ws.id || ''), title: String(ws.title || '全局'), path: String(ws.path || scaffold.root) } : null,
|
|
135
|
+
workspaceError,
|
|
136
|
+
sessionId: made.sessionId || '',
|
|
137
|
+
sessionError: made.error || '',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (method === 'global-session') {
|
|
142
|
+
// 已接入工作区但没有全局会话时,补建一个(不建体系、不动既有文件)。
|
|
143
|
+
const root = currentRoot(env);
|
|
144
|
+
if (!root) return bad('no-workspace', '未找到全局根(工作区未接入)');
|
|
145
|
+
const made = await createGlobalSession(env, root, p.title || '全局');
|
|
146
|
+
invalidateStatus(env);
|
|
147
|
+
if (!made.sessionId) return bad('session-failed', made.error || '建会话失败');
|
|
148
|
+
env.log('补建全局会话:' + made.sessionId);
|
|
149
|
+
return ok({ root, sessionId: made.sessionId });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (method === 'register') {
|
|
153
|
+
const target = String(p.path || '').trim();
|
|
154
|
+
if (!target) return bad('bad-request', '缺少 path(已存在的绝对目录)');
|
|
155
|
+
let st = null;
|
|
156
|
+
try { st = fs.statSync(target); } catch { st = null; }
|
|
157
|
+
if (!st || !st.isDirectory()) return bad('bad-request', '目录不存在或不是目录:' + target);
|
|
158
|
+
const reg = env.registry && env.registry();
|
|
159
|
+
if (!reg || typeof reg.create !== 'function') {
|
|
160
|
+
return { ok: false, error: { code: 'no-registry', message: 'workspaceRegistry 不可用', details: { hint: '请手工把这个路径粘进 DSH 的新建工作区:' + target } }, http: 501 };
|
|
161
|
+
}
|
|
162
|
+
const title = String(p.title || '').trim() || target.split(/[\\/]/).filter(Boolean).pop() || target;
|
|
163
|
+
try {
|
|
164
|
+
const w = await reg.create(target, title);
|
|
165
|
+
invalidateStatus(env);
|
|
166
|
+
return ok({
|
|
167
|
+
id: String((w && w.id) || ''),
|
|
168
|
+
title: String((w && w.title) || title),
|
|
169
|
+
path: String((w && w.path) || target),
|
|
170
|
+
});
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return { ok: false, error: { code: 'register-failed', message: '建工作区失败:' + msgOf(e), details: {} }, http: 500 };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (method === 'extra-prompt') {
|
|
177
|
+
if (typeof p.text !== 'string') return bad('bad-request', '缺少 text(空字符串=不注入)');
|
|
178
|
+
// 写入门禁:内容与开关都没变就不写。提示词一改,下一次请求的整段前缀缓存失效。
|
|
179
|
+
let before = '';
|
|
180
|
+
let beforeEnabled = false;
|
|
181
|
+
try { before = String(env.extraPrompt() || ''); } catch { before = ''; }
|
|
182
|
+
try { beforeEnabled = env.extraPromptEnabled() === true; } catch { beforeEnabled = false; }
|
|
183
|
+
const nextEnabled = typeof p.enabled === 'boolean' ? p.enabled : (String(p.text).trim() !== '');
|
|
184
|
+
if (before === p.text && beforeEnabled === nextEnabled) {
|
|
185
|
+
return ok({ unchanged: true, chars: p.text.length, enabled: beforeEnabled, note: '内容与开关均未变化,已跳过写入(避免无谓的缓存失效)。' });
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const r = env.setExtraPrompt(p.text, nextEnabled);
|
|
189
|
+
invalidateStatus(env);
|
|
190
|
+
env.log('全局前置提示词:' + (r.enabled ? '已开启' : '已关闭') + ',' + r.chars + ' 字符(下一次请求前缀缓存失效)');
|
|
191
|
+
return ok(Object.assign({}, r, {
|
|
192
|
+
changed: true,
|
|
193
|
+
note: '已保存。提示词位于段序 950,改动会让下一次请求的整段前缀缓存失效,建议在压缩或换任的边界处修改。',
|
|
194
|
+
}));
|
|
195
|
+
} catch (e) { return boom(e); }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (method === 'role') {
|
|
199
|
+
if (typeof env.applyRole !== 'function') return { ok: false, error: { code: 'not-ready', message: '宿主侧没有装配 applyRole', details: {} }, http: 501 };
|
|
200
|
+
const r = await env.applyRole({
|
|
201
|
+
sessionId: p.session_id || p.sessionId,
|
|
202
|
+
role: p.role,
|
|
203
|
+
project: p.project,
|
|
204
|
+
task: p.task,
|
|
205
|
+
wake: p.wake !== false,
|
|
206
|
+
});
|
|
207
|
+
env.log('会话角色:' + (r.ok ? (r.role + ' · 存活=' + r.live + ' · 已投递=' + r.delivered) : ('失败 ' + r.error)));
|
|
208
|
+
invalidateStatus(env);
|
|
209
|
+
return r.ok ? ok(r) : bad('bad-request', String(r.error || '设定失败'));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (method === 'move.probe') {
|
|
213
|
+
const src = currentRoot(env);
|
|
214
|
+
if (!src) return bad('no-workspace', '未找到全局根(工作区未接入)');
|
|
215
|
+
return ok(await probeMove({ src, to: p.to }));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (method === 'move.start') {
|
|
219
|
+
const src = currentRoot(env);
|
|
220
|
+
if (!src) return bad('no-workspace', '未找到全局根(工作区未接入)');
|
|
221
|
+
env.log('搬迁:开始复制 ' + src + ' → ' + String(p.to || ''));
|
|
222
|
+
const startedAt = Date.now();
|
|
223
|
+
const r = await startMove({ src, to: p.to });
|
|
224
|
+
env.log('搬迁:' + (r.ok ? '校验通过' : '校验有差异') + ',用时 ' + Math.round((Date.now() - startedAt) / 1000) + ' s');
|
|
225
|
+
return r.ok ? ok(r) : { ok: false, error: { code: 'verify-failed', message: '复制完成但校验有差异,未生成切换脚本', details: r.report }, http: 500 };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (method === 'move.switch') {
|
|
229
|
+
const r = stageSwitch({ relaunch: p.relaunch === true });
|
|
230
|
+
env.log('搬迁:后台切换助手 ' + (r.already ? '无需再派(已切换)' : '已派出 pid=' + r.pid));
|
|
231
|
+
return ok(r);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (method === 'move.status') {
|
|
235
|
+
// 一律以搬迁标记里的源路径为准:修改登记生效后当前工作区根已经变成新位置,
|
|
236
|
+
// 再拿它去比对会把「已经是新位置」误报成「还指着旧路径」。
|
|
237
|
+
return ok(moveStatus());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (method === 'consent.grant') {
|
|
241
|
+
const sid = resolveShortSid(env, p.session_id);
|
|
242
|
+
if (!sid) return bad('bad-request', '缺少 session_id');
|
|
243
|
+
const g = grantConsent(sid, '设置页点击');
|
|
244
|
+
env.log('压缩授权:' + sid + '(5 分钟)');
|
|
245
|
+
invalidateStatus(env);
|
|
246
|
+
return ok(g);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (method === 'consent.list') return ok({ rows: listConsent() });
|
|
250
|
+
|
|
251
|
+
if (method === 'consent.revoke') {
|
|
252
|
+
const sid = String(p.session_id || '').trim();
|
|
253
|
+
if (sid) consumedConsent(sid);
|
|
254
|
+
invalidateStatus(env);
|
|
255
|
+
return ok({ revoked: sid });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (method === 'move.retarget') {
|
|
259
|
+
// 不是「等退出再改」时的即时执行:只在 DSH 已退出后由助手调用;页面走 stageRetarget。
|
|
260
|
+
const plan = applyRetarget();
|
|
261
|
+
invalidateStatus(env);
|
|
262
|
+
return ok(plan);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (method === 'move.retarget-stage') {
|
|
266
|
+
const r = stageRetarget();
|
|
267
|
+
env.log('搬迁:登记修改登记助手 ' + (r.already ? '无需(已在新位置)' : '已派出 pid=' + r.pid));
|
|
268
|
+
return ok(r);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (method === 'move.repair') {
|
|
272
|
+
const r = repairLink();
|
|
273
|
+
env.log('搬迁:重建链接 ' + (r.already ? '已存在' : r.src + ' → ' + r.dst));
|
|
274
|
+
invalidateStatus(env);
|
|
275
|
+
return ok(r);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (method === 'move.cleanup') {
|
|
279
|
+
const r = await cleanupOld({ confirm: p.confirm === true });
|
|
280
|
+
env.log('搬迁:清理旧副本 ' + (r.removed || []).length + ' 个');
|
|
281
|
+
return ok(r);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return bad('unknown-method', '未知方法:' + method, 404);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
return boom(e);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { call, methods: ['status', 'setup', 'deploy', 'global-session', 'register', 'extra-prompt', 'role', 'move.probe', 'move.start', 'move.switch', 'move.status', 'move.retarget', 'move.retarget-stage', 'move.repair', 'move.cleanup', 'consent.grant', 'consent.list', 'consent.revoke'] };
|
|
291
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自带归档器:不依赖 7-Zip 的 tar(.gz) 读写(纯 Node,node:zlib 压缩)。
|
|
3
|
+
* 目的:插件所有功能都必须能用「构建产物 + DSH 自带环境」跑起来;7-Zip 只是可选加速器,不是前提。
|
|
4
|
+
* 格式口径:ustar + GNU 长名(typeflag L);只存普通文件与目录;符号链接、硬链接一律不读不写。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import zlib from 'node:zlib';
|
|
9
|
+
|
|
10
|
+
const BLOCK = 512;
|
|
11
|
+
const MAX_TOTAL = 512 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
function octal(value, len) {
|
|
14
|
+
const s = Math.max(0, Math.floor(value)).toString(8);
|
|
15
|
+
if (s.length > len - 1) throw new Error('归档字段溢出:' + s);
|
|
16
|
+
return s.padStart(len - 1, '0') + String.fromCharCode(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function tarHeader(nameBytes, opts) {
|
|
20
|
+
const o = opts || {};
|
|
21
|
+
const h = Buffer.alloc(BLOCK, 0);
|
|
22
|
+
nameBytes.copy(h, 0, 0, Math.min(nameBytes.length, 100));
|
|
23
|
+
h.write(octal(o.mode === undefined ? 420 : o.mode, 8), 100, 'ascii');
|
|
24
|
+
h.write(octal(0, 8), 108, 'ascii');
|
|
25
|
+
h.write(octal(0, 8), 116, 'ascii');
|
|
26
|
+
h.write(octal(o.size || 0, 12), 124, 'ascii');
|
|
27
|
+
h.write(octal(o.mtime || 0, 12), 136, 'ascii');
|
|
28
|
+
h.write(' ', 148, 'ascii');
|
|
29
|
+
h.write(o.type || '0', 156, 'ascii');
|
|
30
|
+
h.write('ustar' + String.fromCharCode(0), 257, 'ascii');
|
|
31
|
+
h.write('00', 263, 'ascii');
|
|
32
|
+
let sum = 0;
|
|
33
|
+
for (const b of h) sum += b;
|
|
34
|
+
const cs = sum.toString(8).padStart(6, '0');
|
|
35
|
+
h.write(cs + String.fromCharCode(0) + ' ', 148, 'ascii');
|
|
36
|
+
return h;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function padToBlock(buf) {
|
|
40
|
+
const rem = buf.length % BLOCK;
|
|
41
|
+
return rem === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK - rem, 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function walkEntries(root) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const visit = (rel) => {
|
|
47
|
+
const abs = rel ? path.join(root, rel.split('/').join(path.sep)) : root;
|
|
48
|
+
let ents = [];
|
|
49
|
+
try { ents = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
50
|
+
for (const e of ents) {
|
|
51
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
52
|
+
const a = path.join(root, r.split('/').join(path.sep));
|
|
53
|
+
if (e.isDirectory()) {
|
|
54
|
+
out.push({ rel: r + '/', abs: a, dir: true, mtime: 0 });
|
|
55
|
+
visit(r);
|
|
56
|
+
} else if (e.isFile()) {
|
|
57
|
+
let st = null;
|
|
58
|
+
try { st = fs.statSync(a); } catch { continue; }
|
|
59
|
+
out.push({ rel: r, abs: a, dir: false, size: st.size, mtime: st.mtimeMs });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
visit('');
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 把目录打成 tar.gz。返回 { files, entries, bytes, archive }。 */
|
|
68
|
+
export function packDirTarGz(srcDir, outFile) {
|
|
69
|
+
const entries = walkEntries(srcDir);
|
|
70
|
+
const total = entries.reduce((s, e) => s + (e.size || 0), 0);
|
|
71
|
+
if (total > MAX_TOTAL) throw new Error('待归档总量 ' + total + ' B 超过上限 ' + MAX_TOTAL + ' B,拒绝打包');
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let files = 0;
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
const nameBuf = Buffer.from(e.rel, 'utf8');
|
|
76
|
+
const mtime = Math.floor((e.mtime || Date.now()) / 1000);
|
|
77
|
+
if (e.rel.length > 100) {
|
|
78
|
+
const payloadName = Buffer.concat([Buffer.from(e.rel, 'utf8'), Buffer.alloc(1, 0)]);
|
|
79
|
+
chunks.push(
|
|
80
|
+
tarHeader(Buffer.from('././@LongLink', 'utf8'), { size: payloadName.length, mtime, type: 'L' }),
|
|
81
|
+
payloadName,
|
|
82
|
+
padToBlock(payloadName),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (e.dir) {
|
|
86
|
+
chunks.push(tarHeader(nameBuf, { type: '5', mode: 493, mtime }));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const body = fs.readFileSync(e.abs);
|
|
90
|
+
chunks.push(tarHeader(nameBuf, { size: body.length, mtime }), body, padToBlock(body));
|
|
91
|
+
files++;
|
|
92
|
+
}
|
|
93
|
+
chunks.push(Buffer.alloc(BLOCK * 2, 0));
|
|
94
|
+
const tar = Buffer.concat(chunks);
|
|
95
|
+
fs.writeFileSync(outFile, zlib.gzipSync(tar, { level: 6 }));
|
|
96
|
+
return { files, entries: entries.length, bytes: tar.length, archive: outFile };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 归档格式:'7z' | 'targz' | 'unknown'(按魔数判断,不看扩展名)。 */
|
|
100
|
+
export function detectFormat(file) {
|
|
101
|
+
let fd = null;
|
|
102
|
+
try {
|
|
103
|
+
fd = fs.openSync(file, 'r');
|
|
104
|
+
const head = Buffer.alloc(8, 0);
|
|
105
|
+
fs.readSync(fd, head, 0, 8, 0);
|
|
106
|
+
if (head[0] === 0x37 && head[1] === 0x7a && head[2] === 0xbc && head[3] === 0xaf) return '7z';
|
|
107
|
+
if (head[0] === 0x1f && head[1] === 0x8b) return 'targz';
|
|
108
|
+
return 'unknown';
|
|
109
|
+
} catch {
|
|
110
|
+
return 'unknown';
|
|
111
|
+
} finally {
|
|
112
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* ignore */ } }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 把归档里的相对路径规范化;不安全(绝对路径、盘符、..)返回 null。 */
|
|
117
|
+
export function safeRel(name) {
|
|
118
|
+
const raw = String(name || '').replace(/\\/g, '/');
|
|
119
|
+
if (raw === '') return null;
|
|
120
|
+
if (raw.charAt(0) === '/') return null;
|
|
121
|
+
if (/^[a-zA-Z]:/.test(raw)) return null;
|
|
122
|
+
const segs = raw.split('/').filter((s) => s !== '' && s !== '.');
|
|
123
|
+
if (!segs.length) return null;
|
|
124
|
+
for (const s of segs) if (s === '..') return null;
|
|
125
|
+
return segs.join('/');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readOctal(buf, off, len) {
|
|
129
|
+
let s = '';
|
|
130
|
+
for (let i = off; i < off + len; i++) {
|
|
131
|
+
const c = buf[i];
|
|
132
|
+
if (c === 0) break;
|
|
133
|
+
s += String.fromCharCode(c);
|
|
134
|
+
}
|
|
135
|
+
s = s.trim();
|
|
136
|
+
return s === '' ? 0 : parseInt(s, 8) || 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseTar(buf, onEntry) {
|
|
140
|
+
let off = 0;
|
|
141
|
+
let longName = null;
|
|
142
|
+
while (off + BLOCK <= buf.length) {
|
|
143
|
+
const header = buf.subarray(off, off + BLOCK);
|
|
144
|
+
let allZero = true;
|
|
145
|
+
for (const b of header) if (b !== 0) { allZero = false; break; }
|
|
146
|
+
if (allZero) break;
|
|
147
|
+
const size = readOctal(header, 124, 12);
|
|
148
|
+
const typeByte = header[156] === 0 ? 48 : header[156];
|
|
149
|
+
const type = String.fromCharCode(typeByte);
|
|
150
|
+
let nameEnd = header.indexOf(0, 0);
|
|
151
|
+
if (nameEnd === -1 || nameEnd > 100) nameEnd = 100;
|
|
152
|
+
let name = header.toString('utf8', 0, nameEnd);
|
|
153
|
+
const bodyStart = off + BLOCK;
|
|
154
|
+
const body = buf.subarray(bodyStart, Math.min(bodyStart + size, buf.length));
|
|
155
|
+
const pad = (BLOCK - (size % BLOCK)) % BLOCK;
|
|
156
|
+
off = bodyStart + size + pad;
|
|
157
|
+
if (type === 'L') { longName = body.toString('utf8').replace(/\u0000+$/, ''); continue; }
|
|
158
|
+
if (longName) { name = longName; longName = null; }
|
|
159
|
+
onEntry({ name, type, size, body });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 列出 tar.gz 内的条目:[{ rel, size, type }](rel 不安全的条目也照实列出,标 safe:false)。 */
|
|
164
|
+
export function listTarGz(file) {
|
|
165
|
+
const buf = zlib.gunzipSync(fs.readFileSync(file));
|
|
166
|
+
const out = [];
|
|
167
|
+
parseTar(buf, (e) => {
|
|
168
|
+
const rel = safeRel(e.name);
|
|
169
|
+
out.push({ rel: rel === null ? e.name : rel, size: e.size, type: e.type, safe: rel !== null });
|
|
170
|
+
});
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 解 tar.gz 到 destDir;跳过不安全路径与链接类条目。返回 { written, dirs, skipped }。 */
|
|
175
|
+
export function extractTarGz(file, destDir) {
|
|
176
|
+
const buf = zlib.gunzipSync(fs.readFileSync(file));
|
|
177
|
+
const root = path.resolve(destDir);
|
|
178
|
+
let written = 0;
|
|
179
|
+
let dirs = 0;
|
|
180
|
+
const skipped = [];
|
|
181
|
+
parseTar(buf, (e) => {
|
|
182
|
+
const rel = safeRel(e.name);
|
|
183
|
+
if (rel === null) { skipped.push('不安全路径:' + e.name); return; }
|
|
184
|
+
const dest = path.resolve(root, rel.split('/').join(path.sep));
|
|
185
|
+
if (dest !== root && !dest.startsWith(root + path.sep)) { skipped.push('越界:' + rel); return; }
|
|
186
|
+
if (e.type === '5') {
|
|
187
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
188
|
+
dirs++;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (e.type === '1' || e.type === '2') { skipped.push('链接条目不解:' + rel); return; }
|
|
192
|
+
if (e.type !== '0' && e.type !== '') { skipped.push('未知条目类型 ' + e.type + ':' + rel); return; }
|
|
193
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
194
|
+
fs.writeFileSync(dest, e.body);
|
|
195
|
+
written++;
|
|
196
|
+
});
|
|
197
|
+
return { written, dirs, skipped };
|
|
198
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** 起始简报:水位、闸门、活跃与沉默、预警、阶段与提示,一行一字段。 */
|
|
2
|
+
import { driftVerdict } from './drift.mjs';
|
|
3
|
+
|
|
4
|
+
function pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
|
5
|
+
|
|
6
|
+
export function stamp(ms) {
|
|
7
|
+
const d = new Date(ms);
|
|
8
|
+
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 带时区偏移的时间戳(硬规定:对用户可见的时间一律带偏移,例 2026-10-23 21:40 +0800)。 */
|
|
12
|
+
export function stampTz(ms) {
|
|
13
|
+
const d = new Date(ms);
|
|
14
|
+
const off = -d.getTimezoneOffset();
|
|
15
|
+
const sign = off >= 0 ? "+" : "-";
|
|
16
|
+
const a = Math.abs(off);
|
|
17
|
+
return stamp(ms) + " " + sign + pad2(Math.floor(a / 60)) + pad2(a % 60);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 会话短号:去掉 session- 前缀后取末 8 位。 */
|
|
21
|
+
function sidShort(sid) {
|
|
22
|
+
const s = sid.indexOf('session-') === 0 ? sid.slice(8) : sid;
|
|
23
|
+
return s.slice(0, 8);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function thousands(n) {
|
|
27
|
+
const s = String(n);
|
|
28
|
+
let out = "";
|
|
29
|
+
for (let i = 0; i < s.length; i++) {
|
|
30
|
+
if (i > 0 && (s.length - i) % 3 === 0) out += ",";
|
|
31
|
+
out += s[i];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function composeBrief(input) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const cfg = input.cfg || {};
|
|
39
|
+
out.push('== 起始简报 ' + stamp(Date.now()) + ' ==');
|
|
40
|
+
if (input.selfUsed === null || input.selfWin === null) {
|
|
41
|
+
out.push('水位 无法读取,需人工判断');
|
|
42
|
+
} else {
|
|
43
|
+
const pct = (input.selfUsed / input.selfWin) * 100;
|
|
44
|
+
const warn = cfg.warnRatio === undefined ? 0.32 : cfg.warnRatio;
|
|
45
|
+
out.push("水位 " + pct.toFixed(1) + "%(" + thousands(input.selfUsed) + " / " + thousands(input.selfWin) + ")→ " + (pct >= warn * 100 ? "已过阈值,该写交接" : "可继续"));
|
|
46
|
+
}
|
|
47
|
+
const gate = input.gate;
|
|
48
|
+
if (!gate || gate.error) {
|
|
49
|
+
out.push('闸门 ' + (gate && gate.error ? gate.error : '读不到') + ' · 退出码 ? · ?');
|
|
50
|
+
if (gate && gate.noWorkspace) out.push(' 首次安装?把工作区接入即可:ho_workspace(action="create", path="<绝对目录>", title="全局"),再开一个标题含「全局」的会话。');
|
|
51
|
+
} else {
|
|
52
|
+
out.push("闸门 " + gate.total + " 库 · 正文 " + gate.bodyLines + " 行 · 退出码 " + (gate.ok ? 0 : 10) + " · 判定:" + gate.verdict);
|
|
53
|
+
}
|
|
54
|
+
const scan = input.scan;
|
|
55
|
+
if (!scan) {
|
|
56
|
+
out.push('活跃 读不到(投影缓存目录不可用)');
|
|
57
|
+
} else {
|
|
58
|
+
const notifProjects = new Set(scan.notify.map((r) => r.project));
|
|
59
|
+
const skipProjects = new Set(scan.skip.map((r) => r.project));
|
|
60
|
+
out.push("活跃 " + notifProjects.size + " 个项目,需要通知 " + scan.notify.length + " 个会话:" + (scan.notify.length ? scan.notify.map((r) => r.project + "(" + sidShort(r.sid) + ",在任)").join("、") : "无"));
|
|
61
|
+
out.push("沉默 " + skipProjects.size + " 个项目,跳过 " + scan.skip.length + " 个会话(不唤醒,省 token)");
|
|
62
|
+
if (scan.hot.length) out.push("预警 " + scan.hot.length + " 个会话已进预警区(≥" + Math.round(scan.warn) + "%)—— 全为退场、前任则可忽略");
|
|
63
|
+
}
|
|
64
|
+
if (input.drift) {
|
|
65
|
+
out.push('Δ交接 ' + driftVerdict(input.drift));
|
|
66
|
+
const stale = input.drift.rows.filter((r) => r.verdict === '需刷交接');
|
|
67
|
+
if (stale.length) out.push(' 需刷:' + stale.map((r) => r.project + '(' + sidShort(r.sid) + ')').join('、'));
|
|
68
|
+
} else {
|
|
69
|
+
out.push('Δ交接 读不到(会话日志不可用)');
|
|
70
|
+
}
|
|
71
|
+
if (input.advice && input.advice.text) out.push('阶段 ' + input.advice.text);
|
|
72
|
+
out.push('提示 ' + (input.ok ? '按「活跃」名单逐个 peer_send 广播本会话 id 并记回 §3;可正常开始作业' : '本会话已过阈值或闸门异常,应先收齐交接,再开展新任务'));
|
|
73
|
+
return out;
|
|
74
|
+
}
|