@jjh32456/dsh-longlive 1.6.7 → 1.8.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 (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +4 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +47 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
@@ -1,198 +0,0 @@
1
- // @local/dsh-longlive — the CLI's own "is this session still running?" gate
2
- //
3
- // scripts/truncate-cli.mjs is spawned by the orchestrator after the app was
4
- // supposed to exit. It has no tool context, so it must answer the live-target
5
- // question itself. These cases pin that contract:
6
- // * the CLI source does not import the tool layer (the gate cannot be the
7
- // tool's guard reached through a second door);
8
- // * a fresh live-registry row refuses with SESSION_IS_RUNNING and leaves
9
- // every byte alone;
10
- // * an inherited $DSH_SESSION_ID naming the target refuses the same way;
11
- // * the exclusive-handle probe is wired in (held -> running);
12
- // * with no live signal the CLI cuts normally.
13
- //
14
- // Fixtures are synthetic logs under TMP; nothing here reads ~/.dsh/sessions.
15
-
16
- import fs from 'node:fs';
17
- import path from 'node:path';
18
- import crypto from 'node:crypto';
19
- import { spawnSync } from 'node:child_process';
20
- import { fileURLToPath } from 'node:url';
21
-
22
- import { writeFixture } from './truncate-visible.test.mjs';
23
- import { detectLiveTarget, LIVE_TARGET_CODES, sessionIdOfLogFile } from '../src/live-target.js';
24
- import { liveUpdate } from '../src/live.js';
25
- import { ERROR_CODES } from '../src/truncate.js';
26
-
27
- const HERE = path.dirname(fileURLToPath(import.meta.url));
28
- const ROOT = path.resolve(HERE, '..');
29
- const CLI = path.join(ROOT, 'scripts', 'truncate-cli.mjs');
30
-
31
- function sha256(file) {
32
- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
33
- }
34
-
35
- function runCli(args, envOverrides = {}) {
36
- const env = { ...process.env, ...envOverrides };
37
- for (const [key, value] of Object.entries(envOverrides)) {
38
- if (value === undefined) delete env[key];
39
- }
40
- const run = spawnSync(process.execPath, [CLI, ...args], {
41
- cwd: ROOT,
42
- env,
43
- encoding: 'utf8',
44
- timeout: 120_000,
45
- });
46
- const lines = String(run.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
47
- const jsonLine = [...lines].reverse().find((line) => line.startsWith('{')) ?? null;
48
- let json = null;
49
- if (jsonLine) {
50
- try { json = JSON.parse(jsonLine); } catch { json = null; }
51
- }
52
- return {
53
- status: run.status,
54
- json,
55
- stdout: String(run.stdout ?? ''),
56
- stderr: String(run.stderr ?? ''),
57
- };
58
- }
59
-
60
- function visibleArgs(sessionPath, memoryRoot) {
61
- return [
62
- '--session', sessionPath,
63
- '--keep-turns', '5',
64
- '--keep-tokens', '1000000',
65
- '--confirm', 'TRUNCATE-VISIBLE',
66
- '--memory-root', memoryRoot,
67
- ];
68
- }
69
-
70
- export function runCliGuardSuite({ runCase, assert, TMP }) {
71
- const base = path.join(TMP, 'cli-guard');
72
- fs.mkdirSync(base, { recursive: true });
73
- // rc.2: truncate-cli.mjs only writes under <DSH_HOME>/sessions; fixtures
74
- // already live at <base>/home/sessions, so point the child at that home.
75
- const cli = (args, extra = {}) => runCli(args, { DSH_HOME: path.join(base, 'home'), ...extra });
76
-
77
- runCase('CLI1', 'truncate-cli 不 import 工具层,活体判定由 detectLiveTarget 自己完成', () => {
78
- const source = fs.readFileSync(CLI, 'utf8');
79
- assert(!/src[\\/]tools\.js/.test(source), 'CLI source still references src/tools.js');
80
- assert(source.includes('detectLiveTarget'), 'CLI does not call detectLiveTarget');
81
- assert(source.includes('SESSION_IS_RUNNING'), 'CLI does not name the refusal code');
82
- assert(source.includes('--memory-root'), 'CLI cannot receive the memory root');
83
- return 'CLI self-checks via live-target.js only';
84
- });
85
-
86
- runCase('CLI2', 'detectLiveTarget:注册表/独占句柄/$DSH_SESSION_ID 任一命中即 running', () => {
87
- const { file } = writeFixture(base, 'guard-unit');
88
- const targetId = sessionIdOfLogFile(file).id;
89
-
90
- const held = detectLiveTarget(file, {
91
- env: {},
92
- probe: () => {
93
- const error = new Error('held by the app');
94
- error.code = ERROR_CODES.FILE_BUSY;
95
- throw error;
96
- },
97
- });
98
- assert(held.running === true && held.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING,
99
- `held handle -> running=${held.running} code=${held.code}`);
100
- assert(held.reasons.some((item) => item.signal === 'exclusive-handle'),
101
- 'held handle produced no exclusive-handle reason');
102
-
103
- const free = detectLiveTarget(file, { env: {}, probe: () => {} });
104
- assert(free.running === false, `free handle -> running=${free.running}`);
105
- assert(free.checks.some((item) => item.name === 'exclusive-handle' && item.status === 'free'),
106
- 'free handle was not recorded');
107
-
108
- const envHit = detectLiveTarget(file, {
109
- env: { DSH_SESSION_ID: targetId.toUpperCase() },
110
- probe: () => {},
111
- });
112
- assert(envHit.running === true, 'inherited $DSH_SESSION_ID was not treated as running');
113
- assert(envHit.reasons.some((item) => item.signal === 'env-session-id'),
114
- 'env hit produced no env-session-id reason');
115
- assert(envHit.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING, `env hit code ${envHit.code}`);
116
-
117
- const memRoot = path.join(base, 'unit-memory');
118
- fs.mkdirSync(memRoot, { recursive: true });
119
- liveUpdate({ sessionId: targetId, state: 'running' }, { memoryRoot: memRoot });
120
- const registryHit = detectLiveTarget(file, { memoryRoot: memRoot, env: {}, probe: () => {} });
121
- assert(registryHit.running === true, 'fresh live-registry row was not treated as running');
122
- assert(registryHit.reasons.some((item) => item.signal === 'live-registry'),
123
- 'registry hit produced no live-registry reason');
124
- return 'all three signals refuse';
125
- });
126
-
127
- runCase('CLI3', 'CLI 在写前看到 running 注册表行:exit 2 + SESSION_IS_RUNNING,字节不变', () => {
128
- const { file } = writeFixture(base, 'guard-registry');
129
- const targetId = sessionIdOfLogFile(file).id;
130
- const memRoot = path.join(base, 'registry-memory');
131
- fs.mkdirSync(memRoot, { recursive: true });
132
- liveUpdate({ sessionId: targetId, state: 'running' }, { memoryRoot: memRoot });
133
-
134
- const before = sha256(file);
135
- const beforeFiles = fs.readdirSync(path.dirname(file)).sort();
136
- const result = cli(visibleArgs(file, memRoot), { DSH_SESSION_ID: undefined });
137
- assert(result.status === 2, `exit ${result.status} (stderr ${result.stderr.slice(0, 200)})`);
138
- assert(result.json && result.json.ok === false, 'refusal emitted no ok:false JSON');
139
- assert(result.json.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING,
140
- `refusal code ${result.json && result.json.code}`);
141
- assert(sha256(file) === before, 'refused CLI run changed the session bytes');
142
- assert(fs.readdirSync(path.dirname(file)).sort().join('|') === beforeFiles.join('|'),
143
- 'refused CLI run created files beside the session');
144
- return 'registry row blocks the write';
145
- });
146
-
147
- runCase('CLI4', 'CLI 继承 $DSH_SESSION_ID 指向目标会话:exit 2 + SESSION_IS_RUNNING', () => {
148
- const { file } = writeFixture(base, 'guard-env');
149
- const targetId = sessionIdOfLogFile(file).id;
150
- const memRoot = path.join(base, 'env-memory');
151
- fs.mkdirSync(memRoot, { recursive: true });
152
-
153
- const before = sha256(file);
154
- const result = cli(visibleArgs(file, memRoot), { DSH_SESSION_ID: targetId });
155
- assert(result.status === 2, `exit ${result.status}`);
156
- assert(result.json && result.json.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING,
157
- `refusal code ${result.json && result.json.code}`);
158
- const reasons = Array.isArray(result.json.reasons) ? result.json.reasons : [];
159
- assert(reasons.some((item) => item.signal === 'env-session-id'),
160
- 'refusal did not name the env-session-id signal');
161
- assert(sha256(file) === before, 'refused CLI run changed the session bytes');
162
- return 'inherited session id blocks the write';
163
- });
164
-
165
- runCase('CLI6', 'CLI 拒绝 sessions 根外的 --session:exit 2 + E_SESSION_PATH_OUTSIDE,字节不变', () => {
166
- const outsideDir = path.join(base, 'outside');
167
- fs.mkdirSync(outsideDir, { recursive: true });
168
- const outside = path.join(outsideDir, 'session.v4.jsonl.zstd');
169
- fs.copyFileSync(writeFixture(base, 'guard-outside-src').file, outside);
170
- const before = sha256(outside);
171
- const result = cli(visibleArgs(outside, path.join(base, 'outside-memory')), { DSH_SESSION_ID: undefined });
172
- assert(result.status === 2, `exit ${result.status} code=${result.json && result.json.code}`);
173
- assert(result.json && result.json.ok === false, 'outside path emitted no ok:false JSON');
174
- assert(result.json.code === 'E_SESSION_PATH_OUTSIDE', `outside code ${result.json && result.json.code}`);
175
- assert(sha256(outside) === before, 'refused outside path changed the file');
176
- assert(!fs.existsSync(path.join(outsideDir, 'session.v4.jsonl.zstd.bak'))
177
- && !fs.existsSync(path.join(outsideDir, 'session.v4.jsonl.zstd.archive')),
178
- 'refused outside path created side files');
179
- return 'containment refusal keeps a concrete code';
180
- });
181
-
182
- runCase('CLI5', 'CLI 无任何活体信号:正常截断(exit 0,ok true,字节改变)', () => {
183
- const { file } = writeFixture(base, 'guard-control');
184
- const memRoot = path.join(base, 'control-memory');
185
- fs.mkdirSync(memRoot, { recursive: true });
186
-
187
- const before = sha256(file);
188
- const result = cli(visibleArgs(file, memRoot), { DSH_SESSION_ID: undefined });
189
- assert(result.status === 0,
190
- `exit ${result.status} code=${result.json && result.json.code} stdout=${result.stdout.slice(0, 300)}`);
191
- assert(result.json && result.json.ok === true, 'control run did not report ok:true');
192
- assert(result.json.status === 'truncated', `control run status ${result.json && result.json.status}`);
193
- assert(sha256(file) !== before, 'control run did not change the session bytes');
194
- return `control cut ok (${result.json.bytes.before} -> ${result.json.bytes.after} bytes)`;
195
- });
196
- }
197
-
198
- export const CLI_GUARD_CASES = ['CLI1', 'CLI2', 'CLI3', 'CLI4', 'CLI5', 'CLI6'];
@@ -1,408 +0,0 @@
1
- // A1 (conclusion write-back) + A2 (shared-board lastConclusion) tests.
2
- //
3
- // Everything writes under the runner's TMP directory only: the P6 gate in
4
- // test/run.mjs fails the suite if the package tree changes during a run.
5
-
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
-
9
- import {
10
- bridgeMarkerFile,
11
- classifyConclusionText,
12
- conclusionCardFile,
13
- createConclusionBridge,
14
- judgeConclusion,
15
- readBridgeMarker,
16
- } from '../src/conclusion.js';
17
- import {
18
- LAST_CONCLUSION_MAX,
19
- boardHashOf,
20
- createBoardBuilder,
21
- lastConclusionOf,
22
- readBridgeConclusions,
23
- } from '../src/router/service/board.mjs';
24
- import { renderHint } from '../src/router/service/hint.mjs';
25
- import { liveUpdate } from '../src/live.js';
26
-
27
- const NOW_MS = Date.parse('2026-11-05T12:00:00.000Z');
28
- const NOW = new Date(NOW_MS);
29
-
30
- // test/run.mjs passes a plain assert(cond, message) function; keep a small
31
- // object-shaped adapter so the cases below read naturally.
32
- function makeAssert(check) {
33
- const api = (cond, message) => check(cond, message);
34
- api.ok = (value, message) => check(value, message || `expected truthy, got ${String(value)}`);
35
- api.equal = (actual, expected, message) => {
36
- check(actual === expected, message || `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
37
- };
38
- api.notEqual = (actual, expected, message) => {
39
- check(actual !== expected, message || `did not expect ${JSON.stringify(expected)}`);
40
- };
41
- api.deepEqual = (actual, expected, message) => {
42
- check(JSON.stringify(actual) === JSON.stringify(expected),
43
- message || `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
44
- };
45
- return api;
46
- }
47
-
48
- function userEvent(text, extra = {}) {
49
- return {
50
- type: 'user/message',
51
- data: {
52
- content: [{ type: 'text', text }],
53
- source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
54
- role: 'user',
55
- id: `u-${Math.random().toString(16).slice(2)}`,
56
- ...extra,
57
- },
58
- };
59
- }
60
-
61
- function injectedUserEvent(text) {
62
- return {
63
- type: 'user/message',
64
- data: {
65
- content: [{ type: 'text', text }],
66
- source: { kind: 'user' },
67
- role: 'user',
68
- id: 'u-injected',
69
- },
70
- };
71
- }
72
-
73
- function assistantEvent(turn, blocks) {
74
- return {
75
- type: 'assistant/message',
76
- data: {
77
- turn,
78
- step: 1,
79
- message: {
80
- role: 'assistant',
81
- content: blocks,
82
- source: { kind: 'model' },
83
- id: `a-${turn}`,
84
- },
85
- },
86
- };
87
- }
88
-
89
- function turnStart(turn) {
90
- return { type: 'turn/start', data: { turn } };
91
- }
92
-
93
- function turnEnd(turn, kind = 'completed') {
94
- return { type: 'turn/end', data: { turn, reason: { kind } } };
95
- }
96
-
97
- function writeJson(file, value) {
98
- fs.mkdirSync(path.dirname(file), { recursive: true });
99
- fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
100
- }
101
-
102
- export function runConclusionSuite({ runCase, assert: rawAssert, TMP }) {
103
- const assert = makeAssert(rawAssert);
104
-
105
- const fixture = (name) => {
106
- const root = path.join(TMP, `conclusion-${name}`);
107
- fs.mkdirSync(root, { recursive: true });
108
- return root;
109
- };
110
-
111
- const session = { id: 'session-alpha' };
112
-
113
- function feedThreeTurns(bridge) {
114
- bridge.onSessionEvent(session, turnStart(1));
115
- bridge.onSessionEvent(session, userEvent('先看看现在的情况'));
116
- bridge.onSessionEvent(session, assistantEvent(1, [
117
- { type: 'text', text: '现在有两个方案,A 和 B,各有取舍,需要你定一个。' },
118
- ]));
119
- bridge.onSessionEvent(session, turnEnd(1));
120
-
121
- bridge.onSessionEvent(session, turnStart(2));
122
- bridge.onSessionEvent(session, userEvent('A 方案改起来麻烦吗'));
123
- bridge.onSessionEvent(session, assistantEvent(2, [
124
- { type: 'reasoning', text: 'REASONING-内部推理不该进卡' },
125
- { type: 'text', text: 'A 方案改动面大一些,B 方案更稳,两种都能做。' },
126
- ]));
127
- bridge.onSessionEvent(session, turnEnd(2));
128
-
129
- bridge.onSessionEvent(session, turnStart(3));
130
- bridge.onSessionEvent(session, userEvent('改成用 A 方案吧'));
131
- bridge.onSessionEvent(session, assistantEvent(3, [
132
- { type: 'reasoning', text: 'REASONING-最终轮推理' },
133
- { type: 'text', text: '好,按这个来,以后都走 A 方案生成。' },
134
- ]));
135
- bridge.onSessionEvent(session, turnEnd(3));
136
- }
137
-
138
- runCase('K1', 'local decision/fact rules classify without an LLM', () => {
139
- assert.equal(classifyConclusionText('定了,就用 B 方案'), 'decision');
140
- assert.equal(classifyConclusionText('结论是走 A 路线'), 'decision');
141
- assert.equal(classifyConclusionText('结论:走 A 路线'), 'fact');
142
- assert.equal(classifyConclusionText('以后都按这个来'), 'decision');
143
- assert.equal(classifyConclusionText('今天天气不错'), null);
144
- assert.equal(classifyConclusionText(''), null);
145
- assert.equal(judgeConclusion({ userText: '改成 A', assistantText: '好。' }), null);
146
- assert.equal(judgeConclusion({ userText: '随便', assistantText: '窗外的树在动。' }), null);
147
- const judged = judgeConclusion({
148
- userText: '改成用 A 方案',
149
- assistantText: '好,按这个来,以后都走 A 方案生成。',
150
- });
151
- assert.equal(judged.kind, 'decision');
152
- assert.ok(Array.from(judged.body).length <= 500);
153
- assert.ok(Array.from(judged.title).length <= 60);
154
- assert.ok(judged.body.includes('用户:'));
155
- assert.ok(judged.body.includes('助手:'));
156
- });
157
-
158
- runCase('K2', 'three turns write one card; reasoning blocks never reach it', () => {
159
- const root = fixture('card');
160
- const bridge = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
161
- feedThreeTurns(bridge);
162
- const file = conclusionCardFile(root, 'session-alpha', 3);
163
- assert.ok(fs.existsSync(file), 'card should exist for turn 3');
164
- const text = fs.readFileSync(file, 'utf8');
165
- assert.ok(text.startsWith('---\n'));
166
- assert.ok(text.includes('kind: decision'));
167
- assert.ok(text.includes('source_session: session-alpha'));
168
- assert.ok(text.includes('source_turn: 3'));
169
- assert.ok(text.includes('written_at: 2026-11-05'));
170
- assert.ok(!text.includes('REASONING'));
171
- assert.ok(!text.includes('今天天气'));
172
- const body = text.split('\n---\n')[1] ?? '';
173
- assert.ok(Array.from(body).length <= 500, `body too long: ${Array.from(body).length}`);
174
- const marker = readBridgeMarker(root, 'session-alpha');
175
- assert.equal(marker.lastConclusion.turn, 3);
176
- assert.equal(marker.lastConclusion.kind, 'decision');
177
- assert.ok(marker.lastConclusion.text.includes('按这个来'));
178
- assert.ok(Array.from(marker.lastConclusion.text).length <= LAST_CONCLUSION_MAX);
179
- assert.deepEqual(Object.keys(marker.turns), ['3']);
180
- assert.equal(bridge.stats.written, 1);
181
- assert.equal(bridge.stats.skipped, 2);
182
- });
183
-
184
- runCase('K3', 'same session + turn is idempotent (marker survives a new bridge)', () => {
185
- const root = fixture('idempotent');
186
- const first = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
187
- feedThreeTurns(first);
188
- feedThreeTurns(first);
189
- assert.equal(first.stats.duplicates, 1);
190
- const second = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
191
- feedThreeTurns(second);
192
- assert.equal(second.stats.duplicates, 1);
193
- const files = fs.readdirSync(path.join(root, 'topics'));
194
- assert.equal(files.filter((name) => name.startsWith('conclusion-')).length, 1);
195
- });
196
-
197
- runCase('K4', 'privacy gate blocks the card; a throwing gate fails closed', () => {
198
- const root = fixture('privacy');
199
- const skipped = createConclusionBridge({
200
- enabled: true,
201
- memoryRoot: root,
202
- now: () => NOW,
203
- privacyScan: () => ({ verdict: 'local_only', spans: [], reasons: ['test'] }),
204
- });
205
- feedThreeTurns(skipped);
206
- assert.equal(skipped.stats.privacy, 1);
207
- assert.equal(fs.existsSync(conclusionCardFile(root, 'session-alpha', 3)), false);
208
-
209
- const root2 = fixture('privacy-throw');
210
- const failed = createConclusionBridge({
211
- enabled: true,
212
- memoryRoot: root2,
213
- now: () => NOW,
214
- privacyScan: () => { throw new Error('gate down'); },
215
- });
216
- feedThreeTurns(failed);
217
- assert.equal(failed.stats.privacy, 1);
218
- assert.equal(fs.existsSync(conclusionCardFile(root2, 'session-alpha', 3)), false);
219
- });
220
-
221
- runCase('K5', 'non-completed turns and injected user text never write', () => {
222
- const root = fixture('gates');
223
- const bridge = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
224
- bridge.onSessionEvent(session, turnStart(1));
225
- bridge.onSessionEvent(session, injectedUserEvent('<system-reminder>注入块</system-reminder> 改成 A'));
226
- bridge.onSessionEvent(session, assistantEvent(1, [{ type: 'text', text: '好,按这个来,以后都走 A 方案。' }]));
227
- bridge.onSessionEvent(session, turnEnd(1, 'aborted'));
228
- assert.equal(fs.existsSync(conclusionCardFile(root, 'session-alpha', 1)), false);
229
- assert.equal(bridge.stats.skipped, 1);
230
- // reasoning-only assistant messages leave no text to judge
231
- bridge.onSessionEvent(session, turnStart(2));
232
- bridge.onSessionEvent(session, userEvent('改成 A 吧'));
233
- bridge.onSessionEvent(session, assistantEvent(2, [{ type: 'reasoning', text: '只是想一想' }]));
234
- bridge.onSessionEvent(session, turnEnd(2));
235
- assert.equal(bridge.stats.skipped, 2);
236
- });
237
-
238
- runCase('K6', 'a write failure is swallowed and never throws', () => {
239
- const root = fixture('failure');
240
- // Occupy the topics path with a file: mkdirSync/write must fail inside the
241
- // bridge, which has to count an error instead of throwing.
242
- fs.writeFileSync(path.join(root, 'topics'), 'not a directory\n', 'utf8');
243
- const bridge = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
244
- feedThreeTurns(bridge);
245
- assert.equal(bridge.stats.written, 0);
246
- assert.ok(bridge.stats.errors >= 1);
247
- assert.equal(bridge.stats.privacy, 0);
248
- });
249
-
250
- runCase('K7', 'lastConclusionOf accepts only clean strings or {text}', () => {
251
- assert.equal(lastConclusionOf(undefined), null);
252
- assert.equal(lastConclusionOf(null), null);
253
- assert.equal(lastConclusionOf(''), null);
254
- assert.equal(lastConclusionOf(' '), null);
255
- assert.equal(lastConclusionOf(42), null);
256
- assert.equal(lastConclusionOf([]), null);
257
- assert.equal(lastConclusionOf({ text: 42 }), null);
258
- assert.equal(lastConclusionOf({ text: '' }), null);
259
- assert.equal(lastConclusionOf(' a\n b\u0000c '), 'a b c');
260
- const long = lastConclusionOf('x'.repeat(200));
261
- assert.equal(Array.from(long).length, LAST_CONCLUSION_MAX);
262
- assert.ok(long.endsWith('…'));
263
- });
264
-
265
- runCase('K8', 'board focus carries A1 conclusion and boardHash follows it', () => {
266
- const root = fixture('board');
267
- liveUpdate({
268
- sessionId: 'session-alpha',
269
- state: 'running',
270
- goal: '写 A1',
271
- last_action: { kind: 'edit', detail: '改了 board.mjs', at: NOW.toISOString() },
272
- }, { memoryRoot: root, now: NOW_MS });
273
- writeJson(path.join(root, '.longlive', 'bridge', 'session-alpha.json'), {
274
- v: 1,
275
- sessionId: 'session-alpha',
276
- updatedAt: NOW.toISOString(),
277
- lastConclusion: {
278
- text: '好,按这个来,以后都走 A 方案生成。',
279
- kind: 'decision',
280
- turn: 3,
281
- at: NOW.toISOString(),
282
- },
283
- turns: { 3: NOW.toISOString() },
284
- });
285
- const builder = createBoardBuilder({
286
- memoryRoot: root,
287
- config: {},
288
- now: () => NOW,
289
- paths: { bridgeDir: path.join(root, '.longlive', 'bridge') },
290
- });
291
- const { board } = builder.build({});
292
- assert.equal(board.focus.length, 1);
293
- assert.equal(board.focus[0].lastConclusion, '好,按这个来,以后都走 A 方案生成。');
294
- assert.equal(readBridgeConclusions(path.join(root, '.longlive', 'bridge')).get('session-alpha'),
295
- '好,按这个来,以后都走 A 方案生成。');
296
- const without = { ...board, focus: [{ ...board.focus[0], lastConclusion: null }] };
297
- assert.notEqual(boardHashOf(board), boardHashOf(without));
298
- });
299
-
300
- runCase('K9', 'board falls back to last_action.detail and ignores dirty markers', () => {
301
- const root = fixture('board-fallback');
302
- liveUpdate({
303
- sessionId: 'session-beta',
304
- state: 'running',
305
- goal: '写 A2',
306
- last_action: { kind: 'edit', detail: 'turn 结束 #7', at: NOW.toISOString() },
307
- }, { memoryRoot: root, now: NOW_MS });
308
- writeJson(path.join(root, '.longlive', 'bridge', 'session-beta.json'), {
309
- v: 1,
310
- sessionId: 'session-beta',
311
- lastConclusion: { text: ' ' },
312
- });
313
- const builder = createBoardBuilder({ memoryRoot: root, config: {}, now: () => NOW, paths: {} });
314
- const { board } = builder.build({});
315
- assert.equal(board.focus[0].lastConclusion, 'turn 结束 #7');
316
-
317
- // A row with no explicit action only carries `state=...`; that lifecycle
318
- // detail must not be rendered as a conclusion.
319
- liveUpdate({ sessionId: 'session-delta', state: 'running', goal: '无动作' },
320
- { memoryRoot: root, now: NOW_MS });
321
- const withDelta = builder.build({}).board;
322
- const delta = withDelta.focus.find((item) => item.sessionId === 'session-delta');
323
- assert.ok(delta, 'session-delta should be on the board');
324
- assert.equal(delta.lastConclusion, null);
325
-
326
- // Junk JSON and unknown shapes are skipped, not rendered.
327
- writeJson(path.join(root, '.longlive', 'bridge', 'session-gamma.json'), {
328
- v: 1,
329
- sessionId: 'session-gamma',
330
- lastConclusion: { text: { nested: true } },
331
- });
332
- fs.writeFileSync(path.join(root, '.longlive', 'bridge', 'broken.json'), '{not json', 'utf8');
333
- const conclusions = readBridgeConclusions(path.join(root, '.longlive', 'bridge'));
334
- assert.equal(conclusions.has('session-gamma'), false);
335
- assert.equal(conclusions.has('session-beta'), false);
336
- });
337
-
338
- runCase('K10', 'hint renders 目标 / 最近 / 结论 and B sees A', () => {
339
- const root = fixture('hint');
340
- liveUpdate({
341
- sessionId: 'session-a',
342
- state: 'running',
343
- goal: '写 A1 回流',
344
- last_action: { kind: 'edit', detail: '改 conclusion.js', at: NOW.toISOString() },
345
- }, { memoryRoot: root, now: NOW_MS });
346
- writeJson(path.join(root, '.longlive', 'bridge', 'session-a.json'), {
347
- v: 1,
348
- sessionId: 'session-a',
349
- lastConclusion: { text: '决定:结论回流先默认关,等验收后再开。', kind: 'decision', turn: 4, at: NOW.toISOString() },
350
- });
351
- liveUpdate({
352
- sessionId: 'session-b',
353
- state: 'running',
354
- goal: '写 A2 看板',
355
- }, { memoryRoot: root, now: NOW_MS });
356
- const builder = createBoardBuilder({ memoryRoot: root, config: {}, now: () => NOW, paths: {} });
357
- const { board } = builder.build({});
358
- const hint = renderHint(board, { tier: 'full', now: NOW_MS, selfSessionId: 'session-b' });
359
- assert.ok(hint.text.includes('目标「写 A1 回流」'));
360
- assert.ok(hint.text.includes('最近:edit 改 conclusion.js'));
361
- assert.ok(hint.text.includes('结论:决定:结论回流先默认关,等验收后再开。'));
362
- assert.ok(!hint.text.includes('注入'), 'hint must not leak unrelated text');
363
- assert.ok(hint.tokens <= 700);
364
- });
365
-
366
- runCase('K11', 'board write/read keeps lastConclusion in the persisted JSON', () => {
367
- const root = fixture('persist');
368
- liveUpdate({
369
- sessionId: 'session-a',
370
- state: 'idle',
371
- goal: '收尾',
372
- }, { memoryRoot: root, now: NOW_MS });
373
- writeJson(path.join(root, '.longlive', 'bridge', 'session-a.json'), {
374
- v: 1,
375
- sessionId: 'session-a',
376
- lastConclusion: { text: '就这样,A2 用 board 现有字段,不新增配置键。', kind: 'decision', turn: 9 },
377
- });
378
- const vault = path.join(root, '.longlive', 'router');
379
- const builder = createBoardBuilder({
380
- memoryRoot: root,
381
- config: {},
382
- now: () => NOW,
383
- paths: {
384
- boardFile: path.join(vault, 'board.json'),
385
- rosterFile: path.join(vault, 'index', 'roster.json'),
386
- proposalsDir: path.join(vault, 'proposals'),
387
- bridgeDir: path.join(root, '.longlive', 'bridge'),
388
- },
389
- });
390
- const { board } = builder.build({});
391
- const written = builder.write({ board, roster: [] });
392
- assert.equal(written.ok, true);
393
- const persisted = JSON.parse(fs.readFileSync(path.join(vault, 'board.json'), 'utf8'));
394
- assert.equal(persisted.focus[0].lastConclusion, board.focus[0].lastConclusion);
395
- const readBack = builder.read();
396
- assert.equal(readBack.hash, written.board.hash);
397
- });
398
-
399
- runCase('K12', 'disabled bridge is a no-op', () => {
400
- const root = fixture('disabled');
401
- const bridge = createConclusionBridge({ enabled: false, memoryRoot: root, now: () => NOW });
402
- feedThreeTurns(bridge);
403
- assert.equal(bridge.enabled, false);
404
- assert.equal(bridge.stats.written, 0);
405
- assert.equal(fs.existsSync(path.join(root, 'topics')), false);
406
- assert.equal(fs.existsSync(bridgeMarkerFile(root, 'session-alpha')), false);
407
- });
408
- }