@inetafrica/open-claudia 2.15.0 → 3.0.1

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 (152) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +22 -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 +81 -3
  12. package/channels/telegram/adapter.js +65 -5
  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 +155 -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 +542 -224
  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 +121 -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 +125 -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 +171 -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 +1562 -1286
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/single-instance.js +46 -0
  63. package/core/state.js +1084 -97
  64. package/core/subagent.js +72 -98
  65. package/core/system-prompt.js +462 -279
  66. package/core/tool-guard.js +73 -39
  67. package/core/tools.js +22 -5
  68. package/core/transcripts.js +30 -1
  69. package/core/usage-log.js +19 -4
  70. package/core/utility-agent.js +654 -0
  71. package/core/web-auth.js +29 -13
  72. package/docs/CHANNEL_DESIGN.md +181 -0
  73. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  74. package/docs/PROVIDER_MIGRATION.md +67 -0
  75. package/health.js +50 -39
  76. package/package.json +48 -4
  77. package/setup.js +198 -38
  78. package/soul.md +39 -0
  79. package/test-ability-extraction.js +5 -0
  80. package/test-approval-async.js +63 -4
  81. package/test-cursor-removal.js +337 -0
  82. package/test-enforcer.js +70 -27
  83. package/test-fixtures/current-provider-parity.json +132 -0
  84. package/test-fixtures/fake-agent-cli.js +509 -0
  85. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  86. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  87. package/test-fixtures/migrations/claude-only/state.json +5 -0
  88. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  90. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  92. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  93. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  94. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  95. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  97. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  100. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  101. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  102. package/test-fixtures/migrations/missing-project/state.json +4 -0
  103. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  104. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  105. package/test-fixtures/migrations/multi-user/state.json +6 -0
  106. package/test-fixtures/provider-event-samples.json +17 -0
  107. package/test-learning-e2e.js +5 -0
  108. package/test-memory-mutation-queue.js +191 -0
  109. package/test-provider-boot-matrix.js +399 -0
  110. package/test-provider-bootstrap.js +158 -0
  111. package/test-provider-capabilities.js +232 -0
  112. package/test-provider-claude.js +206 -0
  113. package/test-provider-codex.js +228 -0
  114. package/test-provider-compaction.js +371 -0
  115. package/test-provider-core-prompt.js +264 -0
  116. package/test-provider-coupling-audit.js +90 -0
  117. package/test-provider-dream.js +312 -0
  118. package/test-provider-enforcer.js +252 -0
  119. package/test-provider-env-isolation.js +150 -0
  120. package/test-provider-events.js +171 -0
  121. package/test-provider-fixture.js +332 -0
  122. package/test-provider-gateway.js +508 -0
  123. package/test-provider-language.js +89 -0
  124. package/test-provider-migration-backup.js +424 -0
  125. package/test-provider-pack-review.js +436 -0
  126. package/test-provider-parity-e2e.js +537 -0
  127. package/test-provider-prompt-parity.js +89 -0
  128. package/test-provider-recall.js +271 -0
  129. package/test-provider-registry.js +251 -0
  130. package/test-provider-resume-parity.js +41 -0
  131. package/test-provider-run-lifecycle.js +349 -0
  132. package/test-provider-runner.js +271 -0
  133. package/test-provider-scheduler.js +689 -0
  134. package/test-provider-session-commands.js +185 -0
  135. package/test-provider-session-history.js +205 -0
  136. package/test-provider-side-chat.js +337 -0
  137. package/test-provider-state-migration.js +316 -0
  138. package/test-provider-stream-decoder.js +69 -0
  139. package/test-provider-subagent.js +228 -0
  140. package/test-provider-tool-hooks.js +360 -0
  141. package/test-recall-discoverer.js +1 -0
  142. package/test-recall-engine.js +3 -3
  143. package/test-recall-evolution.js +18 -0
  144. package/test-runner-watchdog-static.js +16 -8
  145. package/test-single-runtime.js +35 -0
  146. package/test-telegram-poll-recovery.js +93 -0
  147. package/test-tool-guard.js +56 -0
  148. package/test-tools.js +19 -0
  149. package/test-unified-identity.js +3 -1
  150. package/test-usage-accounting.js +92 -20
  151. package/test-utility-provider-policy.js +486 -0
  152. package/web.js +130 -35
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+
9
+ const root = __dirname;
10
+
11
+ function read(relativePath) {
12
+ return fs.readFileSync(path.join(root, relativePath), "utf8");
13
+ }
14
+
15
+ function sourceFiles(relativePath) {
16
+ const absolute = path.join(root, relativePath);
17
+ if (!fs.statSync(absolute).isDirectory()) return [relativePath];
18
+ return fs.readdirSync(absolute, { withFileTypes: true }).flatMap((entry) => {
19
+ const child = path.join(relativePath, entry.name);
20
+ if (entry.isDirectory()) return sourceFiles(child);
21
+ return entry.isFile() && child.endsWith(".js") ? [child] : [];
22
+ });
23
+ }
24
+
25
+ const activeFiles = [
26
+ ...sourceFiles("core"),
27
+ ...sourceFiles("bin"),
28
+ ...sourceFiles("channels"),
29
+ "bot.js",
30
+ "bot-agent.js",
31
+ "health.js",
32
+ "setup.js",
33
+ "web.js",
34
+ ];
35
+ const activeSource = Object.fromEntries(activeFiles.map((file) => [file, read(file)]));
36
+
37
+ function matches(pattern, allow = new Set()) {
38
+ return Object.entries(activeSource)
39
+ .filter(([file, source]) => !allow.has(file) && pattern.test(source))
40
+ .map(([file]) => file);
41
+ }
42
+
43
+ assert.deepStrictEqual(matches(/\brunClaude(?:Capture|Silent)?\b/), [], "deprecated runner names must be removed");
44
+ assert.deepStrictEqual(matches(/\bbuildClaudeArgs\b/), [], "provider argv construction belongs only in adapters");
45
+ assert.deepStrictEqual(matches(/\brunLegacy(?:Capture|Foreground|Silent)\b/), [], "the dead legacy runner must be deleted");
46
+ assert.deepStrictEqual(matches(/\bDEFAULT_CLAUDE_MODEL\b/), [], "Claude's default model belongs inside its adapter");
47
+ assert.deepStrictEqual(
48
+ matches(/\bspawnSubagent\b/, new Set(["core/subagent.js"])),
49
+ [],
50
+ "direct utility callers must not bypass the provider-neutral utility service",
51
+ );
52
+
53
+ const claudePathAllowlist = new Set([
54
+ "core/auth-flow.js",
55
+ "core/config.js",
56
+ "core/provider-status.js",
57
+ "core/providers/claude.js",
58
+ "setup.js",
59
+ "web.js",
60
+ ]);
61
+ assert.deepStrictEqual(matches(/\bCLAUDE_PATH\b/, claudePathAllowlist), [], "CLAUDE_PATH leaked outside Claude auth/setup scope");
62
+
63
+ for (const file of ["core/runner.js", "core/run-context.js", "core/system-prompt.js", "core/handlers.js", "core/actions.js"]) {
64
+ assert.doesNotMatch(
65
+ activeSource[file],
66
+ /\b(?:lastSessionId|codexSessionId|cursorSessionId)\b/,
67
+ `${file} must use provider/project session helpers`,
68
+ );
69
+ }
70
+ assert.doesNotMatch(
71
+ activeSource["core/state.js"],
72
+ /Object\.defineProperty\(state,\s*legacyKey/,
73
+ "migration aliases must not become live runtime accessors",
74
+ );
75
+ assert.doesNotMatch(activeSource["core/run-context.js"], /\bsessionKey\b/, "run admission stores provider identity, not a legacy pointer key");
76
+
77
+ assert.doesNotMatch(activeSource["core/state.js"], /function normalizeProviderId\(providerId, fallback = ["']claude["']\)/);
78
+ assert.doesNotMatch(activeSource["core/state.js"], /:\s*["']claude["'];\s*\n\s*if \(!Object\.prototype\.hasOwnProperty\.call\(settings, ["']compactWindow["']\)/);
79
+ assert.doesNotMatch(activeSource["core/system-prompt.js"], /state\.settings\?\.backend \|\| ["']claude["']/);
80
+ assert.doesNotMatch(activeSource["setup.js"], /providerId \|\| ["']claude["']/);
81
+
82
+ const packageJson = JSON.parse(read("package.json"));
83
+ assert.doesNotMatch(packageJson.scripts.test, /\bCLAUDE_PATH=node\b/, "the legacy suite cannot require Claude globally");
84
+
85
+ const runner = require("./core/runner");
86
+ for (const name of ["runClaude", "runClaudeCapture", "runClaudeSilent"]) {
87
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(runner, name), false, `${name} export must be removed`);
88
+ }
89
+
90
+ console.log("provider coupling audit OK");
@@ -0,0 +1,312 @@
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 = "__PROVIDER_DREAM_RESULT__";
12
+
13
+ function isolate(kind) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-dream-${kind}-`));
15
+ process.env.OPEN_CLAUDIA_TEST = "1";
16
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(root, "config");
17
+ process.env.PACKS_DIR = path.join(root, "packs");
18
+ process.env.ENTITIES_DIR = path.join(root, "entities");
19
+ process.env.LESSONS_FILE = path.join(root, "lessons.md");
20
+ process.env.PERSONA_FILE = path.join(root, "persona.md");
21
+ process.env.IDEAS_FILE = path.join(root, "ideas.md");
22
+ process.env.RECALL_GRAPH_DB = path.join(root, "recall-graph.db");
23
+ process.env.TRANSCRIPTS_DIR = path.join(root, "transcripts");
24
+ process.env.HOME = root;
25
+ process.env.WORKSPACE = root;
26
+ process.env.RECALL_METRICS = "on";
27
+ process.env.DREAM_INTROSPECT = "on";
28
+ process.env.DREAM_SELF_APPLY = "on";
29
+ process.env.DREAM_EFFORT = "max";
30
+ process.env.DREAM_TIER = "high";
31
+ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
32
+ return root;
33
+ }
34
+
35
+ function registryFor(provider) {
36
+ return {
37
+ getProvider(id) {
38
+ if (id !== provider.id) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
39
+ return provider;
40
+ },
41
+ listProviders: () => [provider],
42
+ providerStatus(id) {
43
+ if (id !== provider.id) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
44
+ return {
45
+ id,
46
+ availability: { state: "available", executable: provider.executable(), reason: null },
47
+ compatibility: { state: "compatible", reason: null },
48
+ auth: { state: "authenticated", reason: null },
49
+ };
50
+ },
51
+ };
52
+ }
53
+
54
+ function packSemantics(pack) {
55
+ return {
56
+ dir: pack.dir,
57
+ name: pack.name,
58
+ description: pack.description,
59
+ tags: pack.tags,
60
+ kind: pack.kind,
61
+ skill: pack.skill,
62
+ sections: pack.sections,
63
+ };
64
+ }
65
+
66
+ function entitySemantics(entity) {
67
+ return {
68
+ slug: entity.slug,
69
+ name: entity.name,
70
+ type: entity.type,
71
+ description: entity.description,
72
+ relationship: entity.relationship,
73
+ sections: entity.sections,
74
+ };
75
+ }
76
+
77
+ async function providerProbe(kind) {
78
+ const root = isolate(kind);
79
+ const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
80
+ const dreamDecision = {
81
+ merges: [],
82
+ umbrellas: [],
83
+ parents: [],
84
+ retag: [{ pack: "project", description: "Provider-neutral dream consolidation fixture", tags: ["dream", "provider", "memory"] }],
85
+ entity_merges: [],
86
+ entity_notes: [{ entity: "parity-harness", notes: "Runs the same deterministic dream decisions through either provider." }],
87
+ archive: [],
88
+ lessons: [],
89
+ persona: "too short",
90
+ tuning: null,
91
+ report: "I tidied the provider-parity memory and kept the durable pieces aligned. 💤",
92
+ };
93
+ const introspectionDecision = {
94
+ lessons_add: [{ text: "Always record the provider and model used for a dream.", src: "project", trigger: "again, tell me which provider dreamed" }],
95
+ doc_edits: [{ pack: "project", section: "State", to: "Dream and introspection both completed through the selected utility provider.", why: "provider parity was verified" }],
96
+ entity_edits: [{ entity: "parity-harness", notes: "Verifies consolidation and introspection through Claude and Codex." }],
97
+ ideas: [{ scope: "oc", text: "Show dream provider metadata in the morning summary." }],
98
+ persona: null,
99
+ report: "I checked my own memory loop and left a clear provider audit trail. 🪞",
100
+ };
101
+
102
+ const { createClaudeProvider } = require("./core/providers/claude");
103
+ const { createCodexProvider } = require("./core/providers/codex");
104
+ const createProvider = kind === "claude" ? createClaudeProvider : createCodexProvider;
105
+ const provider = createProvider({
106
+ resolveExecutable: () => fixture,
107
+ defaultModel: `${kind}-high-fixture`,
108
+ buildEnv: () => ({ PATH: process.env.PATH || "", HOME: root, FAKE_AGENT_KIND: kind }),
109
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
110
+ });
111
+ const originalBuildUtilityInvocation = provider.buildUtilityInvocation;
112
+ const captures = [];
113
+ provider.buildUtilityInvocation = (context) => {
114
+ const invocation = originalBuildUtilityInvocation(context);
115
+ const index = captures.length;
116
+ const capture = path.join(root, `${String(index).padStart(2, "0")}-${context.purpose}.json`);
117
+ captures.push({ capture, purpose: context.purpose });
118
+ invocation.env.FAKE_AGENT_CAPTURE = capture;
119
+ invocation.env.FAKE_AGENT_TEXT = JSON.stringify(context.purpose === "introspection" ? introspectionDecision : dreamDecision);
120
+ return invocation;
121
+ };
122
+
123
+ const model = `${kind}-dream-exact`;
124
+ const { createUtilityAgentService } = require("./core/utility-agent");
125
+ const utility = createUtilityAgentService({
126
+ registry: registryFor(provider),
127
+ env: {
128
+ UTILITY_PROVIDER: "active",
129
+ DEFAULT_PROVIDER: kind,
130
+ [`DREAM_MODEL_${kind.toUpperCase()}`]: model,
131
+ },
132
+ });
133
+ const { MemoryMutationQueue } = require("./core/memory-mutation-queue");
134
+ const queue = new MemoryMutationQueue({
135
+ lockDir: path.join(root, "dream.lock"),
136
+ journalFile: path.join(root, "dream-intents.jsonl"),
137
+ lockPollMs: 5,
138
+ });
139
+ const calls = [];
140
+ const runUtility = (prompt, options) => {
141
+ calls.push({
142
+ purpose: options.purpose,
143
+ provider: options.provider || null,
144
+ tier: options.tier,
145
+ readOnly: options.readOnly,
146
+ hasSchema: !!options.outputSchema,
147
+ effort: options.effort,
148
+ promptBytes: Buffer.byteLength(prompt),
149
+ });
150
+ return utility.spawnUtilityAgent(prompt, options);
151
+ };
152
+
153
+ const dream = require("./core/dream");
154
+ assert.strictEqual(typeof dream.createDreamService, "function", "dream exposes an injectable provider service");
155
+ const service = dream.createDreamService({ spawnUtilityAgent: runUtility, mutationQueue: queue });
156
+ const packs = require("./core/packs");
157
+ const entities = require("./core/entities");
158
+ const lessons = require("./core/lessons");
159
+ const ideas = require("./core/ideas");
160
+ const persona = require("./core/persona");
161
+ dream.writeDreamState({ legacyField: "preserved" });
162
+ packs.createPack({
163
+ dir: "project",
164
+ name: "Project",
165
+ description: "dream provider parity project",
166
+ stance: "Keep provider-neutral memory durable.",
167
+ procedure: "Use deterministic provider fixtures.",
168
+ state: "Dream provider parity is pending.",
169
+ });
170
+ entities.upsertEntity({ name: "Parity Harness", type: "system", description: "dream fixture", notes: "Initial fixture notes." });
171
+ const originalPersona = `Open Claudia stays direct, warm, and precise while testing memory. ${"She preserves durable facts and speaks with calm confidence. ".repeat(2)}`.trim();
172
+ persona.savePersona(originalPersona);
173
+
174
+ const beforePreview = packSemantics(packs.readPack("project"));
175
+ const preview = await service.previewDream();
176
+ assert.strictEqual(preview.provider, kind);
177
+ assert.strictEqual(preview.model, model);
178
+ assert.deepStrictEqual(preview.decision, dream.parseDream(JSON.stringify(dreamDecision)));
179
+ assert.deepStrictEqual(packSemantics(packs.readPack("project")), beforePreview, "dry-run preview never applies its decision");
180
+
181
+ const result = await service.runDream({ trigger: "cron" });
182
+ assert.strictEqual(result.provider, kind);
183
+ assert.strictEqual(result.model, model);
184
+ assert.strictEqual(result.introspectionProvider, kind);
185
+ assert.strictEqual(result.introspectionModel, model);
186
+ assert.strictEqual(result.trigger, "cron");
187
+ assert.match(result.message, new RegExp(`${kind}.*${model}`), "morning summary names the provider and model");
188
+ assert.match(result.message, /provider audit trail/i, "introspection narrative remains in the summary");
189
+
190
+ assert.deepStrictEqual(calls.map((call) => call.purpose), ["dream", "dream", "introspection"]);
191
+ for (const call of calls) {
192
+ assert.strictEqual(call.provider, null, "global dream selection is resolved by DEFAULT_PROVIDER when no chat is active");
193
+ assert.strictEqual(call.tier, "high");
194
+ assert.strictEqual(call.readOnly, true);
195
+ assert.strictEqual(call.hasSchema, true);
196
+ assert.strictEqual(call.effort, "max");
197
+ assert.ok(call.promptBytes > 100);
198
+ }
199
+
200
+ const project = packs.readPack("project");
201
+ const entity = entities.findEntity("Parity Harness");
202
+ assert.strictEqual(project.description, "Provider-neutral dream consolidation fixture");
203
+ assert.deepStrictEqual(project.tags, ["dream", "provider", "memory"]);
204
+ assert.strictEqual(project.sections.State, "Dream and introspection both completed through the selected utility provider.");
205
+ assert.strictEqual(entity.sections.Notes, "Verifies consolidation and introspection through Claude and Codex.");
206
+ assert.strictEqual(lessons.listLessons()[0].text, "Always record the provider and model used for a dream.");
207
+ assert.strictEqual(ideas.listIdeas()[0].text, "Show dream provider metadata in the morning summary.");
208
+ assert.strictEqual(persona.loadPersona(), originalPersona, "persona bounds still reject an undersized model proposal");
209
+
210
+ const state = dream.readDreamState();
211
+ assert.strictEqual(state.provider, kind);
212
+ assert.strictEqual(state.model, model);
213
+ assert.strictEqual(state.introspection.provider, kind);
214
+ assert.strictEqual(state.introspection.model, model);
215
+ assert.strictEqual(state.lastRunStatus, "succeeded");
216
+ assert.strictEqual(state.legacyField, "preserved", "provider metadata is an additive, backward-compatible dream-state migration");
217
+
218
+ assert.ok(result.reportPath && fs.existsSync(result.reportPath));
219
+ const report = fs.readFileSync(result.reportPath, "utf8");
220
+ assert.match(report, new RegExp(`Provider: ${kind}`));
221
+ assert.match(report, new RegExp(`Model: ${model}`));
222
+ assert.match(report, new RegExp(`Introspection: ${kind}.*${model}`));
223
+ const snapshots = require("./core/recall/metrics").readKpi();
224
+ const snapshot = snapshots.at(-1);
225
+ assert.strictEqual(snapshot.providers.dream, kind);
226
+ assert.strictEqual(snapshot.providers.introspection, kind);
227
+ assert.strictEqual(snapshot.models.dream, model);
228
+ assert.strictEqual(snapshot.models.introspection, model);
229
+
230
+ assert.ok(fs.existsSync(path.join(process.env.PACKS_DIR, "project", "PACK.md.bak")), "dream updates retain last-good backups");
231
+ assert.ok(fs.existsSync(`${process.env.PERSONA_FILE}.bak`) || fs.existsSync(path.join(root, "config", "backup")),
232
+ "persona proposal handling retains recovery data without bypassing bounds");
233
+ for (const { capture, purpose } of captures) {
234
+ const record = JSON.parse(fs.readFileSync(capture, "utf8"));
235
+ assert.strictEqual(record.stdin.present, true, `${purpose} prompt uses protected stdin`);
236
+ assert.ok(record.argv.includes(model));
237
+ assert.ok(record.argv.includes(kind === "claude" ? "plan" : "read-only"));
238
+ if (kind === "claude") {
239
+ assert.ok(record.argv.includes("--json-schema"));
240
+ assert.ok(record.argv.includes("max"), "Claude retains maximum dream effort");
241
+ } else {
242
+ assert.ok(record.argv.includes("--output-schema"));
243
+ assert.ok(record.argv.some((arg) => /model_reasoning_effort=.*xhigh/.test(arg)), "generic max effort maps to Codex's strongest supported value");
244
+ }
245
+ }
246
+
247
+ // Provider/model failure still leaves the load-bearing deterministic phases
248
+ // running, and preserves the attempted provider/model in the result.
249
+ packs.createPack({ dir: "family", name: "Family", description: "prefix parent fixture" });
250
+ packs.createPack({ dir: "family-child", name: "Family Child", description: "prefix child fixture" });
251
+ process.env.DREAM_INTROSPECT = "off";
252
+ const failingService = dream.createDreamService({
253
+ mutationQueue: queue,
254
+ spawnUtilityAgent: async () => {
255
+ const error = Object.assign(new Error("provider failure fixture"), {
256
+ code: "UTILITY_PROVIDER_ERROR",
257
+ result: { provider: kind, model, effort: kind === "claude" ? "max" : "xhigh", fallbackUsed: false },
258
+ });
259
+ throw error;
260
+ },
261
+ });
262
+ const failed = await failingService.runDream({ trigger: "failure-fixture" });
263
+ process.env.DREAM_INTROSPECT = "on";
264
+ assert.strictEqual(failed.provider, kind);
265
+ assert.strictEqual(failed.model, model);
266
+ assert.match(failed.report, /skipped the AI-led merges/i);
267
+ assert.strictEqual(packs.readPack("family-child").parent, "family", "deterministic filing survives provider failure");
268
+
269
+ return {
270
+ provider: kind,
271
+ mutations: {
272
+ pack: packSemantics(project),
273
+ entity: entitySemantics(entity),
274
+ lessons: lessons.listLessons().map(({ text, src }) => ({ text, src })),
275
+ ideas: ideas.listIdeas().map(({ scope, text }) => ({ scope, text })),
276
+ persona: persona.loadPersona(),
277
+ },
278
+ };
279
+ }
280
+
281
+ function runChild(kind) {
282
+ const child = spawnSync(process.execPath, [__filename, "--probe", kind], {
283
+ cwd: __dirname,
284
+ env: { ...process.env },
285
+ encoding: "utf8",
286
+ timeout: 60000,
287
+ });
288
+ if (child.status !== 0) throw new Error(`${kind} dream probe failed (${child.status})\n${child.stdout}\n${child.stderr}`);
289
+ const line = child.stdout.split("\n").find((entry) => entry.startsWith(RESULT_PREFIX));
290
+ if (!line) throw new Error(`${kind} dream probe returned no result\n${child.stdout}\n${child.stderr}`);
291
+ return JSON.parse(line.slice(RESULT_PREFIX.length));
292
+ }
293
+
294
+ async function main() {
295
+ if (process.argv[2] === "--probe") {
296
+ const result = await providerProbe(process.argv[3]);
297
+ console.log(`${RESULT_PREFIX}${JSON.stringify(result)}`);
298
+ return;
299
+ }
300
+ const claude = runChild("claude");
301
+ const codex = runChild("codex");
302
+ assert.deepStrictEqual(codex.mutations, claude.mutations, "Claude and Codex dream decisions produce identical durable mutations");
303
+ const cliSource = fs.readFileSync(path.join(__dirname, "bin", "dream.js"), "utf8");
304
+ assert.ok(!cliSource.includes("spawnSubagent"), "manual dry-run no longer bypasses utility-provider selection");
305
+ assert.match(cliSource, /previewDream/);
306
+ console.log("provider dream OK");
307
+ }
308
+
309
+ main().catch((error) => {
310
+ console.error(error.stack || error.message);
311
+ process.exitCode = 1;
312
+ });
@@ -0,0 +1,252 @@
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 = "__PROVIDER_ENFORCER_RESULT__";
12
+
13
+ function isolate(label) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-enforcer-${label}-`));
15
+ process.env.OPEN_CLAUDIA_TEST = "1";
16
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(root, "config");
17
+ process.env.HOME = root;
18
+ process.env.WORKSPACE = root;
19
+ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
20
+ return root;
21
+ }
22
+
23
+ function makeProvider(kind, root, controls) {
24
+ const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
25
+ const { createClaudeProvider } = require("./core/providers/claude");
26
+ const { createCodexProvider } = require("./core/providers/codex");
27
+ const createProvider = kind === "claude" ? createClaudeProvider : createCodexProvider;
28
+ const provider = createProvider({
29
+ hookTransport: false,
30
+ resolveExecutable: () => fixture,
31
+ defaultModel: `${kind}-default-fixture`,
32
+ buildEnv: () => ({ PATH: process.env.PATH || "", HOME: root, FAKE_AGENT_KIND: kind }),
33
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
34
+ });
35
+ const base = provider.buildUtilityInvocation;
36
+ const captures = [];
37
+ provider.buildUtilityInvocation = (context) => {
38
+ const invocation = base(context);
39
+ const control = controls.shift() || { FAKE_AGENT_TEXT: '{"decision":"allow","reason":"default fixture"}' };
40
+ const capture = path.join(root, `${kind}-${captures.length}-${context.purpose}.json`);
41
+ captures.push(capture);
42
+ Object.assign(invocation.env, control, { FAKE_AGENT_CAPTURE: capture });
43
+ return invocation;
44
+ };
45
+ return { provider, captures, fixture };
46
+ }
47
+
48
+ function registry(providerEntries, states = {}) {
49
+ const map = Object.fromEntries(providerEntries.map((entry) => [entry.provider.id, entry]));
50
+ return {
51
+ getProvider(id) {
52
+ if (!map[id]) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
53
+ return map[id].provider;
54
+ },
55
+ listProviders: () => providerEntries.map((entry) => entry.provider),
56
+ providerStatus(id) {
57
+ if (!map[id]) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
58
+ const state = states[id] || {};
59
+ return {
60
+ id,
61
+ availability: state.availability || { state: "available", executable: map[id].fixture, reason: null },
62
+ compatibility: state.compatibility || { state: "compatible", reason: null },
63
+ auth: state.auth || { state: "authenticated", reason: null },
64
+ };
65
+ },
66
+ };
67
+ }
68
+
69
+ function auditSink() {
70
+ const entries = [];
71
+ return { entries, log(kind, payload) { const entry = { kind, ...payload }; entries.push(entry); return entry; } };
72
+ }
73
+
74
+ async function providerProbe(kind) {
75
+ const root = isolate(kind);
76
+ const timeoutSignal = path.join(root, `${kind}-timeout-signal.json`);
77
+ const controls = [
78
+ { FAKE_AGENT_TEXT: '{"decision":"allow","reason":"inside mandate"}' },
79
+ { FAKE_AGENT_TEXT: '{"decision":"block","reason":"outside mandate"}' },
80
+ { FAKE_AGENT_TEXT: '{"decision":"escalate","reason":"owner must decide"}' },
81
+ { FAKE_AGENT_TEXT: "not-json" },
82
+ { FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: timeoutSignal },
83
+ ];
84
+ const entry = makeProvider(kind, root, controls);
85
+ const model = `${kind}-enforcer-exact`;
86
+ const { createUtilityAgentService } = require("./core/utility-agent");
87
+ const utility = createUtilityAgentService({
88
+ registry: registry([entry]),
89
+ env: { ENFORCER_PROVIDER: kind, [`ENFORCER_MODEL_${kind.toUpperCase()}`]: model },
90
+ killGraceMs: 30,
91
+ });
92
+ const enforcer = require("./core/enforcer");
93
+ assert.strictEqual(typeof enforcer.createEnforcerService, "function", "enforcer exposes an injectable utility-provider service");
94
+ const audit = auditSink();
95
+ const service = enforcer.createEnforcerService({
96
+ spawnUtilityAgent: utility.spawnUtilityAgent,
97
+ audit,
98
+ timeoutMs: 2000,
99
+ });
100
+ const speaker = { name: "External Fixture", relationship: "external", mandate: "May discuss invoice status only." };
101
+ const secret = ["sk", "proj", "guard", "fixture", "private"].join("-");
102
+ const allow = await service.vetReply(speaker, `Invoice status is paid. ${secret}`);
103
+ const block = await service.vetReply(speaker, "Here are internal infrastructure details.");
104
+ const escalate = await service.vetAction(speaker, { command: "open-claudia tool run billing change-owner", tier: "write" });
105
+ const malformed = await service.vetReply(speaker, "malformed verdict fixture");
106
+ const timeoutService = enforcer.createEnforcerService({
107
+ spawnUtilityAgent: utility.spawnUtilityAgent,
108
+ audit,
109
+ timeoutMs: 140,
110
+ });
111
+ const timedOut = await timeoutService.vetReply(speaker, "timeout verdict fixture");
112
+
113
+ assert.strictEqual(allow.decision, "allow");
114
+ assert.strictEqual(block.decision, "block");
115
+ assert.strictEqual(escalate.decision, "escalate");
116
+ for (const verdict of [allow, block, escalate]) {
117
+ assert.strictEqual(verdict.provider, kind);
118
+ assert.strictEqual(verdict.model, model);
119
+ assert.strictEqual(verdict.fallbackUsed, false);
120
+ assert.strictEqual(verdict.status, "judged");
121
+ }
122
+ for (const failure of [malformed, timedOut]) {
123
+ assert.strictEqual(failure.decision, "escalate", "guard failures always fail closed");
124
+ assert.strictEqual(failure.provider, kind);
125
+ assert.strictEqual(failure.model, model);
126
+ assert.strictEqual(failure.status, "failed");
127
+ assert.match(failure.reason, /failing closed/);
128
+ }
129
+
130
+ assert.strictEqual(audit.entries.length, 5);
131
+ assert.ok(audit.entries.every((item) => item.kind === "enforcer.verdict"));
132
+ assert.ok(audit.entries.every((item) => item.provider === kind && item.model === model));
133
+ const serializedAudit = JSON.stringify(audit.entries);
134
+ assert.ok(!serializedAudit.includes(secret), "audit metadata never contains the judged prompt or payload");
135
+ assert.ok(!serializedAudit.includes("mandate"), "audit metadata never contains the private mandate");
136
+
137
+ assert.strictEqual(entry.captures.length, 5);
138
+ for (const capture of entry.captures) {
139
+ const raw = fs.readFileSync(capture, "utf8");
140
+ const record = JSON.parse(raw);
141
+ assert.ok(!raw.includes(secret), "fixture capture contains stdin metadata, never the guarded payload");
142
+ assert.strictEqual(record.stdin.present, true);
143
+ assert.ok(record.argv.includes(model));
144
+ assert.ok(record.argv.includes(kind === "claude" ? "plan" : "read-only"));
145
+ assert.ok(record.argv.includes(kind === "claude" ? "--json-schema" : "--output-schema"));
146
+ }
147
+ assert.strictEqual(JSON.parse(fs.readFileSync(timeoutSignal, "utf8")).signal, "SIGTERM");
148
+
149
+ return { kind, decisions: [allow.decision, block.decision, escalate.decision, malformed.decision, timedOut.decision] };
150
+ }
151
+
152
+ async function fallbackProbe() {
153
+ const root = isolate("fallback");
154
+ const claude = makeProvider("claude", root, [{ FAKE_AGENT_SCENARIO: "auth-failure" }]);
155
+ const codex = makeProvider("codex", root, [{ FAKE_AGENT_TEXT: '{"decision":"allow","reason":"fallback judged"}' }]);
156
+ const { createUtilityAgentService } = require("./core/utility-agent");
157
+ const fallbackUtility = createUtilityAgentService({
158
+ registry: registry([claude, codex]),
159
+ env: {
160
+ ENFORCER_PROVIDER: "claude",
161
+ PROVIDER_FALLBACKS: "codex",
162
+ ENFORCER_MODEL_CLAUDE: "claude-enforcer-exact",
163
+ ENFORCER_MODEL_CODEX: "codex-enforcer-exact",
164
+ },
165
+ });
166
+ const enforcer = require("./core/enforcer");
167
+ const fallbackAudit = auditSink();
168
+ const fallbackService = enforcer.createEnforcerService({ spawnUtilityAgent: fallbackUtility.spawnUtilityAgent, audit: fallbackAudit });
169
+ const speaker = { relationship: "external", mandate: "May discuss invoice status." };
170
+ const fallback = await fallbackService.vetReply(speaker, "fallback invoice fixture");
171
+ assert.strictEqual(fallback.decision, "allow");
172
+ assert.strictEqual(fallback.provider, "codex");
173
+ assert.strictEqual(fallback.model, "codex-enforcer-exact");
174
+ assert.strictEqual(fallback.preferredProvider, "claude");
175
+ assert.strictEqual(fallback.fallbackUsed, true);
176
+ assert.strictEqual(fallbackAudit.entries[0].provider, "codex");
177
+ assert.strictEqual(fallbackAudit.entries[0].fallbackUsed, true);
178
+ assert.strictEqual(claude.captures.length, 1, "preferred provider was attempted first");
179
+ assert.strictEqual(codex.captures.length, 1, "only the explicitly configured fallback judged after auth failure");
180
+
181
+ const noFallbackCodex = makeProvider("codex", root, []);
182
+ const noFallbackUtility = createUtilityAgentService({
183
+ registry: registry([claude, noFallbackCodex], {
184
+ claude: { availability: { state: "missing", executable: null, reason: "missing fixture" } },
185
+ }),
186
+ env: { ENFORCER_PROVIDER: "claude" },
187
+ });
188
+ const noFallbackService = enforcer.createEnforcerService({ spawnUtilityAgent: noFallbackUtility.spawnUtilityAgent, audit: auditSink() });
189
+ const noFallback = await noFallbackService.vetReply(speaker, "no implicit fallback fixture");
190
+ assert.strictEqual(noFallback.decision, "escalate");
191
+ assert.strictEqual(noFallback.status, "failed");
192
+ assert.strictEqual(noFallbackCodex.captures.length, 0, "enforcer never invents an unconfigured fallback");
193
+
194
+ const failedClaude = makeProvider("claude", root, []);
195
+ const failedCodex = makeProvider("codex", root, []);
196
+ const totalFailureUtility = createUtilityAgentService({
197
+ registry: registry([failedClaude, failedCodex], {
198
+ claude: { availability: { state: "missing", executable: null, reason: "missing Claude" } },
199
+ codex: { auth: { state: "unauthenticated", reason: "missing Codex auth" } },
200
+ }),
201
+ env: { ENFORCER_PROVIDER: "claude", PROVIDER_FALLBACKS: "codex" },
202
+ });
203
+ const totalAudit = auditSink();
204
+ const totalService = enforcer.createEnforcerService({ spawnUtilityAgent: totalFailureUtility.spawnUtilityAgent, audit: totalAudit });
205
+ const totalFailure = await totalService.vetAction(speaker, { command: "dangerous total failure fixture", tier: "destructive" });
206
+ assert.strictEqual(totalFailure.decision, "escalate");
207
+ assert.strictEqual(totalFailure.status, "failed");
208
+ assert.match(totalFailure.reason, /failing closed/);
209
+ assert.strictEqual(totalAudit.entries[0].decision, "escalate");
210
+ assert.strictEqual(totalAudit.entries[0].status, "failed");
211
+
212
+ return { fallbackProvider: fallback.provider, noFallback: noFallback.decision, totalFailure: totalFailure.decision };
213
+ }
214
+
215
+ function runChild(args) {
216
+ const child = spawnSync(process.execPath, [__filename, ...args], {
217
+ cwd: __dirname,
218
+ env: { ...process.env },
219
+ encoding: "utf8",
220
+ timeout: 30000,
221
+ });
222
+ if (child.status !== 0) throw new Error(`enforcer child ${args.join(" ")} failed (${child.status})\n${child.stdout}\n${child.stderr}`);
223
+ const line = child.stdout.split("\n").find((entry) => entry.startsWith(RESULT_PREFIX));
224
+ if (!line) throw new Error(`enforcer child ${args.join(" ")} returned no result\n${child.stdout}\n${child.stderr}`);
225
+ return JSON.parse(line.slice(RESULT_PREFIX.length));
226
+ }
227
+
228
+ async function main() {
229
+ if (process.argv[2] === "--provider") {
230
+ const result = await providerProbe(process.argv[3]);
231
+ console.log(`${RESULT_PREFIX}${JSON.stringify(result)}`);
232
+ return;
233
+ }
234
+ if (process.argv[2] === "--fallback") {
235
+ const result = await fallbackProbe();
236
+ console.log(`${RESULT_PREFIX}${JSON.stringify(result)}`);
237
+ return;
238
+ }
239
+ const claude = runChild(["--provider", "claude"]);
240
+ const codex = runChild(["--provider", "codex"]);
241
+ assert.deepStrictEqual(claude.decisions, ["allow", "block", "escalate", "escalate", "escalate"]);
242
+ assert.deepStrictEqual(codex.decisions, claude.decisions);
243
+ assert.deepStrictEqual(runChild(["--fallback"]), { fallbackProvider: "codex", noFallback: "escalate", totalFailure: "escalate" });
244
+ const source = fs.readFileSync(path.join(__dirname, "core", "enforcer.js"), "utf8");
245
+ assert.ok(!source.includes("spawnSubagent"), "enforcer no longer uses the compatibility sub-agent transport");
246
+ console.log("provider enforcer OK");
247
+ }
248
+
249
+ main().catch((error) => {
250
+ console.error(error.stack || error.message);
251
+ process.exitCode = 1;
252
+ });