@mingmaple/dsh-handover 0.9.14 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +123 -92
  2. package/docs/migrate.md +52 -41
  3. package/docs/releases/0.9.12.md +2 -3
  4. package/docs/releases/0.9.14.md +1 -5
  5. package/docs/releases/0.9.22.md +34 -0
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +230 -46
  8. package/package.json +74 -71
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +41 -25
  11. package/scripts/blank-smoke.mjs +278 -109
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/e2e-migration.mjs +2 -2
  14. package/scripts/office.mjs +3 -3
  15. package/scripts/portability.mjs +71 -0
  16. package/scripts/style-gate.mjs +571 -0
  17. package/src/client.js +1028 -955
  18. package/src/index.mjs +313 -142
  19. package/src/lib/absrefs.mjs +1 -1
  20. package/src/lib/api.mjs +18 -10
  21. package/src/lib/archive.mjs +1 -1
  22. package/src/lib/brief.mjs +8 -6
  23. package/src/lib/config.mjs +23 -8
  24. package/src/lib/consent.mjs +54 -1
  25. package/src/lib/docmap.mjs +140 -0
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/events.mjs +1 -1
  28. package/src/lib/facts.mjs +19 -12
  29. package/src/lib/gates.mjs +30 -6
  30. package/src/lib/global-task.mjs +69 -0
  31. package/src/lib/guide.mjs +81 -85
  32. package/src/lib/handover-io.mjs +69 -6
  33. package/src/lib/identity.mjs +330 -0
  34. package/src/lib/legacy.mjs +89 -0
  35. package/src/lib/lint.mjs +17 -13
  36. package/src/lib/manual.mjs +135 -59
  37. package/src/lib/mover.mjs +23 -19
  38. package/src/lib/names.mjs +14 -19
  39. package/src/lib/office.mjs +63 -15
  40. package/src/lib/pack-ops.mjs +11 -11
  41. package/src/lib/pathkey.mjs +24 -0
  42. package/src/lib/peer.mjs +4 -4
  43. package/src/lib/phase.mjs +8 -13
  44. package/src/lib/profile.mjs +1 -1
  45. package/src/lib/reminder.mjs +110 -0
  46. package/src/lib/role.mjs +9 -6
  47. package/src/lib/scaffold.mjs +68 -26
  48. package/src/lib/schedule.mjs +5 -6
  49. package/src/lib/session-create.mjs +3 -3
  50. package/src/lib/session-ops.mjs +80 -51
  51. package/src/lib/sessions.mjs +41 -3
  52. package/src/lib/sevenzip.mjs +10 -1
  53. package/src/lib/status.mjs +337 -176
  54. package/src/lib/succession.mjs +16 -16
  55. package/src/lib/system.mjs +364 -0
  56. package/src/lib/zstd-log.mjs +1 -1
  57. package/src/selftest.mjs +6 -6
  58. package/src/templates/Changelog.skeleton.md +3 -3
  59. package/src/templates/Charter.skeleton.md +13 -9
  60. package/src/templates/Handover.global.skeleton.md +20 -13
  61. package/src/templates/Handover.template.md +89 -0
  62. package/src/templates/Plan.skeleton.md +34 -0
  63. package/src/templates/Prompt_Templates.md +101 -20
  64. package/src/templates/Runtime_Manual.md +263 -122
  65. package/src/templates/System.skeleton.md +31 -0
  66. package/src/templates/Systems.global.skeleton.md +13 -0
  67. package/src/templates/rules/Delegation_Rules.md +45 -0
  68. package/src/templates/rules/Handover_Rules.md +46 -23
  69. package/src/templates/rules/Migration_Rules.md +7 -7
  70. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  71. package/src/templates/rules/Writing_Rules.md +33 -16
  72. package/src/templates/standards/README.md +37 -0
  73. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  74. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203//344/276/235/346/215/256_/350/256/272/346/226/207.md +389 -0
  75. package/src/tools/ho_backup.mjs +135 -0
  76. package/src/tools/ho_changelog.mjs +4 -4
  77. package/src/tools/ho_charter.mjs +7 -6
  78. package/src/tools/ho_checkpoint.mjs +36 -32
  79. package/src/tools/ho_docmap.mjs +239 -0
  80. package/src/tools/ho_gate.mjs +11 -5
  81. package/src/tools/ho_handover.mjs +61 -35
  82. package/src/tools/ho_office.mjs +46 -5
  83. package/src/tools/ho_profile.mjs +3 -3
  84. package/src/tools/ho_sessions.mjs +46 -26
  85. package/src/tools/ho_status.mjs +251 -0
  86. package/src/tools/ho_succeed.mjs +3 -3
  87. package/src/tools/ho_workspace.mjs +146 -42
  88. package/src/tools/peer.mjs +416 -0
  89. package/src/tools/ho_brief.mjs +0 -57
  90. package/src/tools/ho_diag.mjs +0 -87
  91. package/src/tools/ho_drift.mjs +0 -41
  92. package/src/tools/ho_init.mjs +0 -82
  93. package/src/tools/ho_pack.mjs +0 -38
  94. package/src/tools/ho_phase.mjs +0 -37
  95. package/src/tools/ho_snapshot.mjs +0 -104
  96. package/src/tools/ho_tokens.mjs +0 -57
  97. package/src/tools/peer_list.mjs +0 -37
  98. package/src/tools/peer_new.mjs +0 -75
  99. package/src/tools/peer_peek.mjs +0 -41
  100. package/src/tools/peer_rename.mjs +0 -31
  101. package/src/tools/peer_send.mjs +0 -52
  102. package/src/tools/peer_status.mjs +0 -46
