@inetafrica/open-claudia 3.2.3 → 3.3.2

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.
package/core/router.js CHANGED
@@ -63,15 +63,24 @@ async function deliverRouterError(error) {
63
63
  // person is still caught once the owner's channels are unified.
64
64
  const processedMessages = new Set();
65
65
  function isDuplicate(envelope) {
66
+ // Memoized per envelope object: the unauthorized path checks dedup before
67
+ // intro handling, then falls through to the normal handlers (which check
68
+ // again) when a known sender's chat was auto-adopted — that second check
69
+ // must not mistake the envelope's own first check for a redelivery.
70
+ if (envelope._dedupSeen !== undefined) return envelope._dedupSeen;
66
71
  const canonical = envelope.canonicalUserId || envelope.channelId;
67
72
  const key = `${canonical}:${envelope.adapter?.id || envelope.adapter?.type}:${envelope.messageId}`;
68
- if (processedMessages.has(key)) return true;
73
+ if (processedMessages.has(key)) {
74
+ envelope._dedupSeen = true;
75
+ return true;
76
+ }
69
77
  processedMessages.add(key);
70
78
  if (processedMessages.size > 400) {
71
79
  const arr = [...processedMessages];
72
80
  processedMessages.clear();
73
81
  arr.slice(-200).forEach((k) => processedMessages.add(k));
74
82
  }
83
+ envelope._dedupSeen = false;
75
84
  return false;
76
85
  }
77
86
 
@@ -141,11 +150,12 @@ async function onMessage(envelope) {
141
150
  }
142
151
 
143
152
  if (!isChatAuthorized(envelope.channelId, envelope)) {
144
- if (envelope.type === "text") {
145
- if (isDuplicate(envelope)) return;
146
- await introFlow.handleInbound(envelope, (text) => send(text));
147
- }
148
- return;
153
+ if (envelope.type !== "text") return;
154
+ if (isDuplicate(envelope)) return;
155
+ const consumed = await introFlow.handleInbound(envelope, (text) => send(text));
156
+ if (consumed) return;
157
+ // false = a transport-verified known sender was auto-adopted and the
158
+ // channel is authorized now — fall through and handle the turn.
149
159
  }
150
160
 
151
161
  // Auto-contact: an authorized speaker the people store doesn't know yet
@@ -604,7 +614,7 @@ async function handleText(envelope) {
604
614
  // ("... for 14d" / "... until 2026-08-30").
605
615
  const { text: coreRule, expiresAt } = require("./mandates").extractExpiry(ruleText);
606
616
  const r = require("./actions").appendMandateLine(
607
- { entitySlug: pend.entitySlug, personName: pend.personName },
617
+ { entitySlug: pend.entitySlug, personId: pend.personId, personName: pend.personName },
608
618
  coreRule,
609
619
  "owner added a per-person rule via approval card",
610
620
  { expiresAt },
package/core/runner.js CHANGED
@@ -1707,7 +1707,13 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
1707
1707
  if (runContext.origin?.adapterId) env.OC_CHANNEL_ADAPTER = runContext.origin.adapterId;
1708
1708
  if (runContext.channelId) env.OC_CHANNEL_ID = String(runContext.channelId);
1709
1709
  if (runContext.origin?.userId) env.OC_CHANNEL_USER_ID = String(runContext.origin.userId);
1710
- if (runContext.canonicalUserId) env.OC_CANONICAL_USER_ID = String(runContext.canonicalUserId);
1710
+ if (runContext.canonicalUserId) {
1711
+ env.OC_CANONICAL_USER_ID = String(runContext.canonicalUserId);
1712
+ // Server-side attestation of the id above: the loopback's owner-gated
1713
+ // kinds only honor a canonicalUserId whose attest verifies, so a shell
1714
+ // that rewrites OC_CANONICAL_USER_ID cannot impersonate the owner.
1715
+ try { env.OC_SPEAKER_ATTEST = require("./speaker-attest").mint(runContext.canonicalUserId); } catch (e) {}
1716
+ }
1711
1717
  if (runContext.sessionId) env.OC_LAST_SESSION_ID = String(runContext.sessionId);
1712
1718
  if (lb?.url) env.OC_SEND_URL = lb.url;
1713
1719
  }
@@ -0,0 +1,56 @@
1
+ // Speaker attestation: lets the loopback server trust OC_CANONICAL_USER_ID.
2
+ //
3
+ // The problem: the loopback's owner-gated kinds (people mutations, auth,
4
+ // intros) identified the caller by payload.canonicalUserId, which the CLI read
5
+ // from env — and env is writable by the agent's shell, so a guarded external
6
+ // turn could forge `OC_CANONICAL_USER_ID=<owner-id> open-claudia people link …`
7
+ // and rewrite the identity map the relationship guard depends on.
8
+ //
9
+ // The fix: the RUNNER (which knows the turn's true speaker from the inbound
10
+ // envelope, server-side) mints an HMAC over the canonical user id with a
11
+ // per-boot secret that lives ONLY in bot-process memory — never on disk, never
12
+ // in the loopback info file, stripped from provider env like all control-plane
13
+ // keys would be (it is simply never exported). The provider spawn env carries
14
+ // OC_SPEAKER_ATTEST next to OC_CANONICAL_USER_ID; the CLI forwards both; the
15
+ // loopback verifies the pair before honoring an owner claim. An agent can
16
+ // still LIE about OC_CANONICAL_USER_ID, but it cannot mint a matching
17
+ // signature for any id other than the one the runner stamped for its turn.
18
+ //
19
+ // Scope and honesty: this authenticates "the runner said this turn speaks as
20
+ // X". It is per-boot (a restart invalidates in-flight attests — same blast
21
+ // radius as the loopback port/token rotating) and deliberately unforgeable
22
+ // rather than merely obscure: no attest, wrong attest, or an attest for a
23
+ // different id all fail closed. It does NOT bind adapter/channel — those stay
24
+ // best-effort inputs to the guarded-speaker checks, whose spoof failure modes
25
+ // are self-limiting (buttons/files land on the spoofed channel, i.e. the
26
+ // owner's own chat, never silently on the attacker's).
27
+
28
+ const crypto = require("crypto");
29
+
30
+ // Lazily created so tests get a fresh secret per process, like production.
31
+ let SECRET = null;
32
+
33
+ function secret() {
34
+ if (!SECRET) SECRET = crypto.randomBytes(32);
35
+ return SECRET;
36
+ }
37
+
38
+ function mint(canonicalUserId) {
39
+ const id = String(canonicalUserId || "");
40
+ if (!id) return "";
41
+ return crypto.createHmac("sha256", secret()).update(id).digest("hex");
42
+ }
43
+
44
+ // Constant-time compare; every malformed shape is a plain false (fail closed).
45
+ function verify(canonicalUserId, attest) {
46
+ const id = String(canonicalUserId || "");
47
+ const sig = String(attest || "");
48
+ if (!id || !sig) return false;
49
+ const expected = mint(id);
50
+ const a = Buffer.from(sig, "utf-8");
51
+ const b = Buffer.from(expected, "utf-8");
52
+ if (a.length !== b.length) return false;
53
+ try { return crypto.timingSafeEqual(a, b); } catch (e) { return false; }
54
+ }
55
+
56
+ module.exports = { mint, verify };
@@ -865,20 +865,29 @@ function buildSpeakerPersonaBlock(opts = {}) {
865
865
  if (!speaker.isOwner && (s.Mandate || "").trim()) {
866
866
  rows.push(`Mandate — what you may do/discuss with them: ${s.Mandate.trim()}`);
867
867
  }
868
- if (rows.length === 0) return "";
868
+ // A bare note used to render nothing — which hid the guard's policy source
869
+ // exactly when it mattered most (auto-added stubs are born bare, so the
870
+ // agent couldn't see WHICH file governs the speaker, or that a same-named
871
+ // note elsewhere is a different identity). For non-owners the block always
872
+ // renders; an owner with an empty note still gets the quiet no-op.
873
+ if (rows.length === 0 && speaker.isOwner) return "";
869
874
  const runIdentity = promptDedupeIdentity(opts);
870
875
  const key = `${adapter.id || "?"}:${channelId}`;
871
876
  const stamp = `${runIdentity}:${slug}:${ent.updated}`;
872
877
  if (speakerPersonaInjectedFor.get(key) === stamp) return "";
873
878
  speakerPersonaInjectedFor.set(key, stamp);
874
879
  const rel = ent.relationship || (speaker.isOwner ? "owner" : "");
880
+ const unverified = !speaker.isOwner && !!speaker.autoCreated;
875
881
  const clip = (str) => (str.length > SPEAKER_PERSONA_MAX_CHARS ? str.slice(0, SPEAKER_PERSONA_MAX_CHARS) + "\n…[truncated — `open-claudia entity show " + slug + "`]" : str);
876
882
  return clip([
877
- `\n\n## Who you're speaking with: ${speaker.name}${rel ? ` (${rel})` : ""}`,
883
+ `\n\n## Who you're speaking with: ${speaker.name}${rel ? ` (${rel})` : ""}${unverified ? " — UNVERIFIED auto-added contact" : ""}`,
878
884
  `Their persona pack (source: ${entitiesLib.ENTITIES_DIR}/${slug}.md) — adapt your voice and scope to it. Edit that file to refine how you treat them.`,
885
+ ...(speaker.isOwner ? [] : [
886
+ `That file is the ONLY note the relationship guard reads for them — a same-named note anywhere else belongs to a DIFFERENT identity and grants nothing here.${unverified ? " The owner has not yet confirmed who they are (verification happens on the owner's approval card or /people verify)." : ""}`,
887
+ ]),
879
888
  "",
880
889
  rows.join("\n"),
881
- ].join("\n"));
890
+ ].join("\n")).trimEnd();
882
891
  }
