@pugi/cli 0.1.0-beta.3 → 0.1.0-beta.31

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 (219) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/artifact-chain/dispatcher.js +148 -0
  10. package/dist/core/artifact-chain/exporter.js +164 -0
  11. package/dist/core/artifact-chain/state.js +243 -0
  12. package/dist/core/artifact-chain/steps.js +169 -0
  13. package/dist/core/auth/env-provider.js +238 -0
  14. package/dist/core/auto-update/channels.js +122 -0
  15. package/dist/core/auto-update/checker.js +241 -0
  16. package/dist/core/auto-update/state.js +235 -0
  17. package/dist/core/bare-mode/index.js +107 -0
  18. package/dist/core/checkpoint/resumer.js +149 -0
  19. package/dist/core/checkpoint/rewinder.js +291 -0
  20. package/dist/core/compact/auto-trigger.js +96 -0
  21. package/dist/core/compact/buffer-rewriter.js +115 -0
  22. package/dist/core/compact/summarizer.js +208 -0
  23. package/dist/core/compact/token-counter.js +108 -0
  24. package/dist/core/consensus/diff-capture.js +73 -0
  25. package/dist/core/context/index.js +7 -0
  26. package/dist/core/context/markdown-traverse.js +255 -0
  27. package/dist/core/cost/rate-card.js +129 -0
  28. package/dist/core/cost/tracker.js +221 -0
  29. package/dist/core/denial-tracking/index.js +8 -0
  30. package/dist/core/denial-tracking/state.js +264 -0
  31. package/dist/core/diagnostics/probe-runner.js +93 -0
  32. package/dist/core/diagnostics/probes/api.js +46 -0
  33. package/dist/core/diagnostics/probes/auth.js +86 -0
  34. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  35. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  36. package/dist/core/diagnostics/probes/config.js +72 -0
  37. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  38. package/dist/core/diagnostics/probes/disk.js +81 -0
  39. package/dist/core/diagnostics/probes/git.js +65 -0
  40. package/dist/core/diagnostics/probes/mcp.js +75 -0
  41. package/dist/core/diagnostics/probes/node.js +59 -0
  42. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  43. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  44. package/dist/core/diagnostics/probes/session.js +74 -0
  45. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  46. package/dist/core/diagnostics/probes/workspace.js +63 -0
  47. package/dist/core/diagnostics/types.js +70 -0
  48. package/dist/core/dispatch/cache-cleanup.js +197 -0
  49. package/dist/core/dispatch/cache-handoff.js +295 -0
  50. package/dist/core/edits/dispatch.js +218 -2
  51. package/dist/core/edits/journal.js +199 -0
  52. package/dist/core/edits/layer-d-ast.js +557 -14
  53. package/dist/core/edits/verify-hook.js +273 -0
  54. package/dist/core/edits/worktree.js +111 -18
  55. package/dist/core/engine/anvil-client.js +115 -5
  56. package/dist/core/engine/budgets.js +89 -0
  57. package/dist/core/engine/context-prefix.js +155 -0
  58. package/dist/core/engine/intent.js +260 -0
  59. package/dist/core/engine/native-pugi.js +852 -210
  60. package/dist/core/engine/prompts.js +89 -6
  61. package/dist/core/engine/strip-internal-fields.js +124 -0
  62. package/dist/core/engine/tool-bridge.js +972 -33
  63. package/dist/core/feedback/queue.js +177 -0
  64. package/dist/core/feedback/submitter.js +145 -0
  65. package/dist/core/file-cache.js +113 -1
  66. package/dist/core/hooks/events.js +44 -0
  67. package/dist/core/hooks/index.js +15 -0
  68. package/dist/core/hooks/registry.js +213 -0
  69. package/dist/core/hooks/runner.js +236 -0
  70. package/dist/core/init/scaffold.js +195 -0
  71. package/dist/core/lsp/cache.js +105 -0
  72. package/dist/core/lsp/client.js +174 -29
  73. package/dist/core/lsp/language-detect.js +66 -0
  74. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  75. package/dist/core/mcp/client.js +75 -6
  76. package/dist/core/mcp/http-server.js +553 -0
  77. package/dist/core/mcp/permission.js +190 -0
  78. package/dist/core/mcp/registry.js +24 -2
  79. package/dist/core/mcp/server-tools.js +219 -0
  80. package/dist/core/mcp/server.js +397 -0
  81. package/dist/core/memory/dual-write.js +416 -0
  82. package/dist/core/memory/dual-write.spec.js +297 -0
  83. package/dist/core/memory/phase1-kinds.js +20 -0
  84. package/dist/core/memory-sync/queue.js +158 -0
  85. package/dist/core/memory-sync/queue.spec.js +105 -0
  86. package/dist/core/onboarding/marker.js +111 -0
  87. package/dist/core/onboarding/telemetry-state.js +108 -0
  88. package/dist/core/output-style/presets.js +176 -0
  89. package/dist/core/output-style/state.js +185 -0
  90. package/dist/core/permissions/gate.js +187 -0
  91. package/dist/core/permissions/index.js +18 -0
  92. package/dist/core/permissions/mode.js +102 -0
  93. package/dist/core/permissions/state.js +215 -0
  94. package/dist/core/permissions/tool-class.js +93 -0
  95. package/dist/core/prd-check/parser.js +215 -0
  96. package/dist/core/prd-check/reporter.js +127 -0
  97. package/dist/core/prd-check/session-review.js +557 -0
  98. package/dist/core/prd-check/verifiers.js +223 -0
  99. package/dist/core/pugi-md/context-injector.js +76 -0
  100. package/dist/core/pugi-md/walk-up.js +207 -0
  101. package/dist/core/release-notes/parser.js +241 -0
  102. package/dist/core/release-notes/state.js +116 -0
  103. package/dist/core/repl/codebase-survey.js +308 -0
  104. package/dist/core/repl/history.js +11 -1
  105. package/dist/core/repl/init-interview.js +457 -0
  106. package/dist/core/repl/model-pricing.js +135 -0
  107. package/dist/core/repl/onboarding-state.js +297 -0
  108. package/dist/core/repl/session.js +1529 -30
  109. package/dist/core/repl/slash-commands.js +361 -13
  110. package/dist/core/repl/store/session-store.js +31 -2
  111. package/dist/core/repl/workspace-context.js +22 -0
  112. package/dist/core/repo-map/build.js +125 -0
  113. package/dist/core/repo-map/cache.js +185 -0
  114. package/dist/core/repo-map/extractor.js +254 -0
  115. package/dist/core/repo-map/formatter.js +145 -0
  116. package/dist/core/repo-map/scanner.js +211 -0
  117. package/dist/core/retry-budget/budget.js +284 -0
  118. package/dist/core/retry-budget/index.js +5 -0
  119. package/dist/core/session.js +44 -0
  120. package/dist/core/settings.js +80 -0
  121. package/dist/core/share/formatter.js +271 -0
  122. package/dist/core/share/redactor.js +221 -0
  123. package/dist/core/share/uploader.js +267 -0
  124. package/dist/core/skills/defaults.js +457 -0
  125. package/dist/core/subagents/dispatcher-real.js +600 -0
  126. package/dist/core/subagents/dispatcher.js +113 -24
  127. package/dist/core/subagents/index.js +18 -5
  128. package/dist/core/subagents/isolation-matrix.js +213 -0
  129. package/dist/core/subagents/spawn.js +19 -4
  130. package/dist/core/telemetry/emitter.js +229 -0
  131. package/dist/core/telemetry/queue.js +251 -0
  132. package/dist/core/theme/context.js +91 -0
  133. package/dist/core/theme/presets.js +228 -0
  134. package/dist/core/theme/state.js +181 -0
  135. package/dist/core/todos/invariant.js +10 -0
  136. package/dist/core/todos/state.js +177 -0
  137. package/dist/core/transport/version-interceptor.js +166 -0
  138. package/dist/core/vim/keymap.js +288 -0
  139. package/dist/core/vim/state.js +92 -0
  140. package/dist/index.js +28 -0
  141. package/dist/runtime/bootstrap.js +190 -0
  142. package/dist/runtime/cli.js +2603 -278
  143. package/dist/runtime/commands/chain.js +489 -0
  144. package/dist/runtime/commands/compact.js +297 -0
  145. package/dist/runtime/commands/cost.js +199 -0
  146. package/dist/runtime/commands/delegate.js +312 -0
  147. package/dist/runtime/commands/dispatch.js +126 -0
  148. package/dist/runtime/commands/doctor.js +390 -0
  149. package/dist/runtime/commands/feedback.js +184 -0
  150. package/dist/runtime/commands/hooks.js +184 -0
  151. package/dist/runtime/commands/lsp.js +212 -28
  152. package/dist/runtime/commands/mcp.js +824 -0
  153. package/dist/runtime/commands/memory.js +508 -0
  154. package/dist/runtime/commands/memory.spec.js +174 -0
  155. package/dist/runtime/commands/model.js +237 -0
  156. package/dist/runtime/commands/onboarding.js +275 -0
  157. package/dist/runtime/commands/patch.js +17 -0
  158. package/dist/runtime/commands/permissions.js +87 -0
  159. package/dist/runtime/commands/plan.js +143 -0
  160. package/dist/runtime/commands/prd-check.js +285 -0
  161. package/dist/runtime/commands/release-notes.js +229 -0
  162. package/dist/runtime/commands/repo-map.js +95 -0
  163. package/dist/runtime/commands/report.js +299 -0
  164. package/dist/runtime/commands/resume.js +118 -0
  165. package/dist/runtime/commands/review-consensus.js +17 -2
  166. package/dist/runtime/commands/rewind.js +333 -0
  167. package/dist/runtime/commands/roster.js +117 -0
  168. package/dist/runtime/commands/sessions.js +163 -0
  169. package/dist/runtime/commands/share.js +316 -0
  170. package/dist/runtime/commands/status.js +178 -0
  171. package/dist/runtime/commands/stickers.js +82 -0
  172. package/dist/runtime/commands/style.js +194 -0
  173. package/dist/runtime/commands/theme.js +196 -0
  174. package/dist/runtime/commands/update.js +289 -0
  175. package/dist/runtime/commands/vim.js +140 -0
  176. package/dist/runtime/commands/worktree.js +50 -6
  177. package/dist/runtime/headless.js +543 -0
  178. package/dist/runtime/load-hooks-or-exit.js +71 -0
  179. package/dist/runtime/plan-decompose.js +531 -0
  180. package/dist/runtime/version.js +65 -0
  181. package/dist/tools/agent-tool.js +229 -0
  182. package/dist/tools/apply-patch.js +281 -39
  183. package/dist/tools/ask-user-question.js +213 -0
  184. package/dist/tools/ask-user.js +115 -0
  185. package/dist/tools/file-tools.js +85 -14
  186. package/dist/tools/mcp-tool.js +260 -0
  187. package/dist/tools/multi-edit.js +361 -0
  188. package/dist/tools/registry.js +30 -2
  189. package/dist/tools/skill-tool.js +96 -0
  190. package/dist/tools/tasks.js +208 -0
  191. package/dist/tools/todo-write.js +184 -0
  192. package/dist/tools/web-fetch.js +147 -2
  193. package/dist/tools/web-search.js +458 -0
  194. package/dist/tui/agent-progress-card.js +111 -0
  195. package/dist/tui/agent-tree.js +10 -0
  196. package/dist/tui/ask-modal.js +2 -2
  197. package/dist/tui/ask-user-question-prompt.js +192 -0
  198. package/dist/tui/compact-banner.js +81 -0
  199. package/dist/tui/conversation-pane.js +82 -8
  200. package/dist/tui/cost-table.js +111 -0
  201. package/dist/tui/doctor-table.js +46 -0
  202. package/dist/tui/feedback-prompt.js +156 -0
  203. package/dist/tui/input-box.js +46 -2
  204. package/dist/tui/markdown-render.js +4 -4
  205. package/dist/tui/onboarding-wizard.js +240 -0
  206. package/dist/tui/repl-render.js +293 -35
  207. package/dist/tui/repl-splash.js +2 -2
  208. package/dist/tui/repl.js +45 -13
  209. package/dist/tui/splash.js +1 -1
  210. package/dist/tui/status-bar.js +94 -16
  211. package/dist/tui/status-table.js +7 -0
  212. package/dist/tui/stickers-art.js +136 -0
  213. package/dist/tui/style-table.js +28 -0
  214. package/dist/tui/theme-table.js +29 -0
  215. package/dist/tui/tool-stream-pane.js +7 -0
  216. package/dist/tui/update-banner.js +20 -2
  217. package/dist/tui/vim-input.js +267 -0
  218. package/docs/examples/codegraph.mcp.json +10 -0
  219. package/package.json +9 -6
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Pugi CLI dual-write client spec — Memory Phase 1 (2026-05-27).
3
+ *
4
+ * Covers:
5
+ * 1. happy path — single batch posts to /api/pugi/sessions/:id/events
6
+ * 2. debounce + batch coalesces multiple enqueues into one POST
7
+ * 3. retry on transient failure with exponential backoff
8
+ * 4. surrender after maxRetries — local NDJSON stays authoritative
9
+ * 5. lastSyncedSeq marker is written + persisted across instances
10
+ * 6. flushBacklog catches up rows with seq > lastSyncedSeq
11
+ * 7. env kill-switch turns dual-write off
12
+ * 8. cliKindToPhase1 remap covers every CLI kind
13
+ * 9. invalid Phase-1 kinds quietly dropped from enqueue
14
+ * 10. dispose awaits the pending flush
15
+ */
16
+ import { afterEach, beforeEach, describe, it } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { resolve } from 'node:path';
21
+ import { DualWriteClient, cliKindToPhase1, debounceMsFromEnv, isDualWriteEnabledFromEnv, isValidPhase1Kind, nextBackoffMs, readSyncState, writeSyncState, } from './dual-write.js';
22
+ import { PUGI_SESSION_EVENT_KINDS, } from './phase1-kinds.js';
23
+ let tmpDir = '';
24
+ beforeEach(() => {
25
+ tmpDir = mkdtempSync(resolve(tmpdir(), 'pugi-dual-write-'));
26
+ });
27
+ afterEach(() => {
28
+ try {
29
+ rmSync(tmpDir, { recursive: true, force: true });
30
+ }
31
+ catch {
32
+ /* ignore */
33
+ }
34
+ });
35
+ function makeStubFetch(responses) {
36
+ const calls = [];
37
+ let i = 0;
38
+ const fetchImpl = async (input, init) => {
39
+ const url = typeof input === 'string' ? input : input.toString();
40
+ const headers = {};
41
+ if (init?.headers) {
42
+ const h = init.headers;
43
+ for (const k of Object.keys(h))
44
+ headers[k.toLowerCase()] = h[k];
45
+ }
46
+ const bodyRaw = typeof init?.body === 'string' ? init.body : '';
47
+ let body = null;
48
+ try {
49
+ body = bodyRaw ? JSON.parse(bodyRaw) : null;
50
+ }
51
+ catch {
52
+ body = bodyRaw;
53
+ }
54
+ calls.push({ url, body, headers });
55
+ const spec = responses[i++] ?? responses[responses.length - 1] ?? { ok: true };
56
+ if (spec.throw)
57
+ throw spec.throw;
58
+ return {
59
+ ok: spec.ok,
60
+ status: spec.status ?? (spec.ok ? 200 : 500),
61
+ statusText: spec.ok ? 'OK' : 'Internal Server Error',
62
+ json: async () => spec.body ?? {
63
+ persistedCount: 0,
64
+ duplicateCount: 0,
65
+ lastSeq: 0,
66
+ },
67
+ };
68
+ };
69
+ return { fetchImpl, calls };
70
+ }
71
+ /* --------------------------------------------------------------- */
72
+ /* Pure helpers */
73
+ /* --------------------------------------------------------------- */
74
+ describe('dual-write: env helpers', () => {
75
+ it('isDualWriteEnabledFromEnv: defaults to ON', () => {
76
+ assert.equal(isDualWriteEnabledFromEnv({}), true);
77
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'true' }), true);
78
+ });
79
+ it('isDualWriteEnabledFromEnv: OFF on common false-ish values', () => {
80
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'false' }), false);
81
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: '0' }), false);
82
+ assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'off' }), false);
83
+ });
84
+ it('debounceMsFromEnv: parses + clamps + falls back', () => {
85
+ assert.equal(debounceMsFromEnv({}), 250);
86
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '500' }), 500);
87
+ // Bogus -> default.
88
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: 'abc' }), 250);
89
+ // Negative -> default.
90
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '-5' }), 250);
91
+ // Too large -> default.
92
+ assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '120000' }), 250);
93
+ });
94
+ it('nextBackoffMs: 1x / 3x / 9x growth', () => {
95
+ assert.equal(nextBackoffMs(1, 100), 100);
96
+ assert.equal(nextBackoffMs(2, 100), 300);
97
+ assert.equal(nextBackoffMs(3, 100), 900);
98
+ });
99
+ it('cliKindToPhase1: covers every CLI kind we know about', () => {
100
+ assert.equal(cliKindToPhase1('user'), 'turn.user');
101
+ assert.equal(cliKindToPhase1('persona'), 'turn.assistant');
102
+ assert.equal(cliKindToPhase1('system'), 'system');
103
+ assert.equal(cliKindToPhase1('tool.start'), 'tool.call');
104
+ assert.equal(cliKindToPhase1('tool.result'), 'tool.result');
105
+ assert.equal(cliKindToPhase1('agent.spawned'), 'dispatch.start');
106
+ assert.equal(cliKindToPhase1('agent.completed'), 'dispatch.end');
107
+ assert.equal(cliKindToPhase1('compaction'), 'compact.boundary');
108
+ // rewind-marker and any other -> null (the CLI keeps these in NDJSON only).
109
+ assert.equal(cliKindToPhase1('rewind-marker'), null);
110
+ assert.equal(cliKindToPhase1('totally-unknown'), null);
111
+ });
112
+ it('isValidPhase1Kind matches the closed set', () => {
113
+ for (const k of PUGI_SESSION_EVENT_KINDS) {
114
+ assert.equal(isValidPhase1Kind(k), true);
115
+ }
116
+ assert.equal(isValidPhase1Kind('user'), false); // CLI kind, not Phase-1
117
+ assert.equal(isValidPhase1Kind('nope'), false);
118
+ });
119
+ });
120
+ /* --------------------------------------------------------------- */
121
+ /* lastSyncedSeq marker */
122
+ /* --------------------------------------------------------------- */
123
+ describe('dual-write: sync state marker', () => {
124
+ it('reads 0 when no marker on disk', () => {
125
+ assert.equal(readSyncState('sess-1', tmpDir), 0);
126
+ });
127
+ it('writes + reads back monotonic seq', () => {
128
+ writeSyncState('sess-1', 42, tmpDir);
129
+ assert.equal(readSyncState('sess-1', tmpDir), 42);
130
+ writeSyncState('sess-1', 100, tmpDir);
131
+ assert.equal(readSyncState('sess-1', tmpDir), 100);
132
+ });
133
+ it('isolates per-session', () => {
134
+ writeSyncState('sess-a', 5, tmpDir);
135
+ writeSyncState('sess-b', 10, tmpDir);
136
+ assert.equal(readSyncState('sess-a', tmpDir), 5);
137
+ assert.equal(readSyncState('sess-b', tmpDir), 10);
138
+ });
139
+ });
140
+ /* --------------------------------------------------------------- */
141
+ /* DualWriteClient end-to-end */
142
+ /* --------------------------------------------------------------- */
143
+ describe('DualWriteClient', () => {
144
+ it('flushes a single batch on demand', async () => {
145
+ const { fetchImpl, calls } = makeStubFetch([
146
+ { ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 2 } },
147
+ ]);
148
+ const client = new DualWriteClient({
149
+ apiUrl: 'http://localhost',
150
+ apiKey: 'test-key',
151
+ sessionId: 'sess-1',
152
+ fetchImpl,
153
+ debounceMs: 5,
154
+ stateDir: tmpDir,
155
+ });
156
+ client.enqueue({ seq: 1, kind: 'turn.user', payload: { text: 'hi' } });
157
+ client.enqueue({ seq: 2, kind: 'turn.assistant', payload: { text: 'hello' } });
158
+ const result = await client.flush();
159
+ await client.dispose();
160
+ assert.equal(calls.length, 1);
161
+ assert.equal(calls[0].headers['authorization'], 'Bearer test-key');
162
+ const sentBody = calls[0].body;
163
+ assert.equal(sentBody.events.length, 2);
164
+ assert.equal(sentBody.events[0].seq, 1);
165
+ assert.equal(sentBody.events[1].kind, 'turn.assistant');
166
+ assert.equal(result?.persistedCount, 2);
167
+ assert.equal(result?.lastSeq, 2);
168
+ // Marker persisted.
169
+ assert.equal(readSyncState('sess-1', tmpDir), 2);
170
+ });
171
+ it('retries on transient failure with exponential backoff', async () => {
172
+ const { fetchImpl, calls } = makeStubFetch([
173
+ { ok: false, status: 503, body: {} },
174
+ { ok: false, status: 503, body: {} },
175
+ { ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 5 } },
176
+ ]);
177
+ const client = new DualWriteClient({
178
+ apiUrl: 'http://localhost',
179
+ apiKey: 'k',
180
+ sessionId: 'sess-r',
181
+ fetchImpl,
182
+ debounceMs: 1, // base for backoff -> 1ms / 3ms / 9ms; fast test
183
+ maxRetries: 3,
184
+ stateDir: tmpDir,
185
+ });
186
+ client.enqueue({ seq: 5, kind: 'turn.user', payload: {} });
187
+ const result = await client.flush();
188
+ await client.dispose();
189
+ assert.equal(calls.length, 3);
190
+ assert.equal(result?.lastSeq, 5);
191
+ assert.equal(readSyncState('sess-r', tmpDir), 5);
192
+ });
193
+ it('surrenders after maxRetries — local NDJSON stays the truth', async () => {
194
+ const { fetchImpl, calls } = makeStubFetch([
195
+ { ok: false, status: 500, body: {} },
196
+ { ok: false, status: 500, body: {} },
197
+ { ok: false, status: 500, body: {} },
198
+ ]);
199
+ const client = new DualWriteClient({
200
+ apiUrl: 'http://localhost',
201
+ apiKey: 'k',
202
+ sessionId: 'sess-fail',
203
+ fetchImpl,
204
+ debounceMs: 1,
205
+ maxRetries: 3,
206
+ stateDir: tmpDir,
207
+ });
208
+ client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
209
+ const result = await client.flush();
210
+ await client.dispose();
211
+ assert.equal(calls.length, 3);
212
+ assert.equal(result, null); // surrender
213
+ // Marker NOT written — next session retries from zero.
214
+ assert.equal(readSyncState('sess-fail', tmpDir), 0);
215
+ });
216
+ it('drops events whose kind is not in the Phase-1 set', async () => {
217
+ const { fetchImpl, calls } = makeStubFetch([
218
+ { ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 1 } },
219
+ ]);
220
+ const client = new DualWriteClient({
221
+ apiUrl: 'http://localhost',
222
+ apiKey: 'k',
223
+ sessionId: 'sess-d',
224
+ fetchImpl,
225
+ debounceMs: 5,
226
+ stateDir: tmpDir,
227
+ });
228
+ // The string is broader than Phase-1 — the client filters silently.
229
+ client.enqueue({ seq: 1, kind: 'rewind-marker', payload: {} });
230
+ client.enqueue({ seq: 2, kind: 'turn.user', payload: {} });
231
+ await client.flush();
232
+ await client.dispose();
233
+ const sent = calls[0].body;
234
+ assert.equal(sent.events.length, 1);
235
+ assert.equal(sent.events[0].seq, 2);
236
+ });
237
+ it('flushBacklog skips events <= lastSyncedSeq and posts the rest', async () => {
238
+ const { fetchImpl, calls } = makeStubFetch([
239
+ { ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 10 } },
240
+ ]);
241
+ // Seed a marker: last synced was seq=5.
242
+ writeSyncState('sess-bk', 5, tmpDir);
243
+ const client = new DualWriteClient({
244
+ apiUrl: 'http://localhost',
245
+ apiKey: 'k',
246
+ sessionId: 'sess-bk',
247
+ fetchImpl,
248
+ stateDir: tmpDir,
249
+ });
250
+ const allEvents = [
251
+ { seq: 1, kind: 'turn.user', payload: {} }, // skip
252
+ { seq: 5, kind: 'turn.assistant', payload: {} }, // skip (<=5)
253
+ { seq: 7, kind: 'turn.user', payload: {} }, // POST
254
+ { seq: 10, kind: 'turn.assistant', payload: {} }, // POST
255
+ ];
256
+ const result = await client.flushBacklog(allEvents);
257
+ assert.equal(calls.length, 1);
258
+ const sent = calls[0].body;
259
+ assert.equal(sent.events.length, 2);
260
+ assert.equal(sent.events[0].seq, 7);
261
+ assert.equal(sent.events[1].seq, 10);
262
+ assert.equal(result.persistedCount, 2);
263
+ assert.equal(result.lastSeq, 10);
264
+ assert.equal(readSyncState('sess-bk', tmpDir), 10);
265
+ await client.dispose();
266
+ });
267
+ it('dispose awaits the in-flight flush', async () => {
268
+ // Use a fetch that resolves after a tiny delay so the dispose has to
269
+ // wait.
270
+ let resolved = false;
271
+ const fetchImpl = async () => {
272
+ await new Promise((r) => setTimeout(r, 10));
273
+ resolved = true;
274
+ return {
275
+ ok: true,
276
+ status: 200,
277
+ statusText: 'OK',
278
+ json: async () => ({ persistedCount: 1, duplicateCount: 0, lastSeq: 1 }),
279
+ };
280
+ };
281
+ const client = new DualWriteClient({
282
+ apiUrl: 'http://localhost',
283
+ apiKey: 'k',
284
+ sessionId: 'sess-disp',
285
+ fetchImpl,
286
+ debounceMs: 1,
287
+ stateDir: tmpDir,
288
+ });
289
+ client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
290
+ // Schedule a flush + immediately dispose; dispose must wait.
291
+ const flushPromise = client.flush();
292
+ await client.dispose();
293
+ await flushPromise;
294
+ assert.equal(resolved, true);
295
+ });
296
+ });
297
+ //# sourceMappingURL=dual-write.spec.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Phase-1 event kind closed set — mirror of
3
+ * `apps/admin-api/src/pugi-session-events/pugi-session-events.types.ts`
4
+ * PUGI_SESSION_EVENT_KINDS.
5
+ *
6
+ * Kept as a separate file so the CLI does not import from admin-api
7
+ * (would drag the whole Nest graph). The TWO lists must stay in
8
+ * lockstep — a new kind requires editing both files.
9
+ */
10
+ export const PUGI_SESSION_EVENT_KINDS = [
11
+ 'turn.user',
12
+ 'turn.assistant',
13
+ 'tool.call',
14
+ 'tool.result',
15
+ 'dispatch.start',
16
+ 'dispatch.end',
17
+ 'compact.boundary',
18
+ 'system',
19
+ ];
20
+ //# sourceMappingURL=phase1-kinds.js.map
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Pugi memory sync queue (ADR-0063 Day 4).
3
+ *
4
+ * Local pending-write queue for `pugi memory` commands when the
5
+ * operator is offline or the admin-api is unreachable. Each pending
6
+ * mutation lands on disk as one JSONL line; `pugi memory sync` reads
7
+ * the queue, fires them to the admin-api in order, and rewrites the
8
+ * file with only the entries that still failed.
9
+ *
10
+ * Storage:
11
+ *
12
+ * ~/.pugi/memory-queue.jsonl (mode 0600)
13
+ *
14
+ * Each line is a fully-typed `PendingMemoryOperation` envelope. The
15
+ * envelope is forward-compatible: an older CLI reading a JSONL file
16
+ * written by a newer CLI silently skips lines whose `op` field is
17
+ * not in its known set (so a partial-rollback scenario doesn't crash
18
+ * the queue).
19
+ *
20
+ * Design intent:
21
+ * - Append-only on disk for the hot path (`pugi memory write` /
22
+ * `pugi memory forget` queue when offline). Rewrites only on
23
+ * successful sync.
24
+ * - One file per operator (PUGI_HOME-aware). Queue is local to the
25
+ * machine — no cross-host coordination. Multi-device sync is
26
+ * deferred to Phase 6 (server-side outbox).
27
+ * - No fsync / atomic rename ceremony in v1 — best effort. The
28
+ * queue is a convenience surface, not a durability primitive;
29
+ * the source of truth is the admin-api row.
30
+ */
31
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { dirname, resolve } from 'node:path';
34
+ import { z } from 'zod';
35
+ /** Six canonical kinds — must mirror `apps/admin-api/src/persona-memory/persona-memory.types.ts`. */
36
+ export const PERSONA_MEMORY_KINDS = [
37
+ 'pattern',
38
+ 'preference',
39
+ 'architecture',
40
+ 'bug',
41
+ 'workflow',
42
+ 'fact',
43
+ ];
44
+ const writeOpSchema = z.object({
45
+ op: z.literal('write'),
46
+ enqueuedAt: z.string().datetime(),
47
+ personaSlug: z.string().min(1).max(64),
48
+ kind: z.enum(PERSONA_MEMORY_KINDS),
49
+ content: z.string().min(1).max(4000),
50
+ forgetAfter: z.string().datetime().nullable().optional(),
51
+ });
52
+ const forgetOpSchema = z.object({
53
+ op: z.literal('forget'),
54
+ enqueuedAt: z.string().datetime(),
55
+ id: z.string().min(1),
56
+ });
57
+ const pendingMemoryOpSchema = z.discriminatedUnion('op', [
58
+ writeOpSchema,
59
+ forgetOpSchema,
60
+ ]);
61
+ /** Default storage path. Override via `PUGI_HOME` for tests / multi-account. */
62
+ export function defaultQueuePath() {
63
+ const root = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
64
+ return resolve(root, 'memory-queue.jsonl');
65
+ }
66
+ /**
67
+ * Append one pending operation to the queue file. Creates the parent
68
+ * directory + file with mode 0600 if missing. Pure-disk, no network.
69
+ *
70
+ * Returns the count of pending ops after the append (1-based) so the
71
+ * CLI command can render "queued (3 pending) — run `pugi memory sync`".
72
+ */
73
+ export function enqueueMemoryOp(op, pathOverride) {
74
+ const fullOp = {
75
+ ...op,
76
+ enqueuedAt: new Date().toISOString(),
77
+ };
78
+ pendingMemoryOpSchema.parse(fullOp);
79
+ const queuePath = pathOverride ?? defaultQueuePath();
80
+ ensureQueueFile(queuePath);
81
+ const existing = readFileSync(queuePath, 'utf-8');
82
+ const line = `${JSON.stringify(fullOp)}\n`;
83
+ writeFileSync(queuePath, `${existing}${line}`, { encoding: 'utf-8', mode: 0o600 });
84
+ // Best-effort chmod (in case the file existed already at the wrong mode).
85
+ try {
86
+ chmodSync(queuePath, 0o600);
87
+ }
88
+ catch {
89
+ // ignore — the file was just written above, mode might be platform-dependent.
90
+ }
91
+ return countPending(queuePath);
92
+ }
93
+ /** Read the queue file and return parsed entries. Skips unknown / malformed lines. */
94
+ export function readMemoryQueue(pathOverride) {
95
+ const queuePath = pathOverride ?? defaultQueuePath();
96
+ if (!existsSync(queuePath))
97
+ return [];
98
+ const raw = readFileSync(queuePath, 'utf-8');
99
+ const out = [];
100
+ for (const line of raw.split(/\r?\n/)) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed)
103
+ continue;
104
+ try {
105
+ const parsed = pendingMemoryOpSchema.parse(JSON.parse(trimmed));
106
+ out.push(parsed);
107
+ }
108
+ catch {
109
+ // forward-compat: a future op kind we don't recognise should not
110
+ // crash the queue; just drop the line during this read.
111
+ continue;
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+ /** Rewrite the queue file with `remaining` entries only. Empty array clears the file. */
117
+ export function rewriteMemoryQueue(remaining, pathOverride) {
118
+ const queuePath = pathOverride ?? defaultQueuePath();
119
+ ensureQueueFile(queuePath);
120
+ if (remaining.length === 0) {
121
+ writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
122
+ return;
123
+ }
124
+ const body = remaining.map((op) => JSON.stringify(op)).join('\n') + '\n';
125
+ writeFileSync(queuePath, body, { encoding: 'utf-8', mode: 0o600 });
126
+ }
127
+ /** Count pending ops without re-parsing every line individually for the typed shape. */
128
+ export function countPending(pathOverride) {
129
+ const queuePath = pathOverride ?? defaultQueuePath();
130
+ if (!existsSync(queuePath))
131
+ return 0;
132
+ const raw = readFileSync(queuePath, 'utf-8');
133
+ let n = 0;
134
+ for (const line of raw.split(/\r?\n/)) {
135
+ if (line.trim().length > 0)
136
+ n++;
137
+ }
138
+ return n;
139
+ }
140
+ /** Quick predicate — was anything ever queued? */
141
+ export function hasPendingOps(pathOverride) {
142
+ return countPending(pathOverride) > 0;
143
+ }
144
+ function ensureQueueFile(queuePath) {
145
+ const dir = dirname(queuePath);
146
+ if (!existsSync(dir))
147
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
148
+ if (!existsSync(queuePath)) {
149
+ writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
150
+ try {
151
+ chmodSync(queuePath, 0o600);
152
+ }
153
+ catch {
154
+ // ignore
155
+ }
156
+ }
157
+ }
158
+ //# sourceMappingURL=queue.js.map
@@ -0,0 +1,105 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { afterEach, beforeEach, describe, it } from 'node:test';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { countPending, enqueueMemoryOp, hasPendingOps, readMemoryQueue, rewriteMemoryQueue, } from './queue.js';
7
+ let tmpRoot = '';
8
+ let queuePath = '';
9
+ beforeEach(() => {
10
+ tmpRoot = mkdtempSync(resolve(tmpdir(), 'pugi-memory-queue-'));
11
+ queuePath = resolve(tmpRoot, 'memory-queue.jsonl');
12
+ });
13
+ afterEach(() => {
14
+ try {
15
+ rmSync(tmpRoot, { recursive: true, force: true });
16
+ }
17
+ catch {
18
+ // ignore
19
+ }
20
+ });
21
+ describe('memory-sync queue', () => {
22
+ it('countPending returns 0 for missing file', () => {
23
+ assert.equal(countPending(queuePath), 0);
24
+ assert.equal(hasPendingOps(queuePath), false);
25
+ });
26
+ it('enqueueMemoryOp appends a write op and returns 1', () => {
27
+ const n = enqueueMemoryOp({
28
+ op: 'write',
29
+ personaSlug: 'mira',
30
+ kind: 'preference',
31
+ content: 'operator prefers pnpm',
32
+ }, queuePath);
33
+ assert.equal(n, 1);
34
+ assert.equal(hasPendingOps(queuePath), true);
35
+ });
36
+ it('enqueueMemoryOp appends multiple ops sequentially', () => {
37
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
38
+ enqueueMemoryOp({ op: 'forget', id: 'mem-abc' }, queuePath);
39
+ assert.equal(countPending(queuePath), 2);
40
+ });
41
+ it('readMemoryQueue returns parsed entries in order', () => {
42
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'workflow', content: 'first' }, queuePath);
43
+ enqueueMemoryOp({ op: 'forget', id: 'mem-1' }, queuePath);
44
+ const ops = readMemoryQueue(queuePath);
45
+ assert.equal(ops.length, 2);
46
+ assert.equal(ops[0]?.op, 'write');
47
+ if (ops[0]?.op === 'write')
48
+ assert.equal(ops[0].content, 'first');
49
+ assert.equal(ops[1]?.op, 'forget');
50
+ });
51
+ it('readMemoryQueue skips malformed lines without crashing', () => {
52
+ writeFileSync(queuePath, [
53
+ JSON.stringify({
54
+ op: 'write',
55
+ personaSlug: 'mira',
56
+ kind: 'fact',
57
+ content: 'a',
58
+ enqueuedAt: new Date().toISOString(),
59
+ }),
60
+ '{not valid json',
61
+ JSON.stringify({ op: 'future_op', whatever: true }),
62
+ ].join('\n'));
63
+ const ops = readMemoryQueue(queuePath);
64
+ assert.equal(ops.length, 1);
65
+ });
66
+ it('rewriteMemoryQueue with empty array clears the file', () => {
67
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
68
+ rewriteMemoryQueue([], queuePath);
69
+ assert.equal(countPending(queuePath), 0);
70
+ const raw = readFileSync(queuePath, 'utf-8');
71
+ assert.equal(raw, '');
72
+ });
73
+ it('rewriteMemoryQueue with remaining entries persists them', () => {
74
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
75
+ enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'b' }, queuePath);
76
+ const all = readMemoryQueue(queuePath);
77
+ rewriteMemoryQueue([all[1]], queuePath);
78
+ const after = readMemoryQueue(queuePath);
79
+ assert.equal(after.length, 1);
80
+ if (after[0]?.op === 'write')
81
+ assert.equal(after[0].content, 'b');
82
+ });
83
+ it('enqueueMemoryOp rejects an invalid kind via Zod', () => {
84
+ assert.throws(() => enqueueMemoryOp({
85
+ op: 'write',
86
+ personaSlug: 'mira',
87
+ // @ts-expect-error — intentional bad value
88
+ kind: 'whatever',
89
+ content: 'a',
90
+ }, queuePath));
91
+ });
92
+ it('enqueueMemoryOp rejects oversized content (>4000 chars)', () => {
93
+ assert.throws(() => enqueueMemoryOp({
94
+ op: 'write',
95
+ personaSlug: 'mira',
96
+ kind: 'fact',
97
+ content: 'x'.repeat(4001),
98
+ }, queuePath));
99
+ });
100
+ it('countPending counts non-empty lines only', () => {
101
+ writeFileSync(queuePath, 'line1\n\n\nline2\n');
102
+ assert.equal(countPending(queuePath), 2);
103
+ });
104
+ });
105
+ //# sourceMappingURL=queue.spec.js.map