@@ -1,177 +1,338 @@
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 || '') };
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
- // 设置页用:已接入工作区但没有全局会话时,给出补建入口。
169
- out.globalSessions = guard(() => {
170
- const all = (out.sessions && Array.isArray(out.sessions.all)) ? out.sessions.all : [];
171
- return all.filter((r) => r && r.role === 'global').length;
172
- }, 0);
173
-
174
- if (out.gate && !out.gate.ok && !out.gate.error) out.notes.push('通用约束条闸门未过:向对应项目会话派发,全局不代写项目库内文件。');
175
- if (out.handover && out.handover.fail) out.notes.push(out.handover.fail + ' 个项目交接体检不合格,交接前必须先补。');
176
- return out;
1
+ /**
2
+ * 设置页用的状态快照(只读):插件自身状态 + 闸门 + 交接体检 + 会话水位 + 最近一次守卫判定。
3
+ * 全部读本地文件,不唤醒任何会话、不花任何会话的 token。
4
+ * 任何一段出错都只让该段带 error 字段,不让整页 500。
5
+ */
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { pathKey } from './pathkey.mjs';
10
+ import { syncCheck } from './gates.mjs';
11
+ import { lintGlobal, lintProject } from './lint.mjs';
12
+ import { scanSessions } from './sessions.mjs';
13
+ import { routeWorkspace, hasCenter } from './workspace.mjs';
14
+ import { stampTz } from './brief.mjs';
15
+ import { buildGuide, SETUP_STEPS } from './guide.mjs';
16
+ import { readRoles, resolveRole, ROLE_LABEL, sessionMeta } from './role.mjs';
17
+ import { listConsent } from './consent.mjs';
18
+ import { listProfiles } from './profile.mjs';
19
+ import { loadOffice, readRegistryProjects, KIND_LABEL } from './office.mjs';
20
+ import { checkRegistries } from './system.mjs';
21
+ import { legacyInfo } from './legacy.mjs';
22
+
23
+ function guard(fn, fallback) {
24
+ try { return fn(); } catch (e) { return { ...fallback, error: String(e && e.message ? e.message : e) }; }
25
+ }
26
+
27
+ /**
28
+ * DSH 工作区索引:读 `~/.dsh/storages/workspace.json`(只读)。
29
+ * 返回 { list, bySession, archived }:
30
+ * - list:按登记顺序的工作区(key、title、path);
31
+ * - bySession:sessionId → 工作区;
32
+ * - archived:已归档会话 id,设置页不再罗列。
33
+ * 读不到时全部为空,调用方据此退化为「按会话目录分组、只按归档表过滤」。
34
+ */
35
+ function loadWorkspaceIndex() {
36
+ const out = { list: [], bySession: new Map(), archived: new Set() };
37
+ let v = null;
38
+ try {
39
+ const file = path.join(os.homedir(), '.dsh', 'storages', 'workspace.json');
40
+ v = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
41
+ } catch { return out; }
42
+ if (!v || typeof v !== 'object') return out;
43
+ const tables = v.tables && v.tables.workspaces ? v.tables.workspaces : {};
44
+ const order = v.global && Array.isArray(v.global.workspaceIds) && v.global.workspaceIds.length
45
+ ? v.global.workspaceIds : Object.keys(tables);
46
+ for (const key of order) {
47
+ const w = tables[key];
48
+ if (!w) continue;
49
+ const item = { key: String(key), title: String(w.title || ''), path: String(w.path || '') };
50
+ out.list.push(item);
51
+ for (const sid of (Array.isArray(w.sessionIds) ? w.sessionIds : [])) out.bySession.set(String(sid), item);
52
+ }
53
+ for (const sid of (v.global && Array.isArray(v.global.archivedSessionIds) ? v.global.archivedSessionIds : [])) {
54
+ out.archived.add(String(sid));
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** 仍然存在的会话目录:`~/.dsh/sessions` 下以 session- 开头的目录名(含已归档里恢复不了的会话就自然不在)。 */
60
+ function liveSessionDirs() {
61
+ const set = new Set();
62
+ try {
63
+ const walk = (dir, depth) => {
64
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
65
+ if (!e.isDirectory()) continue;
66
+ if (e.name.indexOf('session-') === 0) set.add(e.name);
67
+ else if (depth < 4) walk(path.join(dir, e.name), depth + 1);
68
+ }
69
+ };
70
+ walk(path.join(os.homedir(), '.dsh', 'sessions'), 0);
71
+ } catch { /* 读不到就返回空集 */ }
72
+ return set;
73
+ }
74
+
75
+ /** 在任登记:Office.json 中每条登记(全局、各项目、各杂项任务)的现任。
76
+ * 返回 sessionId → { key, kind, kindLabel, name };读不到登记表时为空表,调用方据此退化为「不过滤」。
77
+ * 普通会话不登记,因此不会被当成在任会话。
78
+ */
79
+ function loadDuty(ws) {
80
+ const map = new Map();
81
+ let office = null;
82
+ try { office = loadOffice(ws); } catch { office = null; }
83
+ const lineages = (office && office.lineages) || {};
84
+ for (const key of Object.keys(lineages)) {
85
+ const lin = lineages[key] || {};
86
+ const cur = lin.current;
87
+ if (!cur || !cur.sessionId) continue;
88
+ map.set(String(cur.sessionId), {
89
+ key,
90
+ kind: String(lin.kind || ''),
91
+ kindLabel: KIND_LABEL[lin.kind] || '',
92
+ project: String(lin.project || ''),
93
+ task: String(lin.task || ''),
94
+ name: String(cur.name || key),
95
+ });
96
+ }
97
+ return { map, office };
98
+ }
99
+
100
+ /** 项目交接体检:只查 Registry.md 已登记的项目,未登记的旧目录不在此列。 */
101
+ function lintProjects(ws) {
102
+ const out = [];
103
+ let names = [];
104
+ try { names = readRegistryProjects(ws); } catch { return out; }
105
+ for (const name of names) {
106
+ const r = lintProject(path.join(ws, '_Project_Meta', name, 'Handover.md'));
107
+ out.push({ name, verdict: r.verdict, reasons: Array.isArray(r.reasons) ? r.reasons.slice(0, 5) : [] });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** 杂项目录:规范位置 `_Misc/tasks/<任务>/`,兼容旧布局 `_Misc/<任务>/`;两处都没有就返回空串。 */
113
+ function miscDir(ws, task) {
114
+ const name = String(task || '').trim();
115
+ if (!name) return '';
116
+ const cands = [path.join(ws, '_Misc', 'tasks', name), path.join(ws, '_Misc', name)];
117
+ for (const dir of cands) {
118
+ try { if (fs.statSync(dir).isDirectory()) return dir; } catch { /* 继续看下一个 */ }
119
+ }
120
+ return '';
121
+ }
122
+
123
+ /**
124
+ * 杂项交接体检:按 Office.json 里已登记的杂项条目找任务目录。
125
+ * 登记名与目录名不一致(例如登记「示例任务」、目录名不同)时报「未找到」,
126
+ * 不猜近似目录:猜错会把别人的交接算到这条任务头上。
127
+ */
128
+ function lintMisc(ws, office) {
129
+ const out = [];
130
+ const lineages = (office && office.lineages) || {};
131
+ for (const key of Object.keys(lineages)) {
132
+ const lin = lineages[key] || {};
133
+ if (String(lin.kind || '') !== 'misc') continue;
134
+ const name = String(lin.task || key).trim();
135
+ if (!name) continue;
136
+ const dir = miscDir(ws, name);
137
+ const r = dir
138
+ ? lintProject(path.join(dir, 'Handover.md'))
139
+ : { verdict: '未找到', reasons: ['按登记名找不到任务目录:_Misc/tasks/' + name + ' 或 _Misc/' + name] };
140
+ out.push({ name, verdict: r.verdict, reasons: Array.isArray(r.reasons) ? r.reasons.slice(0, 5) : [] });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ export function buildStatus(env, extra) {
146
+ const info = extra || {};
147
+ const ws = routeWorkspace(env);
148
+ const found = hasCenter(ws);
149
+ const warn = Math.round((env.cfg.remindRatio || 0.35) * 100);
150
+ const duty = loadDuty(ws);
151
+ const onDuty = (sid) => (duty.map.size ? duty.map.has(String(sid || '')) : true);
152
+ const dutyOf = (sid) => duty.map.get(String(sid || '')) || null;
153
+ const out = {
154
+ generatedAt: stampTz(Date.now()),
155
+ plugin: {
156
+ name: info.name || '@mingmaple/dsh-handover',
157
+ version: info.version || '?',
158
+ tools: typeof info.tools === 'number' ? info.tools : 0,
159
+ inject: guard(() => env.injectList(), []),
160
+ initErrors: guard(() => env.initErrors(), []),
161
+ scheduler: info.scheduler || '未知',
162
+ },
163
+ workspace: { root: ws, found },
164
+ ratios: guard(() => ({
165
+ remind: Math.round((env.cfg.remindRatio !== undefined ? env.cfg.remindRatio : 0.35) * 100),
166
+ remind2: Math.round((env.cfg.remind2Ratio !== undefined ? env.cfg.remind2Ratio : 0.5) * 100),
167
+ handover: Math.round((env.cfg.handoverRatio !== undefined ? env.cfg.handoverRatio : 0.35) * 100),
168
+ hard: Math.round((env.cfg.hardRatio !== undefined ? env.cfg.hardRatio : 0.85) * 100),
169
+ }), { remind: 35, remind2: 50, handover: 35, hard: 85 }),
170
+ guardLast: guard(() => env.guardLast(), null),
171
+ extraPrompt: guard(() => (typeof env.extraPrompt === 'function' ? env.extraPrompt() : ''), ''),
172
+ extraPromptEnabled: guard(() => (typeof env.extraPromptEnabled === 'function' ? env.extraPromptEnabled() : false), false),
173
+ extraPromptOrder: 950,
174
+ roles: guard(() => {
175
+ const store = readRoles(env.cfg);
176
+ const map = {};
177
+ for (const sid of Object.keys(store.sessions)) {
178
+ const e = store.sessions[sid] || {};
179
+ map[sid] = { role: String(e.role || ''), project: String(e.project || ''), task: String(e.task || ''), setAt: String(e.setAt || '') };
180
+ }
181
+ return map;
182
+ }, {}),
183
+ consent: guard(() => listConsent().map((r) => ({ ...r, onduty: onDuty(r.sessionId) })), []),
184
+ profiles: guard(() => listProfiles(ws), []),
185
+ guide: guard(() => buildGuide(env.cfg), { intro: [], situations: [], groups: [], rules: [] }),
186
+ gate: null,
187
+ handover: null,
188
+ sessions: null,
189
+ notes: [],
190
+ };
191
+
192
+ if (!found) {
193
+ out.notes.push('未找到 _Control_Center:本实例尚未接入工作区。可在插件 config 中填写 workspaceRoot。');
194
+ out.notes.push('插件本身已加载:工具与设置页不依赖工作区,仅闸门、交接与会话三部分需要工作区。');
195
+ out.setup = { steps: SETUP_STEPS };
196
+ return out;
197
+ }
198
+
199
+ out.gate = guard(() => {
200
+ const g = syncCheck(ws);
201
+ return {
202
+ ok: !!g.ok,
203
+ error: g.error || '',
204
+ verdict: g.verdict || '',
205
+ total: g.total || 0,
206
+ bodyLines: g.bodyLines || 0,
207
+ libraries: (g.libraries || []).map((r) => ({
208
+ name: r.name, ok: !!r.ok, titleOk: !!r.titleOk, waterOk: !!r.waterOk,
209
+ missing: r.missing ? r.missing.length : 0, lines: r.total || 0,
210
+ })),
211
+ };
212
+ }, { ok: false, libraries: [] });
213
+
214
+ out.handover = guard(() => {
215
+ const g = lintGlobal(path.join(ws, '_Control_Center', 'records', 'Global_Handover.md'));
216
+ const projects = lintProjects(ws);
217
+ const misc = lintMisc(ws, duty.office);
218
+ const bad = projects.concat(misc).filter((p) => p.verdict === '不合格');
219
+ return {
220
+ global: { verdict: g.verdict, reasons: Array.isArray(g.reasons) ? g.reasons.slice(0, 5) : [] },
221
+ projects,
222
+ misc,
223
+ fail: bad.length,
224
+ };
225
+ }, { global: { verdict: '读不到', reasons: [] }, projects: [], misc: [], fail: 0 });
226
+
227
+ out.systems = guard(() => {
228
+ const r = checkRegistries(ws);
229
+ return { rows: r.rows.map((x) => ({ label: x.label, rel: x.rel, exists: x.exists, entries: x.entries, bad: x.bad.length })), missing: r.missing };
230
+ }, { rows: [], missing: 0 });
231
+
232
+ try {
233
+ const legacy = legacyInfo(ws);
234
+ if (legacy.exists) {
235
+ out.notes.push('检测到旧文档定位(_Control_Center/records/docmap/):执行 ho_workspace(action="migrate") 并入体系注册。');
236
+ }
237
+ } catch { /* 旧目录检查失败不影响整页 */ }
238
+
239
+ out.sessions = guard(() => {
240
+ const wide = scanSessions({
241
+ dir: env.cacheDir(), meId: null, meCwd: null, warn,
242
+ silentHours: env.cfg.sessionScanHours, projectsOnly: false,
243
+ });
244
+ const store = readRoles(env.cfg);
245
+ const roleOf = (sid, cwd, firstPrompt, title) => {
246
+ const meta = sessionMeta(sid);
247
+ return resolveRole({ cfg: env.cfg, roles: store, sessionId: sid, cwd, firstPrompt, subagent: meta.subagent, title });
248
+ };
249
+ // 工作区与归档:设置页按工作区分组罗列,已归档与目录已不存在的会话不再出现。
250
+ const wsIndex = loadWorkspaceIndex();
251
+ const live = liveSessionDirs();
252
+ // 会话 id 在缓存里有带与不带 session- 前缀两种写法,统一成带前缀再比。
253
+ const sidKey = (sid) => (/^session-/.test(String(sid)) ? String(sid) : 'session-' + String(sid));
254
+ const archived = (sid) => wsIndex.archived.has(sidKey(sid));
255
+ const stillThere = (sid) => live.size === 0 || live.has(sidKey(sid));
256
+ // 分组只认 DSH 工作区登记表的 sessionIds,不按 cwd 推断归属。
257
+ // 未登记的会话归入「未分组」,与 DSH 侧栏一致;cwd 推断会把未分组会话误并入同名工作区。
258
+ const realCache = new Map();
259
+ const realOf = (p) => {
260
+ const key = pathKey(String(p || ''));
261
+ if (realCache.has(key)) return realCache.get(key);
262
+ let v = '';
263
+ try { v = fs.realpathSync(String(p || '')); } catch { v = ''; }
264
+ realCache.set(key, v);
265
+ return v;
266
+ };
267
+ const wsOf = (sid) => wsIndex.bySession.get(sidKey(sid)) || null;
268
+ const mapRow = (r) => {
269
+ const meta = sessionMeta(r.sid);
270
+ const info = roleOf(r.sid, meta.cwd, meta.firstPrompt, r.project);
271
+ const d = dutyOf(r.sid);
272
+ const w = wsOf(r.sid);
273
+ // 目录已不存在的老会话(旧树里删掉的项目库等)不再罗列。
274
+ const cwdOk = !meta.cwd || !!realOf(meta.cwd);
275
+ return {
276
+ sid: r.sid,
277
+ title: r.project === '-' ? '' : r.project,
278
+ project: r.project,
279
+ role: info.role,
280
+ roleLabel: ROLE_LABEL[info.role] || info.role,
281
+ source: info.source,
282
+ projectName: info.project,
283
+ task: info.task,
284
+ cwdOk,
285
+ // 工作区分组:只认 DSH 工作区登记;未登记时留空,客户端归入「未分组」。
286
+ wsKey: w ? w.key : '',
287
+ wsTitle: w ? w.title : '',
288
+ wsPath: w ? w.path : String(meta.cwd || ''),
289
+ // 类别与名称以在任登记为准:登记过的会话才能说清自己是全局、项目还是杂项。
290
+ dutyKind: d ? d.kindLabel : '',
291
+ dutyKey: d ? d.key : '',
292
+ dutyName: d ? d.name : '',
293
+ pct: r.pct,
294
+ state: r.state,
295
+ active: r.mtime >= wide.cutoffMs,
296
+ onduty: !!d,
297
+ mtime: r.mtime,
298
+ seen: stampTz(r.mtime),
299
+ };
300
+ };
301
+ const all = wide.rows
302
+ .filter((r) => r.kind === '平级' && !archived(r.sid) && stillThere(r.sid))
303
+ .map(mapRow);
304
+ // 不按 cwd 是否存在过滤:DSH 的工作区与会话列表只认会话本身与归档表。
305
+ // cwd 已删除的旧会话仍可能被打开或恢复,误删会把 DSH 中存在的会话藏掉。
306
+ // 在任集合:全局、各项目、各杂项任务的现任各一行;普通会话不在登记表里,因此不出现。
307
+ const rows = all.filter((r) => r.onduty).slice(0, 60);
308
+ // 通知:每条登记的在任会话,若本轮仍活跃;已交出的会话即使更活跃也不通知。
309
+ const notify = [];
310
+ for (const sid of duty.map.keys()) {
311
+ const hit = rows.find((r) => r.sid === sid);
312
+ if (hit && hit.mtime >= wide.cutoffMs) notify.push({ sid: hit.sid, key: hit.dutyKey, name: hit.dutyName });
313
+ }
314
+ const hot = rows.filter((r) => r.pct !== null && r.pct >= warn).length;
315
+ return {
316
+ rows,
317
+ all,
318
+ workspaces: wsIndex.list.map((w) => ({ key: w.key, title: w.title || w.path, path: w.path })),
319
+ notify,
320
+ onduty: duty.map.size,
321
+ hot,
322
+ unknown: wide.unknown.length,
323
+ cutoff: stampTz(wide.cutoffMs),
324
+ silentHours: wide.silentHours,
325
+ verdict: hot
326
+ ? hot + ' 个在任会话已过 ' + warn + '% 线,应先收齐交接,再更换会话'
327
+ : '没有在任会话超过 ' + warn + '% 线。',
328
+ };
329
+ }, { rows: [], notify: [], hot: 0, unknown: 0 });
330
+
331
+ // 设置页用:已接入工作区但没有全局会话时,给出补建入口。
332
+ out.globalSessions = guard(() => {
333
+ const all = (out.sessions && Array.isArray(out.sessions.all)) ? out.sessions.all : [];
334
+ return all.filter((r) => r && r.role === 'global').length;
335
+ }, 0);
336
+
337
+ return out;
177
338
  }
@@ -100,7 +100,7 @@ function appendSuccessionBlock(file, lines) {
100
100
  export async function runSuccession(env, exec, opts) {
101
101
  const o = opts || {};
102
102
  const note = typeof o.note === 'string' ? o.note.trim() : '';
103
- const reject = (reason) => '换任未执行,未做任何写入。' + NL + reason;
103
+ const reject = (reason) => '换任未执行,未写入任何内容。' + NL + reason;
104
104
 
105
105
  // 1. 自身角色与类别:只认已登记的角色。
106
106
  const info = env.roleOf(exec) || {};
@@ -114,14 +114,14 @@ export async function runSuccession(env, exec, opts) {
114
114
 
115
115
  // 2. 用户授权:AI 不能自行批准。
116
116
  const selfId = env.selfId(exec);
117
- if (!selfId) return reject('拿不到当前会话 id,无法校验授权。');
117
+ if (!selfId) return reject('无法获取当前会话 id,无法校验授权。');
118
118
  const cs = checkConsent(selfId, 'succeed');
119
119
  if (!cs.ok) {
120
120
  return reject([
121
121
  cs.reason + '。',
122
122
  '授权方式(任选其一):',
123
- ' ① 在聊天框发送 /allow-succeed(最快,仅授权本会话);',
124
- ' ② 在设置页的 dsh-handover 中点击「换任授权」的「允许换任」。',
123
+ ' ① 在聊天框发送 /allow-succeed(仅授权本会话,最为快捷);',
124
+ ' ② 在设置页的 dsh-handover 中,点击「授权」内的「授权换任」。',
125
125
  '授权有效期 5 分钟,用后失效。',
126
126
  ].join(NL));
127
127
  }
@@ -129,7 +129,7 @@ export async function runSuccession(env, exec, opts) {
129
129
  // 3. 交接结构检查(只判结构,不判质量)。
130
130
  const hp = env.handoverFor(exec) || {};
131
131
  const handoverFile = hp.path ? String(hp.path) : '';
132
- if (!handoverFile) return reject('没有交接路径:' + (hp.reason || '本会话角色未确定'));
132
+ if (!handoverFile) return reject('没有可用的交接路径:' + (hp.reason || '本会话角色未确定'));
133
133
  const problems = auditHandover(handoverFile);
134
134
  if (problems.length) {
135
135
  return reject(['交接结构未通过:' + handoverFile]
@@ -145,10 +145,10 @@ export async function runSuccession(env, exec, opts) {
145
145
  const lin = store.lineages[key];
146
146
  // 只有本类别的在任者本人可换任:既防旧会话复活,也防同项目的旁支会话抢先。
147
147
  if (!lin || !lin.current || !lin.current.sessionId) {
148
- return reject('本类别(' + key + ')在在任登记表里还没有现任记录:先由全局建会话并登记,再换任。');
148
+ return reject('本类别(' + key + ')在在任登记表中尚无在任记录:先由全局建立会话并登记,再换任。');
149
149
  }
150
150
  if (String(lin.current.sessionId) !== String(selfId)) {
151
- return reject('本会话不是在任者:本类别现任为 ' + String(lin.current.name || '(未命名)')
151
+ return reject('本会话不是在任者:本类别在任为 ' + String(lin.current.name || '(未命名)')
152
152
  + '(' + String(lin.current.sessionId) + '),不得换任。');
153
153
  }
154
154
  {
@@ -156,17 +156,17 @@ export async function runSuccession(env, exec, opts) {
156
156
  const closed = history.find((h) => h && String(h.sessionId || '') === String(selfId) && h.to);
157
157
  if (closed) {
158
158
  const curName = lin.current && lin.current.name ? lin.current.name : '(空缺)';
159
- return reject('本类别已为本会话记过一次换任(' + String(closed.to || '') + ',现任 ' + curName + '),旧会话不得再次换任。');
159
+ return reject('本类别已为本会话记过一次换任(' + String(closed.to || '') + ',在任 ' + curName + '),旧会话不得再次换任。');
160
160
  }
161
161
  }
162
162
 
163
- // 4.1 水位未到线:本会话就是在任者且水位低于提醒线时,换任通常是多余的。
163
+ // 4.1 水位未到线:本会话就是在任者且水位低于提醒线时,换任通常多余。
164
164
  // 放在授权与交接校验之后、任何写入之前;取不到水位就跳过。
165
165
  if (o.force !== true) {
166
166
  let pct = null;
167
167
  try { pct = typeof env.selfPct === 'function' ? env.selfPct(selfId) : null; } catch { pct = null; }
168
- if (pct !== null && pct !== undefined && Number(pct) < watermark(env.cfg).warnValue) {
169
- return reject('本会话水位 ' + Number(pct).toFixed(1) + '%,尚未到线,通常无需换任;确需换任请传 force=true。');
168
+ if (pct !== null && pct !== undefined && Number(pct) < watermark(env.cfg).remindValue) {
169
+ return reject('本会话水位 ' + Number(pct).toFixed(1) + '%,未到提醒线,通常无需换任;确需换任请传 force=true。');
170
170
  }
171
171
  }
172
172
 
@@ -188,14 +188,14 @@ export async function runSuccession(env, exec, opts) {
188
188
  }
189
189
 
190
190
  // 7. 拼启动提示词:末尾追加交接路径与「先读交接、读毕报到」。
191
- // 「全局 id」填**现任全局**:项目与杂项换任时,前任不是全局,不能顶替这个位置。
191
+ // 「全局 id」填**现任全局**:项目与杂项换任时,已交出的旧会话不是全局,不能顶替这个位置。
192
192
  let prompt = '';
193
193
  try {
194
194
  const globalId = kind === 'global' ? selfId : (currentGlobalId(ws) || selfId);
195
195
  const base = promptFor(env, { ws, kind, project, task, title: name, sessionId: '', globalSessionId: globalId, extra: '' });
196
196
  prompt = base + NL + NL + [
197
197
  '接任交接:' + handoverFile,
198
- '先读这份交接再动手;读毕向用户报告当前停在哪一步、交接列有哪些待办,然后等待用户指派任务。',
198
+ '先读这份交接,再开始作业。读毕向当前用户报告:当前停在哪一步;交接列有哪些待办。随后等待当前用户指派任务。',
199
199
  ].join(NL);
200
200
  } catch (e) {
201
201
  return reject('启动提示词生成失败:' + msgOf(e));
@@ -215,7 +215,7 @@ export async function runSuccession(env, exec, opts) {
215
215
  } catch (e) {
216
216
  return [
217
217
  '会话已建 ' + sessionId + '(' + name + '),但登记在任失败:' + msgOf(e),
218
- '启动提示词尚未投递;先修好登记表,再用 peer_send 通知它。',
218
+ '启动提示词尚未投递。先修好登记表,再用 peer(action="send")通知该会话。',
219
219
  ].join(NL);
220
220
  }
221
221
 
@@ -253,8 +253,8 @@ export async function runSuccession(env, exec, opts) {
253
253
  out.push('换任完成:' + name + '(' + sessionId + ')');
254
254
  out.push('在任登记:' + key + (reg && reg.registry && reg.registry.changed && reg.registry.changed.length
255
255
  ? ',Registry.md 已同步在役列' : ''));
256
- out.push('启动提示词:' + (delivered ? '已投递' : '未送达:' + deliveryError + '。会话已建,可用 peer_send 补投'));
256
+ out.push('启动提示词:' + (delivered ? '已投递' : '未送达:' + deliveryError + '。会话已建,可用 peer(action="send")补投。'));
257
257
  out.push('交接登记:' + appendNote);
258
- out.push('本会话到此停止,等待用户在新会话中派任务。');
258
+ out.push('本会话到此停止,等待当前用户在新会话中指派任务。');
259
259
  return out.join(NL);
260
260
  }