@jjh32456/dsh-longlive 1.6.8 → 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 (288) hide show
  1. package/CHANGELOG.md +69 -1539
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +33 -12
  4. package/README.en.md +17 -7
  5. package/README.md +29 -10
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +17 -12
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +143 -37
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +66 -29
  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 +3 -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 +42 -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 +129 -18
  41. package/src/activity/model.js +11 -1
  42. package/src/activity/store.js +22 -3
  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 +145 -48
  58. package/src/deps.js +6 -4
  59. package/src/features.js +158 -8
  60. package/src/identity/index.js +13 -5
  61. package/src/identity/inject-position.js +2 -2
  62. package/src/index.js +97 -7
  63. package/src/lifecycle/gap-clocks.js +328 -0
  64. package/src/lifecycle/index.js +269 -0
  65. package/src/memory/activity-capture.js +229 -0
  66. package/src/memory/anchors.js +194 -0
  67. package/src/memory/audit-records.js +222 -0
  68. package/src/memory/auto-cards.js +401 -0
  69. package/src/memory/capture.js +442 -0
  70. package/src/memory/card-anchors.js +203 -0
  71. package/src/memory/conflicts.js +106 -0
  72. package/src/memory/decay.js +311 -0
  73. package/src/memory/distill.js +471 -0
  74. package/src/memory/event-index.js +917 -0
  75. package/src/memory/evidence-chain.js +295 -0
  76. package/src/memory/evidence.js +145 -0
  77. package/src/memory/filter-runner.js +1 -1
  78. package/src/memory/filter.js +5 -1
  79. package/src/memory/forget-v2.js +410 -0
  80. package/src/memory/forget.js +71 -0
  81. package/src/memory/frames-activation.js +247 -0
  82. package/src/memory/fts-query.js +174 -0
  83. package/src/memory/governance.js +604 -0
  84. package/src/memory/index.js +1 -0
  85. package/src/memory/l2-gate.js +169 -0
  86. package/src/memory/lock.js +121 -0
  87. package/src/memory/migrate.js +139 -0
  88. package/src/memory/recall-v2.js +223 -0
  89. package/src/memory/recall.js +65 -2
  90. package/src/memory/records.js +425 -0
  91. package/src/memory/retention.js +128 -0
  92. package/src/memory/review.js +124 -0
  93. package/src/memory/trust.js +88 -0
  94. package/src/memory/version-chain.js +264 -0
  95. package/src/memory/wal.js +137 -0
  96. package/src/memory/working-frame.js +516 -0
  97. package/src/metrics/classify.mjs +5 -3
  98. package/src/metrics/index.mjs +21 -1
  99. package/src/metrics/prestep-latency.mjs +300 -0
  100. package/src/metrics/store.mjs +25 -0
  101. package/src/policy-profile.js +18 -3
  102. package/src/recall/anaphora.mjs +87 -0
  103. package/src/recall/cards.mjs +7 -0
  104. package/src/recall/embedding.mjs +8 -8
  105. package/src/recall/entities.mjs +112 -0
  106. package/src/recall/index.js +235 -19
  107. package/src/recall/inject-order.mjs +10 -8
  108. package/src/recall/privacy.mjs +1 -1
  109. package/src/recall/recall-core.mjs +116 -10
  110. package/src/recall/shared-visibility.js +302 -0
  111. package/src/recall/time-normalizer.mjs +196 -0
  112. package/src/role/activation.js +191 -0
  113. package/src/role/attention.js +318 -0
  114. package/src/role/base.js +36 -0
  115. package/src/role/channels.js +423 -0
  116. package/src/role/index.js +425 -0
  117. package/src/role/interfaces.js +115 -0
  118. package/src/role/personality.js +370 -0
  119. package/src/role/readers.js +151 -0
  120. package/src/role/relationships.js +477 -0
  121. package/src/role/store.js +202 -0
  122. package/src/role/writeprotect.js +373 -0
  123. package/src/router/aliases.json +176 -25
  124. package/src/router/background/agent-runner.mjs +1 -1
  125. package/src/router/background/anchor.mjs +1 -1
  126. package/src/router/background/audit.mjs +2 -2
  127. package/src/router/background/budget-ledger.mjs +2 -2
  128. package/src/router/background/host-sim.mjs +1 -1
  129. package/src/router/background/memory.mjs +4 -4
  130. package/src/router/background/queue.mjs +1 -1
  131. package/src/router/background/retry.mjs +1 -1
  132. package/src/router/background/router.mjs +1 -1
  133. package/src/router/background/stream.mjs +1 -1
  134. package/src/router/background/tool-policy.mjs +1 -1
  135. package/src/router/design-headings.json +5 -5
  136. package/src/router/face/config.mjs +1 -1
  137. package/src/router/face/identity.mjs +3 -3
  138. package/src/router/judge.mjs +8 -7
  139. package/src/router/policies/generic.json +21 -4
  140. package/src/router/route.js +109 -22
  141. package/src/router/router.mjs +10 -6
  142. package/src/router/routing-rules.json +260 -445
  143. package/src/router/run-sim.mjs +8 -8
  144. package/src/router/runtime.js +23 -12
  145. package/src/router/service/config.mjs +5 -5
  146. package/src/router/service/context.mjs +1 -1
  147. package/src/router/service/queue.mjs +1 -1
  148. package/src/router/shadow.mjs +4 -3
  149. package/src/surgery-recovery.js +2 -2
  150. package/src/tool-output.js +2 -2
  151. package/src/tools.js +39 -7
  152. package/src/truncate-visible.js +2 -2
  153. package/src/ui/actions.js +1312 -0
  154. package/src/ui/panel-api.js +367 -0
  155. package/src/ui/panel-auth.js +251 -0
  156. package/src/ui/panel-integrity.js +243 -0
  157. package/src/ui/routes-readonly.js +695 -0
  158. package/src/ui/routes.js +3 -0
  159. package/docs/DEV.md +0 -279
  160. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  161. package/docs/PRESENCE-BUS.md +0 -694
  162. package/docs/SURGERY.md +0 -186
  163. package/scripts/mark-core-cards.mjs +0 -289
  164. package/scripts/pack.mjs +0 -392
  165. package/scripts/router-proposals.mjs +0 -386
  166. package/src/router/fixtures/M1.json +0 -16
  167. package/src/router/fixtures/M2.json +0 -16
  168. package/src/router/fixtures/M3.json +0 -20
  169. package/src/router/fixtures/M4.json +0 -20
  170. package/src/router/fixtures/M5.json +0 -45
  171. package/src/router/fixtures/M6.json +0 -53
  172. package/src/router/fixtures/M7.json +0 -46
  173. package/src/router/fixtures/S1.json +0 -20
  174. package/src/router/fixtures/S2.json +0 -23
  175. package/src/router/fixtures/S3.json +0 -20
  176. package/src/router/fixtures/S4.json +0 -34
  177. package/src/router/fixtures/S5.json +0 -43
  178. package/src/router/fixtures/S6.json +0 -47
  179. package/src/router/fixtures/T1.json +0 -23
  180. package/src/router/fixtures/T2.json +0 -16
  181. package/src/router/fixtures/T3.json +0 -19
  182. package/src/router/fixtures/T4.json +0 -16
  183. package/src/router/fixtures/X1.json +0 -17
  184. package/src/router/fixtures/X2.json +0 -20
  185. package/src/router/gen-design-headings.mjs +0 -107
  186. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  187. package/test/activity-realtime.test.mjs +0 -959
  188. package/test/activity-stream.test.mjs +0 -512
  189. package/test/auto-recall.test.mjs +0 -474
  190. package/test/auto-truncate.test.mjs +0 -931
  191. package/test/autobind.test.mjs +0 -399
  192. package/test/bus.test.mjs +0 -1572
  193. package/test/cli-guard.test.mjs +0 -198
  194. package/test/conclusion.test.mjs +0 -408
  195. package/test/config-schema.test.mjs +0 -210
  196. package/test/d6-output.test.mjs +0 -194
  197. package/test/d6.test.mjs +0 -395
  198. package/test/deps.test.mjs +0 -342
  199. package/test/doctor-setup.test.mjs +0 -297
  200. package/test/embedding-recall.test.mjs +0 -347
  201. package/test/feature-flags.test.mjs +0 -585
  202. package/test/filter-shadow.test.mjs +0 -170
  203. package/test/fixtures/activity-append-child.mjs +0 -29
  204. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  205. package/test/fixtures/d6-header-tools.json +0 -1
  206. package/test/fixtures/lock-child.mjs +0 -67
  207. package/test/fixtures/pre-step-container.mjs +0 -100
  208. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  209. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  210. package/test/fixtures/profile-session-longlive-config.json +0 -20
  211. package/test/fixtures/proposal-cas-child.mjs +0 -66
  212. package/test/host-engine-status.test.mjs +0 -307
  213. package/test/hot-truncate.test.mjs +0 -1349
  214. package/test/identity-card-marker.test.mjs +0 -307
  215. package/test/identity-router/run.mjs +0 -721
  216. package/test/integration-pre-step.test.mjs +0 -553
  217. package/test/live.test.mjs +0 -1023
  218. package/test/maintenance-schedule.test.mjs +0 -538
  219. package/test/memory.test.mjs +0 -2828
  220. package/test/metrics.test.mjs +0 -478
  221. package/test/orchestrator.test.mjs +0 -266
  222. package/test/p1_probe.mjs +0 -97
  223. package/test/packaging.test.mjs +0 -184
  224. package/test/portability.test.mjs +0 -343
  225. package/test/rc4-regressions.test.mjs +0 -562
  226. package/test/rc5-regressions.test.mjs +0 -197
  227. package/test/rc6-chinese.test.mjs +0 -394
  228. package/test/router-1100/apply-off.test.mjs +0 -175
  229. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  230. package/test/router-1100/config-context.test.mjs +0 -211
  231. package/test/router-1100/helpers.mjs +0 -129
  232. package/test/router-1100/hint-envelope.test.mjs +0 -150
  233. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  234. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  235. package/test/router-1100/router-apply.test.mjs +0 -372
  236. package/test/router-1100/router-fixb.test.mjs +0 -602
  237. package/test/router-1100/router-proposals.test.mjs +0 -405
  238. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  239. package/test/router-1100/router-security-cr.test.mjs +0 -333
  240. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  241. package/test/router-1100/tools-contract.test.mjs +0 -164
  242. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  243. package/test/router-1100/v6-contract.test.mjs +0 -158
  244. package/test/router-1100/v6b-contract.test.mjs +0 -293
  245. package/test/router-1100/v6c-contract.test.mjs +0 -249
  246. package/test/router-1100/wal-rotation.test.mjs +0 -375
  247. package/test/router-1100/wp1-config.test.mjs +0 -170
  248. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  249. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  250. package/test/router-1100/wp1-status.test.mjs +0 -220
  251. package/test/router-1100/wp1-tools.test.mjs +0 -146
  252. package/test/router-1100/wp5-cli.test.mjs +0 -240
  253. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  254. package/test/router-1100/wp6-merge.test.mjs +0 -386
  255. package/test/router-1100/wp6-wake.test.mjs +0 -502
  256. package/test/router-background/audit.test.mjs +0 -37
  257. package/test/router-background/budget-ledger.test.mjs +0 -330
  258. package/test/router-background/cross-process.test.mjs +0 -222
  259. package/test/router-background/fixtures/cross-child.mjs +0 -85
  260. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  261. package/test/router-background/router.test.mjs +0 -1076
  262. package/test/router-background/stream-retry.test.mjs +0 -358
  263. package/test/router-background/tool-agent.test.mjs +0 -384
  264. package/test/router-face/bridge.test.mjs +0 -169
  265. package/test/router-face/face.test.mjs +0 -316
  266. package/test/router-failclosed.test.mjs +0 -466
  267. package/test/router-identity.test.mjs +0 -384
  268. package/test/router-l1-l2.contract.test.mjs +0 -1075
  269. package/test/router-memory-tools.test.mjs +0 -262
  270. package/test/router-policy.test.mjs +0 -225
  271. package/test/router-runtime.test.mjs +0 -438
  272. package/test/router-shadow.test.mjs +0 -287
  273. package/test/router.test.mjs +0 -903
  274. package/test/run-rc.mjs +0 -315
  275. package/test/run.mjs +0 -2469
  276. package/test/safety-defaults.test.mjs +0 -181
  277. package/test/seeded-load.test.mjs +0 -436
  278. package/test/session-original.test.mjs +0 -338
  279. package/test/state-truth.test.mjs +0 -937
  280. package/test/surgery-recovery.test.mjs +0 -936
  281. package/test/t7_drive.mjs +0 -151
  282. package/test/tool-output.test.mjs +0 -393
  283. package/test/tool-schema.test.mjs +0 -177
  284. package/test/tools_contract.test.mjs +0 -716
  285. package/test/truncate-visible.test.mjs +0 -1150
  286. package/test/ui-flags.test.mjs +0 -477
  287. package/test/v4-hardening.test.mjs +0 -427
  288. package/test/version-consistency.test.mjs +0 -286
@@ -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
- });