@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,293 +0,0 @@
1
- // 1.1.2 contract checks for the three v6 FAILs that remained after 1.1.1:
2
- // K-08 (expiry keeps the proposal body), L-03 (restart rebuilds the queue view
3
- // from proposals/*.json) and K-04 (a pending row is cancellable; `superseded`
4
- // is a legitimate terminal state and is not).
5
-
6
- import assert from 'node:assert/strict';
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import test from 'node:test';
10
-
11
- import { createRouterTools } from '../../src/router/service/tools.mjs';
12
- import { makeService, readJson, tempRoot, writeMemory } from './helpers.mjs';
13
-
14
- const CALLER = { kind: 'root', sessionId: 'root-session' };
15
-
16
- function propose(service, { title, content, target = 'journal/2026-11.md' }) {
17
- return service.proposeProposal({
18
- op: 'journal.append',
19
- target,
20
- title,
21
- content,
22
- }, { caller: CALLER });
23
- }
24
-
25
- function auditRows(vaultDir) {
26
- const file = path.join(vaultDir, 'audit.jsonl');
27
- if (!fs.existsSync(file)) return [];
28
- return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
29
- }
30
-
31
- function writeStaleQueue(vaultDir, proposalId, ticket) {
32
- const file = path.join(vaultDir, 'proposals', 'queue.json');
33
- const queue = readJson(file);
34
- assert.ok(queue, 'queue.json must exist before it is made stale');
35
- queue.rev = Number(queue.rev ?? 0) + 1;
36
- queue.entries = (queue.entries ?? []).map((entry) => (
37
- entry.proposalId === proposalId ? { ...entry, status: 'pending' } : entry
38
- ));
39
- if (!queue.entries.some((entry) => entry.proposalId === proposalId)) {
40
- queue.entries.push({
41
- proposalId,
42
- ticket,
43
- status: 'pending',
44
- sessionId: 'unknown-session',
45
- target: 'journal/2026-11.md',
46
- op: 'journal.append',
47
- });
48
- }
49
- fs.writeFileSync(file, JSON.stringify(queue, null, 2));
50
- return queue;
51
- }
52
-
53
- function newEnv(temp, { config = {}, now } = {}) {
54
- const memoryRoot = path.join(temp, 'memory');
55
- fs.mkdirSync(memoryRoot, { recursive: true });
56
- writeMemory(memoryRoot);
57
- return makeService({
58
- temp,
59
- memoryRoot,
60
- vaultDir: path.join(temp, 'vault'),
61
- ...(now ? { now } : {}),
62
- config: { proposalEnabled: true, applyEnabled: true, ...config },
63
- });
64
- }
65
-
66
- test('v6b K-08: blocked-on-user expiry keeps the proposal body and only moves status', async () => {
67
- const temp = tempRoot('v6b-k08-retention-');
68
- let clock = new Date('2026-11-05T05:00:00.000Z');
69
- const env = newEnv(temp, {
70
- now: () => clock,
71
- config: { blockedOnUserTtlMs: 24 * 3600 * 1000 },
72
- });
73
- try {
74
- const started = await env.service.start();
75
- assert.equal(started.ok, true, JSON.stringify(started));
76
- const proposed = await propose(env.service, { title: 'blocked', content: 'blocked body' });
77
- assert.equal(proposed.ok, true, JSON.stringify(proposed));
78
- await env.service.requestApply({ mode: 'approve', id: proposed.id, confirm: proposed.id.slice(-6) }, { caller: CALLER });
79
-
80
- // A foreign live writer wins the lease, so the apply attempt parks the row
81
- // in blocked-on-user instead of applying it.
82
- fs.mkdirSync(path.join(env.vaultDir, 'locks'), { recursive: true });
83
- fs.writeFileSync(path.join(env.vaultDir, 'locks', 'writer.lease.json'), JSON.stringify({
84
- v: 1,
85
- holder: 'v6b-other',
86
- proposalId: 'other',
87
- pid: process.pid,
88
- host: 'other-host',
89
- runId: 'other-run',
90
- acquiredAt: clock.toISOString(),
91
- expiresAt: new Date(clock.getTime() + 10 * 60 * 1000).toISOString(),
92
- renewals: 0,
93
- }));
94
- const queued = await env.service.requestApply({ mode: 'apply', id: proposed.id, confirm: proposed.id.slice(-6) }, { caller: CALLER });
95
- assert.equal(queued.ok, true, JSON.stringify(queued));
96
-
97
- clock = new Date(clock.getTime() + 5 * 60 * 1000 + 1000);
98
- await env.service.tick({ tier: 'full', reason: 'v6b-k08' });
99
- const file = path.join(env.vaultDir, 'proposals', `${proposed.id}.json`);
100
- assert.equal(readJson(file).status, 'blocked-on-user');
101
- assert.equal(fs.existsSync(file), true);
102
-
103
- clock = new Date(clock.getTime() + 24 * 3600 * 1000 + 1000);
104
- await env.service.tick({ tier: 'full', reason: 'v6b-k08' });
105
- assert.equal(fs.existsSync(file), true, 'expiry must not delete the proposal body');
106
- const expired = readJson(file);
107
- assert.equal(expired.status, 'expired');
108
- assert.equal(expired.queue.ticket, 'q-0001', 'the released ticket stays readable on the expired body');
109
- assert.equal(env.service.status().queue.expired, 1);
110
-
111
- const rows = auditRows(env.vaultDir);
112
- const expiry = rows.filter((row) => row.kind === 'proposal.expired').at(-1);
113
- assert.ok(expiry, 'expiry must be audited');
114
- assert.equal(expiry.bodyKept, true);
115
- assert.equal(expiry.reason, 'blocked-on-user-ttl');
116
- assert.equal(rows.some((row) => row.kind === 'proposal.retention_lost'), false);
117
-
118
- // Ticket release keeps `tailTicket` semantics: the released q-0001 is not
119
- // reused, the next proposal goes to the tail as q-0002.
120
- const next = await propose(env.service, { title: 'next', content: 'next body', target: 'journal/2026-12.md' });
121
- assert.equal(next.ticket, 2);
122
- assert.equal(next.ticketName, 'q-0002');
123
- } finally {
124
- await env.service.dispose();
125
- fs.rmSync(temp, { recursive: true, force: true });
126
- }
127
- });
128
-
129
- test('v6b K-08: the 72h proposal TTL expires in place instead of removing the file', async () => {
130
- const temp = tempRoot('v6b-k08-ttl-');
131
- let clock = new Date('2026-11-05T05:00:00.000Z');
132
- const env = newEnv(temp, { now: () => clock, config: { proposalTtlH: 72 } });
133
- try {
134
- const started = await env.service.start();
135
- assert.equal(started.ok, true, JSON.stringify(started));
136
- const proposed = await propose(env.service, { title: 'stale', content: 'stale body' });
137
- assert.equal(proposed.ok, true, JSON.stringify(proposed));
138
- clock = new Date(clock.getTime() + 72 * 3600 * 1000 + 1000);
139
- await env.service.tick({ tier: 'full', reason: 'v6b-k08-ttl' });
140
-
141
- const file = path.join(env.vaultDir, 'proposals', `${proposed.id}.json`);
142
- assert.equal(fs.existsSync(file), true, 'proposal TTL must not delete the proposal body');
143
- assert.equal(readJson(file).status, 'expired');
144
- assert.equal(env.service.status().queue.expired, 1);
145
- const expiry = auditRows(env.vaultDir).filter((row) => row.kind === 'proposal.expired').at(-1);
146
- assert.equal(expiry.bodyKept, true);
147
- assert.equal(expiry.reason, 'proposal-ttl');
148
- } finally {
149
- await env.service.dispose();
150
- fs.rmSync(temp, { recursive: true, force: true });
151
- }
152
- });
153
-
154
- test('v6b L-03: a restart rebuilds list/queue/status from proposals/*.json', async () => {
155
- const temp = tempRoot('v6b-l03-restart-');
156
- const first = newEnv(temp);
157
- let firstId = null;
158
- try {
159
- const started = await first.service.start();
160
- assert.equal(started.ok, true, JSON.stringify(started));
161
- const one = await propose(first.service, { title: 'one', content: 'body one' });
162
- const two = await propose(first.service, { title: 'two', content: 'body two' });
163
- assert.equal(one.ok, true, JSON.stringify(one));
164
- assert.equal(two.ok, true, JSON.stringify(two));
165
- // Same-target small writes coalesce: the second row is legitimately
166
- // superseded by the first (DESIGN §5.8/K20), it is not a lost proposal.
167
- assert.equal(readJson(path.join(first.vaultDir, 'proposals', `${two.id}.json`)).status, 'superseded');
168
- await first.service.requestApply({ mode: 'approve', id: one.id, confirm: one.id.slice(-6) }, { caller: CALLER });
169
- firstId = one.id;
170
- await first.service.stop('v6b-l03');
171
- await first.service.dispose();
172
-
173
- // Simulate the crash window: the proposal file says approved, the queue
174
- // cache still says pending. Start must reconcile the cache, not trust it.
175
- writeStaleQueue(first.vaultDir, firstId, 'q-0001');
176
-
177
- const second = newEnv(temp);
178
- try {
179
- const restarted = await second.service.start();
180
- assert.equal(restarted.ok, true, JSON.stringify(restarted));
181
- const caller = CALLER;
182
- const list = second.service.proposalsView({ mode: 'list', limit: 50 }, { caller });
183
- assert.equal(list.ok, true, JSON.stringify(list));
184
- assert.equal(list.proposals.length, 2, JSON.stringify(list.proposals));
185
- const byTicket = new Map(list.proposals.map((row) => [row.ticket, row]));
186
- assert.equal(byTicket.get('q-0001').state, 'approved');
187
- assert.equal(byTicket.get('q-0001').status, 'approved');
188
- assert.equal(byTicket.get('q-0002').state, 'superseded');
189
-
190
- const queue = second.service.proposalsView({ mode: 'queue' }, { caller });
191
- const entry = (queue.entries ?? []).find((item) => item.ticket === 'q-0001');
192
- assert.equal(entry.status, 'approved', 'the rebuilt queue view must agree with the proposal file');
193
-
194
- const queueFile = readJson(path.join(second.vaultDir, 'proposals', 'queue.json'));
195
- assert.equal(queueFile.entries.find((item) => item.ticket === 'q-0001').status, 'approved');
196
-
197
- const status = second.service.status();
198
- assert.equal(status.proposals.pending, 0);
199
- assert.equal(status.proposals.approved, 1);
200
- assert.equal(status.proposals.superseded, 1);
201
- assert.equal(status.queue.pending, 0);
202
-
203
- const restored = auditRows(second.vaultDir).filter((row) => row.kind === 'queue.restored');
204
- assert.ok(restored.length >= 1, 'a reconciliation must be audited');
205
- assert.equal(restored.at(-1).written, true);
206
- } finally {
207
- await second.service.dispose();
208
- }
209
- } finally {
210
- fs.rmSync(temp, { recursive: true, force: true });
211
- }
212
- });
213
-
214
- test('v6b K-04: pending rows are cancellable by id and by ticket; superseded rows are terminal', async () => {
215
- const temp = tempRoot('v6b-k04-cancel-');
216
- const env = newEnv(temp);
217
- try {
218
- const started = await env.service.start();
219
- assert.equal(started.ok, true, JSON.stringify(started));
220
- const one = await propose(env.service, { title: 'keep', content: 'keep body' });
221
- const two = await propose(env.service, { title: 'merge-2', content: 'merge body 2' });
222
- const three = await propose(env.service, { title: 'merge-3', content: 'merge body 3' });
223
- assert.equal(one.ok, true, JSON.stringify(one));
224
-
225
- const list = env.service.proposalsView({ mode: 'list', limit: 20 }, { caller: CALLER });
226
- assert.equal(list.ok, true);
227
- assert.equal(list.proposals.length, 3);
228
- const rows = new Map(list.proposals.map((row) => [row.ticket, row]));
229
- assert.equal(rows.get('q-0001').state, 'pending');
230
- assert.equal(rows.get('q-0001').cancellable, true);
231
- assert.equal(rows.get('q-0001').terminal, false);
232
- for (const absorbedId of [two.id, three.id]) {
233
- const stored = readJson(path.join(env.vaultDir, 'proposals', `${absorbedId}.json`));
234
- const row = rows.get(stored.queue?.ticket);
235
- assert.ok(row, `absorbed row ${absorbedId} must stay listed`);
236
- assert.equal(row.state, 'superseded');
237
- assert.equal(row.cancellable, false);
238
- assert.equal(row.terminal, true);
239
- }
240
-
241
- // Cancel the pending row through the tool seam, addressed by ticket only.
242
- const tools = createRouterTools(env.service);
243
- const applyTool = tools.find((tool) => tool.name === 'router_apply');
244
- assert.ok(applyTool, 'router_apply must exist');
245
- const cancelled = await applyTool.invoke({ mode: 'cancel', ticket: 'q-0001', note: 'v6b' }, { caller: CALLER });
246
- assert.equal(cancelled.ok, true, JSON.stringify(cancelled));
247
- assert.equal(cancelled.state, 'cancelled');
248
- assert.equal(readJson(path.join(env.vaultDir, 'proposals', `${one.id}.json`)).status, 'cancelled');
249
- assert.ok(auditRows(env.vaultDir).some((row) => row.kind === 'proposal.cancel' && row.id === one.id));
250
-
251
- // A terminal row cannot be cancelled again, and superseded rows never were
252
- // cancellable: the criterion is "pending rows are cancellable", not "all rows".
253
- const again = await env.service.requestApply({ mode: 'cancel', id: one.id }, { caller: CALLER });
254
- assert.equal(again.ok, false);
255
- assert.equal(again.code, 'E_BAD_STATE');
256
- const supersededId = readJson(path.join(env.vaultDir, 'proposals', `${two.id}.json`)).id;
257
- const rejected = await env.service.requestApply({ mode: 'cancel', id: supersededId }, { caller: CALLER });
258
- assert.equal(rejected.ok, false);
259
- assert.equal(rejected.code, 'E_BAD_STATE');
260
- } finally {
261
- await env.service.dispose();
262
- fs.rmSync(temp, { recursive: true, force: true });
263
- }
264
- });
265
-
266
- test('v6b K-04/L-03: router_proposals rows expose state+status and match status counts', async () => {
267
- const temp = tempRoot('v6b-rows-');
268
- const env = newEnv(temp);
269
- try {
270
- const started = await env.service.start();
271
- assert.equal(started.ok, true, JSON.stringify(started));
272
- const one = await propose(env.service, { title: 'rows', content: 'row body' });
273
- const two = await propose(env.service, { title: 'rows-2', content: 'row body 2' });
274
- assert.equal(one.ok, true, JSON.stringify(one));
275
-
276
- const tools = createRouterTools(env.service);
277
- const listTool = tools.find((tool) => tool.name === 'router_proposals');
278
- const listed = await listTool.invoke({ mode: 'list', limit: 20 }, { caller: CALLER });
279
- assert.equal(listed.ok, true, JSON.stringify(listed));
280
- for (const row of listed.proposals) {
281
- assert.equal(typeof row.state, 'string');
282
- assert.equal(row.status, row.state, 'status must mirror state');
283
- assert.equal(typeof row.cancellable, 'boolean');
284
- assert.equal(typeof row.terminal, 'boolean');
285
- }
286
- const status = env.service.status();
287
- assert.equal(listed.proposals.length, status.proposals.pending + status.proposals.approved + status.proposals.superseded);
288
- assert.ok(listed.proposals.some((row) => row.ticket === 'q-0002' && row.state === 'superseded'));
289
- } finally {
290
- await env.service.dispose();
291
- fs.rmSync(temp, { recursive: true, force: true });
292
- }
293
- });
@@ -1,249 +0,0 @@
1
- // 1.1.3 contract checks for the v7 gaps that 1.1.2 still exposed:
2
- // P0 anchor off-by-one (restart with a ledger must not raise E_LEDGER_TAMPERED)
3
- // F-03 audit.repair (a truncated half-line must leave an audit event)
4
- // E-06/G-02/G-03 budget.reclaim (previous-run reservations are released and audited)
5
- // D-05 routerTestHooks (full+reflect both over budget can be constructed; off by default)
6
- //
7
- // Everything is local temp storage; no host, no network, no real model.
8
-
9
- import assert from 'node:assert/strict';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import test from 'node:test';
13
-
14
- import { readAnchor } from '../../src/router/background/anchor.mjs';
15
- import { AuditLog, readAudit } from '../../src/router/background/audit.mjs';
16
- import { BudgetLedger, foldRecords, readLedger } from '../../src/router/background/budget-ledger.mjs';
17
- import {
18
- appendTask,
19
- makeService,
20
- taskInput,
21
- tempRoot,
22
- } from './helpers.mjs';
23
-
24
- function auditRows(file) {
25
- if (!fs.existsSync(file)) return [];
26
- return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
27
- }
28
-
29
- function recordAt(records, seq) {
30
- return records.find((record) => record.seq === seq) ?? null;
31
- }
32
-
33
- async function stopAndDispose(env) {
34
- try { await env.service.stop(); } catch { /* best effort */ }
35
- try { await env.service.dispose(); } catch { /* best effort */ }
36
- }
37
-
38
- test('v6c P0: restart with a ledger is healthy; the anchor points at the last audit record', async () => {
39
- const temp = tempRoot('v6c-p0-');
40
- const first = makeService({ temp, deps: { runId: 'run-p0-a' } });
41
- let second = null;
42
- try {
43
- const started = await first.service.start();
44
- assert.equal(started.ok, true, JSON.stringify(started));
45
- appendTask(first.service, taskInput('p0-task'));
46
- const tick = await first.service.tick({ tier: 'full' });
47
- assert.equal(tick.ok, true, JSON.stringify(tick));
48
-
49
- const ledgerFile = path.join(temp, 'vault', 'runs', 'run-p0-a', 'budget.ledger.jsonl');
50
- const folded = foldRecords(readLedger(ledgerFile).records);
51
- assert.ok(folded.used > 0, 'the tick must settle real tokens, so the restart is "with a ledger"');
52
-
53
- await stopAndDispose(first);
54
-
55
- const auditFile = path.join(temp, 'vault', 'audit.jsonl');
56
- const anchorFile = path.join(temp, 'vault', 'runs', 'run-p0-a', 'anchor.jsonl');
57
- const auditRecords = readAudit(auditFile).records;
58
- const anchorRecords = readAnchor(anchorFile).records;
59
- const lastAnchor = anchorRecords[anchorRecords.length - 1];
60
- assert.ok(lastAnchor, 'stop() must leave an anchor behind');
61
- // The seal may happen before the final audit row of stop(), so check the
62
- // prefix it claims: the seq must exist and its hash must match.
63
- const claimed = recordAt(auditRecords, lastAnchor.audit.seq);
64
- assert.ok(claimed, `anchor claims audit seq ${lastAnchor.audit.seq}, which does not exist (off-by-one)`);
65
- assert.equal(claimed.hash, lastAnchor.audit.last_hash);
66
-
67
- second = makeService({ temp, deps: { runId: 'run-p0-b' } });
68
- const restarted = await second.service.start();
69
- assert.equal(restarted.ok, true, JSON.stringify(restarted));
70
- assert.equal(restarted.state, 'running');
71
- assert.notEqual(restarted.code, 'E_LEDGER_TAMPERED');
72
- assert.ok(!(second.service.status().warnings ?? []).includes('E_LEDGER_TAMPERED'));
73
- } finally {
74
- if (second) await stopAndDispose(second);
75
- if (first.service.status().state === 'running') await stopAndDispose(first);
76
- fs.rmSync(temp, { recursive: true, force: true });
77
- }
78
- });
79
-
80
- test('v6c F-03: a truncated audit half-line is dropped and recorded as audit.repair', async () => {
81
- const temp = tempRoot('v6c-f03-');
82
- const file = path.join(temp, 'audit.jsonl');
83
- try {
84
- const log = new AuditLog({ file, runId: 'run-f03' });
85
- log.open();
86
- log.append('test.before', { n: 1 });
87
- log.append('test.before', { n: 2 });
88
- log.close();
89
-
90
- const partial = '{"v":1,"seq":99,"kind":"broken-tail"';
91
- fs.appendFileSync(file, partial, 'utf8');
92
-
93
- const reopened = new AuditLog({ file, runId: 'run-f03' });
94
- reopened.open();
95
- const afterOpen = readAudit(file);
96
- assert.equal(afterOpen.badLines, 0);
97
- assert.equal(afterOpen.truncated, 0);
98
- assert.deepEqual(afterOpen.records.map((record) => record.seq), [1, 2, 3]);
99
- const repaired = afterOpen.records[2];
100
- assert.equal(repaired.kind, 'audit.repair');
101
- assert.equal(repaired.bytes_dropped, Buffer.byteLength(partial));
102
- assert.equal(repaired.reason, 'partial_tail');
103
-
104
- // Later writes continue the sequence and stay parseable.
105
- reopened.append('test.after', { n: 3 });
106
- const afterWrite = readAudit(file);
107
- assert.equal(afterWrite.badLines, 0);
108
- assert.equal(afterWrite.records.length, 4);
109
- assert.equal(afterWrite.records[3].seq, 4);
110
- assert.equal(afterWrite.records[3].kind, 'test.after');
111
- reopened.close();
112
-
113
- // A clean reopen must not invent a repair event.
114
- const clean = new AuditLog({ file, runId: 'run-f03' });
115
- clean.open();
116
- assert.equal(readAudit(file).records.length, 4);
117
- clean.close();
118
- } finally {
119
- fs.rmSync(temp, { recursive: true, force: true });
120
- }
121
- });
122
-
123
- test('v6c E-06/G-02/G-03: boot reclaims previous-run reservations and audits budget.reclaim', async () => {
124
- const temp = tempRoot('v6c-reclaim-');
125
- const vaultDir = path.join(temp, 'vault');
126
- const orphanDir = path.join(vaultDir, 'runs', 'run-orphan');
127
- fs.mkdirSync(orphanDir, { recursive: true });
128
- const orphanFile = path.join(orphanDir, 'budget.ledger.jsonl');
129
- const tokens = 777;
130
-
131
- // A crashed child left one reservation behind: its pid is still alive (ours),
132
- // so the lease is what marks it dead.
133
- const orphan = new BudgetLedger({
134
- file: orphanFile,
135
- runId: 'run-orphan',
136
- maxTokens: 50000,
137
- pid: process.pid,
138
- reservationLeaseMs: 1,
139
- });
140
- const reserved = orphan.reserve({ taskId: 'crashed-task', tokens, leaseMs: 1 });
141
- assert.equal(reserved.ok, true, JSON.stringify(reserved));
142
- await new Promise((resolve) => setTimeout(resolve, 25));
143
-
144
- let first = null;
145
- let second = null;
146
- try {
147
- first = makeService({
148
- temp,
149
- deps: { runId: 'run-after-crash-1' },
150
- config: { allowMissingAnchor: true },
151
- });
152
- const started = await first.service.start();
153
- assert.equal(started.ok, true, JSON.stringify(started));
154
- assert.equal(started.state, 'running');
155
-
156
- const audit = auditRows(path.join(vaultDir, 'audit.jsonl'));
157
- const reclaims = audit.filter((row) => row.kind === 'budget.reclaim');
158
- assert.equal(reclaims.length, 1, JSON.stringify(reclaims));
159
- const [reclaim] = reclaims;
160
- assert.equal(reclaim.origin, 'startup');
161
- assert.equal(reclaim.released, 1);
162
- assert.equal(reclaim.freed_tokens, tokens);
163
- assert.equal(reclaim.charged, 0);
164
- assert.equal(reclaim.runs.length, 1);
165
- assert.equal(reclaim.runs[0].run_id, 'run-orphan');
166
- assert.equal(reclaim.runs[0].scope, 'previous_run');
167
- assert.equal(reclaim.runs[0].released, 1);
168
-
169
- // The old run is back to zero: no charge, no active reservation.
170
- const folded = foldRecords(readLedger(orphanFile).records);
171
- assert.equal(folded.used, 0);
172
- assert.equal(folded.reserved, 0);
173
- assert.equal(folded.active.size, 0);
174
- const release = readLedger(orphanFile).records.find((record) => record.op === 'release');
175
- assert.ok(release, 'reclaim must append a release record');
176
- assert.equal(release.released_tokens, tokens);
177
-
178
- const status = first.service.status();
179
- assert.equal(status.run.usedTokens, 0);
180
- assert.equal(status.today.usedTokens, 0);
181
- assert.ok(!audit.some((row) => row.kind === 'budget.settle' && row.run_id === 'run-orphan'));
182
-
183
- await stopAndDispose(first);
184
- first = null;
185
-
186
- // A second restart has nothing left to reclaim: no duplicate billing event.
187
- second = makeService({
188
- temp,
189
- deps: { runId: 'run-after-crash-2' },
190
- config: { allowMissingAnchor: true },
191
- });
192
- const restarted = await second.service.start();
193
- assert.equal(restarted.ok, true, JSON.stringify(restarted));
194
- const reclaimsAgain = auditRows(path.join(vaultDir, 'audit.jsonl'))
195
- .filter((row) => row.kind === 'budget.reclaim');
196
- assert.equal(reclaimsAgain.length, 1, 'reclaim must be idempotent across restarts');
197
- assert.equal(foldRecords(readLedger(orphanFile).records).used, 0);
198
- } finally {
199
- if (first) await stopAndDispose(first);
200
- if (second) await stopAndDispose(second);
201
- fs.rmSync(temp, { recursive: true, force: true });
202
- }
203
- });
204
-
205
- test('v6c D-05: the test hook can force full+reflect over budget without touching a model', async () => {
206
- const temp = tempRoot('v6c-d05-block-');
207
- const env = makeService({
208
- temp,
209
- deps: { runId: 'run-d05-block', testHooks: { forceContextOverBudget: true } },
210
- });
211
- try {
212
- const started = await env.service.start();
213
- assert.equal(started.ok, true, JSON.stringify(started));
214
- appendTask(env.service, taskInput('d05-task'));
215
- const tick = await env.service.tick({ tier: 'full' });
216
- assert.equal(tick.ok, true, JSON.stringify(tick));
217
-
218
- const status = env.service.status();
219
- assert.equal(status.state, 'blocked');
220
- assert.equal(status.reason, 'E_CONTEXT_OVER_BUDGET');
221
- assert.equal(env.llm.calls, 0, 'a blocked context must not call the model');
222
- assert.equal(status.run.usedTokens, 0);
223
- assert.equal(foldRecords(readLedger(path.join(temp, 'vault', 'runs', 'run-d05-block', 'budget.ledger.jsonl')).records).reserved, 0);
224
- assert.ok(!auditRows(path.join(temp, 'vault', 'audit.jsonl'))
225
- .some((row) => row.kind === 'budget.settle'));
226
- } finally {
227
- await stopAndDispose(env);
228
- fs.rmSync(temp, { recursive: true, force: true });
229
- }
230
- });
231
-
232
- test('v6c D-05: without the hook the same service runs normally (hook is off by default)', async () => {
233
- const temp = tempRoot('v6c-d05-normal-');
234
- const env = makeService({ temp, deps: { runId: 'run-d05-normal' } });
235
- try {
236
- const started = await env.service.start();
237
- assert.equal(started.ok, true, JSON.stringify(started));
238
- appendTask(env.service, taskInput('d05-normal'));
239
- const tick = await env.service.tick({ tier: 'full' });
240
- assert.equal(tick.ok, true, JSON.stringify(tick));
241
- assert.equal(tick.processed, 1);
242
- assert.equal(env.llm.calls, 1);
243
- assert.equal(env.service.status().state, 'running');
244
- assert.equal(env.service.status().reason, 'started');
245
- } finally {
246
- await stopAndDispose(env);
247
- fs.rmSync(temp, { recursive: true, force: true });
248
- }
249
- });