@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,386 +0,0 @@
1
- // WP6 — L4 small-write coalescing + L4c planner (DESIGN §5.8/§2.4, K20).
2
- //
3
- // Layer-1 mock contract: no host, no model, no network. Pure planner tests run
4
- // first, then the proposal engine against a real temp vault so a failure here is
5
- // a store/queue protocol failure, not a mock artefact.
6
-
7
- import assert from 'node:assert/strict';
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
- import test from 'node:test';
11
-
12
- import { normalizeRouterConfig } from '../../src/router/service/config.mjs';
13
- import { createProposalEngine } from '../../src/router/service/proposals.mjs';
14
- import { createQueueManager } from '../../src/router/service/queue.mjs';
15
- import { createWal } from '../../src/router/service/wal.mjs';
16
- import { createWakeChannel } from '../../src/router/service/wake.mjs';
17
- import { readProposal, readQueueFile } from '../../src/router/service/store.mjs';
18
- import {
19
- SMALL_WRITE_SEGMENT_MAX_BYTES,
20
- SMALL_WRITE_MERGED_MAX_BYTES,
21
- SMALL_WRITE_SEPARATOR,
22
- buildL4JournalContent,
23
- canMergeSmallWrite,
24
- planL4Merge,
25
- planSmallWriteMerge,
26
- } from '../../src/router/service/merge.mjs';
27
- import { readJson, tempRoot, writeMemory } from './helpers.mjs';
28
-
29
- function setup(prefix = 'wp6-merge-') {
30
- const temp = tempRoot(prefix);
31
- const memoryRoot = path.join(temp, 'memory');
32
- fs.mkdirSync(memoryRoot, { recursive: true });
33
- writeMemory(memoryRoot);
34
- const vaultDir = path.join(temp, 'vault');
35
- const config = normalizeRouterConfig({
36
- proposalEnabled: true,
37
- applyEnabled: false,
38
- agingSeconds: 10,
39
- l4MergeEnabled: false,
40
- });
41
- const audit = [];
42
- const auditFn = (kind, data = {}) => audit.push({ kind, ...data });
43
- const paths = { vaultDir, proposalsDir: path.join(vaultDir, 'proposals'), liveRoot: memoryRoot };
44
- const wal = createWal({ file: path.join(paths.proposalsDir, 'wal.jsonl'), audit: auditFn });
45
- const engine = createProposalEngine({ config, paths, memoryRoot, audit: auditFn, now: () => new Date() });
46
- const wakeChannel = createWakeChannel({ vaultDir, config, audit: auditFn, now: () => new Date() });
47
- engine.wake = wakeChannel;
48
- const queue = createQueueManager({ paths, config, wal, audit: auditFn, now: () => new Date() });
49
- return {
50
- temp,
51
- memoryRoot,
52
- vaultDir,
53
- config,
54
- paths,
55
- audit,
56
- wal,
57
- engine,
58
- queue,
59
- wakeChannel,
60
- wakeFile: path.join(vaultDir, 'wake-ready.jsonl'),
61
- cleanup: () => fs.rmSync(temp, { recursive: true, force: true }),
62
- };
63
- }
64
-
65
- function propose(env, content, extra = {}) {
66
- return env.engine.propose({
67
- op: 'journal.append',
68
- target: 'journal/2026-11.md',
69
- title: `t-${content.slice(0, 20)}`,
70
- content,
71
- ...extra,
72
- }, { caller: { kind: 'root', sessionId: 'root-session' } });
73
- }
74
-
75
- function fakeProposal(id, content, overrides = {}) {
76
- return {
77
- id,
78
- op: 'journal.append',
79
- target: 'journal/2026-11.md',
80
- content,
81
- status: 'pending',
82
- baseSha: 'sha256:aaa',
83
- mergeKey: 'sha256:mmm',
84
- rev: 1,
85
- stateLog: [],
86
- queue: { ticket: `q-${id.slice(1).padStart(4, '0')}` },
87
- ...overrides,
88
- };
89
- }
90
-
91
- function wakeLines(env) {
92
- try {
93
- return fs.readFileSync(env.wakeFile, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
94
- } catch {
95
- return [];
96
- }
97
- }
98
-
99
- test('merge: every gate is required (target/op/mergeKey/adjacent/2KB/4KB/base)', () => {
100
- const previous = fakeProposal('p1', 'alpha');
101
- const next = fakeProposal('p2', 'beta');
102
- const ok = canMergeSmallWrite(previous, next, { adjacent: true });
103
- assert.equal(ok.ok, true);
104
- assert.equal(ok.content, `alpha${SMALL_WRITE_SEPARATOR}beta`);
105
- assert.equal(ok.bytes, Buffer.byteLength(ok.content));
106
-
107
- assert.equal(canMergeSmallWrite(previous, { ...next, target: 'journal/2026-12.md' }, { adjacent: true }).code, 'target-mismatch');
108
- assert.equal(canMergeSmallWrite(previous, { ...next, op: 'topic.append' }, { adjacent: true }).code, 'op-mismatch');
109
- assert.equal(canMergeSmallWrite(previous, { ...next, mergeKey: 'sha256:other' }, { adjacent: true }).code, 'merge-key-mismatch');
110
- assert.equal(canMergeSmallWrite(previous, next, { adjacent: false }).code, 'not-adjacent');
111
- assert.equal(canMergeSmallWrite(previous, { ...next, status: 'approved' }, { adjacent: true }).code, 'not-pending');
112
- assert.equal(canMergeSmallWrite({ ...previous, baseSha: 'sha256:old' }, next, { adjacent: true, currentSha: 'sha256:new' }).code, 'base-moved');
113
-
114
- const atLimit = 'x'.repeat(SMALL_WRITE_SEGMENT_MAX_BYTES);
115
- assert.equal(canMergeSmallWrite(fakeProposal('p1', atLimit), fakeProposal('p2', 'y'), { adjacent: true }).ok, true);
116
- assert.equal(canMergeSmallWrite(fakeProposal('p1', `${atLimit}x`), fakeProposal('p2', 'y'), { adjacent: true }).code, 'segment-too-large');
117
- assert.equal(
118
- canMergeSmallWrite(
119
- fakeProposal('p1', atLimit),
120
- fakeProposal('p2', atLimit),
121
- { adjacent: true },
122
- ).code,
123
- 'merged-too-large',
124
- );
125
- assert.ok(Buffer.byteLength(`${atLimit}${SMALL_WRITE_SEPARATOR}${atLimit}`) > SMALL_WRITE_MERGED_MAX_BYTES);
126
-
127
- // A null currentSha is the "target not created yet" snapshot: null===null.
128
- assert.equal(canMergeSmallWrite(
129
- { ...previous, baseSha: null },
130
- { ...next, baseSha: null },
131
- { adjacent: true, currentSha: null },
132
- ).ok, true);
133
- });
134
-
135
- test('merge: later proposal is absorbed into the earlier one (mergedInto/superseded)', () => {
136
- const previous = fakeProposal('p1', 'alpha');
137
- const next = fakeProposal('p2', 'beta');
138
- const plan = planSmallWriteMerge(previous, next, { at: '2026-11-05T00:00:00.000Z', adjacent: true });
139
- assert.equal(plan.ok, true);
140
- assert.equal(plan.keeper.id, 'p1');
141
- assert.equal(plan.absorbed.id, 'p2');
142
- assert.equal(plan.keeper.queue.ticket, 'q-0001');
143
- assert.equal(plan.keeper.content, `alpha${SMALL_WRITE_SEPARATOR}beta`);
144
- assert.equal(plan.keeper.rev, 2);
145
- assert.equal(plan.keeper.stateLog.at(-1).note, 'merged-in:q-0002');
146
- assert.equal(plan.absorbed.status, 'superseded');
147
- assert.equal(plan.absorbed.queue.mergedInto, 'p1');
148
- assert.equal(plan.absorbed.queue.supersededBy, 'p1');
149
- assert.equal(plan.absorbed.queue.ticket, 'q-0002');
150
- assert.equal(plan.absorbed.stateLog.at(-1).to, 'superseded');
151
- // dedupeKey is recomputed over the merged body, not inherited from the keeper.
152
- assert.notEqual(plan.keeper.dedupeKey, previous.dedupeKey);
153
- // The keeper keeps the first segment first: there is no reordering.
154
- assert.ok(plan.keeper.content.startsWith('alpha'));
155
- });
156
-
157
- test('merge: engine coalesces adjacent small writes, keeps one ticket, one wake line, one apply slot', async () => {
158
- const env = setup();
159
- try {
160
- const first = await propose(env, 'alpha');
161
- const second = await propose(env, 'beta');
162
- assert.equal(first.ok, true);
163
- assert.equal(second.ok, true);
164
- assert.equal(second.merged, true);
165
- assert.equal(second.mergedInto, first.id);
166
- assert.equal(second.ticket, first.ticket);
167
-
168
- const keeper = readProposal(env.paths.proposalsDir, first.id);
169
- const absorbed = readProposal(env.paths.proposalsDir, second.id);
170
- assert.equal(keeper.status, 'pending');
171
- assert.equal(keeper.content, `alpha${SMALL_WRITE_SEPARATOR}beta`);
172
- // scanProposalDir's __file breadcrumb must not leak into the persisted JSON.
173
- const keeperOnDisk = JSON.parse(fs.readFileSync(path.join(env.paths.proposalsDir, `${first.id}.json`), 'utf8'));
174
- assert.equal(Object.hasOwn(keeperOnDisk, '__file'), false);
175
- assert.equal(absorbed.status, 'superseded');
176
- assert.equal(absorbed.queue.mergedInto, first.id);
177
- assert.equal(absorbed.queue.supersededBy, first.id);
178
-
179
- const queueFile = readQueueFile(path.join(env.paths.proposalsDir, 'queue.json'));
180
- assert.deepEqual(queueFile.entries.map((entry) => entry.proposalId), [first.id]);
181
- assert.equal(queueFile.entries[0].ticket, first.ticketName);
182
-
183
- const merges = env.audit.filter((row) => row.kind === 'merge.superseded');
184
- assert.equal(merges.length, 1);
185
- assert.equal(merges[0].keeperId, first.id);
186
- assert.equal(merges[0].absorbedId, second.id);
187
-
188
- // Tier A: the absorbed proposal must not add a second ready line.
189
- const lines = wakeLines(env);
190
- assert.equal(lines.length, 1);
191
- assert.equal(lines[0].ticket, first.ticketName);
192
-
193
- // K20: only one apply is possible because only the keeper is queued.
194
- const queue = createQueueManager({
195
- paths: env.paths,
196
- config: env.config,
197
- wal: env.wal,
198
- audit: () => {},
199
- now: () => new Date(),
200
- });
201
- await queue.maintain();
202
- const again = readProposal(env.paths.proposalsDir, second.id);
203
- assert.equal(again.status, 'superseded', 'M-09: never auto-split back');
204
- assert.equal(readProposal(env.paths.proposalsDir, first.id).content, `alpha${SMALL_WRITE_SEPARATOR}beta`);
205
- } finally {
206
- env.cleanup();
207
- }
208
- });
209
-
210
- test('merge: seqCounter is not recycled after an absorbed ticket', async () => {
211
- const env = setup();
212
- try {
213
- const first = await propose(env, 'alpha');
214
- const second = await propose(env, 'beta');
215
- assert.equal(second.merged, true);
216
- const third = await propose(env, 'gamma');
217
- assert.equal(third.merged, true, 'third also coalesces into the keeper');
218
- assert.equal(third.ticket, first.ticket);
219
- // The absorbed seq (q-0002) was spent and must not be handed out again.
220
- const fourth = await propose(env, 'delta', { target: 'topics/other.md', op: 'topic.append' });
221
- assert.equal(fourth.ok, true);
222
- assert.equal(fourth.ticket, 4);
223
- assert.equal(fourth.ticketName, 'q-0004');
224
- } finally {
225
- env.cleanup();
226
- }
227
- });
228
-
229
- test('merge: 2KB per segment and 4KB total are hard limits', async () => {
230
- const env = setup();
231
- try {
232
- const first = await propose(env, 'a'.repeat(SMALL_WRITE_SEGMENT_MAX_BYTES));
233
- const big = await propose(env, 'b'.repeat(SMALL_WRITE_SEGMENT_MAX_BYTES));
234
- assert.equal(big.merged, undefined, '2048+2048 plus separator exceeds 4096');
235
- assert.notEqual(big.ticket, first.ticket);
236
- const tooBig = await propose(env, 'c'.repeat(SMALL_WRITE_SEGMENT_MAX_BYTES + 1));
237
- assert.equal(tooBig.merged, undefined, 'a single segment over 2048 never merges');
238
- } finally {
239
- env.cleanup();
240
- }
241
- });
242
-
243
- test('merge: a non-adjacent proposal never reaches past its queue predecessor', async () => {
244
- const env = setup();
245
- try {
246
- const first = await propose(env, 'alpha');
247
- const middle = await propose(env, 'middle', { target: 'topics/other.md', op: 'topic.append' });
248
- const third = await propose(env, 'gamma');
249
- assert.equal(middle.merged, undefined);
250
- assert.equal(third.merged, undefined);
251
- assert.notEqual(third.ticket, first.ticket);
252
- const keeper = readProposal(env.paths.proposalsDir, first.id);
253
- assert.equal(keeper.content, 'alpha');
254
- assert.equal(readProposal(env.paths.proposalsDir, third.id).status, 'pending');
255
- } finally {
256
- env.cleanup();
257
- }
258
- });
259
-
260
- test('merge: a moved base keeps both proposals queued (no merge, no split)', async () => {
261
- const env = setup();
262
- try {
263
- const journal = path.join(env.memoryRoot, 'journal');
264
- fs.mkdirSync(journal, { recursive: true });
265
- const target = path.join(journal, '2026-11.md');
266
- fs.writeFileSync(target, 'old\n', 'utf8');
267
- const first = await propose(env, 'alpha');
268
- fs.writeFileSync(target, 'new\n', 'utf8');
269
- const second = await propose(env, 'beta');
270
- assert.equal(first.ok, true);
271
- assert.equal(second.ok, true);
272
- assert.equal(second.merged, undefined);
273
- assert.notEqual(second.ticket, first.ticket);
274
- assert.equal(readProposal(env.paths.proposalsDir, first.id).content, 'alpha');
275
- assert.equal(readProposal(env.paths.proposalsDir, second.id).status, 'pending');
276
- } finally {
277
- env.cleanup();
278
- }
279
- });
280
-
281
- test('merge: identical content dedupes before merge is considered (K20)', async () => {
282
- const env = setup();
283
- try {
284
- const first = await propose(env, 'same-body');
285
- const second = await propose(env, 'same-body');
286
- assert.equal(second.duplicateOf, first.id);
287
- assert.equal(readProposal(env.paths.proposalsDir, second.id), null);
288
- assert.equal(env.audit.filter((row) => row.kind === 'merge.superseded').length, 0);
289
- } finally {
290
- env.cleanup();
291
- }
292
- });
293
-
294
- test('L4: disabled is an exact no-op; window, rounds, root, quota and per-session/day gate the fold', () => {
295
- const proposals = [
296
- { id: 'p1', origin: { sessionId: 's1' }, title: '标题一', queue: { ticket: 'q-0001' } },
297
- { id: 'p2', origin: { sessionId: 's1' }, title: '标题二', queue: { ticket: 'q-0002' } },
298
- ];
299
- const sessions = [{ sessionId: 's1', goal: '把 Router 1.1.0 收尾', roundsSinceLastMerge: 3 }];
300
-
301
- const disabled = planL4Merge({ enabled: false, now: new Date(2026, 10, 5, 5, 0, 0), sessions, proposals });
302
- assert.deepEqual(disabled.proposals, []);
303
- assert.equal(disabled.reason, 'disabled');
304
-
305
- const before = planL4Merge({ enabled: true, now: new Date(2026, 10, 5, 3, 59, 0), sessions, proposals });
306
- assert.equal(before.reason, 'before-window');
307
-
308
- const tooFewRounds = planL4Merge({
309
- enabled: true,
310
- now: new Date(2026, 10, 5, 5, 0, 0),
311
- sessions: [{ ...sessions[0], roundsSinceLastMerge: 2 }],
312
- proposals,
313
- });
314
- assert.equal(tooFewRounds.proposals.length, 0);
315
-
316
- const subagent = planL4Merge({
317
- enabled: true,
318
- now: new Date(2026, 10, 5, 5, 0, 0),
319
- sessions: [{ ...sessions[0], isSubagent: true }],
320
- proposals,
321
- });
322
- assert.equal(subagent.proposals.length, 0);
323
-
324
- const plan = planL4Merge({ enabled: true, now: new Date(2026, 10, 5, 5, 0, 0), sessions, proposals });
325
- assert.equal(plan.proposals.length, 1);
326
- const input = plan.proposals[0];
327
- assert.equal(input.op, 'journal.append');
328
- assert.equal(input.target, 'journal/2026-11.md');
329
- assert.equal(input.risk, 'medium');
330
- assert.equal(input.content.split('\n').length, 3);
331
- assert.ok(input.content.split('\n').every((line) => line.startsWith('- ')));
332
- assert.ok(input.content.length <= 600);
333
- assert.ok(input.content.includes('把 Router 1.1.0 收尾'));
334
- assert.ok(input.evidence.includes('live:s1'));
335
- assert.ok(input.evidence.includes('proposal:p1'));
336
- assert.equal(plan.state.count, 1);
337
-
338
- const sameDayAgain = planL4Merge({
339
- enabled: true,
340
- now: new Date(2026, 10, 5, 6, 0, 0),
341
- sessions,
342
- proposals,
343
- state: plan.state,
344
- });
345
- assert.equal(sameDayAgain.proposals.length, 0, 'one fold per session per local day');
346
-
347
- const manySessions = Array.from({ length: 7 }, (_, index) => ({
348
- sessionId: `s${index + 1}`,
349
- goal: `goal-${index + 1}`,
350
- roundsSinceLastMerge: 3,
351
- }));
352
- const manyProposals = manySessions.map((session, index) => ({
353
- id: `p${index + 1}`,
354
- origin: { sessionId: session.sessionId },
355
- title: `title-${index + 1}`,
356
- queue: { ticket: `q-000${index + 1}` },
357
- }));
358
- const capped = planL4Merge({
359
- enabled: true,
360
- now: new Date(2026, 10, 5, 5, 0, 0),
361
- sessions: manySessions,
362
- proposals: manyProposals,
363
- });
364
- assert.equal(capped.proposals.length, 5, 'global daily cap is 5');
365
- assert.equal(capped.state.count, 5);
366
- });
367
-
368
- test('L4: content is exactly three bullets and always <=600 chars', () => {
369
- const text = buildL4JournalContent({ goal: 'g'.repeat(500), titles: ['t'.repeat(500)], nextTicket: 'q-9999' });
370
- assert.ok(text.length <= 600);
371
- assert.equal(text.split('\n').length, 3);
372
- assert.ok(text.split('\n').every((line) => line.startsWith('- ')));
373
- });
374
-
375
- test('L4: engine proposals do not trigger the fold while routerL4MergeEnabled=false', () => {
376
- const env = setup();
377
- try {
378
- assert.equal(env.config.l4MergeEnabled, false);
379
- const plan = planL4Merge({ enabled: env.config.l4MergeEnabled, sessions: [], proposals: [] });
380
- assert.equal(plan.reason, 'disabled');
381
- assert.equal(fs.existsSync(path.join(env.vaultDir, 'l4-merge-state.json')), false);
382
- assert.equal(env.audit.filter((row) => row.kind.startsWith('merge.l4')).length, 0);
383
- } finally {
384
- env.cleanup();
385
- }
386
- });