@mingmaple/dsh-handover 0.0.0-stage → 0.9.6

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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -2
  3. package/docs/migrate.md +61 -0
  4. package/docs/troubleshoot.md +48 -0
  5. package/package.json +55 -3
  6. package/scripts/blank-dsh.mjs +203 -0
  7. package/scripts/blank-smoke.mjs +820 -0
  8. package/scripts/build.mjs +58 -0
  9. package/scripts/check-docs.mjs +74 -0
  10. package/scripts/e2e-migration.mjs +127 -0
  11. package/scripts/lib/register.mjs +103 -0
  12. package/src/client.js +887 -0
  13. package/src/cordis.patch.yml +16 -0
  14. package/src/index.mjs +631 -0
  15. package/src/lib/absrefs.mjs +112 -0
  16. package/src/lib/api.mjs +234 -0
  17. package/src/lib/archive.mjs +198 -0
  18. package/src/lib/brief.mjs +74 -0
  19. package/src/lib/charter.mjs +28 -0
  20. package/src/lib/config.mjs +36 -0
  21. package/src/lib/consent.mjs +84 -0
  22. package/src/lib/drift.mjs +65 -0
  23. package/src/lib/events.mjs +89 -0
  24. package/src/lib/extra-prompt.mjs +41 -0
  25. package/src/lib/facts.mjs +70 -0
  26. package/src/lib/gates.mjs +148 -0
  27. package/src/lib/guide.mjs +99 -0
  28. package/src/lib/handover-io.mjs +49 -0
  29. package/src/lib/lint.mjs +152 -0
  30. package/src/lib/manual.mjs +124 -0
  31. package/src/lib/mover.mjs +663 -0
  32. package/src/lib/names.mjs +33 -0
  33. package/src/lib/output.mjs +11 -0
  34. package/src/lib/pack-ops.mjs +329 -0
  35. package/src/lib/peer.mjs +260 -0
  36. package/src/lib/phase.mjs +34 -0
  37. package/src/lib/profile.mjs +204 -0
  38. package/src/lib/registry.mjs +53 -0
  39. package/src/lib/retarget-helper.tmpl +46 -0
  40. package/src/lib/role.mjs +217 -0
  41. package/src/lib/rpc-carrier.mjs +65 -0
  42. package/src/lib/scaffold.mjs +118 -0
  43. package/src/lib/schedule.mjs +227 -0
  44. package/src/lib/session-ops.mjs +362 -0
  45. package/src/lib/sessions.mjs +142 -0
  46. package/src/lib/sevenzip.mjs +48 -0
  47. package/src/lib/status.mjs +171 -0
  48. package/src/lib/text.mjs +74 -0
  49. package/src/lib/webroute.mjs +130 -0
  50. package/src/lib/workspace.mjs +23 -0
  51. package/src/lib/zstd-log.mjs +65 -0
  52. package/src/selftest.mjs +84 -0
  53. package/src/templates/Changelog.skeleton.md +10 -0
  54. package/src/templates/Charter.skeleton.md +53 -0
  55. package/src/templates/Handover.global.skeleton.md +56 -0
  56. package/src/templates/Prompt_Templates.md +96 -0
  57. package/src/templates/Runtime_Manual.md +166 -0
  58. package/src/templates/rules/Handover_Rules.md +43 -0
  59. package/src/templates/rules/Migration_Rules.md +32 -0
  60. package/src/templates/rules/Misc_Task_Standard.md +23 -0
  61. package/src/templates/rules/Writing_Rules.md +24 -0
  62. package/src/tools/ho_brief.mjs +57 -0
  63. package/src/tools/ho_changelog.mjs +59 -0
  64. package/src/tools/ho_charter.mjs +89 -0
  65. package/src/tools/ho_checkpoint.mjs +125 -0
  66. package/src/tools/ho_diag.mjs +85 -0
  67. package/src/tools/ho_drift.mjs +41 -0
  68. package/src/tools/ho_gate.mjs +69 -0
  69. package/src/tools/ho_handover.mjs +83 -0
  70. package/src/tools/ho_init.mjs +82 -0
  71. package/src/tools/ho_pack.mjs +38 -0
  72. package/src/tools/ho_phase.mjs +37 -0
  73. package/src/tools/ho_profile.mjs +97 -0
  74. package/src/tools/ho_sessions.mjs +114 -0
  75. package/src/tools/ho_snapshot.mjs +103 -0
  76. package/src/tools/ho_tokens.mjs +57 -0
  77. package/src/tools/ho_workspace.mjs +213 -0
  78. package/src/tools/peer_list.mjs +37 -0
  79. package/src/tools/peer_new.mjs +75 -0
  80. package/src/tools/peer_peek.mjs +41 -0
  81. package/src/tools/peer_rename.mjs +31 -0
  82. package/src/tools/peer_send.mjs +52 -0
  83. package/src/tools/peer_status.mjs +46 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 设置页用的状态快照(只读):插件自身状态 + 闸门 + 交接体检 + 会话水位 + 最近一次守卫判定。
