@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,28 @@
|
|
|
1
|
+
/** Charter(规则正文)访问:只读,不改。 */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { NL } from './text.mjs';
|
|
4
|
+
|
|
5
|
+
const FENCE = String.fromCharCode(96, 96, 96);
|
|
6
|
+
export const TITLE_KEY = "运行期省 token(通用约束";
|
|
7
|
+
export const WATER_KEY = "上下文水位自查";
|
|
8
|
+
|
|
9
|
+
export function charterPath(ws) {
|
|
10
|
+
return path.join(ws, '_Control_Center', 'rules', 'Workspace_Charter.md');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 从 §8.1.1.4 的 markdown 代码块现取「通用约束条」正文:
|
|
15
|
+
* 返回 { title, body },无法获取返回 null。改正文不用改代码。
|
|
16
|
+
*/
|
|
17
|
+
export function canonicalBlock(text) {
|
|
18
|
+
const i = text.indexOf(FENCE + 'markdown');
|
|
19
|
+
if (i === -1) return null;
|
|
20
|
+
const rest = text.slice(i + FENCE.length + 8);
|
|
21
|
+
const j = rest.indexOf(FENCE);
|
|
22
|
+
if (j === -1) return null;
|
|
23
|
+
const lines = rest.slice(0, j).split(NL).map((l) => l.trim()).filter((l) => l !== '');
|
|
24
|
+
let idx = -1;
|
|
25
|
+
for (let k = 0; k < lines.length; k++) if (lines[k].indexOf('运行期按') !== -1) { idx = k; break; }
|
|
26
|
+
if (idx === -1) return null;
|
|
27
|
+
return { title: lines[idx], body: lines.slice(idx) };
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** dsh-handover 配置:全部可被 profile patch 的 config 覆盖。 */
|
|
2
|
+
export const DEFAULTS = {
|
|
3
|
+
workspaceRoot: '', // 留空则从会话 cwd 推导(向上找 _Control_Center 目录)
|
|
4
|
+
warnRatio: 0.32,
|
|
5
|
+
handoverRatio: 0.35,
|
|
6
|
+
hardRatio: 0.85,
|
|
7
|
+
maxCompactions: 3,
|
|
8
|
+
autoResume: true,
|
|
9
|
+
sessionScanHours: 12,
|
|
10
|
+
handoverPath: '', // 留空 = 按会话角色解析(全局 records/Global_Handover.md;项目 _Project_Meta/<项目>/Handover.md;杂项 _Misc/tasks/<任务>/Handover.md)。非空则强制该路径(演练用)
|
|
11
|
+
rolesPath: '', // 留空 = ~/.dsh/storages/dsh-handover.roles.json
|
|
12
|
+
// 续跑提示:{n} {max} {handover} {role} 会被替换成真值;{handover} 是本会话自己的交接绝对路径。
|
|
13
|
+
// 只在助手登记压缩时选 resume=continue(默认)才投递;resume=wait 表示已收尾,不唤醒。
|
|
14
|
+
resumePrompt: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。第一步:ho_handover(action=read) 取回本会话的交接({handover});然后按 §2「下一步」继续(若 §2 已无待办,向用户一句话收尾并停止)。不要复述交接内容,不要重做已完成的事。完整流程:ho_charter(doc="runtime")。',
|
|
15
|
+
resumePromptUnknown: '【dsh-handover 自动续跑·第 {n}/{max} 次压缩】压缩已完成,历史已被摘要替换。本会话是普通会话,不参与交接;如需继续,请由全局会话为其指定角色。',
|
|
16
|
+
compactTimeoutMs: 120000,
|
|
17
|
+
compactAttempts: 3,
|
|
18
|
+
waitIdleMs: 180000, // 等会话空闲的上限(超过就放弃并回告)
|
|
19
|
+
driftSlack: 3, // Δ交接容忍轮数(超过即需刷交接)
|
|
20
|
+
wakeAuthority: 'on', // 会话守卫硬拦:on / off(off=连 peer_new 也不拦)
|
|
21
|
+
directPeer: 'on', // 会话之间直接 peer_send:on 允许(不经全局转述);off 退回「只能向全局报告」的旧口径
|
|
22
|
+
roleGating: 'on', // 按角色裁剪工具与提示段:on / off(off=所有会话都看得见全部工具)
|
|
23
|
+
reuseCheck: true, // 建新会话前查在任会话水位(低于 warnRatio 拒绝)
|
|
24
|
+
prepackMinAgeMin: 90, // 打包前要求会话静止分钟数
|
|
25
|
+
packFormat: 'auto', // 归档格式:auto(有 7-Zip 用 7z,否则内置 tar.gz)、7z、tar
|
|
26
|
+
sevenZipPath: '', // 留空则自动探测 C:\Program Files\7-Zip\7z.exe
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function resolveConfig(raw) {
|
|
30
|
+
const out = { ...DEFAULTS };
|
|
31
|
+
if (raw && typeof raw === "object") {
|
|
32
|
+
for (const k of Object.keys(DEFAULTS)) {
|
|
33
|
+
if (raw[k] !== undefined && raw[k] !== null) out[k] = raw[k];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 压缩授权:压缩会丢历史,所以「同意」必须由用户自己产生,AI 不能自己给自己批。
|
|
3
|
+
*
|
|
4
|
+
* 做法:唯一的授权入口是设置页上的一次点击(用户在场、只能是用户按),
|
|
5
|
+
* 授权一次有效 5 分钟、只对指定会话生效;`ho_checkpoint`、全局代排与调度器三处都校验它。
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const CONSENT_TTL_MS = 5 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
function consentPath() {
|
|
14
|
+
const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
|
15
|
+
return path.join(home, '.dsh', 'storages', 'dsh-handover.consent.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readAll() {
|
|
19
|
+
try {
|
|
20
|
+
const t = fs.readFileSync(consentPath(), 'utf8').replace(/^\uFEFF/, '');
|
|
21
|
+
const v = JSON.parse(t);
|
|
22
|
+
return v && typeof v === 'object' ? v : {};
|
|
23
|
+
} catch { return {}; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeAll(v) {
|
|
27
|
+
const p = consentPath();
|
|
28
|
+
try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch { /* ignore */ }
|
|
29
|
+
const tmp = p + '.tmp';
|
|
30
|
+
fs.writeFileSync(tmp, JSON.stringify(v, null, 2), 'utf8');
|
|
31
|
+
fs.renameSync(tmp, p);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stampNow() {
|
|
35
|
+
const d = new Date(Date.now() + 8 * 3600 * 1000);
|
|
36
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
37
|
+
return d.getUTCFullYear() + '-' + p(d.getUTCMonth() + 1) + '-' + p(d.getUTCDate()) + ' ' + p(d.getUTCHours()) + ':' + p(d.getUTCMinutes()) + ' +0800';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 用户点了一次「允许该会话压缩一次」。 */
|
|
41
|
+
export function grantConsent(sessionId, note) {
|
|
42
|
+
const sid = String(sessionId || '');
|
|
43
|
+
if (!sid) throw new Error('缺少会话 id');
|
|
44
|
+
const all = readAll();
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
all[sid] = { at: now, until: now + CONSENT_TTL_MS, atText: stampNow(), note: String(note || '') };
|
|
47
|
+
writeAll(all);
|
|
48
|
+
return { sessionId: sid, until: new Date(all[sid].until).toISOString(), minutes: CONSENT_TTL_MS / 60000, atText: all[sid].atText };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 授权是否有效(每次读取时一并清除已过期者)。 */
|
|
52
|
+
export function checkConsent(sessionId) {
|
|
53
|
+
const sid = String(sessionId || '');
|
|
54
|
+
if (!sid) return { ok: false, reason: '没有会话 id' };
|
|
55
|
+
const all = readAll();
|
|
56
|
+
const hit = all[sid];
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
let dirty = false;
|
|
59
|
+
for (const k of Object.keys(all)) {
|
|
60
|
+
if (!all[k] || !all[k].until || all[k].until <= now) { delete all[k]; dirty = true; }
|
|
61
|
+
}
|
|
62
|
+
if (dirty) { try { writeAll(all); } catch { /* ignore */ } }
|
|
63
|
+
if (!hit || !hit.until || hit.until <= now) {
|
|
64
|
+
return { ok: false, reason: '用户还没有在设置页点「允许压缩」' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, until: hit.until, leftMs: hit.until - now, atText: hit.atText || '' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function consumedConsent(sessionId) {
|
|
70
|
+
const all = readAll();
|
|
71
|
+
const sid = String(sessionId || '');
|
|
72
|
+
if (all[sid]) { delete all[sid]; try { writeAll(all); } catch { /* ignore */ } }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function listConsent() {
|
|
76
|
+
const all = readAll();
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
return Object.keys(all).map((sid) => ({
|
|
79
|
+
sessionId: sid,
|
|
80
|
+
leftMs: Math.max(0, (all[sid].until || 0) - now),
|
|
81
|
+
atText: all[sid].atText || '',
|
|
82
|
+
note: all[sid].note || '',
|
|
83
|
+
})).filter((r) => r.leftMs > 0);
|
|
84
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** 交接漂移(替代 handover_check.mjs):Δ = 会话当前轮 − 交接写入轮;> slack 即需刷交接。 */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readJsonDir, pick } from './text.mjs';
|
|
5
|
+
import { projectOf, projCacheDir, isSubagent } from './sessions.mjs';
|
|
6
|
+
import { findLog, decodeLog, turnStarts } from './zstd-log.mjs';
|
|
7
|
+
|
|
8
|
+
export function computeDrift(opts) {
|
|
9
|
+
const ws = opts.ws;
|
|
10
|
+
const slack = opts.slack === undefined ? 3 : opts.slack;
|
|
11
|
+
const all = opts.all === true;
|
|
12
|
+
const cacheDir = opts.cacheDir || projCacheDir();
|
|
13
|
+
const rows = [];
|
|
14
|
+
const seen = new Map();
|
|
15
|
+
for (const f of readJsonDir(cacheDir)) {
|
|
16
|
+
const proj = projectOf(f.data).project;
|
|
17
|
+
if (!proj || proj === '-' || proj.indexOf('<') !== -1 || proj.indexOf('>') !== -1) continue;
|
|
18
|
+
if (!fs.existsSync(path.join(ws, 'Projects', proj))) continue;
|
|
19
|
+
const sid = f.name.replace('.json', '');
|
|
20
|
+
const turns = Number(pick(f.data, 'record', 'rows', 'sessionStats', 'val', 'turns') || 0);
|
|
21
|
+
rows.push({ sid, project: proj, turns, mtime: f.mtimeMs, kind: isSubagent(f.data) ? '子会话' : '平级' });
|
|
22
|
+
}
|
|
23
|
+
let picked;
|
|
24
|
+
if (all) picked = rows;
|
|
25
|
+
else {
|
|
26
|
+
for (const r of rows) {
|
|
27
|
+
const cur = seen.get(r.project);
|
|
28
|
+
if (!cur || r.mtime > cur.mtime) seen.set(r.project, r);
|
|
29
|
+
}
|
|
30
|
+
picked = Array.from(seen.values());
|
|
31
|
+
}
|
|
32
|
+
const out = [];
|
|
33
|
+
let fresh = 0, need = 0, unknown = 0;
|
|
34
|
+
for (const s of picked.slice().sort((a, b) => b.mtime - a.mtime)) {
|
|
35
|
+
const hand = path.join(ws, '_Project_Meta', s.project, 'Handover.md');
|
|
36
|
+
let hm = 0;
|
|
37
|
+
try { hm = fs.statSync(hand).mtimeMs; } catch { /* 没有交接 */ }
|
|
38
|
+
const log = findLog(s.sid, opts.logsRoot);
|
|
39
|
+
const recs = log ? decodeLog(log) : null;
|
|
40
|
+
let handoverTurn = null;
|
|
41
|
+
if (recs) {
|
|
42
|
+
for (const t of turnStarts(recs)) if (t.time <= hm) handoverTurn = t.turn;
|
|
43
|
+
if (handoverTurn === null) handoverTurn = 0;
|
|
44
|
+
}
|
|
45
|
+
let verdict = '', delta = null;
|
|
46
|
+
if (recs) {
|
|
47
|
+
delta = s.turns - (handoverTurn || 0);
|
|
48
|
+
verdict = delta <= slack ? '已最新' : '需刷交接';
|
|
49
|
+
} else {
|
|
50
|
+
delta = null;
|
|
51
|
+
verdict = hm && s.mtime <= hm ? '已最新(仅时间戳)' : '需人工确认';
|
|
52
|
+
}
|
|
53
|
+
if (verdict === '已最新' || verdict === '已最新(仅时间戳)') fresh++;
|
|
54
|
+
else if (verdict === '需刷交接') need++;
|
|
55
|
+
else unknown++;
|
|
56
|
+
out.push({ project: s.project, kind: s.kind, sid: s.sid, turns: s.turns, handoverTurn, delta, verdict });
|
|
57
|
+
}
|
|
58
|
+
return { rows: out, fresh, need, unknown, slack };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function driftVerdict(res) {
|
|
62
|
+
return '判定:' + res.fresh + ' 个已最新、' + res.need + ' 个需刷交接' +
|
|
63
|
+
(res.unknown ? '、' + res.unknown + ' 个需人工确认' : '') +
|
|
64
|
+
(res.need || res.unknown ? ';先让需刷的会话写交接,再搬' : ';可以直接搬');
|
|
65
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** 会话事件派生:已压缩次数与压缩锁状态。主数据源=会话日志(eventsOf 在本运行时常常为空),事件数组只作兜底。 */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { findLog, decodeLog } from './zstd-log.mjs';
|
|
5
|
+
import { projCacheDir } from './sessions.mjs';
|
|
6
|
+
|
|
7
|
+
/** 取本会话事件数组;无法获取返回 []。 */
|
|
8
|
+
export function eventsOf(exec) {
|
|
9
|
+
try {
|
|
10
|
+
const s = exec && exec.agent && exec.agent.session;
|
|
11
|
+
if (s && Array.isArray(s.events)) return s.events;
|
|
12
|
+
} catch { /* ignore */ }
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 返回 { used, lock }:
|
|
18
|
+
* used = compaction/summary 次数(= 已压缩次数)
|
|
19
|
+
* lock = 'clean' | 'open'(有未闭合的 compaction/start,且晚于最近一次 session/end-seed)
|
|
20
|
+
*/
|
|
21
|
+
export function countCompactions(events) {
|
|
22
|
+
let used = 0;
|
|
23
|
+
let pendingStart = null;
|
|
24
|
+
let lastEndSeed = -1;
|
|
25
|
+
for (let i = 0; i < events.length; i++) {
|
|
26
|
+
const e = events[i];
|
|
27
|
+
const t = e && e.type;
|
|
28
|
+
if (t === 'session/end-seed') lastEndSeed = i;
|
|
29
|
+
else if (t === 'compaction/start') pendingStart = i;
|
|
30
|
+
else if (t === 'compaction/summary') used++;
|
|
31
|
+
else if (t === 'compaction/end') pendingStart = null;
|
|
32
|
+
}
|
|
33
|
+
const open = pendingStart !== null && pendingStart > lastEndSeed;
|
|
34
|
+
return { used, lock: open ? 'open' : 'clean' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 按 (sid, 日志 size+mtime) 缓存,避免每轮解几 MB 日志。
|
|
38
|
+
const cache = new Map();
|
|
39
|
+
|
|
40
|
+
/** 从会话日志读压缩状态:{ used, lock, lastSummaryMs, source }。读不到日志时退回事件数组。 */
|
|
41
|
+
export function compactionStateOf(sessionId, exec) {
|
|
42
|
+
const fromEvents = () => {
|
|
43
|
+
const c = countCompactions(eventsOf(exec));
|
|
44
|
+
return { used: c.used, lock: c.lock, lastSummaryMs: null, source: 'events' };
|
|
45
|
+
};
|
|
46
|
+
const sid = sessionId ? String(sessionId) : '';
|
|
47
|
+
if (!sid) return fromEvents();
|
|
48
|
+
const file = findLog(sid);
|
|
49
|
+
if (!file) return fromEvents();
|
|
50
|
+
let st = null;
|
|
51
|
+
try { st = fs.statSync(file); } catch { return fromEvents(); }
|
|
52
|
+
const hit = cache.get(sid);
|
|
53
|
+
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.value;
|
|
54
|
+
let recs = null;
|
|
55
|
+
try { recs = decodeLog(file); } catch { recs = null; }
|
|
56
|
+
if (!recs || !recs.length) return fromEvents();
|
|
57
|
+
const c = countCompactions(recs);
|
|
58
|
+
let last = null;
|
|
59
|
+
for (const r of recs) {
|
|
60
|
+
if (r && r.type === 'compaction/summary' && typeof r.time === 'number') last = last === null ? r.time : Math.max(last, r.time);
|
|
61
|
+
}
|
|
62
|
+
const value = { used: c.used, lock: c.lock, lastSummaryMs: last, source: 'log' };
|
|
63
|
+
cache.set(sid, { size: st.size, mtimeMs: st.mtimeMs, value });
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 投影缓存里该会话文件的 mtimeMs(判断「压缩刚完成、缓存未刷新」用)。 */
|
|
68
|
+
function projCacheMtimeOf(sessionId) {
|
|
69
|
+
const sid = sessionId ? String(sessionId) : '';
|
|
70
|
+
if (!sid) return null;
|
|
71
|
+
try {
|
|
72
|
+
const dir = projCacheDir();
|
|
73
|
+
for (const name of fs.readdirSync(dir)) {
|
|
74
|
+
if (!name.endsWith('.json')) continue;
|
|
75
|
+
if (name.replace('.json', '') !== sid) continue;
|
|
76
|
+
return fs.statSync(path.join(dir, name)).mtimeMs;
|
|
77
|
+
}
|
|
78
|
+
} catch { /* ignore */ }
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 压缩后读数滞后提示:日志里已有 summary,但投影缓存比它旧 ⇒ 返回一句中文提示,否则空串。 */
|
|
83
|
+
export function stalePressureNote(sessionId) {
|
|
84
|
+
const st = compactionStateOf(sessionId, null);
|
|
85
|
+
if (!st.lastSummaryMs) return '';
|
|
86
|
+
const m = projCacheMtimeOf(sessionId);
|
|
87
|
+
if (m === null || m >= st.lastSummaryMs) return '';
|
|
88
|
+
return '(压缩刚完成,投影缓存尚未刷新,水位读数滞后一轮;下一次请求后自动正常)';
|
|
89
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用户全局前置提示词:存在本机 ~/.dsh/storages/ 下。
|
|
3
|
+
* 与插件自身的会话约束无关:仅当用户显式开启且内容非空时注入全部会话;
|
|
4
|
+
* 关闭或留空时不注册提示段,不占用注入点位,避免与其他提示词插件冲突。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** 与官方 SECTION_ORDERS 对齐:FILE_REFERENCE=900,工具段从 1000 起。 */
|
|
11
|
+
export const EXTRA_PROMPT_ORDER = 950;
|
|
12
|
+
export const EXTRA_PROMPT_SECTION = 'dsh-handover:extra-prompt';
|
|
13
|
+
const EXTRA_PROMPT_MAX = 20000;
|
|
14
|
+
|
|
15
|
+
function storePath() {
|
|
16
|
+
return path.join(os.homedir(), '.dsh', 'storages', 'dsh-handover.extra-prompt.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 返回 { enabled, text }。旧文件只有 text 时,按「内容非空即开启」迁移。 */
|
|
20
|
+
export function readExtraPrompt() {
|
|
21
|
+
try {
|
|
22
|
+
const j = JSON.parse(fs.readFileSync(storePath(), 'utf8'));
|
|
23
|
+
const text = typeof j.text === 'string' ? j.text : '';
|
|
24
|
+
const enabled = typeof j.enabled === 'boolean' ? j.enabled : text.trim() !== '';
|
|
25
|
+
return { enabled, text };
|
|
26
|
+
} catch {
|
|
27
|
+
return { enabled: false, text: '' };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 保存并返回 { enabled, text };空内容一律视为关闭。 */
|
|
32
|
+
export function writeExtraPrompt(text, enabled) {
|
|
33
|
+
const clean = String(text == null ? '' : text).trim();
|
|
34
|
+
if (clean.length > EXTRA_PROMPT_MAX) throw new Error('全局前置提示词过长(上限 ' + EXTRA_PROMPT_MAX + ' 字符)');
|
|
35
|
+
const on = enabled !== false && clean.length > 0;
|
|
36
|
+
const p = storePath();
|
|
37
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
38
|
+
const body = { enabled: on, text: clean, updatedAt: new Date().toISOString() };
|
|
39
|
+
fs.writeFileSync(p, JSON.stringify(body, null, 2) + '\n', 'utf8');
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 口径单一来源:水位阈值、压缩次数与提示段 token 预算。
|
|
3
|
+
* 运行期文本一律由本模块渲染;改数值只改 config.mjs 的默认值或本文件的预算表。
|
|
4
|
+
* 文档与测试引用同一组常量,避免同一事实多处各写一遍。
|
|
5
|
+
*/
|
|
6
|
+
import { DEFAULTS } from './config.mjs';
|
|
7
|
+
|
|
8
|
+
/** 比例转百分比文本:0.32 → "32%"。 */
|
|
9
|
+
function pctOf(ratio) {
|
|
10
|
+
const n = Math.round(Number(ratio) * 1000) / 10;
|
|
11
|
+
return (Number.isInteger(n) ? String(n) : n.toFixed(1)) + '%';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 水位与压缩口径:全部由配置推导。 */
|
|
15
|
+
export function watermark(cfg) {
|
|
16
|
+
const c = cfg || DEFAULTS;
|
|
17
|
+
const warn = pctOf(c.warnRatio !== undefined ? c.warnRatio : DEFAULTS.warnRatio);
|
|
18
|
+
const handover = pctOf(c.handoverRatio !== undefined ? c.handoverRatio : DEFAULTS.handoverRatio);
|
|
19
|
+
const hard = pctOf(c.hardRatio !== undefined ? c.hardRatio : DEFAULTS.hardRatio);
|
|
20
|
+
const max = c.maxCompactions !== undefined ? c.maxCompactions : DEFAULTS.maxCompactions;
|
|
21
|
+
return { warn, handover, hard, max, warnValue: Math.round((c.warnRatio !== undefined ? c.warnRatio : DEFAULTS.warnRatio) * 100) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 一句式交接规则(全局与项目提示段共用)。 */
|
|
25
|
+
export function handoverRuleText(cfg) {
|
|
26
|
+
const w = watermark(cfg);
|
|
27
|
+
return '水位 ' + w.warn + ' 提醒,' + w.handover + ' 交接线,' + w.hard + ' 硬上限;至多压缩 '
|
|
28
|
+
+ w.max + ' 次,第 ' + (w.max + 1) + ' 次换任。';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 设置页使用说明里的紧写形式。 */
|
|
32
|
+
export function compactSummaryText(cfg) {
|
|
33
|
+
const w = watermark(cfg);
|
|
34
|
+
return w.warn + ' 提醒、' + w.handover + ' 交接线、' + w.hard + ' 硬上限;同一会话最多压缩 ' + w.max + ' 次';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 提示段与工具描述的加权 token 估算:CJK 与全角标点每字符 1 token,其余每 4 字符 1 token。 */
|
|
38
|
+
export function estimateTokens(text) {
|
|
39
|
+
const s = String(text === null || text === undefined ? '' : text);
|
|
40
|
+
let wide = 0;
|
|
41
|
+
let narrow = 0;
|
|
42
|
+
for (const ch of s) {
|
|
43
|
+
const cp = ch.codePointAt(0);
|
|
44
|
+
if (isWide(cp)) wide += 1;
|
|
45
|
+
else narrow += 1;
|
|
46
|
+
}
|
|
47
|
+
return wide + Math.ceil(narrow / 4);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isWide(cp) {
|
|
51
|
+
return (cp >= 0x2000 && cp <= 0x206f) // 通用标点(破折号、省略号、弯引号)
|
|
52
|
+
|| (cp >= 0x3000 && cp <= 0x303f) // CJK 标点
|
|
53
|
+
|| (cp >= 0x2e80 && cp <= 0x9fff) // CJK 部首至统一汉字
|
|
54
|
+
|| (cp >= 0xf900 && cp <= 0xfaff) // 兼容汉字
|
|
55
|
+
|| (cp >= 0xfe30 && cp <= 0xfe4f) // CJK 兼容形式
|
|
56
|
+
|| (cp >= 0xff00 && cp <= 0xffef); // 全角字符
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 上下文成本上限(加权 token)。
|
|
61
|
+
* 全局段按短工作区路径计;worst 档按真实长路径加迁移提示行计。
|
|
62
|
+
* 单条工具描述单独设限,避免每次请求的固定开销无界增长。
|
|
63
|
+
*/
|
|
64
|
+
export const TOKEN_BUDGET = {
|
|
65
|
+
globalTokens: 320,
|
|
66
|
+
globalWorstTokens: 360,
|
|
67
|
+
projectTokens: 300,
|
|
68
|
+
projectWorstTokens: 340,
|
|
69
|
+
toolDescTokens: 70,
|
|
70
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** 闸门:通用约束条同步复核与交接文档体检。 */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readUtf8, isFile, isDir } from './text.mjs';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { charterPath, canonicalBlock, TITLE_KEY, WATER_KEY } from './charter.mjs';
|
|
6
|
+
import { readProfile, listProfiles } from './profile.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 读 Registry.md 的「形态」列 → { 项目名: 形态 }。
|
|
10
|
+
* 口径(Charter §1.6、§2.5):只有 library 才受通用约束条闸门管;artifact、shortcut、product 属工程型,豁免。
|
|
11
|
+
* 缺该列或读不到 → 全部按 library 处理(向后兼容)。
|
|
12
|
+
*/
|
|
13
|
+
function registryForms(ws) {
|
|
14
|
+
const p = path.join(ws, '_Project_Meta', 'Registry.md');
|
|
15
|
+
if (!isFile(p)) return {};
|
|
16
|
+
let text = '';
|
|
17
|
+
try { text = readUtf8(p); } catch { return {}; }
|
|
18
|
+
const out = {};
|
|
19
|
+
let head = null;
|
|
20
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
21
|
+
const s = raw.trim();
|
|
22
|
+
if (!s.startsWith('|')) continue;
|
|
23
|
+
const cells = s.replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
|
|
24
|
+
if (!head) {
|
|
25
|
+
if (cells.indexOf('项目名') !== -1) head = cells;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (head.indexOf('形态') === -1) return {};
|
|
29
|
+
if (cells.every((c) => /^[-:\s]*$/.test(c))) continue;
|
|
30
|
+
const iName = head.indexOf('项目名');
|
|
31
|
+
const iForm = head.indexOf('形态');
|
|
32
|
+
if (cells.length <= Math.max(iName, iForm)) continue;
|
|
33
|
+
out[cells[iName]] = cells[iForm].replace(/`/g, '').trim();
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 工作区下所有「库型」项目库(带 RULES.md):返回 [{ name, root }];工程型按形态跳过。 */
|
|
39
|
+
function libraryRoots(ws) {
|
|
40
|
+
const forms = registryForms(ws);
|
|
41
|
+
const projDir = path.join(ws, 'Projects');
|
|
42
|
+
const out = [];
|
|
43
|
+
let projects = [];
|
|
44
|
+
try { projects = fs.readdirSync(projDir); } catch { return out; }
|
|
45
|
+
for (const name of projects) {
|
|
46
|
+
const pdir = path.join(projDir, name);
|
|
47
|
+
if (!isDir(pdir)) continue;
|
|
48
|
+
const form = forms[name] || 'library';
|
|
49
|
+
if (form !== 'library') continue;
|
|
50
|
+
// 骨架档案可让项目决定是否启用 24 行约束条;未注册时按默认(启用)处理。
|
|
51
|
+
const pf = readProfile(ws, { role: 'project', project: name });
|
|
52
|
+
if (pf.exists && pf.profile.gate && pf.profile.gate.libraryBlock === false) continue;
|
|
53
|
+
let libs = [];
|
|
54
|
+
try { libs = fs.readdirSync(pdir); } catch { continue; }
|
|
55
|
+
for (const lib of libs) {
|
|
56
|
+
const root = path.join(pdir, lib);
|
|
57
|
+
if (!isDir(root)) continue;
|
|
58
|
+
if (isFile(path.join(root, 'RULES.md'))) out.push({ name, root });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 同步复核:返回 { libraries, total, missing, verdict, ok }。 */
|
|
65
|
+
/** 插件内置的 Charter 骨架(dist/templates/Charter.skeleton.md);工作区那份只是覆盖。 */
|
|
66
|
+
function bundledCharter() {
|
|
67
|
+
try {
|
|
68
|
+
const p = new URL('../templates/Charter.skeleton.md', import.meta.url);
|
|
69
|
+
return fs.readFileSync(p, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function syncCheck(ws) {
|
|
76
|
+
const charter = charterPath(ws);
|
|
77
|
+
// 口径:**插件自带的这套默认规则才是本体**,工作区里的同名文件是「本地化覆盖」。
|
|
78
|
+
// 所以只有「连 _Control_Center 都没有」才算没接入工作区;有目录但没 Charter 时用内置那份继续跑。
|
|
79
|
+
const hasCenter = isDir(path.join(ws, '_Control_Center'));
|
|
80
|
+
let text = '';
|
|
81
|
+
let builtin = false;
|
|
82
|
+
if (isFile(charter)) {
|
|
83
|
+
text = readUtf8(charter);
|
|
84
|
+
} else if (hasCenter) {
|
|
85
|
+
text = bundledCharter();
|
|
86
|
+
builtin = true;
|
|
87
|
+
} else {
|
|
88
|
+
return {
|
|
89
|
+
error: '这个实例还没接入工作区:读不到 ' + charter,
|
|
90
|
+
path: charter,
|
|
91
|
+
ok: false,
|
|
92
|
+
noWorkspace: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const blk = canonicalBlock(text);
|
|
96
|
+
if (!blk || !blk.body || blk.body.length === 0) {
|
|
97
|
+
return { error: '无法获取 §8.1.1.4 的正文(结构可能变了)', path: charter, ok: false };
|
|
98
|
+
}
|
|
99
|
+
const rows = [];
|
|
100
|
+
let bad = 0;
|
|
101
|
+
for (const lib of libraryRoots(ws)) {
|
|
102
|
+
const rules = readUtf8(path.join(lib.root, 'RULES.md'));
|
|
103
|
+
const missing = blk.body.filter((l) => rules.indexOf(l) === -1);
|
|
104
|
+
const titleOk = rules.indexOf(TITLE_KEY) !== -1;
|
|
105
|
+
const waterOk = rules.indexOf(WATER_KEY) !== -1;
|
|
106
|
+
const ok = titleOk && missing.length === 0 && waterOk;
|
|
107
|
+
if (!ok) bad++;
|
|
108
|
+
rows.push({ name: lib.name, titleOk, waterOk, missing, total: blk.body.length, ok });
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
libraries: rows,
|
|
112
|
+
profiles: listProfiles(ws),
|
|
113
|
+
total: rows.length,
|
|
114
|
+
bodyLines: blk.body.length,
|
|
115
|
+
builtin,
|
|
116
|
+
missing: bad,
|
|
117
|
+
ok: bad === 0,
|
|
118
|
+
verdict: bad === 0
|
|
119
|
+
|
|
120
|
+
? "全部落位。"
|
|
121
|
+
: bad + ' 个库有缺 —— 向对应项目会话派发(由它自己写进本库 RULES.md,全局不得改项目库内文件)。',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function renderSyncCheck(res) {
|
|
126
|
+
const out = [];
|
|
127
|
+
if (res.error) { out.push(res.error + ':' + res.path); return out; }
|
|
128
|
+
out.push('通用约束条同步复核 库数 ' + res.total + ' 正文 ' + res.bodyLines + ' 行 来源 §8.1.1.4' + (res.builtin ? '(工作区没有 Workspace_Charter.md,用插件内置口径)' : ''));
|
|
129
|
+
for (const r of res.libraries) {
|
|
130
|
+
const flag = r.ok ? '通过' : '未通过';
|
|
131
|
+
out.push(' ' + r.name + ' 标题 ' + (r.titleOk ? '通过' : '缺') + ' 正文 ' + (r.total - r.missing.length) + '/' + r.total +
|
|
132
|
+
' 水位自查 ' + (r.waterOk ? '通过' : '缺') + ' ' + flag);
|
|
133
|
+
for (const m of r.missing.slice(0, 3)) out.push(" 缺: " + m.slice(0, 70));
|
|
134
|
+
}
|
|
135
|
+
const ps = res.profiles || [];
|
|
136
|
+
if (ps.length) {
|
|
137
|
+
out.push('');
|
|
138
|
+
out.push('项目与杂项骨架');
|
|
139
|
+
for (const p of ps) {
|
|
140
|
+
const flag = p.exists ? (p.ok ? '通过' : '未通过') : '待注册';
|
|
141
|
+
const miss = p.missing && p.missing.length ? ' 缺 ' + p.missing.join('、') : '';
|
|
142
|
+
out.push(' ' + p.name + ' ' + flag + ' 类别 ' + p.kind + miss);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
out.push('');
|
|
146
|
+
out.push('判定:' + res.verdict);
|
|
147
|
+
return out;
|
|
148
|
+
}
|