883
892
 
884
893
  // External-mode posture block (Phase 3). For a non-owner (guarded) speaker,
@@ -896,9 +905,17 @@ function buildExternalModeBlock() {
896
905
  const who = speaker.name || "this person";
897
906
  const mandate = (speaker.mandate || "").trim();
898
907
  const unverified = !!(speaker.person && speaker.person.autoCreated);
908
+ let sourceLine = "";
909
+ if (speaker.slug) {
910
+ try {
911
+ const entitiesLib = require("./entities");
912
+ sourceLine = `Guard policy source: ${entitiesLib.ENTITIES_DIR}/${speaker.slug}.md — the ONLY note the guard reads for ${who}. A same-named note elsewhere is a different identity and grants nothing here.`;
913
+ } catch (e) {}
914
+ }
899
915
  const lines = [
900
916
  `\n\n## External speaker — restricted mode`,
901
- `You are talking to ${who}, who is NOT the owner. You are operating under a strict, owner-defined scope.`,
917
+ `You are talking to ${who}, who is NOT the owner. You are operating under a strict, owner-defined scope.${unverified ? ` Their identity is UNVERIFIED (auto-added on first contact — only the owner can verify them, via the approval card or /people verify).` : ""}`,
918
+ ...(sourceLine ? [sourceLine] : []),
902
919
  "",
903
920
  mandate
904
921
  ? `Mandate — the ONLY things you are cleared to do or discuss with ${who}:\n${mandate}`
@@ -0,0 +1,156 @@
1
+ // Owner verification of auto-added contacts — the second half of the
2
+ // ensureContact story. ensureContact deliberately NEVER links a first-time
3
+ // speaker to an existing note by display name (names are claimable, so that
4
+ // would be an impersonation path); it mints an unverified stub instead. This
5
+ // module gives the OWNER the explicit, audited act that closes the loop:
6
+ // confirm who the speaker is, optionally bind them to the existing trusted
7
+ // note, retire the stub, and clear the unverified flag the enforcer surfaces.
8
+ //
9
+ // Security invariants (each refusal below is one of them):
10
+ // - Verification is an OWNER decision arriving via a chat-envelope surface
11
+ // (approval-card button / /people slash command). There is deliberately no
12
+ // CLI or loopback verb: identity decisions never run on agent-forgeable
13
+ // input. Callers must gate on the transport envelope BEFORE calling here.
14
+ // - Verification is NOT trust: it settles who the person is. Relationship
15
+ // upgrades and mandate lines remain separate explicit owner grants.
16
+ // - Never link to an owner-relationship note (R1 defense-in-depth: speakerFor
17
+ // already coerces such a claim, but this surface refuses to create it).
18
+ // - Never link two people to one note: a persona pack carries a Mandate, and
19
+ // sharing one across identities would silently share granted capabilities.
20
+ // - Only auto-added (autoCreated) contacts are verifiable here. Re-pointing an
21
+ // already-verified person is intentionally NOT offered, so this surface can
22
+ // never be used as a general re-identity tool.
23
+
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+ const people = require("./people");
27
+ const entities = require("./entities");
28
+ const audit = require("./audit");
29
+
30
+ // Same-name suggestions for the owner surfaces: existing person-type notes
31
+ // this unverified contact could plausibly BE. Excludes the contact's own stub,
32
+ // owner-relationship notes, and notes already bound to another person record.
33
+ // Suggestion-only — verifyContact re-validates whatever the owner picks.
34
+ function candidateEntitiesFor(person) {
35
+ if (!person) return [];
36
+ const stubSlug = people.resolveEntitySlug(person);
37
+ const wanted = String(person.name || "").trim().toLowerCase();
38
+ if (!wanted) return [];
39
+ const taken = new Set(
40
+ people.list()
41
+ .filter((p) => p.id !== person.id)
42
+ .map((p) => people.resolveEntitySlug(p))
43
+ .filter(Boolean),
44
+ );
45
+ const out = [];
46
+ for (const e of entities.listEntities()) {
47
+ if (e.type !== "person") continue;
48
+ if (e.slug === stubSlug) continue;
49
+ if (e.relationship === "owner") continue;
50
+ if (taken.has(e.slug)) continue;
51
+ const names = [e.name, ...(e.aliases || [])]
52
+ .map((n) => String(n || "").trim().toLowerCase())
53
+ .filter(Boolean);
54
+ if (names.includes(wanted)) out.push(e);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function refuse(reason, extra = {}) {
60
+ return { ok: false, reason, ...extra };
61
+ }
62
+
63
+ // Verify (and optionally link) one auto-added contact.
64
+ // personId — the people.json record to verify
65
+ // linkSlug — optional existing note to bind them to; omitted = "this stub
66
+ // IS the right note, just mark them verified"
67
+ // by / via — provenance for the audit trail (owner id, surface name)
68
+ function verifyContact({ personId, linkSlug = null, by = "", via = "" } = {}) {
69
+ const person = people.findById(String(personId || ""));
70
+ if (!person) return refuse("person not found");
71
+ if (person.isOwner) return refuse("the owner record is never routed through contact verification");
72
+ if (!person.autoCreated) return refuse(`${person.name} is already verified`, { already: true });
73
+
74
+ const stubSlug = people.resolveEntitySlug(person);
75
+ let targetSlug = stubSlug;
76
+ let supersededStub = false;
77
+
78
+ if (linkSlug) {
79
+ const wantedSlug = entities.slugify(String(linkSlug));
80
+ if (wantedSlug && wantedSlug !== stubSlug) {
81
+ const target = entities.readEntity(wantedSlug);
82
+ if (!target) return refuse(`no note found at ${wantedSlug}`);
83
+ if (target.type !== "person") return refuse(`${wantedSlug} is a ${target.type} note, not a person`);
84
+ if (target.relationship === "owner") {
85
+ return refuse("refusing to link a contact to an owner-relationship note — ownership only ever comes from the people record");
86
+ }
87
+ const holder = people.list().find(
88
+ (p) => p.id !== person.id && people.resolveEntitySlug(p) === target.slug,
89
+ );
90
+ if (holder) {
91
+ return refuse(`that note already belongs to ${holder.name} — two people can never share one persona pack`);
92
+ }
93
+ targetSlug = target.slug;
94
+
95
+ // Provenance onto the surviving note, then retire the stub file as
96
+ // .superseded (kept on disk — the merge is inspectable, not destructive).
97
+ entities.upsertEntity({
98
+ slug: target.slug,
99
+ name: target.name,
100
+ log: `owner verified ${person.name} as this contact (absorbed auto-added stub ${stubSlug})`,
101
+ }, "owner-verify");
102
+ if (stubSlug) {
103
+ const stubFile = path.join(entities.ENTITIES_DIR, `${stubSlug}.md`);
104
+ try {
105
+ if (fs.existsSync(stubFile)) {
106
+ fs.renameSync(stubFile, `${stubFile}.superseded`);
107
+ supersededStub = true;
108
+ }
109
+ } catch (e) { /* best-effort: a stranded stub is inert once unlinked */ }
110
+ try { entities.markIndexDirty(); } catch (e) {}
111
+ }
112
+ people.setEntitySlug(person.id, target.slug);
113
+ }
114
+ }
115
+
116
+ if (targetSlug === stubSlug && stubSlug) {
117
+ // Verify-only: the stub becomes the canonical note for this person. The
118
+ // description keeps its first-seen provenance, minus the unverified label.
119
+ const stub = entities.readEntity(stubSlug);
120
+ const description = stub && stub.description
121
+ ? stub.description
122
+ .replace(/^Auto-added contact \(unverified\)/, "Verified contact")
123
+ .replace(/\s*Possible existing note:.*$/, "")
124
+ : "Verified contact — identity confirmed by the owner.";
125
+ entities.upsertEntity({
126
+ slug: stubSlug,
127
+ name: person.name,
128
+ description,
129
+ log: "owner verified this contact; this note is now their canonical persona pack",
130
+ }, "owner-verify");
131
+ }
132
+
133
+ const fresh = people.findById(person.id) || person;
134
+ const updated = people.update(person.id, {
135
+ autoCreated: false,
136
+ verifiedAt: new Date().toISOString(),
137
+ verifiedBy: String(by || "owner"),
138
+ verifiedVia: String(via || ""),
139
+ handles: (fresh.handles || []).map((h) => (
140
+ h.approvedBy === "auto-contact" ? { ...h, approvedBy: String(by || "owner") } : h
141
+ )),
142
+ });
143
+
144
+ audit.log("people.verify", {
145
+ by: String(by || "owner"),
146
+ via: String(via || ""),
147
+ personId: person.id,
148
+ name: person.name,
149
+ slug: targetSlug,
150
+ supersededStub: supersededStub ? stubSlug : null,
151
+ });
152
+
153
+ return { ok: true, person: updated, slug: targetSlug, superseded: supersededStub ? stubSlug : null };
154
+ }
155
+
156
+ module.exports = { verifyContact, candidateEntitiesFor };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.2.3",
3
+ "version": "3.3.2",
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": {
@@ -11,7 +11,7 @@
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
13
  "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.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-refusal-recovery.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-credential-capture.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-kazee-media-caption.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
14
- "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-recall-query.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-walker-score.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-score-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-cokept-warming.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-pack-search.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-extractive-injection.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-mandate-hygiene.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-external-rule-scopes.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-cards.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-kazee-group-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-kazee-group-context.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-people-owner-seed.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-typing-heartbeat.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 && 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-say-loopback.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-union.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-subst-score.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-section-budgets.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-dedup-adjudicator.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-prospective.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-regressions.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-cost-report.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-context.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-mining.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-certification.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-apprenticeship.js"
14
+ "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-recall-query.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-walker-score.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-score-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-cokept-warming.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-pack-search.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-extractive-injection.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-mandate-hygiene.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-external-rule-scopes.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-cards.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-kazee-group-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-kazee-group-context.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-people-owner-seed.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-typing-heartbeat.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 && 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-say-loopback.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-union.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-subst-score.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-section-budgets.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-dedup-adjudicator.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-prospective.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-regressions.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-cost-report.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-context.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-mining.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-certification.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-apprenticeship.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-attest.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-verify-contact.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-loopback-owner-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-intro-adoption.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
package/test-enforcer.js CHANGED
@@ -378,5 +378,36 @@ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
378
378
  await runInChat({ ...grantCtx, triageApprovalId: escReply.id }, () => enforcer.vetReply(extSpeaker, "reply-kind-grant-payload"));
379
379
  assert.ok(!/TURN GRANT/.test(grantPrompt), "only a triage-kind approval grants the turn");
380
380
 
381
+ // ── stranded-mandate hint (dual-file trap): a grant written to the
382
+ // name-obvious note while the speaker's record reads a hashed slug does
383
+ // nothing — the card must SAY so at the moment it bites, and the stranded
384
+ // text must never be applied or quoted. ──
385
+ const rahil = people.add({ name: "Rahil Malde" });
386
+ people.setEntitySlug(rahil.id, "rahil-malde-abc123");
387
+ people.linkHandle(rahil.id, { adapter: "telegram", channelId: "333" });
388
+ entities.upsertEntity({ slug: "rahil-malde-abc123", name: "Rahil Malde", type: "person", relationship: "external" });
389
+ entities.upsertEntity({ slug: "rahil-malde", name: "Rahil Malde", type: "person", relationship: "external",
390
+ mandate: "- May coordinate ticket triage." });
391
+ const strandedSpeaker = relationship.speakerFor("telegram", "333");
392
+ assert.strictEqual(String(strandedSpeaker.mandate || "").trim(), "",
393
+ "the stranded text never reaches the live mandate");
394
+ sends.length = 0;
395
+ const escStr = await enforcer.escalate({ speaker: strandedSpeaker, kind: "reply", payloadText: "p", summary: "s", reason: "r" });
396
+ assert.strictEqual(escStr.escalated, true, "stranded-mandate escalation still posts");
397
+ const strCard = sends.find((s) => s.channelId === CH_OWNER);
398
+ assert.ok(strCard && /rahil-malde\.md/.test(strCard.text) && /NO effect/.test(strCard.text),
399
+ "the owner card names the file holding the dead grant");
400
+ assert.ok(!/ticket triage/.test(strCard.text), "the stranded text itself stays off the card");
401
+
402
+ // A different person legitimately holding the name slug silences the hint —
403
+ // that mandate is THEIRS, not a stranded copy of this speaker's.
404
+ const rahil2 = people.add({ name: "Rahil M Other" });
405
+ people.setEntitySlug(rahil2.id, "rahil-malde");
406
+ sends.length = 0;
407
+ await enforcer.escalate({ speaker: relationship.speakerFor("telegram", "333"), kind: "reply", payloadText: "p", summary: "s", reason: "r" });
408
+ const strCard2 = sends.find((s) => s.channelId === CH_OWNER);
409
+ assert.ok(strCard2 && !/NO effect/.test(strCard2.text),
410
+ "no hint when the sibling note belongs to a real other person");
411
+
381
412
  console.log("enforcer OK");
382
413
  })().catch((e) => { console.error(e); process.exit(1); });
@@ -25,7 +25,10 @@ function fixture() {
25
25
  assert.strictEqual(f.events[0].payload.reason, "unsupported_media");
26
26
  f = fixture();
27
27
  await f.manager.incoming({ fromUserId: "owner-1", fromUserName: "Owner", chatId: "chat", roomId: 12, callType: "audio" });
28
- assert.strictEqual(f.events[0].name, "call:accept");
28
+ // Ringing must go out first (caller UI feedback), then the accept.
29
+ assert.strictEqual(f.events[0].name, "call:ringing");
30
+ assert.deepStrictEqual(f.events[0].payload, { toUserId: "owner-1", chatId: "chat" });
31
+ assert.strictEqual(f.events[1].name, "call:accept");
29
32
  f.manager.session.options.onTranscript("What is on my agenda?");
30
33
  assert.strictEqual(f.inbound[0].payload.raw.ephemeralAudio, true);
31
34
  // Call turns must mute observer banners (spoken otherwise) and flag live-call
@@ -29,8 +29,9 @@ class FakePC {
29
29
  }
30
30
 
31
31
  class FakeSpeechStream {
32
- constructor({ onTranscript } = {}) {
32
+ constructor({ onTranscript, onPartial } = {}) {
33
33
  this.onTranscript = onTranscript;
34
+ this.onPartial = onPartial;
34
35
  this.audioFrames = [];
35
36
  this.commits = 0;
36
37
  this.closedStream = false;
@@ -41,12 +42,12 @@ class FakeSpeechStream {
41
42
  close() { this.closedStream = true; }
42
43
  }
43
44
 
44
- function makeSession({ synthesize, maxCallMs, idleMs, asCaller, greeting } = {}) {
45
+ function makeSession({ synthesize, maxCallMs, idleMs, finalWaitMs, asCaller, greeting } = {}) {
45
46
  const ended = [];
46
47
  const transcripts = [];
47
48
  const session = new KazeeCallSession({
48
49
  roomId: 7, chatId: "chat", callerId: "owner", botUserId: "bot", janusUrl: "ws://test",
49
- maxCallMs, idleMs, asCaller, greeting,
50
+ maxCallMs, idleMs, finalWaitMs, asCaller, greeting,
50
51
  synthesize: synthesize || (async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 2), sampleRate: 48000 })),
51
52
  onTranscript: (text, meta) => transcripts.push({ text, meta }),
52
53
  onEnded: (reason) => ended.push(reason),
@@ -127,12 +128,98 @@ async function testPrebufferAndCommit() {
127
128
  assert.strictEqual(sub.stt.audioFrames.length, 0, "silence stays out of STT");
128
129
  for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
129
130
  assert.strictEqual(sub.stt.audioFrames.length, 8, "onset flushes the prebuffer (5 quiet + 2 onset + current)");
131
+ sub.stt.onPartial("hello there"); // recogniser heard words, so the energy close commits directly
130
132
  for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
131
133
  assert.strictEqual(sub.stt.commits, 1, "turn commits after sustained silence");
132
134
  gen.close();
133
135
  await session.close();
134
136
  }
135
137
 
138
+ async function testHoldForWords() {
139
+ const { session } = makeSession({});
140
+ await session._subscribe(101, "caller");
141
+ const sub = session.subscribers.get(101);
142
+ const gen = new OpusAudioCodec();
143
+ for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
144
+ for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
145
+ assert.strictEqual(sub.stt.commits, 0, "energy close with zero words holds the commit");
146
+ assert(sub.awaitingWordsSince > 0, "hold state armed");
147
+ sub.stt.onPartial("hello there");
148
+ assert.strictEqual(sub.stt.commits, 1, "first words release the held commit");
149
+ assert.strictEqual(sub.awaitingWordsSince, 0);
150
+ assert(session._thinkTimer, "released commit acks and thinks like a normal turn");
151
+ gen.close();
152
+ await session.close();
153
+ }
154
+
155
+ async function testHoldTimeoutDropsNoiseTurn() {
156
+ const { session, transcripts } = makeSession({});
157
+ await session._subscribe(101, "caller");
158
+ const sub = session.subscribers.get(101);
159
+ const gen = new OpusAudioCodec();
160
+ for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
161
+ for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
162
+ sub.awaitingWordsSince = Date.now() - 5000; // hold expired
163
+ session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
164
+ assert.strictEqual(sub.stt.commits, 1, "expired hold still finalizes the audio");
165
+ assert.strictEqual(sub.noiseCommit, true);
166
+ assert.strictEqual(session._thinkTimer, null, "noise commit stays silent — no ack, no ticking");
167
+ sub.stt.onTranscript("uh"); // babble leaked a stray word into the transcript
168
+ assert.strictEqual(transcripts.length, 0, "sub-threshold noise transcript never dispatches");
169
+ gen.close();
170
+ await session.close();
171
+ }
172
+
173
+ async function testWordStallRescue() {
174
+ const { session } = makeSession({});
175
+ await session._subscribe(101, "caller");
176
+ const sub = session.subscribers.get(101);
177
+ const gen = new OpusAudioCodec();
178
+ for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
179
+ sub.stt.onPartial("so the deploy");
180
+ sub.lastWordAt = Date.now() - 4500; // word timeline stalled well past its cadence
181
+ session._onFeedRtp(101, { payload: toneOpus(gen, 12000) }); // background keeps energy loud
182
+ assert.strictEqual(sub.stt.commits, 1, "stalled words commit even while energy stays loud");
183
+ assert.strictEqual(sub.vad.active, false, "rescue resets the energy turn");
184
+ gen.close();
185
+ await session.close();
186
+ }
187
+
188
+ async function testInterimFallbackAndLateFinal() {
189
+ const { session, transcripts } = makeSession({ finalWaitMs: 30 });
190
+ await session._subscribe(101, "caller");
191
+ const sub = session.subscribers.get(101);
192
+ const gen = new OpusAudioCodec();
193
+ for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
194
+ sub.stt.onPartial("here is my answer");
195
+ for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
196
+ assert.strictEqual(sub.stt.commits, 1);
197
+ assert.strictEqual(transcripts.length, 0, "dispatch waits for the final first");
198
+ await wait(80);
199
+ assert.strictEqual(transcripts.length, 1, "missing final falls back to the interim");
200
+ assert.strictEqual(transcripts[0].text, "here is my answer");
201
+ sub.stt.onTranscript("here is my answer sir");
202
+ assert.strictEqual(transcripts.length, 1, "late final after fallback is dropped, not double-answered");
203
+ gen.close();
204
+ await session.close();
205
+ }
206
+
207
+ async function testTrickleBuffering() {
208
+ const { session } = makeSession({});
209
+ const added = [];
210
+ session.publisher = { pc: { addIceCandidate: async (c) => { added.push(c.candidate); } }, pending: [], remoteSet: false };
211
+ session._onPublisherEvent({ janus: "trickle", candidate: { candidate: "cand-early", sdpMid: "0" } });
212
+ assert.strictEqual(added.length, 0, "candidates before the answer are buffered");
213
+ assert.strictEqual(session.publisher.pending.length, 1);
214
+ session._flushTrickle(session.publisher);
215
+ assert.deepStrictEqual(added, ["cand-early"], "buffered candidates flush once the answer lands");
216
+ session._onPublisherEvent({ janus: "trickle", candidate: { candidate: "cand-late", sdpMid: "0" } });
217
+ assert.deepStrictEqual(added, ["cand-early", "cand-late"], "later candidates apply directly");
218
+ session._onPublisherEvent({ janus: "trickle", candidate: { completed: true } });
219
+ assert.strictEqual(added.length, 2, "end-of-candidates marker is ignored");
220
+ await session.close();
221
+ }
222
+
136
223
  async function testTranscriptAttribution() {
137
224
  const { session, transcripts } = makeSession({});
138
225
  await session._subscribe(101, "alice@kazee");
@@ -245,8 +332,11 @@ async function testCallerModeRoomLifecycle() {
245
332
  const spoken = [];
246
333
  session.speak = async (text) => { spoken.push(text); return true; };
247
334
  await session._subscribe(101, "sumeet@kazee");
335
+ assert.deepStrictEqual(spoken, [], "no greeting while the publisher media leg is still down");
336
+ session._publisherUp = true;
337
+ session._maybeGreet();
248
338
  await session._subscribe(102, "second@kazee");
249
- assert.deepStrictEqual(spoken, ["hi there"], "greeting speaks exactly once, on first remote feed");
339
+ assert.deepStrictEqual(spoken, ["hi there"], "greeting speaks exactly once, after media-up + first remote feed");
250
340
  await session.close();
251
341
  const destroy = messages.find((m) => m?.request === "destroy");
252
342
  assert.ok(destroy && destroy.room === 7, "created room is destroyed on close");
@@ -283,6 +373,7 @@ async function testThinkingCues() {
283
373
  // plays an audible "heard you" chime, and starts ticking right away — STT
284
374
  // finalization takes seconds and must not sound like a dead line.
285
375
  for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
376
+ sub.stt.onPartial("what is the status");
286
377
  for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
287
378
  assert.strictEqual(sub.stt.commits, 1);
288
379
  await wait(60);
@@ -335,6 +426,11 @@ async function testCloseResolvesSpeech() {
335
426
  await testSpeakCancellation();
336
427
  await testEchoGateAndBargeIn();
337
428
  await testPrebufferAndCommit();
429
+ await testHoldForWords();
430
+ await testHoldTimeoutDropsNoiseTurn();
431
+ await testWordStallRescue();
432
+ await testInterimFallbackAndLateFinal();
433
+ await testTrickleBuffering();
338
434
  await testTranscriptAttribution();
339
435
  await testIdleTimeout();
340
436
  await testIdleBargeInKeepsCall();