@inetafrica/open-claudia 3.5.13 → 3.5.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.14
4
+
5
+ Decisions stop sinking and going stale (Sumeet, 5 Oct: "they tend to fall quite far back in the chat ... after a while they might be invalid"). Bot side of "decisions inline"; works with today's chat-central, and the "waiting on you" strip comes with the server and app changes. Canary first, on the sales bot.
6
+
7
+ - **The bot closes decisions that no longer apply.** Each turn now lists the decision cards this bot asked in that conversation (the same thread, or the main timeline) that are still open, oldest first, with how long ago they were asked. If the latest message answers one, or it no longer applies, the bot closes it with `open-claudia card update <id> --close --note "why"`. Nothing is added when none are open.
8
+ - **One reminder for a decision left overnight.** At the first 09:00 (`OC_TIMEZONE`, default Africa/Nairobi) at least 8 hours after it was asked, the bot checks the card on the server and, only if the server shows it still open, replies to it in its own chat and thread: "⏳ Still waiting on you: <title>". Reminders go out between 09:00 and 18:00 local, so a restart at night never posts a batch. Never a new card, never twice (a "⏰ Later" reply counts as the card's one reminder), nothing for cards over 7 days old.
9
+ - **A late answer is checked first.** An answer to a card asked more than 6 hours earlier wakes the conversation with "check it still applies before acting".
10
+ - **"⏰ Later" keeps the decision where it is.** The card stays open with a note ("⏰ Later: I'll remind you at 14:30."); when the time comes the bot replies to that card instead of closing it and posting a new one. The next queued decision is still asked meanwhile. A decision snoozed before this release comes back as a new card, as before.
11
+ - **The card ledger keeps each card's state current** (closed or changed by the bot, answered, or read from the server), and the Kazee adapter can read one card's current state (`readCard`).
12
+ - **Tests:** `test-decision-upkeep.js` (new); `test-kazee-cards.js` now expects Later to keep the card open.
13
+
3
14
  ## 3.5.13
4
15
 
5
16
  Group chats stop seeing notes meant only for the owner, and token usage alerts are off by default. Canary first, on the sales bot.
@@ -1132,10 +1132,20 @@ class KazeeAdapter {
1132
1132
  }
1133
1133
 
1134
1134
  // Change a card this bot sent: { status: "closed"|"open", note, card }.
1135
+ // The card ledger keeps the new state, so open decisions stay current.
1135
1136
  async updateCard(messageId, patch = {}) {
1136
1137
  const res = await this._request("PUT", `/message/${encodeURIComponent(messageId)}/card`, patch);
1137
1138
  if (res && res.success === false) throw new Error(res.error || "card update refused");
1138
- return res?.card || null;
1139
+ const view = res?.card || null;
1140
+ try { require("../../core/cards").noteCardUpdate(messageId, patch, view); } catch (e) { console.error("Kazee updateCard: ledger not updated:", e.message); }
1141
+ return view;
1142
+ }
1143
+
1144
+ // A card as the server shows it now (its state: open, closed, decided),
1145
+ // read through the one-message lookup. Null when the message is gone.
1146
+ async readCard(chatId, messageId) {
1147
+ const m = await this._messageInChat(String(chatId), String(messageId));
1148
+ return m?.card || null;
1139
1149
  }
1140
1150
 
1141
1151
  // Get-or-create the 1:1 chat between the bot and a user, returning its
package/core/cards.js CHANGED
@@ -12,6 +12,12 @@
12
12
  // rest wait; Later snoozes, Tell me more asks the origin
13
13
  // conversation for a voice explainer
14
14
  //
15
+ // Open decisions must not sink and go stale (Sumeet, 5 Oct 2026): the ledger
16
+ // keeps each card's state current, the turn's prompt lists the decisions
17
+ // still open in that conversation so the bot closes the ones that no longer
18
+ // apply, a decision left overnight gets one reminder in its own thread, and a
19
+ // late answer wakes with "check this still applies".
20
+ //
15
21
  // Safety: an answer wakes the conversation that sent the card, which runs as
16
22
  // that card's sender. Anything a person typed (a rating comment) is quoted as
17
23
  // their words, never as instructions. Cards that anyone in a group may answer
@@ -29,6 +35,19 @@ const CARD_KEEP_MS = 45 * 24 * 60 * 60 * 1000;
29
35
  const CARD_KEEP_MAX = 2000;
30
36
  const SNOOZE_MS = Number(process.env.OC_DECISION_SNOOZE_MS || 3 * 60 * 60 * 1000);
31
37
  const COMMENT_MAX = 500;
38
+ const HOUR_MS = 60 * 60 * 1000;
39
+ const DAY_MS = 24 * HOUR_MS;
40
+ const OPEN_DECISION_MAX_AGE_MS = 14 * DAY_MS;
41
+ const REMIND_MAX_AGE_MS = 7 * DAY_MS;
42
+ const REMIND_MIN_WAIT_MS = 8 * HOUR_MS;
43
+ const REMIND_HOUR = 9;
44
+ const REMIND_LAST_HOUR = 18;
45
+ const LATE_ANSWER_MS = 6 * HOUR_MS;
46
+ const PROMPT_DECISIONS_MAX = 5;
47
+ const PROMPT_TITLE_MAX = 80;
48
+ const PROMPT_TITLE_MIN = 20;
49
+ const PROMPT_SECTION_MAX = 600;
50
+ const timeZone = () => process.env.OC_TIMEZONE || "Africa/Nairobi";
32
51
 
33
52
  function readJson(file, fallback) {
34
53
  try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (_) { return fallback; }
@@ -77,6 +96,97 @@ function updateCardRecord(messageId, patch) {
77
96
  return cards[String(messageId)];
78
97
  }
79
98
 
99
+ /** A card is open unless the server (or the bot) closed or decided it. */
100
+ function cardIsOpen(state) {
101
+ if (!state) return true;
102
+ if (state.decided) return false;
103
+ return !["closed", "decided", "expired"].includes(state.status);
104
+ }
105
+
106
+ /**
107
+ * The bot changed one of its own cards (PUT /message/:id/card): keep the
108
+ * ledger's lastState current. `view` is the server's answer when there is one.
109
+ */
110
+ function noteCardUpdate(messageId, patch = {}, view = null) {
111
+ const cur = getCard(messageId);
112
+ if (!cur) return null;
113
+ const at = new Date().toISOString();
114
+ let lastState;
115
+ if (view && view.state) {
116
+ lastState = { ...view.state, at };
117
+ } else {
118
+ lastState = { ...(cur.lastState || {}), status: patch.status || cur.lastState?.status || "open", at };
119
+ if (patch.note !== undefined && patch.note !== null) {
120
+ const note = String(patch.note).trim();
121
+ if (note) lastState.note = note;
122
+ else delete lastState.note;
123
+ }
124
+ }
125
+ return updateCardRecord(messageId, { lastState, ...(patch.card?.title ? { title: patch.card.title } : {}) });
126
+ }
127
+
128
+ /** Where a card was posted: a queued decision's home, else the conversation that sent it. */
129
+ function placeOf(rec) {
130
+ return rec.home || rec.origin || {};
131
+ }
132
+
133
+ /**
134
+ * The bot's own decision cards still waiting for an answer, oldest first.
135
+ * With channelId: only those asked in that conversation (its thread, or the
136
+ * main timeline when threadId is empty).
137
+ */
138
+ function openDecisions({ adapterId = null, channelId, threadId = null, now = Date.now() } = {}) {
139
+ const queue = new Map(loadDecisions().map((d) => [d.id, d]));
140
+ return Object.values(loadCards())
141
+ .filter((c) => c && c.kind === "decision" && !c.answeredAt && cardIsOpen(c.lastState))
142
+ .filter((c) => now - Date.parse(c.sentAt || 0) <= OPEN_DECISION_MAX_AGE_MS)
143
+ .filter((c) => !c.decisionId || !queue.has(c.decisionId) || ["queued", "asked", "snoozed"].includes(queue.get(c.decisionId).status))
144
+ .filter((c) => {
145
+ if (channelId === undefined || channelId === null) return true;
146
+ const p = placeOf(c);
147
+ return String(p.channelId || "") === String(channelId)
148
+ && String(p.threadId || "") === String(threadId || "")
149
+ && (!adapterId || !p.adapter || p.adapter === adapterId);
150
+ })
151
+ .sort((a, b) => Date.parse(a.sentAt) - Date.parse(b.sentAt));
152
+ }
153
+
154
+ function plural(n, word) {
155
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
156
+ }
157
+
158
+ /** "5 minutes", "3 hours", "2 days". */
159
+ function ageWords(ms) {
160
+ if (ms < HOUR_MS) return plural(Math.max(1, Math.floor(ms / 60000)), "minute");
161
+ if (ms < 2 * DAY_MS) return plural(Math.floor(ms / HOUR_MS), "hour");
162
+ return plural(Math.floor(ms / DAY_MS), "day");
163
+ }
164
+
165
+ function shortTitle(title, max) {
166
+ const t = String(title || "a decision").replace(/\s+/g, " ").trim().replace(/"/g, "'");
167
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
168
+ }
169
+
170
+ /**
171
+ * The per-turn prompt section listing the decisions this bot asked in this
172
+ * conversation that are still open, so it closes the ones answered in words
173
+ * or no longer needed. "" when there are none. Titles are cut to keep the
174
+ * whole section under PROMPT_SECTION_MAX characters.
175
+ */
176
+ function openDecisionsPromptSection({ adapterId = null, channelId, threadId = null, now = Date.now() } = {}) {
177
+ if (!channelId) return "";
178
+ const open = openDecisions({ adapterId, channelId, threadId, now });
179
+ if (!open.length) return "";
180
+ const shown = open.slice(0, PROMPT_DECISIONS_MAX);
181
+ const head = "## Open decisions you asked here";
182
+ const more = open.length > shown.length ? [`- and ${open.length - shown.length} more`] : [];
183
+ const tail = "If the latest message answers one, or it no longer applies, close it: `open-claudia card update <messageId> --close --note \"<why, in plain words>\"`. Never leave an answered or moot decision open.";
184
+ const line = (c, title) => `- [${c.messageId}] "${title}" — asked ${ageWords(now - Date.parse(c.sentAt))} ago`;
185
+ const fixed = [head, ...shown.map((c) => line(c, "")), ...more, tail].join("\n").length;
186
+ const titleMax = Math.max(PROMPT_TITLE_MIN, Math.min(PROMPT_TITLE_MAX, Math.floor((PROMPT_SECTION_MAX - fixed) / shown.length)));
187
+ return [head, ...shown.map((c) => line(c, shortTitle(c.title, titleMax))), ...more, tail].join("\n");
188
+ }
189
+
80
190
  /** Default wake policy: one person's card wakes on each answer; open polls don't. */
81
191
  function defaultWake(card) {
82
192
  const responders = card?.policy?.responders;
@@ -117,11 +227,20 @@ function totalsLine(card) {
117
227
  return parts.join("; ");
118
228
  }
119
229
 
120
- function describeResponse(rec, payload) {
230
+ /** For a wake-up: a card answered long after it was asked may be out of date. */
231
+ function lateAnswerLine(sentAt, now = Date.now()) {
232
+ const age = now - Date.parse(sentAt || 0);
233
+ if (!sentAt || !Number.isFinite(age) || age <= LATE_ANSWER_MS) return "";
234
+ return `This was asked ${ageWords(age)} ago. Before acting on it, check it still applies; if things have moved on, say so briefly instead of acting.`;
235
+ }
236
+
237
+ function describeResponse(rec, payload, now = Date.now()) {
121
238
  const who = payload.userName || (payload.userId ? `user ${payload.userId}` : "Someone (anonymous card)");
122
239
  const title = rec.title || payload.card?.title || "your card";
240
+ const late = lateAnswerLine(rec.sentAt, now);
123
241
  if (payload.action) {
124
- return `[Card ${payload.messageId}] ${who} tapped "${payload.label || payload.action}" on "${title}".`;
242
+ const tapped = `[Card ${payload.messageId}] ${who} tapped "${payload.label || payload.action}" on "${title}".`;
243
+ return late ? `${tapped}\n${late}` : tapped;
125
244
  }
126
245
  const comment = payload.answer && payload.answer.comment ? quote(payload.answer.comment) : "";
127
246
  const lines = [`[Card ${payload.messageId}] ${who} answered "${title}": ${answerLabel(payload)}.`];
@@ -129,6 +248,7 @@ function describeResponse(rec, payload) {
129
248
  const totals = totalsLine(payload.card);
130
249
  if (totals) lines.push(`Totals: ${totals}.`);
131
250
  lines.push("This is their reply to the card you sent. Act on it if your work was waiting for it; otherwise just note it.");
251
+ if (late) lines.push(late);
132
252
  return lines.join("\n");
133
253
  }
134
254
 
@@ -179,7 +299,12 @@ async function handleCardResponse(envelope, deps = {}) {
179
299
  console.log(`[cards] answer on unknown card ${messageId} (sent before this bot kept a ledger, or by another bot)`);
180
300
  return { handled: false, reason: "unknown card" };
181
301
  }
182
- updateCardRecord(messageId, { lastState: payload.card?.state || null, lastResponseAt: new Date().toISOString() });
302
+ const at = new Date().toISOString();
303
+ updateCardRecord(messageId, {
304
+ lastState: payload.card?.state ? { ...payload.card.state, at } : rec.lastState || null,
305
+ lastResponseAt: at,
306
+ ...(payload.answer ? { answeredAt: rec.answeredAt || at } : {}),
307
+ });
183
308
 
184
309
  if (rec.decisionId) return handleDecisionResponse(rec, payload, envelope, deps);
185
310
 
@@ -248,11 +373,19 @@ function decisionCard(d, position, total) {
248
373
  return card;
249
374
  }
250
375
 
376
+ /** The one reply that brings an open decision back to the top of the chat. */
377
+ function reminderText(title) {
378
+ return `⏳ Still waiting on you: ${title || "a decision"}`;
379
+ }
380
+
251
381
  /**
252
382
  * Ask the next decision in a home chat when none is out. `send` posts a card
253
- * and returns its message id: (home, card) => Promise<string>.
383
+ * and returns its message id: (home, card) => Promise<string>. `remind`
384
+ * posts a plain reply: (home, text, { threadId, replyTo }) => Promise. A
385
+ * snoozed decision keeps its card open; when its time is up it gets one
386
+ * reply on that card (not a new card) and counts as asked again.
254
387
  */
255
- async function pumpDecisions({ send, now = Date.now(), homeKey = null } = {}) {
388
+ async function pumpDecisions({ send, remind = null, updateCard = null, now = Date.now(), homeKey = null } = {}) {
256
389
  const list = loadDecisions();
257
390
  const homes = new Map();
258
391
  for (const d of list) {
@@ -264,8 +397,37 @@ async function pumpDecisions({ send, now = Date.now(), homeKey = null } = {}) {
264
397
  }
265
398
  const asked = [];
266
399
  for (const [key, ds] of homes) {
267
- if (ds.some((d) => d.status === "asked")) continue;
268
- const due = ds.filter((d) => d.status === "queued" || (d.status === "snoozed" && Date.parse(d.notBefore || 0) <= now));
400
+ const isDue = (d) => d.status === "snoozed" && Date.parse(d.notBefore || 0) <= now;
401
+ let remindedHere = false;
402
+ for (const d of ds.filter((x) => isDue(x) && x.keptCard && x.askedMessageId)) {
403
+ if (!remind) continue;
404
+ try {
405
+ await remind(d.home, reminderText(d.title), { threadId: d.home.threadId || null, replyTo: d.askedMessageId });
406
+ } catch (e) {
407
+ console.error(`[cards] snoozed decision ${d.id} reminder failed:`, e.message);
408
+ continue;
409
+ }
410
+ const fresh = loadDecisions();
411
+ const target = fresh.find((x) => x.id === d.id);
412
+ if (!target || target.status !== "snoozed") continue;
413
+ target.status = "asked";
414
+ target.remindedAt = new Date(now).toISOString();
415
+ saveDecisions(fresh);
416
+ // This reply is the card's one reminder: the overnight pass skips it.
417
+ updateCardRecord(d.askedMessageId, { remindedAt: target.remindedAt });
418
+ if (updateCard) {
419
+ try {
420
+ const view = await updateCard(d.askedMessageId, { note: "" });
421
+ noteCardUpdate(d.askedMessageId, { note: "" }, view);
422
+ } catch (_) { /* the reminder is out; a stale note is harmless */ }
423
+ }
424
+ d.status = "asked";
425
+ remindedHere = true;
426
+ asked.push({ key, decisionId: d.id, messageId: d.askedMessageId, reminded: true });
427
+ }
428
+ if (remindedHere || ds.some((d) => d.status === "asked")) continue;
429
+ // A decision snoozed before cards stayed open had its card closed: ask it again as a new card.
430
+ const due = ds.filter((d) => d.status === "queued" || (isDue(d) && !d.keptCard));
269
431
  if (!due.length) continue;
270
432
  const next = due.sort((a, b) => (a.status === "queued") - (b.status === "queued") || a.seq - b.seq)[0];
271
433
  const open = ds.filter((d) => d.status !== "snoozed" || d === next).length;
@@ -299,15 +461,22 @@ async function handleDecisionResponse(rec, payload, envelope, deps = {}) {
299
461
  if (!d) return { handled: false, reason: "unknown decision" };
300
462
  const updateCard = deps.updateCard || ((messageId, patch) => envelope.adapter?.updateCard?.(messageId, patch));
301
463
  const send = deps.sendCard || ((home, card) => envelope.adapter?.sendCard?.(home.channelId, card, { threadId: home.threadId || null }));
464
+ const remind = deps.remind || ((home, text, opts) => envelope.adapter?.send?.(home.channelId, text, opts));
465
+ const pump = () => pumpDecisions({ send, remind, updateCard, homeKey: `${d.home.adapter}|${d.home.channelId}` });
302
466
 
303
467
  if (payload.action === "later") {
468
+ // The card stays open where it is (no closing it and posting a new one
469
+ // later); a note on it says when it comes back. A note-only change just
470
+ // redraws the card: no new message, no notification.
304
471
  d.status = "snoozed";
472
+ d.keptCard = true;
305
473
  d.notBefore = new Date(Date.now() + SNOOZE_MS).toISOString();
306
474
  d.snoozes = (d.snoozes || 0) + 1;
307
475
  saveDecisions(list);
308
- const until = new Date(d.notBefore).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: process.env.OC_TIMEZONE || "Africa/Nairobi" });
309
- try { await updateCard(rec.messageId, { status: "closed", note: `Snoozed until ${until}. I'll ask again then.` }); } catch (e) { console.error("[cards] snooze note failed:", e.message); }
310
- await pumpDecisions({ send, homeKey: `${d.home.adapter}|${d.home.channelId}` });
476
+ const until = new Date(d.notBefore).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timeZone() });
477
+ const patch = { note: `⏰ Later: I'll remind you at ${until}.` };
478
+ try { noteCardUpdate(rec.messageId, patch, await updateCard(rec.messageId, patch)); } catch (e) { console.error("[cards] snooze note failed:", e.message); }
479
+ await pump();
311
480
  return { handled: true, snoozed: true };
312
481
  }
313
482
  if (payload.action === "more") {
@@ -320,18 +489,23 @@ async function handleDecisionResponse(rec, payload, envelope, deps = {}) {
320
489
  return { handled: true, explained: true };
321
490
  }
322
491
  if (payload.answer) {
492
+ const wasSnoozed = d.status === "snoozed" && d.keptCard;
323
493
  d.status = "decided";
324
494
  d.answer = payload.answer;
325
495
  d.answerLabel = answerLabel(payload);
326
496
  d.decidedAt = new Date().toISOString();
327
497
  saveDecisions(list);
498
+ if (wasSnoozed) {
499
+ try { noteCardUpdate(rec.messageId, { note: "" }, await updateCard(rec.messageId, { note: "" })); } catch (_) { /* only clears the "remind you at" note */ }
500
+ }
501
+ const late = lateAnswerLine(rec.sentAt);
328
502
  wakeOrigin(
329
503
  d.origin,
330
- `[Decision ${d.id}] Sumeet decided "${d.title}": ${d.answerLabel}.\nThis was queued from this conversation. Carry on with the work that was waiting for it, and tell him briefly what happens next.`,
504
+ `[Decision ${d.id}] Sumeet decided "${d.title}": ${d.answerLabel}.\nThis was queued from this conversation. Carry on with the work that was waiting for it, and tell him briefly what happens next.${late ? `\n${late}` : ""}`,
331
505
  `Decided: ${d.title}`,
332
506
  deps,
333
507
  );
334
- await pumpDecisions({ send, homeKey: `${d.home.adapter}|${d.home.channelId}` });
508
+ await pump();
335
509
  return { handled: true, decided: true };
336
510
  }
337
511
  return { handled: false, reason: "nothing to do" };
@@ -342,18 +516,94 @@ async function cancelDecision(id, { updateCard } = {}) {
342
516
  const d = list.find((x) => x.id === id);
343
517
  if (!d) return null;
344
518
  if (!["queued", "asked", "snoozed"].includes(d.status)) return d;
345
- const wasAsked = d.status === "asked";
519
+ const cardOpen = d.status === "asked" || (d.status === "snoozed" && d.keptCard);
346
520
  d.status = "cancelled";
347
521
  d.cancelledAt = new Date().toISOString();
348
522
  saveDecisions(list);
349
- if (wasAsked && d.askedMessageId && updateCard) {
350
- try { await updateCard(d.askedMessageId, { status: "closed", note: "Withdrawn: no longer needed." }); } catch (_) {}
523
+ if (cardOpen && d.askedMessageId && updateCard) {
524
+ const patch = { status: "closed", note: "Withdrawn: no longer needed." };
525
+ try { noteCardUpdate(d.askedMessageId, patch, await updateCard(d.askedMessageId, patch)); } catch (_) {}
351
526
  }
352
527
  return d;
353
528
  }
354
529
 
355
- // Snoozed decisions come back when their time is up; nothing else would ask
356
- // them again, so the bot looks every few minutes.
530
+ // ---------------------------------------------------------------------------
531
+ // One reminder for a decision left waiting overnight
532
+
533
+ function zonedParts(t, tz) {
534
+ const parts = new Intl.DateTimeFormat("en-US", {
535
+ timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
536
+ }).formatToParts(new Date(t));
537
+ const p = Object.fromEntries(parts.map((x) => [x.type, Number(x.value)]));
538
+ return { year: p.year, month: p.month, day: p.day, hour: p.hour % 24, minute: p.minute, second: p.second };
539
+ }
540
+
541
+ /**
542
+ * The instant of hour:00 on a local calendar day (day may overflow; Date.UTC
543
+ * rolls it). One offset lookup is enough for a morning hour: clocks change
544
+ * in the small hours, never within a few hours of 09:00.
545
+ */
546
+ function zonedTime(year, month, day, hour, tz) {
547
+ const guess = Date.UTC(year, month - 1, day, hour);
548
+ const p = zonedParts(guess, tz);
549
+ const offset = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - guess;
550
+ return guess - offset;
551
+ }
552
+
553
+ /** The first 09:00 local at least 8 hours after the decision was asked. */
554
+ function reminderDueAt(sentAt, tz = timeZone()) {
555
+ const earliest = Date.parse(sentAt) + REMIND_MIN_WAIT_MS;
556
+ const p = zonedParts(earliest, tz);
557
+ const sameDay = zonedTime(p.year, p.month, p.day, REMIND_HOUR, tz);
558
+ return sameDay >= earliest ? sameDay : zonedTime(p.year, p.month, p.day + 1, REMIND_HOUR, tz);
559
+ }
560
+
561
+ /**
562
+ * Post the one reminder each open decision card is due, as a plain reply to
563
+ * the card in its own chat and thread. `readState(rec)` asks the server for
564
+ * the card's current state; only a card the server shows as open gets the
565
+ * reminder (the ledger misses answers the bot never received), and a failed
566
+ * or empty read is tried again on a later tick. Without `readState` the
567
+ * ledger decides. Reminders go out between 09:00 and 18:00 local only, so a
568
+ * restart at night never posts a batch of them.
569
+ * `send(place, text, { threadId, replyTo })` posts the reply.
570
+ */
571
+ async function remindOpenDecisions({ send, readState = null, now = Date.now(), tz = timeZone() } = {}) {
572
+ const localHour = zonedParts(now, tz).hour;
573
+ if (localHour < REMIND_HOUR || localHour >= REMIND_LAST_HOUR) return [];
574
+ const queue = new Map(loadDecisions().map((d) => [d.id, d]));
575
+ const due = openDecisions({ now }).filter((c) => !c.remindedAt
576
+ && now - Date.parse(c.sentAt) <= REMIND_MAX_AGE_MS
577
+ && now >= reminderDueAt(c.sentAt, tz)
578
+ // a snoozed decision comes back through the queue at its own time
579
+ && !(c.decisionId && queue.get(c.decisionId)?.status === "snoozed"));
580
+ const reminded = [];
581
+ for (const c of due) {
582
+ if (readState) {
583
+ let state = null;
584
+ try { state = await readState(c); } catch (e) { console.error(`[cards] reading card ${c.messageId} failed, trying again later:`, e.message); }
585
+ if (!state) continue;
586
+ updateCardRecord(c.messageId, { lastState: { ...state, at: new Date(now).toISOString() } });
587
+ if (!cardIsOpen(state)) continue;
588
+ }
589
+ // Claimed before sending: a reminder is never posted twice, even if the
590
+ // send fails half way.
591
+ updateCardRecord(c.messageId, { remindedAt: new Date(now).toISOString() });
592
+ const place = placeOf(c);
593
+ try {
594
+ await send(place, reminderText(c.title), { threadId: place.threadId || null, replyTo: c.messageId });
595
+ reminded.push(c.messageId);
596
+ } catch (e) {
597
+ updateCardRecord(c.messageId, { remindFailed: String(e.message || e).slice(0, 200) });
598
+ console.error(`[cards] reminder for card ${c.messageId} failed:`, e.message);
599
+ }
600
+ }
601
+ return reminded;
602
+ }
603
+
604
+ // Snoozed decisions come back when their time is up, and decisions left
605
+ // overnight get their one reminder; nothing else would, so the bot looks
606
+ // every few minutes.
357
607
  let pumpTimer = null;
358
608
  function startDecisionPump(registry, intervalMs = 5 * 60 * 1000) {
359
609
  if (pumpTimer) return pumpTimer;
@@ -362,7 +612,27 @@ function startDecisionPump(registry, intervalMs = 5 * 60 * 1000) {
362
612
  if (!adapter || typeof adapter.sendCard !== "function") return null;
363
613
  return adapter.sendCard(home.channelId, card, { threadId: home.threadId || null });
364
614
  };
365
- const tick = () => pumpDecisions({ send }).catch((e) => console.error("[cards] decision pump failed:", e.message));
615
+ const remind = (place, text, opts) => {
616
+ const adapter = registry.findAdapter(place.adapter);
617
+ if (!adapter || typeof adapter.send !== "function") throw new Error(`no adapter ${place.adapter}`);
618
+ return adapter.send(place.channelId, text, opts);
619
+ };
620
+ const updateCard = (messageId, patch) => {
621
+ const rec = getCard(messageId);
622
+ const adapter = rec && registry.findAdapter(placeOf(rec).adapter);
623
+ return adapter && typeof adapter.updateCard === "function" ? adapter.updateCard(messageId, patch) : null;
624
+ };
625
+ const readState = async (rec) => {
626
+ const place = placeOf(rec);
627
+ const adapter = registry.findAdapter(place.adapter);
628
+ if (!adapter || typeof adapter.readCard !== "function") return null;
629
+ const card = await adapter.readCard(place.channelId, rec.messageId);
630
+ return card?.state || null;
631
+ };
632
+ const tick = async () => {
633
+ await pumpDecisions({ send, remind, updateCard }).catch((e) => console.error("[cards] decision pump failed:", e.message));
634
+ await remindOpenDecisions({ send: remind, readState }).catch((e) => console.error("[cards] decision reminders failed:", e.message));
635
+ };
366
636
  pumpTimer = setInterval(tick, intervalMs);
367
637
  if (pumpTimer.unref) pumpTimer.unref();
368
638
  const first = setTimeout(tick, 30 * 1000);
@@ -377,6 +647,14 @@ module.exports = {
377
647
  recordSentCard,
378
648
  getCard,
379
649
  updateCardRecord,
650
+ noteCardUpdate,
651
+ cardIsOpen,
652
+ openDecisions,
653
+ openDecisionsPromptSection,
654
+ ageWords,
655
+ lateAnswerLine,
656
+ reminderDueAt,
657
+ remindOpenDecisions,
380
658
  defaultWake,
381
659
  describeResponse,
382
660
  handleCardResponse,
package/core/loopback.js CHANGED
@@ -234,7 +234,7 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
234
234
  }
235
235
  try {
236
236
  const view = await target.updateCard(String(payload.messageId), patch);
237
- cards.updateCardRecord(payload.messageId, { lastState: view?.state || null, ...(patch.card?.title ? { title: patch.card.title } : {}) });
237
+ cards.noteCardUpdate(payload.messageId, patch, view);
238
238
  return reply(res, 200, { ok: true, card: view });
239
239
  } catch (e) {
240
240
  return reply(res, 400, { error: e.message });
@@ -282,7 +282,11 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
282
282
  });
283
283
  let asked = [];
284
284
  try {
285
- asked = await cards.pumpDecisions({ send: (home, card) => ((registry.findAdapter(home.adapter) || own).sendCard(home.channelId, card, { threadId: home.threadId || null })) });
285
+ asked = await cards.pumpDecisions({
286
+ send: (home, card) => ((registry.findAdapter(home.adapter) || own).sendCard(home.channelId, card, { threadId: home.threadId || null })),
287
+ remind: (home, text, opts) => ((registry.findAdapter(home.adapter) || own).send(home.channelId, text, opts)),
288
+ updateCard: (messageId, patch) => (typeof own.updateCard === "function" ? own.updateCard(messageId, patch) : null),
289
+ });
286
290
  } catch (e) {
287
291
  const waiting = cards.loadDecisions().filter((x) => ["queued", "asked", "snoozed"].includes(x.status)).length;
288
292
  return reply(res, 200, { ok: true, decision: d, askedNow: false, waiting, warning: `queued, but asking failed: ${e.message}` });
@@ -540,6 +540,15 @@ function buildDynamicContextBlock(opts = {}) {
540
540
  ...parallelConversationLines(opts.state || currentState(), opts.runContext),
541
541
  ];
542
542
  if (adapter && channelId) {
543
+ // Decision cards this bot asked in this conversation that still wait: it
544
+ // closes the ones answered in words or no longer needed. Never breaks the
545
+ // prompt (a missing or broken cards.json just means none).
546
+ try {
547
+ const section = require("./cards").openDecisionsPromptSection({
548
+ adapterId: adapter.id, channelId, threadId: opts.runContext?.threadId || currentThreadId() || null,
549
+ });
550
+ if (section) lines.push("", section);
551
+ } catch (_) { /* no open decisions shown */ }
543
552
  const pending = tasksStore.pendingSummary(adapter.id, channelId);
544
553
  if (pending.length > 0) {
545
554
  const key = dedupeScope(adapter, channelId, opts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.5.13",
3
+ "version": "3.5.14",
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-codex-device-login.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-upgrade-path.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-kazee-context-media.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.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-group-status-notes.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-call-speech.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 && node test-codex-rollout.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-transcript-room-scope.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-read-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-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-kazee-threads.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-thread-lanes.js && node test-thread-titles.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-thread-stop.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-stream-preview-purpose.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-loopback-threads.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-transcript-threads.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-run-attest.js && node test-sandbox-probe.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-bot-endpoint.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-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-cli-owner-gates.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-watch-check-env.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 && 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-grants.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-room-provider-settings.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-store-durability.js && node test-kazee-cards.js && node test-passive-replies.js && node test-kazee-bot-loops.js && node test-placeholder-reply.js && node test-media-speech.js && node test-passive-replies-router.js && node test-loopback-cards.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 && node test-codex-rollout.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-transcript-room-scope.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-read-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-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-kazee-threads.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-thread-lanes.js && node test-thread-titles.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-thread-stop.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-stream-preview-purpose.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-loopback-threads.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-transcript-threads.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-run-attest.js && node test-sandbox-probe.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-bot-endpoint.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-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-cli-owner-gates.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-watch-check-env.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 && 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-grants.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-room-provider-settings.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-store-durability.js && node test-kazee-cards.js && node test-decision-upkeep.js && node test-passive-replies.js && node test-kazee-bot-loops.js && node test-placeholder-reply.js && node test-media-speech.js && node test-passive-replies-router.js && node test-loopback-cards.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
@@ -553,7 +553,13 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
553
553
  // words in a direct chat, so group chats never get junk "standing rule" posts,
554
554
  // and token usage alerts are off by default and never go to a group chat.
555
555
  // Canary on the sales bot only.
556
- assert.strictEqual(pkg.version, "3.5.13", "release version must remain unchanged without explicit approval");
556
+ // 3.5.14 approved by Sumeet 2026-10-05 14:56 EAT ("yep" to "Ship decisions
557
+ // inline? ... Open Claudia: release 3.5.14 to the bots ... Reply yes to ship
558
+ // all of it"): open decisions stay current (listed per conversation, closed
559
+ // when they no longer apply), one reminder in working hours only when the
560
+ // server shows the card open, and "Later" keeps the card open with a note.
561
+ // Canary on the sales bot, then the fleet.
562
+ assert.strictEqual(pkg.version, "3.5.14", "release version must remain unchanged without explicit approval");
557
563
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
558
564
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
559
565
  }