@inetafrica/open-claudia 3.0.26 → 3.0.28

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,165 @@
1
+ // Always-on prompt budget instrumentation (Track X0). One JSONL line per real
2
+ // user turn plus a rolling summary, so we can baseline the fixed prompt floor
3
+ // (soul + lessons + runtime state + slash-command list + tool headlines +
4
+ // relationship/mandate) and watch it grow as new systems are bolted on. This
5
+ // is read-only telemetry: it never changes what is sent to the model, it only
6
+ // measures it. Mirrors the storage shape of core/recall/metrics.js.
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const CONFIG_DIR = require("../config-dir");
11
+
12
+ const LOG_FILE = path.join(CONFIG_DIR, "prompt-budget.jsonl");
13
+ const SUMMARY_FILE = path.join(CONFIG_DIR, "prompt-budget-summary.json");
14
+ let VERSION = "";
15
+ try { VERSION = require("../package.json").version || ""; } catch (e) {}
16
+
17
+ const MAX_LOG_BYTES = 2 * 1024 * 1024; // truncate the jsonl past ~2MB so telemetry never eats disk
18
+
19
+ function enabled() {
20
+ return String(process.env.PROMPT_BUDGET || "on").toLowerCase() !== "off";
21
+ }
22
+
23
+ // Local, network-free token estimate. Markdown/English averages ~4 chars per
24
+ // token; this is deliberately an ESTIMATE — consistency across turns matters
25
+ // more than matching a specific tokenizer, since X0 tracks growth over time.
26
+ function estimateTokens(str) {
27
+ const chars = typeof str === "string" ? str.length : 0;
28
+ return Math.ceil(chars / 4);
29
+ }
30
+
31
+ function part(str) {
32
+ const chars = typeof str === "string" ? str.length : 0;
33
+ return { chars, tokens: estimateTokens(str) };
34
+ }
35
+
36
+ function addSize(a, b) {
37
+ return { chars: a.chars + b.chars, tokens: a.tokens + b.tokens };
38
+ }
39
+
40
+ // Which named prompt components form the fixed "always-on floor" vs the
41
+ // variable, on-demand recall payload and the turn's own user input.
42
+ const ALWAYS_ON_KEYS = [
43
+ "core", // soul + persona + lessons + skill index + tool index + fixed boilerplate
44
+ "runtime", // runtime state block
45
+ "speaker", // speaker + team roster + slash-command list
46
+ "speakerPersona",
47
+ "provider", // provider-native tools note
48
+ "voice",
49
+ "relationship", // external-mode / mandate
50
+ "spacesMode",
51
+ ];
52
+ const VARIABLE_KEYS = ["transcript", "recall"];
53
+
54
+ // Break an assembled turn's named parts into sizes. `parts` keys mirror the
55
+ // components of buildTurnPrompt(); any subset is tolerated.
56
+ function measure(parts = {}) {
57
+ const components = {};
58
+ for (const [k, v] of Object.entries(parts)) components[k] = part(v);
59
+
60
+ let alwaysOn = { chars: 0, tokens: 0 };
61
+ for (const k of ALWAYS_ON_KEYS) if (components[k]) alwaysOn = addSize(alwaysOn, components[k]);
62
+
63
+ let variable = { chars: 0, tokens: 0 };
64
+ for (const k of VARIABLE_KEYS) if (components[k]) variable = addSize(variable, components[k]);
65
+
66
+ const recall = components.recall || { chars: 0, tokens: 0 };
67
+ const userPrompt = components.userPrompt || { chars: 0, tokens: 0 };
68
+ const total = addSize(addSize(alwaysOn, variable), userPrompt);
69
+
70
+ return { components, alwaysOn, recall, variable, userPrompt, total };
71
+ }
72
+
73
+ function readSummary() {
74
+ try { return JSON.parse(fs.readFileSync(SUMMARY_FILE, "utf8")) || {}; } catch (e) { return {}; }
75
+ }
76
+ function writeSummary(s) {
77
+ try { fs.writeFileSync(SUMMARY_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
78
+ }
79
+
80
+ function bumpSummary(m, meta) {
81
+ const s = readSummary();
82
+ s.turns = (s.turns || 0) + 1;
83
+ s.alwaysOnTokensTotal = (s.alwaysOnTokensTotal || 0) + m.alwaysOn.tokens;
84
+ s.recallTokensTotal = (s.recallTokensTotal || 0) + m.recall.tokens;
85
+ s.totalTokensTotal = (s.totalTokensTotal || 0) + m.total.tokens;
86
+ s.alwaysOnTokensMax = Math.max(s.alwaysOnTokensMax || 0, m.alwaysOn.tokens);
87
+ s.componentTokensTotal = s.componentTokensTotal || {};
88
+ for (const [k, v] of Object.entries(m.components)) {
89
+ s.componentTokensTotal[k] = (s.componentTokensTotal[k] || 0) + v.tokens;
90
+ }
91
+ // Latest snapshot — the current per-component breakdown ("what is the floor
92
+ // right now"), distinct from the rolling averages above.
93
+ s.latest = {
94
+ ts: new Date().toISOString(),
95
+ v: VERSION,
96
+ provider: meta.provider || "",
97
+ alwaysOnTokens: m.alwaysOn.tokens,
98
+ recallTokens: m.recall.tokens,
99
+ totalTokens: m.total.tokens,
100
+ components: Object.fromEntries(Object.entries(m.components).map(([k, v]) => [k, v.tokens])),
101
+ };
102
+ s.updatedAt = new Date().toISOString();
103
+ writeSummary(s);
104
+ }
105
+
106
+ function appendLog(rec) {
107
+ try {
108
+ try {
109
+ const st = fs.statSync(LOG_FILE);
110
+ if (st.size > MAX_LOG_BYTES) fs.rmSync(LOG_FILE, { force: true });
111
+ } catch (e) {}
112
+ fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
113
+ } catch (e) {}
114
+ }
115
+
116
+ // Record one real turn's prompt budget. Best-effort; never throws.
117
+ function record(measurement, meta = {}) {
118
+ if (!enabled()) return;
119
+ try {
120
+ const m = measurement && measurement.total ? measurement : null;
121
+ if (!m) return;
122
+ const rec = {
123
+ ts: new Date().toISOString(),
124
+ v: VERSION,
125
+ provider: meta.provider || "",
126
+ runId: meta.runId || null,
127
+ alwaysOnTokens: m.alwaysOn.tokens,
128
+ recallTokens: m.recall.tokens,
129
+ totalTokens: m.total.tokens,
130
+ components: Object.fromEntries(Object.entries(m.components).map(([k, v]) => [k, v.tokens])),
131
+ };
132
+ appendLog(rec);
133
+ bumpSummary(m, meta);
134
+ } catch (e) {}
135
+ }
136
+
137
+ // Human-readable rolling view (rendered by `open-claudia prompt-budget`).
138
+ function summary() {
139
+ const s = readSummary();
140
+ const turns = s.turns || 0;
141
+ const avg = (total) => (turns ? Math.round((total || 0) / turns) : 0);
142
+ const componentAvg = {};
143
+ for (const [k, v] of Object.entries(s.componentTokensTotal || {})) componentAvg[k] = avg(v);
144
+ return {
145
+ turns,
146
+ avgAlwaysOnTokens: avg(s.alwaysOnTokensTotal),
147
+ maxAlwaysOnTokens: s.alwaysOnTokensMax || 0,
148
+ avgRecallTokens: avg(s.recallTokensTotal),
149
+ avgTotalTokens: avg(s.totalTokensTotal),
150
+ componentAvgTokens: componentAvg,
151
+ latest: s.latest || null,
152
+ updatedAt: s.updatedAt || "",
153
+ };
154
+ }
155
+
156
+ function _resetForTest() {
157
+ try { fs.rmSync(LOG_FILE, { force: true }); } catch (e) {}
158
+ try { fs.rmSync(SUMMARY_FILE, { force: true }); } catch (e) {}
159
+ }
160
+
161
+ module.exports = {
162
+ LOG_FILE, SUMMARY_FILE, VERSION, enabled,
163
+ ALWAYS_ON_KEYS, VARIABLE_KEYS,
164
+ estimateTokens, measure, record, summary, _resetForTest,
165
+ };
package/core/runner.js CHANGED
@@ -761,7 +761,7 @@ function createRunnerService(dependencies = {}) {
761
761
  let bundle;
762
762
  let invocation;
763
763
  try {
764
- bundle = await promptBuilder(prompt, { runContext, provider });
764
+ bundle = await promptBuilder(prompt, { runContext, provider, recordBudget: !capture });
765
765
  invocation = invocationBuilder(provider, invocationContextForRun(runContext, bundle, provider));
766
766
  if (dependencies.decorateInvocation) {
767
767
  invocation = dependencies.decorateInvocation(invocation, runContext, bundle, provider);
@@ -1860,6 +1860,23 @@ async function handoffMemoryReview(result, runContext, store, state) {
1860
1860
  });
1861
1861
  }
1862
1862
 
1863
+ // Track C — owner-guardrail capture. After a foreground turn, a deterministic
1864
+ // detector spots durable-rule statements ("from now on / never / stop") in the
1865
+ // owner's message and captures them into lessons.md (or asks first). Owner-only
1866
+ // and best-effort: it never blocks or breaks a turn.
1867
+ async function considerGuardrail(result, runContext, store) {
1868
+ try {
1869
+ if (!result.ok || runContext.purpose !== "foreground") return;
1870
+ const guardrail = require("./guardrail");
1871
+ if (!guardrail.enabled()) return;
1872
+ const relationship = require("./relationship");
1873
+ const speaker = relationship.speakerFor(store?.adapter?.type, store?.channelId, store?.userId);
1874
+ if (relationship.guardActive(speaker)) return; // external speakers never capture
1875
+ const announce = (text, opts) => chatContext.run(store, () => send(text, opts));
1876
+ await guardrail.consider({ text: runContext.userPrompt, speaker, announce });
1877
+ } catch (e) { /* best-effort — a guardrail miss must never break a turn */ }
1878
+ }
1879
+
1863
1880
  function deferredRunResult() {
1864
1881
  let resolve;
1865
1882
  let reject;
@@ -1920,6 +1937,7 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
1920
1937
  try { service.turnObserver.onTurnSettled(!!result.ok); } catch (e) { /* best-effort */ }
1921
1938
  }
1922
1939
  if (!internalCapture) await handoffMemoryReview(result, runContext, store, state);
1940
+ if (!internalCapture) await considerGuardrail(result, runContext, store);
1923
1941
  if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
1924
1942
  state.settings.budget = null;
1925
1943
  try { saveState({ strict: true }); }
package/core/state.js CHANGED
@@ -652,6 +652,21 @@ function acquireRunLock(state = currentState()) {
652
652
  return true;
653
653
  }
654
654
 
655
+ // True when ANY user has a turn running, admitted-and-preparing, compacting, or
656
+ // waiting in its queue. Same busy predicate as acquireRunLock, but across every
657
+ // user state rather than one — the Telegram poll-wedge backstop consults this
658
+ // before a self-exit so a reboot never interrupts in-flight work. It fires on a
659
+ // timer outside any channel context (no "current" user) and a reboot kills the
660
+ // whole process, so it must scan all states, not just currentState().
661
+ function anyTurnActive() {
662
+ for (const s of userStates.values()) {
663
+ if (!s) continue;
664
+ if (s.runningProcess || s.preparingRun || s.isCompacting) return true;
665
+ if (Array.isArray(s.messageQueue) && s.messageQueue.length) return true;
666
+ }
667
+ return false;
668
+ }
669
+
655
670
  function migrationSources() {
656
671
  return defaultMigrationSources({
657
672
  configDir: CONFIG_DIR,
@@ -1363,6 +1378,7 @@ module.exports = {
1363
1378
  resetSessionUsage,
1364
1379
  resetSettings,
1365
1380
  acquireRunLock,
1381
+ anyTurnActive,
1366
1382
  saveState,
1367
1383
  loadSessions,
1368
1384
  loadSessionsDocument,
@@ -15,6 +15,7 @@ const {
15
15
  const tasksStore = require("./tasks");
16
16
  const people = require("./people");
17
17
  const commandsRegistry = require("./commands");
18
+ const promptBudget = require("./prompt-budget");
18
19
 
19
20
  function loadSoul() {
20
21
  const soul = fs.readFileSync(SOUL_FILE, "utf-8");
@@ -1068,12 +1069,40 @@ function createPromptBuilder(dependencies = {}) {
1068
1069
  spacesTaskMode,
1069
1070
  ].filter((part) => typeof part === "string" && part.trim()).join("\n\n");
1070
1071
 
1072
+ // Track X0: measure the fixed always-on floor vs the variable recall
1073
+ // payload for this turn. Pure measurement — attached to the bundle for
1074
+ // any caller; only recorded to telemetry when the caller opts in
1075
+ // (real user turns, never sub-agent/capture builds).
1076
+ let budget = null;
1077
+ try {
1078
+ budget = promptBudget.measure({
1079
+ core: coreInstructions,
1080
+ transcript: transcriptContext,
1081
+ runtime: runtimeContext,
1082
+ speaker: speakerContext,
1083
+ speakerPersona,
1084
+ provider: providerContext,
1085
+ recall: recallResult.dynamicContext,
1086
+ voice: voiceContext,
1087
+ relationship: relationshipContext,
1088
+ spacesMode: spacesTaskMode,
1089
+ userPrompt,
1090
+ });
1091
+ if (opts.recordBudget) {
1092
+ promptBudget.record(budget, {
1093
+ provider: opts.provider?.id,
1094
+ runId: opts.runContext?.runId,
1095
+ });
1096
+ }
1097
+ } catch (_) { budget = null; }
1098
+
1071
1099
  return {
1072
1100
  coreInstructions,
1073
1101
  dynamicContext,
1074
1102
  userPrompt,
1075
1103
  transcriptPaths,
1076
1104
  recallMetadata: recallResult.metadata,
1105
+ promptBudget: budget,
1077
1106
  };
1078
1107
  },
1079
1108
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.26",
3
+ "version": "3.0.28",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js",
12
+ "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js",
13
13
  "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js"
14
14
  },
15
15
  "files": [
@@ -66,6 +66,10 @@
66
66
  "test-identity-prune.js",
67
67
  "test-kazee-message-id.js",
68
68
  "test-kazee-channel-health.js",
69
+ "test-prompt-budget.js",
70
+ "test-agency-ledger.js",
71
+ "test-agency-deliberate.js",
72
+ "test-guardrail.js",
69
73
  "test-delivery-contract.js",
70
74
  "test-run-lock.js",
71
75
  "test-web-sessions.js",
@@ -0,0 +1,119 @@
1
+ // Track B1 — advisory deliberation. Deterministic triage/rank, confidence floor
2
+ // and clear-margin behaviour, the injectable judge (including failure fallback),
3
+ // and best-effort shadow logging. No network, redirected config dir.
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "oc-delib-"));
11
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
12
+
13
+ const D = require("./core/agency/deliberate");
14
+
15
+ const NOW = Date.parse("2026-07-15T12:00:00.000Z");
16
+ const DAY = 86400000;
17
+
18
+ function entry(o) {
19
+ return Object.assign(
20
+ { source: "task", kind: "task", id: "x", title: "t", status: "open",
21
+ priority: null, due: null, lastTouched: NOW, staleDays: 0, blockedOn: null,
22
+ overdue: false, meta: {} },
23
+ o,
24
+ );
25
+ }
26
+ function ledgerOf(entries) {
27
+ return { generatedAt: NOW, counts: { total: entries.length }, entries };
28
+ }
29
+
30
+ // --- triage: notifications never candidates; bare open local task isn't either
31
+ assert.strictEqual(D.isCandidate(entry({ source: "notification", status: "info" }), NOW), false);
32
+ assert.strictEqual(D.isCandidate(entry({ source: "task", status: "open", due: null }), NOW), false);
33
+ assert.strictEqual(D.isCandidate(entry({ source: "task", status: "in_progress" }), NOW), true);
34
+ assert.strictEqual(D.isCandidate(entry({ source: "spaces", staleDays: 3 }), NOW), true);
35
+
36
+ // --- scoreOf: overdue dominates, signals surface
37
+ const sc = D.scoreOf(entry({ overdue: true, due: NOW - 2 * DAY, priority: "high" }), NOW);
38
+ assert.ok(sc.score > 100, "overdue+high scores high");
39
+ assert.ok(sc.signals.some((s) => s.k === "overdue"));
40
+ assert.ok(sc.signals.some((s) => s.k === "priority"));
41
+
42
+ (async () => {
43
+ // --- clear winner: overdue high-priority Spaces task beats an in-progress task
44
+ const p1 = await D.deliberate(ledgerOf([
45
+ entry({ source: "spaces", id: "s1", title: "Overdue!", overdue: true, due: NOW - 2 * DAY, priority: "high", staleDays: 2 }),
46
+ entry({ source: "task", id: "t1", title: "WIP", status: "in_progress", staleDays: 1 }),
47
+ entry({ source: "notification", id: "n1", status: "info" }),
48
+ ]), { now: NOW });
49
+ assert.strictEqual(p1.proposed, true);
50
+ assert.strictEqual(p1.pick.id, "s1", "overdue item picked");
51
+ assert.strictEqual(p1.confidence, "high", "clear margin → high confidence");
52
+ assert.strictEqual(p1.method, "deterministic");
53
+ assert.strictEqual(p1.actionType, "ask", "overdue Spaces assignment → nudge");
54
+ assert.ok(p1.why.join(" ").includes("overdue"));
55
+ assert.ok(D.render(p1).includes("Overdue!"));
56
+
57
+ // --- default-silence: lone far-future item scores below the floor
58
+ const p2 = await D.deliberate(ledgerOf([
59
+ entry({ source: "spaces", id: "s2", title: "Someday", due: NOW + 20 * DAY, staleDays: 1 }),
60
+ ]), { now: NOW });
61
+ assert.strictEqual(p2.proposed, false, "below confidence floor → propose nothing");
62
+ assert.ok(D.render(p2).includes("Nothing pressing"));
63
+
64
+ // --- ambiguous + judge OFF → deterministic, medium confidence
65
+ const twoWip = ledgerOf([
66
+ entry({ source: "task", id: "t1", title: "WIP one", status: "in_progress", staleDays: 1 }),
67
+ entry({ source: "task", id: "t2", title: "WIP two", status: "in_progress", staleDays: 1 }),
68
+ ]);
69
+ const p3 = await D.deliberate(twoWip, { now: NOW });
70
+ assert.strictEqual(p3.proposed, true);
71
+ assert.strictEqual(p3.method, "deterministic");
72
+ assert.strictEqual(p3.confidence, "medium", "close margin → medium");
73
+
74
+ // --- ambiguous + injected judge overrides the pick + action
75
+ let judgeSaw = null;
76
+ const p4 = await D.deliberate(twoWip, {
77
+ now: NOW, useJudge: true,
78
+ judge: async (top) => { judgeSaw = top.map((c) => c.id); return { pickId: "t2", actionType: "schedule", why: "second is riper", confidence: "high" }; },
79
+ });
80
+ assert.deepStrictEqual(judgeSaw, ["t1", "t2"], "judge sees the top-N");
81
+ assert.strictEqual(p4.method, "judge");
82
+ assert.strictEqual(p4.pick.id, "t2", "judge override applied");
83
+ assert.strictEqual(p4.actionType, "schedule");
84
+ assert.strictEqual(p4.judgeWhy, "second is riper");
85
+
86
+ // --- judge failure falls back to deterministic, never throws
87
+ const p5 = await D.deliberate(twoWip, { now: NOW, useJudge: true, judge: async () => { throw new Error("boom"); } });
88
+ assert.strictEqual(p5.method, "deterministic");
89
+ assert.strictEqual(p5.pick.id, "t1");
90
+
91
+ // --- judge NOT called when the winner is clear (margin ≥ CLEAR_MARGIN)
92
+ let called = false;
93
+ const p6 = await D.deliberate(ledgerOf([
94
+ entry({ source: "spaces", id: "s1", title: "Overdue!", overdue: true, due: NOW - 5 * DAY, priority: "high" }),
95
+ entry({ source: "task", id: "t1", title: "WIP", status: "in_progress" }),
96
+ ]), { now: NOW, useJudge: true, judge: async () => { called = true; return null; } });
97
+ assert.strictEqual(called, false, "clear margin skips the judge");
98
+ assert.strictEqual(p6.pick.id, "s1");
99
+
100
+ // --- shadow record: writes a jsonl row; AGENCY_SHADOW=off suppresses it
101
+ process.env.AGENCY_SHADOW = "on";
102
+ D.record(p1, { channel: "telegram:123" });
103
+ assert.ok(fs.existsSync(D.LOG_FILE), "shadow log written");
104
+ let lines = fs.readFileSync(D.LOG_FILE, "utf8").trim().split("\n");
105
+ assert.strictEqual(lines.length, 1);
106
+ const row = JSON.parse(lines[0]);
107
+ assert.strictEqual(row.pickId, "s1");
108
+ assert.strictEqual(row.channel, "telegram:123");
109
+ assert.strictEqual(row.method, "deterministic");
110
+
111
+ process.env.AGENCY_SHADOW = "off";
112
+ D.record(p1, {});
113
+ lines = fs.readFileSync(D.LOG_FILE, "utf8").trim().split("\n");
114
+ assert.strictEqual(lines.length, 1, "AGENCY_SHADOW=off suppresses the write");
115
+ process.env.AGENCY_SHADOW = "on";
116
+
117
+ try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
118
+ console.log("test-agency-deliberate: OK");
119
+ })();
@@ -0,0 +1,114 @@
1
+ // Track B0 — agency ledger aggregator. Exercises the pure build()/normalizers/
2
+ // render, plus collect() with an injected Spaces fetch (no subprocess) and a
3
+ // redirected config dir so no real task/connector files are touched.
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "oc-ledger-"));
11
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
12
+
13
+ const ledger = require("./core/agency/ledger");
14
+
15
+ const NOW = Date.parse("2026-07-15T12:00:00.000Z");
16
+ const DAY = 86400000;
17
+
18
+ // --- normalizeTask: local task, never overdue, no due date ---
19
+ const t1 = ledger.normalizeTask(
20
+ { id: "a", content: "Ship X", status: "in_progress", updatedAt: NOW - 3600000 },
21
+ NOW,
22
+ );
23
+ assert.strictEqual(t1.source, "task");
24
+ assert.strictEqual(t1.status, "in_progress");
25
+ assert.strictEqual(t1.overdue, false);
26
+ assert.strictEqual(t1.due, null);
27
+
28
+ // --- normalizeSpacesTask: overdue + status + space name + priority ---
29
+ const s1 = ledger.normalizeSpacesTask(
30
+ {
31
+ id: "s1", title: "Leave page", status: "open", priority: "high",
32
+ dueDate: "2026-07-14T00:00:00.000Z", updatedAt: NOW - 2 * DAY,
33
+ spaceId: { name: "HR + Product" }, subtaskCount: 2, subtaskCompletedCount: 1,
34
+ },
35
+ NOW,
36
+ );
37
+ assert.strictEqual(s1.source, "spaces");
38
+ assert.strictEqual(s1.overdue, true, "past dueDate + not done → overdue");
39
+ assert.strictEqual(s1.meta.space, "HR + Product");
40
+ assert.strictEqual(s1.priority, "high");
41
+
42
+ // a completed Spaces task is done and never overdue, even past its due date
43
+ const s2 = ledger.normalizeSpacesTask(
44
+ { id: "s2", title: "done", status: "completed", dueDate: "2026-07-01T00:00:00.000Z" },
45
+ NOW,
46
+ );
47
+ assert.strictEqual(s2.status, "done");
48
+ assert.strictEqual(s2.overdue, false);
49
+
50
+ // --- normalizeInbox: actor + task title fold into a readable line ---
51
+ const n1 = ledger.normalizeInbox(
52
+ { id: "n1", type: "comment", actorName: "Jane", taskTitle: "Fix bug", createdAt: NOW - 1800000 },
53
+ NOW,
54
+ );
55
+ assert.strictEqual(n1.source, "notification");
56
+ assert.ok(n1.title.includes("Jane") && n1.title.includes("Fix bug"));
57
+ assert.strictEqual(n1.status, "info");
58
+
59
+ // --- build: drops completed, ranks overdue first, counts by source/stale ---
60
+ const l = ledger.build({
61
+ tasks: [
62
+ { id: "a", content: "In progress task", status: "in_progress", updatedAt: NOW - 3600000 },
63
+ { id: "b", content: "Done task", status: "completed", updatedAt: NOW - DAY },
64
+ { id: "c", content: "Stale open", status: "pending", updatedAt: NOW - 30 * DAY },
65
+ ],
66
+ spacesMine: [
67
+ { id: "s1", title: "Overdue!", status: "open", dueDate: "2026-07-13T00:00:00.000Z", updatedAt: NOW - 5 * DAY },
68
+ ],
69
+ inboxes: [
70
+ { id: "n1", type: "comment", actorName: "Jane", taskTitle: "T", createdAt: NOW - 1800000 },
71
+ ],
72
+ now: NOW,
73
+ });
74
+ assert.strictEqual(l.counts.total, 4, "1 completed dropped from 5 inputs");
75
+ assert.strictEqual(l.counts.overdue, 1);
76
+ assert.strictEqual(l.entries[0].id, "s1", "overdue entry ranked first");
77
+ assert.strictEqual(l.counts.stale, 1, "30d-untouched task is stale");
78
+ assert.strictEqual(l.counts.bySource.task, 2);
79
+ assert.strictEqual(l.counts.bySource.spaces, 1);
80
+ assert.strictEqual(l.counts.bySource.notification, 1);
81
+
82
+ // --- render: summary line + per-source sections ---
83
+ const text = ledger.render(l, { now: NOW });
84
+ assert.ok(text.includes("Agenda"));
85
+ assert.ok(text.includes("4 open"));
86
+ assert.ok(text.includes("1 overdue"));
87
+ assert.ok(text.includes("Spaces (assigned to me)"));
88
+ assert.ok(text.includes("Overdue!"));
89
+
90
+ // empty ledger renders the all-clear
91
+ const empty = ledger.render(ledger.build({ now: NOW }), { now: NOW });
92
+ assert.ok(empty.includes("Nothing on your plate"));
93
+
94
+ // --- collect: injected Spaces fetch, no adapter (no task files), no subprocess ---
95
+ (async () => {
96
+ const r = await ledger.collect({
97
+ now: NOW,
98
+ fetchSpacesMine: async () => [{ id: "s9", title: "Injected", status: "open" }],
99
+ });
100
+ assert.ok(r.entries.some((e) => e.id === "s9"), "injected spaces task present");
101
+ assert.strictEqual(typeof r.counts.total, "number");
102
+
103
+ // includeSpaces:false skips the fetch entirely
104
+ let called = false;
105
+ const r2 = await ledger.collect({
106
+ now: NOW, includeSpaces: false,
107
+ fetchSpacesMine: async () => { called = true; return [{ id: "nope" }]; },
108
+ });
109
+ assert.strictEqual(called, false, "includeSpaces:false skips the Spaces fetch");
110
+ assert.ok(!r2.entries.some((e) => e.id === "nope"));
111
+
112
+ try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
113
+ console.log("test-agency-ledger: OK");
114
+ })();