3
+ * 全部读本地文件,不唤醒任何会话、不花任何会话的 token。
4
+ * 任何一段出错都只让该段带 error 字段,不让整页 500。
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { isDir } from './text.mjs';
9
+ import { syncCheck } from './gates.mjs';
10
+ import { lintGlobal, lintProject } from './lint.mjs';
11
+ import { scanSessions } from './sessions.mjs';
12
+ import { routeWorkspace, hasCenter } from './workspace.mjs';
13
+ import { stampTz } from './brief.mjs';
14
+ import { buildGuide, SETUP_STEPS } from './guide.mjs';
15
+ import { readRoles, resolveRole, ROLE_LABEL, sessionMeta } from './role.mjs';
16
+ import { listConsent } from './consent.mjs';
17
+ import { listProfiles } from './profile.mjs';
18
+
19
+ function guard(fn, fallback) {
20
+ try { return fn(); } catch (e) { return { ...fallback, error: String(e && e.message ? e.message : e) }; }
21
+ }
22
+
23
+ /** 项目交接体检:_Project_Meta/<项目>/Handover.md。 */
24
+ function lintProjects(ws) {
25
+ const out = [];
26
+ let dirs = [];
27
+ try { dirs = fs.readdirSync(path.join(ws, '_Project_Meta')).sort(); } catch { return out; }
28
+ for (const name of dirs) {
29
+ const dir = path.join(ws, '_Project_Meta', name);
30
+ try { if (!isDir(dir)) continue; } catch { continue; }
31
+ const r = lintProject(path.join(dir, 'Handover.md'));
32
+ out.push({ name, verdict: r.verdict, reasons: Array.isArray(r.reasons) ? r.reasons.slice(0, 5) : [] });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export function buildStatus(env, extra) {
38
+ const info = extra || {};
39
+ const ws = routeWorkspace(env);
40
+ const found = hasCenter(ws);
41
+ const warn = Math.round((env.cfg.warnRatio || 0.32) * 100);
42
+ const out = {
43
+ generatedAt: stampTz(Date.now()),
44
+ plugin: {
45
+ name: info.name || '@mingmaple/dsh-handover',
46
+ version: info.version || '?',
47
+ tools: typeof info.tools === 'number' ? info.tools : 0,
48
+ inject: guard(() => env.injectList(), []),
49
+ initErrors: guard(() => env.initErrors(), []),
50
+ scheduler: info.scheduler || '未知',
51
+ },
52
+ workspace: { root: ws, found },
53
+ ratios: guard(() => ({
54
+ warn: Math.round((env.cfg.warnRatio !== undefined ? env.cfg.warnRatio : 0.32) * 100),
55
+ handover: Math.round((env.cfg.handoverRatio !== undefined ? env.cfg.handoverRatio : 0.35) * 100),
56
+ hard: Math.round((env.cfg.hardRatio !== undefined ? env.cfg.hardRatio : 0.85) * 100),
57
+ }), { warn: 32, handover: 35, hard: 85 }),
58
+ guardLast: guard(() => env.guardLast(), null),
59
+ extraPrompt: guard(() => (typeof env.extraPrompt === 'function' ? env.extraPrompt() : ''), ''),
60
+ extraPromptEnabled: guard(() => (typeof env.extraPromptEnabled === 'function' ? env.extraPromptEnabled() : false), false),
61
+ extraPromptOrder: 950,
62
+ roles: guard(() => {
63
+ const store = readRoles(env.cfg);
64
+ const map = {};
65
+ for (const sid of Object.keys(store.sessions)) {
66
+ const e = store.sessions[sid] || {};
67
+ map[sid] = { role: String(e.role || ''), project: String(e.project || ''), task: String(e.task || ''), setAt: String(e.setAt || ''), bootstrappedAt: String(e.bootstrappedAt || '') };
68
+ }
69
+ return map;
70
+ }, {}),
71
+ consent: guard(() => listConsent(), []),
72
+ profiles: guard(() => listProfiles(ws), []),
73
+ guide: guard(() => buildGuide(env.cfg), { intro: [], situations: [], groups: [], rules: [] }),
74
+ gate: null,
75
+ handover: null,
76
+ sessions: null,
77
+ notes: [],
78
+ };
79
+
80
+ if (!found) {
81
+ out.notes.push('未找到 _Control_Center:这个实例还没接入工作区。可在插件 config 里写 workspaceRoot。');
82
+ out.notes.push('插件本身已加载:工具与设置页不依赖工作区,只有闸门、交接、会话三段需要工作区。');
83
+ out.setup = { steps: SETUP_STEPS };
84
+ return out;
85
+ }
86
+
87
+ out.gate = guard(() => {
88
+ const g = syncCheck(ws);
89
+ return {
90
+ ok: !!g.ok,
91
+ error: g.error || '',
92
+ verdict: g.verdict || '',
93
+ total: g.total || 0,
94
+ bodyLines: g.bodyLines || 0,
95
+ libraries: (g.libraries || []).map((r) => ({
96
+ name: r.name, ok: !!r.ok, titleOk: !!r.titleOk, waterOk: !!r.waterOk,
97
+ missing: r.missing ? r.missing.length : 0, lines: r.total || 0,
98
+ })),
99
+ };
100
+ }, { ok: false, libraries: [] });
101
+
102
+ out.handover = guard(() => {
103
+ const g = lintGlobal(path.join(ws, '_Control_Center', 'records', 'Global_Handover.md'));
104
+ const projects = lintProjects(ws);
105
+ return {
106
+ global: { verdict: g.verdict, reasons: Array.isArray(g.reasons) ? g.reasons.slice(0, 5) : [] },
107
+ projects,
108
+ fail: projects.filter((p) => p.verdict === '不合格').length,
109
+ };
110
+ }, { global: { verdict: '读不到', reasons: [] }, projects: [], fail: 0 });
111
+
112
+ out.sessions = guard(() => {
113
+ const scan = scanSessions({
114
+ dir: env.cacheDir(),
115
+ meId: null,
116
+ meCwd: null,
117
+ warn,
118
+ silentHours: env.cfg.sessionScanHours,
119
+ projectsOnly: true,
120
+ });
121
+ const store = readRoles(env.cfg);
122
+ const roleOf = (sid, cwd, firstPrompt, title) => {
123
+ const meta = sessionMeta(sid);
124
+ return resolveRole({ cfg: env.cfg, roles: store, sessionId: sid, cwd, firstPrompt, subagent: meta.subagent, title });
125
+ };
126
+ const wide = scanSessions({
127
+ dir: env.cacheDir(), meId: null, meCwd: null, warn, silentHours: env.cfg.sessionScanHours, projectsOnly: false,
128
+ });
129
+ const all = wide.rows.filter((r) => r.kind === '平级').map((r) => {
130
+ const meta = sessionMeta(r.sid);
131
+ const info = roleOf(r.sid, meta.cwd, meta.firstPrompt, r.project);
132
+ return {
133
+ sid: r.sid,
134
+ title: r.project === '-' ? '' : r.project,
135
+ role: info.role,
136
+ roleLabel: ROLE_LABEL[info.role] || info.role,
137
+ source: info.source,
138
+ project: info.project,
139
+ task: info.task,
140
+ pct: r.pct,
141
+ state: r.state,
142
+ active: r.mtime >= scan.cutoffMs,
143
+ seen: stampTz(r.mtime),
144
+ };
145
+ });
146
+ return {
147
+ rows: scan.rows.slice(0, 60).map((r) => {
148
+ const meta = sessionMeta(r.sid);
149
+ const info = roleOf(r.sid, meta.cwd, meta.firstPrompt, r.project);
150
+ return {
151
+ sid: r.sid, project: r.project, pct: r.pct, state: r.state,
152
+ role: info.role, roleLabel: ROLE_LABEL[info.role] || info.role,
153
+ active: r.mtime >= scan.cutoffMs, seen: stampTz(r.mtime),
154
+ };
155
+ }),
156
+ all,
157
+ notify: scan.notify.map((r) => ({ sid: r.sid, project: r.project })),
158
+ hot: scan.hot.length,
159
+ unknown: scan.unknown.length,
160
+ cutoff: stampTz(scan.cutoffMs),
161
+ silentHours: scan.silentHours,
162
+ verdict: scan.hot.length
163
+ ? scan.hot.length + ' 个会话已进预警区(不低于 ' + warn + '%),应先收齐交接,再换会话'
164
+ : '没有会话进入预警区。',
165
+ };
166
+ }, { rows: [], notify: [], hot: 0, unknown: 0 });
167
+
168
+ if (out.gate && !out.gate.ok && !out.gate.error) out.notes.push('通用约束条闸门未过:向对应项目会话派发,全局不代写项目库内文件。');
169
+ if (out.handover && out.handover.fail) out.notes.push(out.handover.fail + ' 个项目交接体检不合格,交接前必须先补。');
170
+ return out;
171
+ }
@@ -0,0 +1,74 @@
1
+ /** 文本/文件小工具:无第三方依赖,全部同步 IO(工具调用内使用)。 */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export const NL = String.fromCharCode(10);
6
+
7
+ export function readUtf8(p) {
8
+ return fs.readFileSync(p, 'utf8');
9
+ }
10
+
11
+ export function isFile(p) {
12
+ try { return fs.statSync(p).isFile(); } catch { return false; }
13
+ }
14
+
15
+ export function isDir(p) {
16
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
17
+ }
18
+
19
+ /**
20
+ * 读目录下全部 *.json,返回 [{ name, path, data, mtimeMs }],读不动的跳过。
21
+ *
22
+ * 投影缓存里有几百个文件、几十 MB,单次读取约 0.2 秒;按会话逐条判定角色时会反复调用。
23
+ * 因此加一层 1.5 秒的进程内缓存:同一轮状态计算只解析一次,跨轮仍是新数据。
24
+ */
25
+ const JSON_DIR_TTL_MS = 1500;
26
+ const jsonDirCache = new Map();
27
+
28
+ export function readJsonDir(dir) {
29
+ const key = String(dir);
30
+ const now = Date.now();
31
+ const hit = jsonDirCache.get(key);
32
+ if (hit && now - hit.at < JSON_DIR_TTL_MS) return hit.items;
33
+ const out = [];
34
+ let names = [];
35
+ try { names = fs.readdirSync(dir); } catch { return out; }
36
+ for (const name of names) {
37
+ if (!name.endsWith('.json')) continue;
38
+ const p = path.join(dir, name);
39
+ try {
40
+ const st = fs.statSync(p);
41
+ if (!st.isFile()) continue;
42
+ out.push({ name, path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')), mtimeMs: st.mtimeMs });
43
+ } catch { /* 跳过坏文件 */ }
44
+ }
45
+ jsonDirCache.set(key, { at: Date.now(), items: out });
46
+ return out;
47
+ }
48
+
49
+ /** 安全按路径取值,无法获取返回 null。 */
50
+ export function pick(node) {
51
+ let cur = node;
52
+ for (let i = 1; i < arguments.length; i++) {
53
+ const k = arguments[i];
54
+ if (cur === null || typeof cur !== "object" || !(k in cur)) return null;
55
+ cur = cur[k];
56
+ }
57
+ return cur === undefined ? null : cur;
58
+ }
59
+
60
+ /** 按标题行切节:返回标题匹配 name 的整段(到下一个 ## 为止);找不到返回 null。 */
61
+ export function sectionLines(text, name) {
62
+ const lines = text.split(NL);
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (lines[i].startsWith('## ') && lines[i].indexOf(name) !== -1) {
65
+ const out = [];
66
+ for (let j = i + 1; j < lines.length; j++) {
67
+ if (lines[j].startsWith('## ')) break;
68
+ out.push(lines[j]);
69
+ }
70
+ return out;
71
+ }
72
+ }
73
+ return null;
74
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 设置页数据的**旧通道**:GET /dsh-handover/status.json、POST /setup /register /extra-prompt /role。
3
+ *
4
+ * 主通道是 DSH 认证的 /api 载体(见 rpc-carrier.mjs):本机 GUI 服务器对裸请求一律返回 403。
5
+ * 这里保留给没有 connection 服务的宿主与 CLI 作兜底,实现统一委托 api.mjs,避免两套逻辑漂移。
6
+ */
7
+ import { createApi } from './api.mjs';
8
+
9
+ const ROUTE = '/dsh-handover';
10
+ const MAX_BODY = 64 * 1024;
11
+
12
+ /** 读 JSON 请求体;出错返回 { error }。 */
13
+ function readJson(req) {
14
+ return new Promise((resolve) => {
15
+ let done = false;
16
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
17
+ let size = 0;
18
+ const chunks = [];
19
+ try {
20
+ req.on('data', (c) => {
21
+ size += c.length;
22
+ if (size > MAX_BODY) { finish({ error: '请求体过大' }); try { req.destroy(); } catch { /* ignore */ } return; }
23
+ chunks.push(c);
24
+ });
25
+ req.on('end', () => {
26
+ const text = Buffer.concat(chunks).toString('utf8').trim();
27
+ if (!text) { finish({ value: {} }); return; }
28
+ try { finish({ value: JSON.parse(text) }); } catch { finish({ error: '请求体不是合法 JSON' }); }
29
+ });
30
+ req.on('error', () => finish({ error: '读取请求体失败' }));
31
+ } catch {
32
+ finish({ error: '读取请求体失败' });
33
+ }
34
+ });
35
+ }
36
+
37
+ function sendJson(res, code, obj) {
38
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
39
+ res.end(JSON.stringify(obj));
40
+ }
41
+
42
+ function msgOf(e) { return String(e && e.message ? e.message : e); }
43
+
44
+ export function registerStatusRoute(ctx, env, info, apiIn) {
45
+ const install = (wctx) => {
46
+ try {
47
+ const webServer = wctx && wctx.webServer;
48
+ if (!webServer || typeof webServer.register !== 'function') {
49
+ env.log('状态路由:webServer 不可用,跳过设置页 HTTP 兜底(主通道是 /api 载体)');
50
+ return;
51
+ }
52
+ const api = apiIn || createApi(env, info);
53
+
54
+ /** 旧通道出口:把统一信封翻成 { ok, ...value }(页面两种通道都能吃)。 */
55
+ const reply = (res, r) => {
56
+ if (!r || r.ok !== true) {
57
+ const err = (r && r.error) || {};
58
+ const out = { ok: false, error: String(err.message || '失败') };
59
+ if (err.details && typeof err.details === 'object') Object.assign(out, err.details);
60
+ sendJson(res, (r && r.http) || 400, out);
61
+ return;
62
+ }
63
+ const v = r.value && typeof r.value === 'object' ? r.value : { value: r.value };
64
+ sendJson(res, 200, Object.assign({ ok: true }, v));
65
+ };
66
+
67
+ const readBody = async (req, res) => {
68
+ const body = await readJson(req);
69
+ if (body.error) { sendJson(res, 400, { ok: false, error: body.error }); return null; }
70
+ return body.value || {};
71
+ };
72
+
73
+ const dispose = webServer.register({
74
+ kind: 'prefix',
75
+ path: ROUTE,
76
+ handler: async (req, res) => {
77
+ try {
78
+ const pathname = String((req && req.url) || '').split('?')[0];
79
+ const method = String((req && req.method) || 'GET').toUpperCase();
80
+ const tail = pathname.slice(ROUTE.length);
81
+ env.log('HTTP ' + method + ' ' + pathname);
82
+
83
+ if (tail === '/status.json') {
84
+ if (method !== 'GET' && method !== 'HEAD') {
85
+ res.writeHead(405, { allow: 'GET, HEAD' });
86
+ res.end();
87
+ return;
88
+ }
89
+ const r = await api.call('status', {});
90
+ if (r.ok !== true) { sendJson(res, r.http || 500, { error: String((r.error && r.error.message) || '失败') }); return; }
91
+ const body = JSON.stringify(r.value);
92
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
93
+ res.end(method === 'HEAD' ? undefined : body);
94
+ return;
95
+ }
96
+
97
+ if (tail === '/setup' || tail === '/register' || tail === '/extra-prompt' || tail === '/role') {
98
+ if (method !== 'POST') { res.writeHead(405, { allow: 'POST' }); res.end(); return; }
99
+ const input = await readBody(req, res);
100
+ if (input === null) return;
101
+ const name = tail.slice(1);
102
+ const r = await api.call(name, input);
103
+ if (name === 'setup') {
104
+ env.log('首次安装向导:' + (r.ok ? '已建 ' + String((r.value && r.value.root) || '') : '失败 ' + String((r.error && r.error.message) || '')));
105
+ }
106
+ if (name === 'extra-prompt') {
107
+ env.log('全局前置提示词:' + (r.ok ? (String((r.value && r.value.chars) || 0) + ' 字符') : '失败'));
108
+ }
109
+ reply(res, r);
110
+ return;
111
+ }
112
+
113
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
114
+ res.end('404');
115
+ } catch (e) {
116
+ try { sendJson(res, 500, { error: msgOf(e) }); } catch { /* 已结束 */ }
117
+ }
118
+ },
119
+ });
120
+ if (typeof wctx.effect === 'function') wctx.effect(() => dispose, 'dsh-handover: 设置页路由');
121
+ } catch (e) {
122
+ env.log('状态路由注册失败:' + msgOf(e));
123
+ }
124
+ };
125
+ try {
126
+ if (typeof ctx.inject === 'function') ctx.inject(['webServer'], install);
127
+ } catch (e) {
128
+ env.log('状态路由装配失败:' + msgOf(e));
129
+ }
130
+ }
@@ -0,0 +1,23 @@
1
+ /** 工作区解析:路由侧(设置页,没有 exec)与工具侧共用一套判定。 */
2
+ import path from 'node:path';
3
+ import { isDir } from './text.mjs';
4
+ import { workspaceRows } from './registry.mjs';
5
+
6
+ export function hasCenter(p) {
7
+ if (!p || p === '-') return false;
8
+ try { return isDir(path.join(p, '_Control_Center')); } catch { return false; }
9
+ }
10
+
11
+ /**
12
+ * 没有 exec 时的兜底:配置 workspaceRoot → 标题含「全局」且带 _Control_Center 的工作区
13
+ * → 任一带 _Control_Center 的工作区 → 进程 cwd。
14
+ */
15
+ export function routeWorkspace(env) {
16
+ try { if (env && env.cfg && env.cfg.workspaceRoot) return env.cfg.workspaceRoot; } catch { /* ignore */ }
17
+ let rows = [];
18
+ try { rows = workspaceRows(); } catch { rows = []; }
19
+ const list = Array.isArray(rows) ? rows : [];
20
+ for (const r of list) if (String(r.title).indexOf('全局') !== -1 && hasCenter(r.path)) return r.path;
21
+ for (const r of list) if (hasCenter(r.path)) return r.path;
22
+ try { return process.cwd(); } catch { return ''; }
23
+ }
@@ -0,0 +1,65 @@
1
+ /** DSH 会话日志:定位 + 多帧 zstd 解码(与 handover_check.mjs 同一做法)。 */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import zlib from 'node:zlib';
6
+
7
+ const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
8
+
9
+ export function logsRoot() {
10
+ return path.join(os.homedir(), '.dsh', 'sessions');
11
+ }
12
+
13
+ /** 在 ~/.dsh/sessions 下找 <sid>/session.v4.jsonl.zstd(深度 ≤3)。 */
14
+ export function findLog(sid, root) {
15
+ let hit = null;
16
+ const base = root || logsRoot();
17
+ const walk = (dir, depth) => {
18
+ if (hit || depth > 3) return;
19
+ let ents = [];
20
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
21
+ for (const e of ents) {
22
+ if (!e.isDirectory()) continue;
23
+ const p = path.join(dir, e.name);
24
+ if (e.name === sid) {
25
+ const f = path.join(p, 'session.v4.jsonl.zstd');
26
+ if (fs.existsSync(f)) { hit = f; return; }
27
+ }
28
+ walk(p, depth + 1);
29
+ }
30
+ };
31
+ walk(base, 0);
32
+ return hit;
33
+ }
34
+
35
+ /** 解码多帧 zstd 日志,返回事件数组(坏帧跳过)。 */
36
+ export function decodeLog(file) {
37
+ let buf;
38
+ try { buf = fs.readFileSync(file); } catch { return null; }
39
+ const starts = [];
40
+ let i = 0;
41
+ while (true) { const j = buf.indexOf(MAGIC, i); if (j < 0) break; starts.push(j); i = j + 4; }
42
+ if (!starts.length) return null;
43
+ starts.push(buf.length);
44
+ let text = "";
45
+ for (let k = 0; k < starts.length - 1; k++) {
46
+ try { text += zlib.zstdDecompressSync(buf.subarray(starts[k], starts[k + 1])).toString("utf8"); } catch { /* 坏帧 */ }
47
+ }
48
+ const out = [];
49
+ for (const line of text.split(String.fromCharCode(10))) {
50
+ if (!line.trim()) continue;
51
+ try { out.push(JSON.parse(line)); } catch { /* 非 JSON 行 */ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** 取 turn/start 事件的时间戳序列(按时间排序)。 */
57
+ export function turnStarts(records) {
58
+ const starts = [];
59
+ for (const r of records) {
60
+ if (!r || r.type !== "turn/start") continue;
61
+ const turn = r.data && typeof r.data.turn === "number" ? r.data.turn : null;
62
+ if (turn !== null && typeof r.time === "number") starts.push({ turn, time: r.time });
63
+ }
64
+ return starts.sort((a, b) => a.time - b.time);
65
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 脱离 DSH 的自测台:用假 env 直接跑工具 execute,打印只读结果,供人工比对;不参与发布门禁。
3
+ * 用法:node selftest.mjs [--ws <工作区根>] [--session <session id>] [--tool brief|gate|sessions|phase|charter|all]
4
+ */
5
+ import { resolveConfig } from './lib/config.mjs';
6
+ import { projCacheDir, pressurePct } from './lib/sessions.mjs';
7
+ import { readJsonDir, isDir } from './lib/text.mjs';
8
+ import { pmBriefTool } from './tools/ho_brief.mjs';
9
+ import { pmGateTool } from './tools/ho_gate.mjs';
10
+ import { pmSessionsTool } from './tools/ho_sessions.mjs';
11
+ import { pmPhaseTool } from './tools/ho_phase.mjs';
12
+ import { pmCharterTool } from './tools/ho_charter.mjs';
13
+ import path from 'node:path';
14
+
15
+ const argv = process.argv.slice(2);
16
+ function argOf(flag, dflt) {
17
+ const i = argv.indexOf(flag);
18
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : dflt;
19
+ }
20
+
21
+ /** 从 cwd 向上找带 _Control_Center 的目录;找不到就用 cwd(公开仓库里不写死任何本机路径)。 */
22
+ function autoWs() {
23
+ let cur = process.cwd();
24
+ for (let i = 0; i < 8 && cur; i++) {
25
+ if (isDir(path.join(cur, "_Control_Center"))) return cur;
26
+ const up = path.dirname(cur);
27
+ if (up === cur) break;
28
+ cur = up;
29
+ }
30
+ return process.cwd();
31
+ }
32
+
33
+ const ws = argOf("--ws", autoWs());
34
+ const selfId = argOf("--session", "");
35
+ const only = argOf("--tool", "all");
36
+
37
+ const cfg = resolveConfig({ workspaceRoot: ws });
38
+ const env = {
39
+ cfg,
40
+ cacheDir: () => projCacheDir(),
41
+ resolveWs: () => cfg.workspaceRoot,
42
+ selfId: () => (selfId || null),
43
+ selfCwd: () => null,
44
+ selfPct(id) {
45
+ if (!id) return null;
46
+ const target = id.indexOf('session-') === 0 ? id : 'session-' + id;
47
+ for (const f of readJsonDir(projCacheDir())) {
48
+ const stem = f.name.replace('.json', '');
49
+ if (stem === target || stem === id) return pressurePct(f.data);
50
+ }
51
+ return null;
52
+ },
53
+ compactionCount: () => ({ used: null, note: '(批 B 接入事件计数)' }),
54
+ lockState: () => 'unknown',
55
+ };
56
+
57
+ const exec = null;
58
+ const cases = [
59
+ { t: pmBriefTool(env), a: {}, flag: "brief" },
60
+ { t: pmGateTool(env), a: { scope: "all" }, flag: "gate" },
61
+ { t: pmSessionsTool(env), a: {}, flag: "sessions" },
62
+ { t: pmPhaseTool(env), a: {}, flag: "phase" },
63
+ { t: pmCharterTool(env), a: { doc: "charter", section: "8.1.1.4", maxLines: 30 }, flag: "charter" },
64
+ ];
65
+
66
+ let failed = 0;
67
+ for (const c of cases) {
68
+ if (only !== "all" && only !== c.flag) continue;
69
+ const bar = "==== ho_" + c.flag + " ====";
70
+ console.log(bar);
71
+ try {
72
+ const out = await c.t.execute(c.a, exec);
73
+ console.log(out.text);
74
+ } catch (e) {
75
+ failed++;
76
+ console.log("FAILED: " + (e && e.message ? e.message : String(e)));
77
+ if (e && e.stack) console.log(e.stack.split(String.fromCharCode(10)).slice(0, 4).join(String.fromCharCode(10)));
78
+ }
79
+ console.log("");
80
+ }
81
+ if (failed) {
82
+ console.log("自测失败 " + failed + " 项");
83
+ process.exitCode = 1;
84
+ }
@@ -0,0 +1,10 @@
1
+ # Global Handover Changelog(全局交接变更日志)
2
+
3
+ > 追加式历史日志。每次改动 _Control_Center 的全局规则、结构或 Global_Handover.md 本身时,追加一行。
4
+ > 由插件 ho_changelog 按格式追加(新行插在最新一行之前)。
5
+
6
+ ## 变更记录
7
+
8
+ | 日期 | 变更 | 执行者 |
9
+ |---|---|---|
10
+ | 2026-10-24 | 由 dsh-handover 首次安装向导生成工作区骨架 | setup |
@@ -0,0 +1,53 @@
1
+ # Workspace Charter(骨架)
2
+
3
+ > 本文件由 dsh-handover 首次安装向导生成,只含跑起来所需的最小内容;按需继续写。
4
+ > 铁律:§8.1.1.4 那 24 行是唯一正文来源,任何项目库的 RULES.md 都要逐字复制它(外加一行「上下文水位自查」)。改这里就必须同步改所有库。
5
+
6
+ ## 8. 工程约束
7
+
8
+ ### 8.1.1.4 项目侧通用约束条(可直接粘贴;改这里就要重新同步所有项目)
9
+
10
+ > 同步方式=把下面代码块的内容逐行复制进 `Projects/<项目名>/<库名>/RULES.md`,再加一行「上下文水位自查」。
11
+
12
+ ```markdown
13
+ **运行期省 token(通用约束;项目无关,全部在役项目共用这一份正文;数值按 27 个退役会话跨项目实测定)**
14
+
15
+ 运行期按**步数**花钱:实测成本 ≈ **步数 × 每步约 1.7k token**(四分位 1.3k–2.0k,最高 3.3k),
16
+ 与项目类型、会话角色无关。⇒ 唯一可伸缩的杠杆是**步数**。
17
+
18
+ 1. **单轮工具调用 ≤ 20 步**。实测正常轮的中位数只有 **7 步**;>20 步的轮占 15%,却是账单主体。
19
+ 超了就停:先把「建一小段 → 跑一次 → 看一眼」固化成一条不变量,再继续。
20
+ 2. **同一动作在本会话第 3 次出现,就固化成 `_Scripts/` 下的命令**,之后只许调参调用、不许现场重写。
21
+ (实测:近似重复动作只占 1.8%,但出现过单个动作重复 11 次的实例 —— 这条是抓长尾。)
22
+ 3. **一条命令验多个假设**,不要一步一问。
23
+ 4. **脚本输出要「读一次就能决定下一步」**,不要打印全量清单。
24
+ (实测:平均每个会话有 12.6 条 >4 KB 的工具返回,最大 54 KB。它的代价不在被重复读 —— 339 条里
25
+ 近似重复 0 条 —— 而在此后**每一步都要把它重读一遍**。)
26
+ 5. **任务开始用 `todo_write` 列出计划**,让用户看得见总步数与进度。
27
+ 6. **跨版本对比 / 一次性实验先隔离运行根**:把待跑的脚本与数据**整体复制到库根之外的独立副本**再跑
28
+ (系统临时目录即可)。判据=**这个脚本的库根会解析到哪**;只要可能落到真库或归档目录,就先复制。
29
+ 7. **构建期可以铺张**(反复试错),不必为省 token 牺牲效率。
30
+ 8. **不可逆动作的最小充分核验**:不要求「重查一遍」,只要求动手前能回答四问 —— **目标**(动谁)、**范围**(动多少)、
31
+ **时效**(依据是否本会话内 / 无后续写入 / 无并发)、**逃生**(错了能否恢复)。四问都能从上下文回答 ⇒ 直接做,依据一句;
32
+ **缺哪项只补哪项**;**逃生=不可恢复**才升级为全量清单 + 备份 + 试运行 + 用户确认。
33
+ **本会话内用户亲口说的、或你自己刚跑出来的**信息**计入**核验,不得重复核验。判不准时先给拟做动作与依据、
34
+ 指出缺口,再问是否补查 —— 不要用「我不猜,先检查」开场。
35
+ 9. **防步数失控三条(电脑侧 A/B 实测;第 1 条约束的补丁)**。实测:单轮 ≤20 步**两版都落到 33–36 步**,说明「超了就停」是事后话 ⇒ 改成事前拦:
36
+ ① **动手前写步数预算**("这活我打算 ≤N 步");**实测热轮的两大块**=读规则/长文(一次读完、别反复翻同义文件)+同一动作重复(先固化再跑),先压这两块。
37
+ ② **同一命令在一处重复调用,就把「路径与参数」硬化** —— 输出路径**一律绝对路径**(实测反面:`--out` 用相对路径,4 张产物落进库根);默认参数写成脚本常量,别每次重猜(实测反面:同一参数误用默认值,4 张返工)。
38
+ ③ **隔离胜过小心删** —— 长循环/批处理先落临时目录再做,事后无需清理(隔离运行根是第 6 条,这里补「默认就这么干」);`job_output` 同一 job **轮询 ≤2 次**,等就等,不要反复看。
39
+ 本条**不要求**改计步口径:探针取的是日志事实,你自数的「轮/步」跟它对不上时,**以日志为准**,也别为了好看改自己的计数。
40
+ ```
41
+
42
+ ## 9. 上下文水位自查
43
+
44
+ 每个项目库的 `RULES.md` 必须含一行「上下文水位自查」(说明该库会话在多少水位时写交接、多少时换任)。
45
+ 闸门(ho_gate)会逐库检查:标题行在、24 行正文一行不缺、水位自查在。缺任何一项,该库判红。
46
+
47
+ ## 10. 工作区约定
48
+
49
+ - 全局交接:`_Control_Center/records/Global_Handover.md`(ho_handover 读写,写前自动备份)
50
+ - 台账:`_Control_Center/records/Global_Changelog.md`(ho_changelog 按格式追加)
51
+ - 会话提示词模板:`_Control_Center/templates/Prompt_Templates.md`(ho_sessions 的 action=prompt 渲染)
52
+ - 项目库:`Projects/<项目名>/<库名>/`,每库一份 `RULES.md`;项目交接在 `_Project_Meta/<项目名>/Handover.md`
53
+ - 杂项:`_Misc/tasks/<任务短名>/`;归档 `_Archives/`;临时与快照 `_Temp/`
@@ -0,0 +1,56 @@
1
+ # 全局 交接文档
2
+
3
+ > 本文件由 **dsh-handover 首次安装向导**生成。§7 里那行 session id 是**占位**——第一个全局会话接手时把它换成本机真实的 id。
4
+
5
+ ## 0. 身份与寻址
6
+
7
+ | 项 | 值 |
8
+ |---|---|
9
+ | 会话类型 | 全局 |
10
+ | 本任 | 待第一个全局会话接手 |
11
+ | 链内代数 | 第 1 代(本机序号,接手时核对) |
12
+ | 接任 | 无(首任) |
13
+ | 上级、报告对象 | 用户 |
14
+ | 本文件 | _Control_Center/records/Global_Handover.md |
15
+ | 冲突以谁为准 | rules/Workspace_Charter.md |
16
+ | 修订 | 首次安装向导生成 |
17
+
18
+ ## 1. 正在做的事
19
+
20
+ | 项 | 现状 |
21
+ |---|---|
22
+ | 初始化 | 工作区骨架已生成;等第一个标题含「全局」的会话接手 |
23
+
24
+ ## 2. 下一步
25
+
26
+ | # | 动作 | 落点 | 完成判据 |
27
+ |---|---|---|---|
28
+ | 1 | 全局会话接手:核闸门、把 §0 身份信息填实 | 本文件 §0 | ho_gate 三项全绿 |
29
+ | 2 | 按需新建项目库 | Projects/ | 各库 RULES.md 24/24 + 上下文水位自查 |
30
+
31
+ ## 3. 待用户决定
32
+
33
+ (暂无)
34
+
35
+ ## 4. 用户要求逐条落位
36
+
37
+ | # | 用户原话要点 | 状态 | 落在哪 |
38
+ |---|---|---|---|
39
+ | 1 | 安装插件并在指定目录建好全局根 | 已落地 | 本文件 |
40
+
41
+ ## 5. 坑点与禁区
42
+
43
+ - 禁区:改 §8.1.1.4 那 24 行正文会让所有项目库对不上——要改就同步改全部库。
44
+
45
+ ## 6. 关键路径
46
+
47
+ - 交接:_Control_Center/records/Global_Handover.md
48
+ - 规则:_Control_Center/rules/Workspace_Charter.md
49
+ - 台账:_Control_Center/records/Global_Changelog.md
50
+ - 提示词模板:_Control_Center/templates/Prompt_Templates.md
51
+
52
+ ## 7. 会话 id 对照
53
+
54
+ | 角色 | session id | 状态 |
55
+ |---|---|---|
56
+ | 全局 | session-00000000-0000-0000-0000-000000000000 | 占位:接手时换成本机真实的全局会话 id |