@pugi/cli 0.1.0-beta.25 → 0.1.0-beta.27

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.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Fork-subagent prompt-cache inheritance (Leak L10 — 2026-05-27).
3
+ *
4
+ * Claude Code's leaked sub-agent spawn pattern: when a parent agent
5
+ * dispatches a child via the Task tool, the child boots with the parent's
6
+ * prompt cache REFERENCE inherited — not the conversation transcript
7
+ * verbatim, but a provider-native cache handle that lets the child reuse
8
+ * the parent's system prompt + cached tool definitions without re-paying
9
+ * the prompt-prefix tokens on the first turn.
10
+ *
11
+ * Anthropic's `cache_control` block in their messages API exposes this
12
+ * directly via cache breakpoints. OpenAI / xAI / Gemini have similar
13
+ * primitives at different granularities; the wire payload our Anvil
14
+ * proxy speaks is provider-agnostic, so we forward a generic
15
+ * `parent_cache_id` hint and Anvil routes it onto the underlying
16
+ * provider's cache primitive (or silently drops it if the provider
17
+ * doesn't support cache inheritance — graceful degrade).
18
+ *
19
+ * What this module does:
20
+ *
21
+ * 1. `inheritCacheContext(parentSessionId, childAgentId)` —
22
+ * synthesises a cache handle for a child dispatch. Persists the
23
+ * handle to `.pugi/cache-refs/<child-agent-id>.json` so:
24
+ *
25
+ * a. The child's own boot path can read it (e.g. a child engine
26
+ * loop running in a worktree subshell where the in-memory
27
+ * DispatcherContext isn't reachable).
28
+ * b. `pugi dispatch list-cache-refs` can surface active refs
29
+ * for debugging "why is my cache hit rate so low".
30
+ * c. `pugi dispatch clear-cache-refs --older-than 1h` can
31
+ * garbage-collect stale refs from crashed/killed children.
32
+ *
33
+ * 2. `readCacheRef(workspaceRoot, childAgentId)` — child-side read.
34
+ * Returns the persisted handle so the child's first engine loop
35
+ * turn can include the parent_cache_id hint in its request.
36
+ *
37
+ * 3. `cacheHandoffHookForRequest(handle)` — produces the wire payload
38
+ * shape that the Anvil bridge can splice into the
39
+ * `engineLoopServerRequest` body. Lives here (not in the bridge)
40
+ * so the cache-control schema is in one place.
41
+ *
42
+ * What this module does NOT do:
43
+ *
44
+ * - It does not replay the parent's conversation transcript into the
45
+ * child. That would defeat the cyber-zoo isolation contract
46
+ * (`shared_fs_readonly` etc.). The child gets a CACHE HINT — the
47
+ * provider may use it to skip re-tokenising shared prompt prefix,
48
+ * but the LOGICAL conversation always starts fresh from the child's
49
+ * own system prompt + brief.
50
+ *
51
+ * - It does not assume any provider honours the hint. Cache-miss is
52
+ * the default path. If Anvil routes the request to a model whose
53
+ * provider doesn't expose cache inheritance, the request still
54
+ * succeeds — just at full prompt-prefix cost.
55
+ *
56
+ * - It does not rotate or invalidate the parent's cache on the
57
+ * parent's side. Parent cache lifecycle is owned by the parent
58
+ * engine loop; the child holds a read-only reference.
59
+ *
60
+ * Cross-reference:
61
+ * - apps/pugi-cli/src/core/subagents/dispatcher-real.ts — where the
62
+ * child engine loop is driven; the cache_id from this module is
63
+ * forwarded onto Anvil via the `extensions` field of the engine
64
+ * loop server request (β2 forward-compat slot).
65
+ * - packages/pugi-sdk/src/engine-loop.ts — engineLoopServerRequest
66
+ * schema; cache-handoff field is OPTIONAL and additive.
67
+ *
68
+ * Leak research §L10 (Claude Code sub-agent spawn fork pattern):
69
+ * docs/research/2026-05-27-leak-parity-sprint.md (research memo).
70
+ */
71
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
72
+ import { join, resolve as resolvePath } from 'node:path';
73
+ import { randomUUID } from 'node:crypto';
74
+ import { z } from 'zod';
75
+ /* ------------------------------------------------------------------ */
76
+ /* Types + schema */
77
+ /* ------------------------------------------------------------------ */
78
+ /**
79
+ * Persisted cache reference. The shape is intentionally minimal —
80
+ * provider-specific cache tokens are opaque strings so the file format
81
+ * does not couple to any one provider's API.
82
+ *
83
+ * - `cacheId` — opaque provider hint forwarded to Anvil.
84
+ * Synthesized client-side as `pugi-cache-<uuid>`
85
+ * so the server has a stable correlation key
86
+ * even when the underlying provider's cache
87
+ * object is not yet provisioned.
88
+ * - `contextRef` — logical reference key the parent uses to tag
89
+ * which prompt segments to re-use. Same value
90
+ * as cacheId for the v1 wire (single breakpoint
91
+ * per parent); reserved for future multi-breakpoint
92
+ * schemes (split system + tools + first-user
93
+ * segments).
94
+ * - `parentSessionId` — debugging / GC scoping. `list-cache-refs`
95
+ * can group by parent session.
96
+ * - `childAgentId` — the dispatch this ref is scoped to.
97
+ * - `createdAt` — ISO timestamp for `--older-than` cleanup.
98
+ * - `schemaVersion` — pinned to 1; bumped on breaking shape change.
99
+ */
100
+ export const cacheRefSchema = z.object({
101
+ schemaVersion: z.literal(1),
102
+ cacheId: z.string().min(1).max(256),
103
+ contextRef: z.string().min(1).max(256),
104
+ parentSessionId: z.string().min(1).max(256),
105
+ childAgentId: z.string().min(1).max(256),
106
+ createdAt: z.string().min(1),
107
+ });
108
+ /* ------------------------------------------------------------------ */
109
+ /* Path resolution */
110
+ /* ------------------------------------------------------------------ */
111
+ /**
112
+ * Resolve the directory under `.pugi/` where cache refs live. The
113
+ * directory is created on first write — readers tolerate its absence
114
+ * (no refs = empty list, not an error).
115
+ */
116
+ export function cacheRefDir(workspaceRoot) {
117
+ return resolvePath(workspaceRoot, '.pugi', 'cache-refs');
118
+ }
119
+ function cacheRefPath(workspaceRoot, childAgentId) {
120
+ // Sanitise the child id — directory traversal via crafted child id
121
+ // would let a dispatch target write outside the cache-refs dir. The
122
+ // child id format is owned by spawn.ts (`subagent-<uuid>`) but we
123
+ // defend in depth.
124
+ const safe = childAgentId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 200);
125
+ return join(cacheRefDir(workspaceRoot), `${safe}.json`);
126
+ }
127
+ /**
128
+ * Synthesize a cache handle for a child dispatch and persist it to
129
+ * `.pugi/cache-refs/<childAgentId>.json`. Returns the in-memory handle
130
+ * the dispatcher feeds into the Anvil wire request.
131
+ *
132
+ * Idempotent: calling twice with the same `childAgentId` overwrites
133
+ * the existing ref. The dispatcher's `taskId` is a UUID per dispatch,
134
+ * so collisions are not expected, but the overwrite semantic is safer
135
+ * than failing — a stale ref from a previous run (e.g. process killed
136
+ * between spawn and run) should not block a fresh dispatch.
137
+ */
138
+ export function inheritCacheContext(parentSessionId, childAgentId, options) {
139
+ if (!parentSessionId) {
140
+ throw new Error('inheritCacheContext: parentSessionId must be non-empty');
141
+ }
142
+ if (!childAgentId) {
143
+ throw new Error('inheritCacheContext: childAgentId must be non-empty');
144
+ }
145
+ const now = options.now ?? defaultNow;
146
+ const cacheIdFactory = options.cacheIdFactory ?? defaultCacheId;
147
+ const cacheId = cacheIdFactory();
148
+ const ref = {
149
+ schemaVersion: 1,
150
+ cacheId,
151
+ contextRef: cacheId,
152
+ parentSessionId,
153
+ childAgentId,
154
+ createdAt: now(),
155
+ };
156
+ const dir = cacheRefDir(options.workspaceRoot);
157
+ mkdirSync(dir, { recursive: true });
158
+ const path = cacheRefPath(options.workspaceRoot, childAgentId);
159
+ writeFileSync(path, JSON.stringify(ref, null, 2), 'utf8');
160
+ return {
161
+ cacheId: ref.cacheId,
162
+ contextRef: ref.contextRef,
163
+ persistedPath: path,
164
+ };
165
+ }
166
+ /**
167
+ * Child-side read. Returns null when the ref is missing or malformed
168
+ * (the child boots without cache inheritance — degrade is silent so a
169
+ * corrupted ref does not block a dispatch).
170
+ *
171
+ * Malformed ref files are NOT auto-deleted by this read path — that's
172
+ * the cleanup command's job. A failed parse here just returns null so
173
+ * the dispatch proceeds at full prompt-prefix cost.
174
+ */
175
+ export function readCacheRef(workspaceRoot, childAgentId) {
176
+ const path = cacheRefPath(workspaceRoot, childAgentId);
177
+ let raw;
178
+ try {
179
+ raw = readFileSync(path, 'utf8');
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ const validated = cacheRefSchema.safeParse(parsed);
192
+ if (!validated.success)
193
+ return null;
194
+ return validated.data;
195
+ }
196
+ /**
197
+ * List every persisted cache ref under `.pugi/cache-refs/`. Used by
198
+ * `pugi dispatch list-cache-refs` and by GC sweeps in
199
+ * `cache-cleanup.ts`. Returns refs in deterministic
200
+ * (filename-ascending) order so the CLI output is stable.
201
+ *
202
+ * Malformed ref files are silently skipped — the cleanup command can
203
+ * surface them via a separate `--show-corrupt` flag if needed.
204
+ */
205
+ export function listCacheRefs(workspaceRoot) {
206
+ const dir = cacheRefDir(workspaceRoot);
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ const refs = [];
215
+ for (const entry of entries) {
216
+ const full = join(dir, entry);
217
+ let raw;
218
+ try {
219
+ raw = readFileSync(full, 'utf8');
220
+ }
221
+ catch {
222
+ continue;
223
+ }
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(raw);
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ const validated = cacheRefSchema.safeParse(parsed);
232
+ if (!validated.success)
233
+ continue;
234
+ refs.push(validated.data);
235
+ }
236
+ return refs;
237
+ }
238
+ /* ------------------------------------------------------------------ */
239
+ /* Wire-format hook */
240
+ /* ------------------------------------------------------------------ */
241
+ /**
242
+ * Project the in-memory handle (or persisted ref) onto the wire-format
243
+ * hint object that the Anvil bridge merges into the engine-loop
244
+ * request body. Pulled out so callers (dispatcher-real, future bridge
245
+ * adapters) all speak the same shape.
246
+ */
247
+ export function cacheHandoffHookForRequest(source) {
248
+ return {
249
+ parent_cache_id: source.cacheId,
250
+ cache_context_ref: source.contextRef,
251
+ };
252
+ }
253
+ /* ------------------------------------------------------------------ */
254
+ /* Internals */
255
+ /* ------------------------------------------------------------------ */
256
+ function defaultNow() {
257
+ return new Date().toISOString();
258
+ }
259
+ function defaultCacheId() {
260
+ return `pugi-cache-${randomUUID()}`;
261
+ }
262
+ /* ------------------------------------------------------------------ */
263
+ /* Stat helper (used by cache-cleanup.ts) */
264
+ /* ------------------------------------------------------------------ */
265
+ /**
266
+ * Exposed for cache-cleanup.ts so the GC sweep can read mtime without
267
+ * re-implementing the path-resolution logic. Returns null when the
268
+ * file is gone (a concurrent cleanup may have raced us — silent miss).
269
+ */
270
+ export function cacheRefMtime(workspaceRoot, childAgentId) {
271
+ try {
272
+ const stat = statSync(cacheRefPath(workspaceRoot, childAgentId));
273
+ return stat.mtime;
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ /**
280
+ * Delete a single cache ref. Returns true when the file existed and
281
+ * was removed, false when it was already gone. Used by both the
282
+ * `clear-cache-refs` CLI and by post-dispatch cleanup (a successful
283
+ * subagent run does not need the cache ref to outlive its dispatch).
284
+ */
285
+ export function deleteCacheRef(workspaceRoot, childAgentId) {
286
+ const path = cacheRefPath(workspaceRoot, childAgentId);
287
+ try {
288
+ rmSync(path, { force: false });
289
+ return true;
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
295
+ //# sourceMappingURL=cache-handoff.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