@inetafrica/open-claudia 2.15.0 → 3.0.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 (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +66 -3
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +115 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +21 -4
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +117 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +121 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +170 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. package/web.js +130 -35
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ const RESULT_PREFIX = "__PACK_REVIEW_RESULT__";
12
+
13
+ function deferred() {
14
+ let resolve;
15
+ let reject;
16
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
17
+ return { promise, resolve, reject };
18
+ }
19
+
20
+ function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
21
+
22
+ function isolate(prefix) {
23
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
24
+ process.env.OPEN_CLAUDIA_TEST = "1";
25
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(root, "config");
26
+ process.env.PACKS_DIR = path.join(root, "packs");
27
+ process.env.ENTITIES_DIR = path.join(root, "entities");
28
+ process.env.LESSONS_FILE = path.join(root, "lessons.md");
29
+ process.env.RECALL_METRICS = "on";
30
+ process.env.HOME = root;
31
+ process.env.WORKSPACE = root;
32
+ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
33
+ return root;
34
+ }
35
+
36
+ function providerRegistry(provider) {
37
+ return {
38
+ getProvider(id) {
39
+ if (id !== provider.id) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
40
+ return provider;
41
+ },
42
+ listProviders: () => [provider],
43
+ providerStatus(id) {
44
+ if (id !== provider.id) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
45
+ return {
46
+ id,
47
+ availability: { state: "available", executable: provider.executable(), reason: null },
48
+ compatibility: { state: "compatible", reason: null },
49
+ auth: { state: "authenticated", reason: null },
50
+ };
51
+ },
52
+ };
53
+ }
54
+
55
+ function semanticPack(pack) {
56
+ return {
57
+ dir: pack.dir,
58
+ name: pack.name,
59
+ description: pack.description,
60
+ tags: pack.tags,
61
+ kind: pack.kind,
62
+ sections: pack.sections,
63
+ };
64
+ }
65
+
66
+ function semanticEntity(entity) {
67
+ return {
68
+ slug: entity.slug,
69
+ name: entity.name,
70
+ type: entity.type,
71
+ aliases: entity.aliases,
72
+ description: entity.description,
73
+ relationship: entity.relationship,
74
+ sections: entity.sections,
75
+ };
76
+ }
77
+
78
+ async function providerProbe(kind) {
79
+ const root = isolate(`open-claudia-review-${kind}-`);
80
+ const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
81
+ const capture = path.join(root, `${kind}-capture.json`);
82
+ const secret = ["sk", "proj", "provider", "review", "secret", "fixture"].join("-");
83
+ const decision = {
84
+ actions: [
85
+ {
86
+ action: "update",
87
+ pack: "project",
88
+ state: "Provider-neutral review is complete.",
89
+ stance: "Keep the original project facts and use bounded utility review.",
90
+ procedure: "Run the reviewer read-only, then serialize its mutation batch.",
91
+ journal: "Verified provider-neutral memory review.",
92
+ },
93
+ {
94
+ action: "update",
95
+ pack: "unrelated",
96
+ state: "THIS BLIND REWRITE MUST NOT LAND",
97
+ journal: "Recorded a safe append-only observation outside active scope.",
98
+ },
99
+ { action: "create", dir: "over-limit", name: "Over Limit", state: "must not exist" },
100
+ ],
101
+ entities: [
102
+ { name: "Ada", type: "person", description: "Project owner", notes: "Owns the project.", log: "Confirmed ownership.", relationship: "owner", style: "Concise and technical." },
103
+ { name: "Nairobi", type: "place", description: "Delivery location", notes: "Primary delivery location.", log: "Used for delivery." },
104
+ { name: "Parity Harness", type: "system", description: "Provider parity fixture", notes: "Runs deterministic provider fixtures.", log: "Verified both adapters." },
105
+ { name: "Over Entity Limit", type: "thing", description: "must not exist", notes: "must not exist", log: "must not exist" },
106
+ ],
107
+ lessons: [
108
+ { text: "Always serialize memory mutation batches.", src: "project", trigger: "actually, serialize the whole batch" },
109
+ { text: "This second lesson must be clipped.", src: "project", trigger: "again" },
110
+ ],
111
+ };
112
+
113
+ const { createClaudeProvider } = require("./core/providers/claude");
114
+ const { createCodexProvider } = require("./core/providers/codex");
115
+ const createProvider = kind === "claude" ? createClaudeProvider : createCodexProvider;
116
+ const provider = createProvider({
117
+ resolveExecutable: () => fixture,
118
+ defaultModel: `${kind}-default-fixture`,
119
+ buildEnv: () => ({
120
+ PATH: process.env.PATH || "",
121
+ HOME: root,
122
+ FAKE_AGENT_KIND: kind,
123
+ FAKE_AGENT_CAPTURE: capture,
124
+ FAKE_AGENT_TEXT: JSON.stringify(decision),
125
+ }),
126
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
127
+ });
128
+ const model = `${kind}-review-exact`;
129
+ const { createUtilityAgentService } = require("./core/utility-agent");
130
+ const service = createUtilityAgentService({
131
+ registry: providerRegistry(provider),
132
+ env: {
133
+ UTILITY_PROVIDER: kind,
134
+ [`PACK_REVIEW_MODEL_${kind.toUpperCase()}`]: model,
135
+ },
136
+ });
137
+ const { MemoryMutationQueue } = require("./core/memory-mutation-queue");
138
+ const queue = new MemoryMutationQueue({
139
+ lockDir: path.join(root, "review.lock"),
140
+ journalFile: path.join(root, "review-intents.jsonl"),
141
+ lockPollMs: 5,
142
+ });
143
+ const packs = require("./core/packs");
144
+ const entities = require("./core/entities");
145
+ const lessons = require("./core/lessons");
146
+ const { createPackReviewer, REVIEW_OUTPUT_SCHEMA } = require("./core/pack-review");
147
+ packs.createPack({
148
+ dir: "project",
149
+ name: "Project",
150
+ description: "provider parity project",
151
+ stance: "Preserve this durable stance.",
152
+ procedure: "Preserve this durable procedure.",
153
+ state: "Review is pending.",
154
+ });
155
+ packs.createPack({
156
+ dir: "unrelated",
157
+ name: "Unrelated",
158
+ description: "unrelated durable memory",
159
+ state: "Unrelated current truth must survive.",
160
+ });
161
+
162
+ const callMetadata = [];
163
+ const reviewer = createPackReviewer({
164
+ mutationQueue: queue,
165
+ spawnUtilityAgent: (prompt, options) => {
166
+ callMetadata.push({
167
+ purpose: options.purpose,
168
+ activeProvider: options.activeProvider,
169
+ tier: options.tier,
170
+ readOnly: options.readOnly,
171
+ outputSchema: options.outputSchema,
172
+ secretRedacted: !prompt.includes(secret) && prompt.includes("[REDACTED_OPENAI_KEY]"),
173
+ });
174
+ return service.spawnUtilityAgent(prompt, options);
175
+ },
176
+ cancelUtilityAgents: service.cancelAll,
177
+ });
178
+ const announcements = [];
179
+ const result = await reviewer.reviewTurn({
180
+ userText: `Actually, serialize the whole batch while reviewing this provider parity project. ${secret} ${"durable context ".repeat(20)}`,
181
+ assistantText: `Implemented and verified the provider-neutral reviewer with active-pack protection. ${"verification detail ".repeat(20)}`,
182
+ activePacks: ["project"],
183
+ provider: kind,
184
+ runId: `${kind}-review-run`,
185
+ announce: async (text) => announcements.push(text),
186
+ });
187
+
188
+ assert.strictEqual(result.status, "applied");
189
+ assert.strictEqual(result.provider, kind);
190
+ assert.strictEqual(result.model, model);
191
+ assert.strictEqual(result.fallbackUsed, false);
192
+ assert.strictEqual(callMetadata.length, 1);
193
+ assert.deepStrictEqual(callMetadata[0], {
194
+ purpose: "review",
195
+ activeProvider: kind,
196
+ tier: "medium",
197
+ readOnly: true,
198
+ outputSchema: REVIEW_OUTPUT_SCHEMA,
199
+ secretRedacted: true,
200
+ });
201
+ assert.strictEqual(announcements.length, 1, "one complete reviewer batch produces one announcement");
202
+
203
+ const project = packs.readPack("project");
204
+ const unrelated = packs.readPack("unrelated");
205
+ assert.strictEqual(project.sections.State, "Provider-neutral review is complete.");
206
+ assert.match(project.sections.Journal, /Verified provider-neutral memory review/);
207
+ assert.strictEqual(unrelated.sections.State, "Unrelated current truth must survive.", "blind section rewrites stay blocked");
208
+ assert.match(unrelated.sections.Journal, /safe append-only observation/);
209
+ assert.strictEqual(packs.readPack("over-limit"), null, "pack action limit is preserved");
210
+ assert.strictEqual(entities.listEntities().length, 3, "entity action limit is preserved");
211
+ assert.strictEqual(entities.findEntity("Over Entity Limit"), null);
212
+ assert.strictEqual(lessons.listLessons().length, 1, "lesson action limit is preserved");
213
+ assert.strictEqual(lessons.listLessons()[0].text, "Always serialize memory mutation batches.");
214
+
215
+ const captureRecord = JSON.parse(fs.readFileSync(capture, "utf8"));
216
+ assert.ok(!fs.readFileSync(capture, "utf8").includes(secret), "private review text never enters argv or fixture logs");
217
+ assert.strictEqual(captureRecord.stdin.present, true);
218
+ assert.ok(captureRecord.argv.includes(model), "provider-specific exact reviewer model reaches the adapter");
219
+ if (kind === "claude") assert.ok(captureRecord.argv.includes("plan"), "Claude reviewer is read-only");
220
+ else {
221
+ assert.ok(captureRecord.argv.includes("read-only"), "Codex reviewer is read-only");
222
+ assert.ok(captureRecord.argv.includes("--output-schema"), "Codex receives the reviewer schema file");
223
+ }
224
+
225
+ const metrics = JSON.parse(fs.readFileSync(path.join(process.env.OPEN_CLAUDIA_CONFIG_DIR, "recall-metrics-summary.json"), "utf8"));
226
+ assert.strictEqual(metrics.reviewerByProviderModel[`${kind}:${model}`], 1);
227
+ const intents = fs.readFileSync(path.join(root, "review-intents.jsonl"), "utf8");
228
+ assert.ok(!intents.includes(secret), "intent journal contains metadata, never review content");
229
+
230
+ return {
231
+ provider: kind,
232
+ model,
233
+ mutations: {
234
+ packs: [semanticPack(project), semanticPack(unrelated)],
235
+ entities: entities.listEntities().map(semanticEntity),
236
+ lessons: lessons.listLessons().map(({ text, src }) => ({ text, src })),
237
+ },
238
+ };
239
+ }
240
+
241
+ async function lifecycleProbe() {
242
+ const root = isolate("open-claudia-review-lifecycle-");
243
+ const packs = require("./core/packs");
244
+ const { MemoryMutationQueue } = require("./core/memory-mutation-queue");
245
+ const { createPackReviewer } = require("./core/pack-review");
246
+ packs.createPack({ dir: "project", name: "Project", description: "lifecycle fixture", state: "initial state" });
247
+ const longTurn = {
248
+ userText: `Review the lifecycle behavior. ${"bounded durable context ".repeat(20)}`,
249
+ assistantText: `The lifecycle behavior is under deterministic test. ${"verified implementation detail ".repeat(20)}`,
250
+ activePacks: ["project"],
251
+ };
252
+
253
+ // A queued dream owns the shared mutation lane; a completed reviewer model
254
+ // cannot mutate the same stores until that whole dream batch releases it.
255
+ const serializationQueue = new MemoryMutationQueue({
256
+ lockDir: path.join(root, "serialization.lock"),
257
+ journalFile: path.join(root, "serialization.jsonl"),
258
+ lockPollMs: 5,
259
+ });
260
+ const dreamGate = deferred();
261
+ let dreamStarted = false;
262
+ const dream = serializationQueue.enqueue("dream", async () => {
263
+ dreamStarted = true;
264
+ await dreamGate.promise;
265
+ }, { purpose: "dream", trigger: "test" });
266
+ while (!dreamStarted) await wait(5);
267
+ const serializedReviewer = createPackReviewer({
268
+ mutationQueue: serializationQueue,
269
+ spawnUtilityAgent: async (_prompt, options) => ({
270
+ ok: true,
271
+ text: JSON.stringify({
272
+ actions: [{ action: "update", pack: "project", state: "serialized review state", journal: "Review followed the dream batch." }],
273
+ entities: [],
274
+ lessons: [],
275
+ }),
276
+ provider: options.activeProvider,
277
+ model: "serialized-review-model",
278
+ fallbackUsed: false,
279
+ }),
280
+ });
281
+ const serializedReview = serializedReviewer.reviewTurn({ ...longTurn, provider: "codex" });
282
+ await wait(30);
283
+ assert.strictEqual(packs.readPack("project").sections.State, "initial state", "review waits behind the active dream mutation batch");
284
+ dreamGate.resolve();
285
+ await Promise.all([dream, serializedReview]);
286
+ assert.strictEqual(packs.readPack("project").sections.State, "serialized review state");
287
+
288
+ // The memory version is captured before awaiting the model. A newer write
289
+ // wins; only the review's append-only journal observation is safe to retain.
290
+ const staleQueue = new MemoryMutationQueue({
291
+ lockDir: path.join(root, "stale.lock"),
292
+ journalFile: path.join(root, "stale.jsonl"),
293
+ lockPollMs: 5,
294
+ });
295
+ const staleGate = deferred();
296
+ let capturedOptions = null;
297
+ const staleReviewer = createPackReviewer({
298
+ mutationQueue: staleQueue,
299
+ spawnUtilityAgent: async (_prompt, options) => {
300
+ capturedOptions = options;
301
+ return staleGate.promise;
302
+ },
303
+ });
304
+ const input = { ...longTurn, provider: "codex", runId: "captured-run" };
305
+ const staleHandle = staleReviewer.reviewTurn(input);
306
+ input.provider = "claude";
307
+ assert.strictEqual(staleReviewer.activeReviewCount(), 1, "review handles remain tracked while model work is pending");
308
+ packs.updatePack("project", { state: "newer foreground state" }, "user");
309
+ staleGate.resolve({
310
+ ok: true,
311
+ text: JSON.stringify({
312
+ actions: [{ action: "update", pack: "project", state: "stale reviewer state", journal: "Stale review observation retained safely." }],
313
+ entities: [],
314
+ lessons: [],
315
+ }),
316
+ provider: "codex",
317
+ model: "captured-codex-model",
318
+ fallbackUsed: false,
319
+ });
320
+ const staleResult = await staleHandle;
321
+ assert.strictEqual(capturedOptions.activeProvider, "codex", "the admitted provider is captured before asynchronous review work");
322
+ assert.strictEqual(staleResult.status, "applied");
323
+ assert.strictEqual(packs.readPack("project").sections.State, "newer foreground state", "stale reviewer state never replaces a newer write");
324
+ assert.match(packs.readPack("project").sections.Journal, /Stale review observation retained safely/);
325
+ assert.strictEqual(staleReviewer.activeReviewCount(), 0);
326
+
327
+ // A store failure is a failed serialized batch, is journalled, and does not
328
+ // poison later queue work or emit a success announcement.
329
+ const failureQueue = new MemoryMutationQueue({
330
+ lockDir: path.join(root, "failure.lock"),
331
+ journalFile: path.join(root, "failure.jsonl"),
332
+ lockPollMs: 5,
333
+ });
334
+ const failureReviewer = createPackReviewer({
335
+ mutationQueue: failureQueue,
336
+ spawnUtilityAgent: async () => ({
337
+ ok: true,
338
+ text: JSON.stringify({ actions: [{ action: "update", pack: "project", journal: "write must fail" }], entities: [], lessons: [] }),
339
+ provider: "claude",
340
+ model: "failure-model",
341
+ fallbackUsed: false,
342
+ }),
343
+ });
344
+ const originalUpdatePack = packs.updatePack;
345
+ packs.updatePack = () => { throw Object.assign(new Error("pack write fixture failed"), { code: "PACK_WRITE_FAILED" }); };
346
+ const failureAnnouncements = [];
347
+ let failedResult;
348
+ try {
349
+ failedResult = await failureReviewer.reviewTurn({ ...longTurn, provider: "claude", announce: async (text) => failureAnnouncements.push(text) });
350
+ } finally {
351
+ packs.updatePack = originalUpdatePack;
352
+ }
353
+ assert.strictEqual(failedResult.status, "failed");
354
+ assert.strictEqual(failureAnnouncements.length, 0);
355
+ assert.match(fs.readFileSync(path.join(root, "failure.jsonl"), "utf8"), /"status":"failed"/);
356
+ assert.strictEqual(await failureQueue.enqueue("after-failure", async () => "healthy"), "healthy");
357
+
358
+ // Shutdown stops admission, records the still-pending intent, invokes
359
+ // utility cancellation after the bounded drain, and settles the handle.
360
+ const shutdownQueue = new MemoryMutationQueue({
361
+ lockDir: path.join(root, "shutdown.lock"),
362
+ journalFile: path.join(root, "shutdown.jsonl"),
363
+ lockPollMs: 5,
364
+ });
365
+ const shutdownGate = deferred();
366
+ let cancelCalls = 0;
367
+ const shutdownReviewer = createPackReviewer({
368
+ mutationQueue: shutdownQueue,
369
+ spawnUtilityAgent: async () => shutdownGate.promise,
370
+ cancelUtilityAgents: async () => {
371
+ cancelCalls += 1;
372
+ shutdownGate.resolve({
373
+ ok: true,
374
+ text: JSON.stringify({ actions: [], entities: [], lessons: [] }),
375
+ provider: "codex",
376
+ model: "shutdown-model",
377
+ fallbackUsed: false,
378
+ });
379
+ return { drained: true, active: 0 };
380
+ },
381
+ });
382
+ const pendingReview = shutdownReviewer.reviewTurn({ ...longTurn, provider: "codex", runId: "pending-shutdown-run" });
383
+ assert.strictEqual(shutdownReviewer.activeReviewCount(), 1);
384
+ const drain = await shutdownReviewer.drainReviews({ timeoutMs: 25, cancelOnTimeout: true });
385
+ assert.strictEqual(drain.drained, false, "a bounded timeout is reported even when cancellation subsequently settles work");
386
+ assert.strictEqual(cancelCalls, 1);
387
+ assert.strictEqual((await pendingReview).status, "no_change");
388
+ assert.match(fs.readFileSync(path.join(root, "shutdown.jsonl"), "utf8"), /"status":"pending_on_shutdown"/);
389
+ const late = await shutdownReviewer.reviewTurn({ ...longTurn, provider: "claude" });
390
+ assert.strictEqual(late.status, "not_admitted");
391
+ assert.match(fs.readFileSync(path.join(root, "shutdown.jsonl"), "utf8"), /"status":"pending_intent_rejected"/);
392
+
393
+ return { serialized: true, staleProtected: true, failureRecorded: true, shutdownDrained: true };
394
+ }
395
+
396
+ function runChild(args) {
397
+ const child = spawnSync(process.execPath, [__filename, ...args], {
398
+ cwd: __dirname,
399
+ env: { ...process.env },
400
+ encoding: "utf8",
401
+ timeout: 30000,
402
+ });
403
+ if (child.status !== 0) {
404
+ throw new Error(`child ${args.join(" ")} failed (${child.status})\n${child.stdout}\n${child.stderr}`);
405
+ }
406
+ const line = child.stdout.split("\n").find((entry) => entry.startsWith(RESULT_PREFIX));
407
+ if (!line) throw new Error(`child ${args.join(" ")} returned no result\n${child.stdout}\n${child.stderr}`);
408
+ return JSON.parse(line.slice(RESULT_PREFIX.length));
409
+ }
410
+
411
+ async function main() {
412
+ if (process.argv[2] === "--provider-probe") {
413
+ const result = await providerProbe(process.argv[3]);
414
+ console.log(`${RESULT_PREFIX}${JSON.stringify(result)}`);
415
+ return;
416
+ }
417
+ if (process.argv[2] === "--lifecycle-probe") {
418
+ const result = await lifecycleProbe();
419
+ console.log(`${RESULT_PREFIX}${JSON.stringify(result)}`);
420
+ return;
421
+ }
422
+
423
+ const claude = runChild(["--provider-probe", "claude"]);
424
+ const codex = runChild(["--provider-probe", "codex"]);
425
+ assert.deepStrictEqual(codex.mutations, claude.mutations, "identical fixture decisions produce identical memory mutations under Claude and Codex");
426
+ assert.strictEqual(claude.provider, "claude");
427
+ assert.strictEqual(codex.provider, "codex");
428
+ const lifecycle = runChild(["--lifecycle-probe"]);
429
+ assert.deepStrictEqual(lifecycle, { serialized: true, staleProtected: true, failureRecorded: true, shutdownDrained: true });
430
+ console.log("provider pack review OK");
431
+ }
432
+
433
+ main().catch((error) => {
434
+ console.error(error.stack || error.message);
435
+ process.exitCode = 1;
436
+ });