@inetafrica/open-claudia 3.5.81 → 3.5.82

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.82
4
+
5
+ 3.5.81 plus the waiting-on-you list (Sumeet 14 Nov 15:18 EAT, card 6af83e0b: "Yes, release it").
6
+
7
+ "Waiting on you" for every thread (Sumeet's 7-day trial in his Mail thread, Nov 2026: asks waiting on him went 11 -> 4; "build it into Maya for every thread"). Built on the owner's decision queue in `core/cards.js`: one shared list per home chat, each item keeping its own thread and origin conversation.
8
+
9
+ - **The card says "Waiting on you · 1 of N"** (N = queued, asked and snoozed in that chat; "Waiting on you" when alone). My pick comes first, marked "(my pick)"; the server recommendation stays.
10
+ - **🗑 Drop** beside 🎧 Tell me more and ⏰ Later: the item is `dropped`, its card closes with "Dropped.", the origin conversation is told with a quiet wake, and the next item goes out at once.
11
+ - **`decide done <id> ["what happened"]`** for what he did himself or answered in words elsewhere: `done`, its card closes with "✅ Done: …", the next goes out. `decide cancel` stays "withdrawn, no longer needed" and now also sends the next. Loopback `decide-done`; node and Rust CLI.
12
+ - **Asked 3 days ago** (`OC_DECISION_STALE_MS`): the old card closes ("Moved to a fresh card.") and a fresh card ("Asked N days ago.") offers my pick plus "Something else" (a decision card needs 2-4 options), with Later and Drop, once per item, 09:00-18:00 local. "Something else" wakes the asking conversation to ask him what he wants instead. If the server refuses it, the fresh card carries every option. The overnight reminder skips an item re-asked that day.
13
+ - **One catch-up after 24 h of him quiet** in a home chat (`OC_DECISION_CATCHUP_MS`, 09:00-21:00 local): "While you were away, N things wait on you:" with up to five items, posted in the chat itself; never twice for one quiet stretch. His messages are noted from `core/router.js` into `decisions-meta.json` (`OC_DECISIONS_META_FILE`).
14
+ - **The morning walk** (`OC_DECISION_WALK_AT`, default 09:45; `OC_DECISION_WALK=off`): once a local day, each origin conversation with items waiting over 20 h gets one quiet wake to tick off (`decide done`), cancel or leave them.
15
+ - **Quiet wake-ups** (`core/scheduler.js`, `core/runner.js`): a job with `quiet: true` posts no "Wakeup:" line, and its reply of exactly `[quiet]` is not posted.
16
+ - **`decide list [--all]`**: every open item in queue order with position, status, age, home thread and title, then "N waiting on you"; `--all` adds the last 20 settled with their outcome.
17
+ - The per-turn open-decisions section names queued items by decision id and says: did it himself or answered in words -> `decide done`; no longer needed -> `decide cancel`; information only -> tell him once, never queue it. Its budget is 700 characters (was 600).
18
+
19
+ Tests: `test-waiting-on-you.js` (new); `test-kazee-cards.js`, `test-decision-upkeep.js`, `test-placeholder-reply.js`, `test-provider-scheduler.js` and `test-rust-cli-parity.js` updated.
20
+
21
+ - `open-claudia decide ... --due YYYY-MM-DD`: an item needed by a date goes ahead of older items once it is 3 days off (soonest first), and from 2 days off it takes the place of a card out without a date; that card closes with "Back on your list: something with a deadline goes first." The card says "Needed by Wed 18 Nov."; `decide list` (Node and Rust) shows "(due Wed 18 Nov)". Found by the Mail thread on 14 Nov: the LINX vote closing Wed 18 Nov sat 23rd in line.
22
+ - A title over 140 or an option label over 80 characters is shortened with "…" and a warning, instead of failing the whole call with a 400.
23
+
3
24
  ## 3.5.81
4
25
 
5
26
  3.5.80 plus one change (Sumeet 13 Nov 18:03 EAT: personal bots may send Kazee Chat messages as their owner, as long as Chat shows the AI mark).
package/bin/cards.js CHANGED
@@ -279,23 +279,53 @@ async function runCard(args) {
279
279
  }
280
280
 
281
281
  const DECIDE_USAGE = `Usage:
282
- open-claudia decide "<question>" --option <id>="<label>" --option <id>="<label>" [--pick <id>] [--body "why it matters"] [--tag "Area"]
283
- open-claudia decide list
282
+ open-claudia decide "<question>" --option <id>="<label>" --option <id>="<label>" [--pick <id>] [--body "why it matters"] [--tag "Area"] [--due YYYY-MM-DD]
283
+ open-claudia decide list [--all]
284
+ open-claudia decide done <id> ["what happened"]
284
285
  open-claudia decide cancel <id>
285
- Owner only. Decisions go to your Kazee chat as cards, one at a time; the answer
286
+ Owner only. Decisions wait on you in your Kazee chat, one card at a time
287
+ ("Waiting on you · 1 of N"), until you answer, snooze or drop them; the answer
286
288
  wakes the conversation that queued it. "Later" snoozes it for 3 hours, "Tell me
287
- more" asks this conversation for a short voice explainer.`;
289
+ more" asks this conversation for a short voice explainer, "Drop" settles it
290
+ without doing it. done: you did it yourself or answered in words elsewhere.
291
+ --due: needed by that day; moves up within 3 days, and within 2 takes the
292
+ place of a card out without a date. cancel: withdrawn, no longer needed. list --all adds the last 20 settled.`;
293
+
294
+ /** "2026-11-18" -> "Wed 18 Nov". */
295
+ function dueLabel(ymd) {
296
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(ymd || ""))) return "";
297
+ const [y, m, d] = String(ymd).split("-").map(Number);
298
+ if (m < 1 || m > 12) return "";
299
+ const t = new Date(Date.UTC(y, m - 1, d));
300
+ return `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][t.getUTCDay()]} ${d} ${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]}`;
301
+ }
288
302
 
289
303
  async function runDecide(args) {
290
304
  const first = args[0];
291
305
  try {
292
306
  if (first === "list") {
293
- const res = await postJson("decide-list", {});
294
- if (!res.decisions.length) return console.log("No decisions queued.");
295
- for (const d of res.decisions) {
296
- const extra = d.status === "decided" ? ` -> ${d.answerLabel}` : d.status === "snoozed" ? ` until ${d.notBefore}` : "";
297
- console.log(`${d.id} ${d.status.padEnd(9)} ${d.title}${extra}`);
307
+ const all = args.includes("--all");
308
+ const res = await postJson("decide-list", { all: all || undefined });
309
+ const items = res.decisions;
310
+ if (!Array.isArray(items)) throw new Error("the bot's answer has no list of decisions");
311
+ const lines = [];
312
+ for (const [i, d] of items.entries()) {
313
+ const until = d.status === "snoozed" && d.notBefore ? ` (until ${d.notBefore})` : "";
314
+ const due = typeof d.due === "string" && dueLabel(d.due) ? ` (due ${dueLabel(d.due)})` : "";
315
+ const extra = `${due}${until}`;
316
+ lines.push(`${i + 1}. ${d.id} ${d.status.padEnd(8)} ${d.age.padEnd(10)} ${d.thread.padEnd(8)} ${d.title}${extra}`);
317
+ }
318
+ lines.push(`${items.length} waiting on you`);
319
+ if (all && Array.isArray(res.settled) && res.settled.length) {
320
+ lines.push("Last settled:");
321
+ for (const d of res.settled) lines.push(`${d.id} ${d.status.padEnd(9)} ${d.title}${d.outcome ? ` -> ${d.outcome}` : ""}`);
298
322
  }
323
+ for (const line of lines) console.log(line);
324
+ return;
325
+ }
326
+ if (first === "done") {
327
+ const res = await postJson("decide-done", { id: args[1], note: args[2] });
328
+ console.log(`Done ${res.decision.id}: ${res.decision.title}`);
299
329
  return;
300
330
  }
301
331
  if (first === "cancel") {
@@ -318,7 +348,8 @@ async function runDecide(args) {
318
348
  const pick = flag(rest, "--pick");
319
349
  const body = flag(rest, "--body");
320
350
  const tag = flag(rest, "--tag");
321
- const res = await postJson("decide-add", { title, options, pick, body, tag });
351
+ const due = flag(rest, "--due");
352
+ const res = await postJson("decide-add", { title, options, pick, body, tag, due });
322
353
  const where = res.askedNow ? "asked now" : `queued (${res.waiting} open)`;
323
354
  console.log(`Decision ${res.decision.id} ${where}: ${res.decision.title}${res.warning ? `\n${res.warning}` : ""}`);
324
355
  } catch (e) {
package/bin/cli.js CHANGED
@@ -696,7 +696,7 @@ Send tools (only work inside an active bot-spawned task):
696
696
  open-claudia card send <spec|-> Send a Kazee Card (decision, choice, rating, slider, form, report); answers wake this conversation
697
697
  open-claudia card pieces List the pieces a card is built from (table, tick, date, slots, rows, board...) and their examples
698
698
  open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <name> for specs)
699
- open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
699
+ open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (waits until answered, snoozed or dropped; decide list|done|cancel)
700
700
  open-claudia view send <page.html> --title "<t>" [--handler <file>] Send an interactive web page (Kazee Chat); view push|example
701
701
  open-claudia teach list|show|finish|cancel <id> Browser recordings from Teach me; finish deletes the pictures, cancel the whole recording
702
702
 
package/core/cards.js CHANGED
@@ -31,6 +31,12 @@ const { atomicWriteFileSync } = require("./fsutil");
31
31
 
32
32
  const CARDS_FILE = () => process.env.OC_CARDS_FILE || path.join(CONFIG_DIR, "cards.json");
33
33
  const DECISIONS_FILE = () => process.env.OC_DECISIONS_FILE || path.join(CONFIG_DIR, "decisions.json");
34
+ // Beside decisions.json: per home chat, when the owner last wrote there and
35
+ // when he last got a catch-up; the local day of the last morning walk.
36
+ const DECISIONS_META_FILE = () => process.env.OC_DECISIONS_META_FILE || path.join(path.dirname(DECISIONS_FILE()), "decisions-meta.json");
37
+ // Waiting on him: until he answers, snoozes, drops or does it himself.
38
+ const OPEN_STATUSES = ["queued", "asked", "snoozed"];
39
+ const isOpenDecision = (d) => !!d && OPEN_STATUSES.includes(d.status);
34
40
  const CARD_KEEP_MS = 45 * 24 * 60 * 60 * 1000;
35
41
  const CARD_KEEP_MAX = 2000;
36
42
  const SNOOZE_MS = Number(process.env.OC_DECISION_SNOOZE_MS || 3 * 60 * 60 * 1000);
@@ -46,7 +52,29 @@ const LATE_ANSWER_MS = 6 * HOUR_MS;
46
52
  const PROMPT_DECISIONS_MAX = 5;
47
53
  const PROMPT_TITLE_MAX = 80;
48
54
  const PROMPT_TITLE_MIN = 20;
49
- const PROMPT_SECTION_MAX = 600;
55
+ // 700: room for the line that information is told once, never queued.
56
+ const PROMPT_SECTION_MAX = 700;
57
+ // Waiting on you (Sumeet's 7-day trial, Nov 2026): a card asked 3+ days ago is
58
+ // asked again once on a fresh card; one catch-up after 24 h of him quiet; a
59
+ // morning walk re-checks what still applies.
60
+ const STALE_MS = () => Number(process.env.OC_DECISION_STALE_MS) || 3 * DAY_MS;
61
+ const STALE_FIRST_HOUR = 9;
62
+ const STALE_LAST_HOUR = 18;
63
+ const CATCHUP_MS = () => Number(process.env.OC_DECISION_CATCHUP_MS) || DAY_MS;
64
+ const CATCHUP_FIRST_HOUR = 9;
65
+ const CATCHUP_LAST_HOUR = 21;
66
+ const CATCHUP_LINES = 5;
67
+ const WALK_MIN_AGE_MS = 20 * HOUR_MS;
68
+ const WALK_WINDOW_MS = 8 * HOUR_MS;
69
+ const PICK_MARK = " (my pick)";
70
+ const OPTION_LABEL_MAX = 80;
71
+ // An item needed by a date (decide --due YYYY-MM-DD, local day): within
72
+ // DUE_SOON_DAYS it goes ahead of older items; within DUE_URGENT_DAYS it also
73
+ // takes the place of a card out without a date (Mail thread, 14 Nov: the LINX
74
+ // vote closing Wed 18 Nov sat 23rd in line, one card at a time).
75
+ const DUE_SOON_DAYS = 3;
76
+ const DUE_URGENT_DAYS = 2;
77
+ const DUE_RE = /^\d{4}-\d{2}-\d{2}$/;
50
78
  const timeZone = () => process.env.OC_TIMEZONE || "Africa/Nairobi";
51
79
 
52
80
  function readJson(file, fallback) {
@@ -173,7 +201,12 @@ function openDecisions({ adapterId = null, channelId, threadId = null, now = Dat
173
201
  return Object.values(loadCards())
174
202
  .filter((c) => c && c.kind === "decision" && !c.answeredAt && cardIsOpen(c.lastState))
175
203
  .filter((c) => now - Date.parse(c.sentAt || 0) <= OPEN_DECISION_MAX_AGE_MS)
176
- .filter((c) => !c.decisionId || !queue.has(c.decisionId) || ["queued", "asked", "snoozed"].includes(queue.get(c.decisionId).status))
204
+ .filter((c) => {
205
+ if (!c.decisionId || !queue.has(c.decisionId)) return true;
206
+ const d = queue.get(c.decisionId);
207
+ // A decision asked again on a fresh card: only that card is open.
208
+ return isOpenDecision(d) && (!d.askedMessageId || String(d.askedMessageId) === String(c.messageId));
209
+ })
177
210
  .filter((c) => {
178
211
  if (channelId === undefined || channelId === null) return true;
179
212
  const p = placeOf(c);
@@ -213,10 +246,17 @@ function openDecisionsPromptSection({ adapterId = null, channelId, threadId = nu
213
246
  const shown = open.slice(0, PROMPT_DECISIONS_MAX);
214
247
  const head = "## Open decisions you asked here";
215
248
  const more = open.length > shown.length ? [`- and ${open.length - shown.length} more`] : [];
216
- 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.";
217
- const line = (c, title) => `- [${c.messageId}] "${title}" — asked ${ageWords(now - Date.parse(c.sentAt))} ago`;
249
+ const queued = shown.some((c) => c.decisionId);
250
+ const loose = shown.some((c) => !c.decisionId);
251
+ const tail = [
252
+ queued ? "A [dec_…] item waits on Sumeet until settled: if he did it himself or answered in words, `open-claudia decide done <id> \"<what happened>\"`; if it is no longer needed, `open-claudia decide cancel <id>`." : "",
253
+ loose ? "If the latest message answers one, or it no longer applies, close it: `open-claudia card update <messageId> --close --note \"<why, in plain words>\"`." : "",
254
+ "Information only, nothing to decide: tell him once, never queue it. Never leave an answered or moot decision open.",
255
+ ].filter(Boolean).join(" ");
256
+ const line = (c, title) => `- [${c.decisionId || c.messageId}] "${title}" — asked ${ageWords(now - Date.parse(c.sentAt))} ago`;
218
257
  const fixed = [head, ...shown.map((c) => line(c, "")), ...more, tail].join("\n").length;
219
- const titleMax = Math.max(PROMPT_TITLE_MIN, Math.min(PROMPT_TITLE_MAX, Math.floor((PROMPT_SECTION_MAX - fixed) / shown.length)));
258
+ const budget = PROMPT_SECTION_MAX + (queued ? 200 : 0);
259
+ const titleMax = Math.max(PROMPT_TITLE_MIN, Math.min(PROMPT_TITLE_MAX, Math.floor((budget - fixed) / shown.length)));
220
260
  return [head, ...shown.map((c) => line(c, shortTitle(c.title, titleMax))), ...more, tail].join("\n");
221
261
  }
222
262
 
@@ -459,7 +499,9 @@ function describeResponse(rec, payload, now = Date.now()) {
459
499
 
460
500
  // `source` tags the job for job lists and audits: "card" for card answers,
461
501
  // "browser" for the Browser app's hand-backs and Teach-me recordings.
462
- function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500, source = "card" } = {}) {
502
+ // `quiet`: the job posts no "Wakeup:" line and a reply of exactly [quiet] is
503
+ // not posted (core/scheduler.js, core/runner.js).
504
+ function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500, source = "card", quiet = false } = {}) {
463
505
  if (!origin || !origin.channelId || !origin.provider || !origin.project || !origin.canonicalUserId) {
464
506
  throw new Error("the card's origin conversation is unknown");
465
507
  }
@@ -487,6 +529,7 @@ function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500, source =
487
529
  prompt,
488
530
  label: String(label).slice(0, 60),
489
531
  source,
532
+ ...(quiet ? { quiet: true } : {}),
490
533
  fireAt: Date.now() + delayMs,
491
534
  });
492
535
  }
@@ -530,8 +573,9 @@ function loadDecisions() {
530
573
  }
531
574
  function saveDecisions(list) {
532
575
  // Keep every open decision and the last 200 settled ones.
533
- const open = list.filter((d) => ["queued", "asked", "snoozed"].includes(d.status));
534
- const settled = list.filter((d) => !["queued", "asked", "snoozed"].includes(d.status)).slice(-200);
576
+ // Settled: decided, dropped, done, cancelled.
577
+ const open = list.filter(isOpenDecision);
578
+ const settled = list.filter((d) => !isOpenDecision(d)).slice(-200);
535
579
  writeJson(DECISIONS_FILE(), [...open, ...settled].sort((a, b) => a.seq - b.seq));
536
580
  }
537
581
 
@@ -551,6 +595,7 @@ function addDecision(input) {
551
595
  options: input.options,
552
596
  pick: input.pick || null,
553
597
  tag: input.tag || null,
598
+ due: input.due && DUE_RE.test(String(input.due)) ? String(input.due) : null,
554
599
  home: input.home, // { adapter, adapterType, channelId, threadId, ownerKazeeUserId }
555
600
  origin: input.origin, // run context to wake with the answer
556
601
  createdAt: new Date().toISOString(),
@@ -560,23 +605,122 @@ function addDecision(input) {
560
605
  return d;
561
606
  }
562
607
 
563
- function decisionCard(d, position, total) {
608
+ function pickLabel(label) {
609
+ const l = String(label || "");
610
+ if (/\bmy pick\b/i.test(l)) return l;
611
+ const room = OPTION_LABEL_MAX - PICK_MARK.length;
612
+ return `${l.length > room ? `${l.slice(0, room - 1)}…` : l}${PICK_MARK}`;
613
+ }
614
+
615
+ /**
616
+ * The card for one item on the owner's list. `stale`: asked again after
617
+ * days unanswered: "Asked N days ago." on top, my pick + Something else (all options
618
+ * when there is none, or with `allOptions`), Later and Drop.
619
+ */
620
+ const SOMETHING_ELSE = "something_else";
621
+ const CARD_BODY_MAX = 600;
622
+
623
+ function decisionCard(d, position, total, { stale = false, allOptions = false, now = Date.now() } = {}) {
624
+ const pick = d.pick && d.options.some((o) => o.id === d.pick) ? d.pick : null;
625
+ let options = pick ? [...d.options.filter((o) => o.id === pick), ...d.options.filter((o) => o.id !== pick)] : [...d.options];
626
+ // A decision card needs 2-4 options (chat-central cards.ts), so "only my
627
+ // pick" is my pick plus Something else; Later and Drop stay as extras.
628
+ if (stale && pick && !allOptions && options.length > 1) options = [options[0], { id: SOMETHING_ELSE, label: "Something else" }];
564
629
  const card = {
565
630
  v: 1,
566
631
  kind: "decision",
567
- kicker: total > 1 ? `Decision ${position} of ${total}` : "Decision",
632
+ kicker: total > 1 ? `Waiting on you · ${position} of ${total}` : "Waiting on you",
568
633
  title: d.title,
569
- input: { options: d.options.map((o, i) => ({ id: o.id, label: o.label, style: i === 0 ? "primary" : "secondary" })) },
570
- extras: [{ id: "more", label: "🎧 Tell me more" }, { id: "later", label: "⏰ Later" }],
634
+ input: { options: options.map((o, i) => ({ id: o.id, label: o.id === pick ? pickLabel(o.label) : o.label, style: i === 0 ? "primary" : "secondary" })) },
635
+ extras: [
636
+ ...(stale ? [] : [{ id: "more", label: "🎧 Tell me more" }]),
637
+ { id: "later", label: "⏰ Later" },
638
+ { id: "drop", label: "🗑 Drop" },
639
+ ],
571
640
  policy: { responders: [String(d.home.ownerKazeeUserId)], mode: "first" },
572
641
  footer: "Only you can answer",
573
642
  };
574
643
  if (d.tag) card.tag = d.tag;
575
- if (d.body) card.body = d.body;
576
- if (d.pick && d.options.some((o) => o.id === d.pick)) card.recommendation = { option: d.pick };
644
+ const since = Date.parse(d.askedAt || d.createdAt || now);
645
+ const needed = DUE_RE.test(String(d.due || "")) ? `Needed by ${dayLabel(d.due).replace(/ \d{4}$/, "")}.` : null;
646
+ const body = [stale ? `Asked ${ageWords(Math.max(0, now - since))} ago.` : null, needed, d.body].filter(Boolean).join("\n\n") || null;
647
+ // chat-central refuses a card body over 600 characters; the "Asked N days
648
+ // ago." line on a fresh card can push a full body past it (14 Nov: the
649
+ // 39-day-old card was refused on every tick).
650
+ if (body) card.body = body.length > CARD_BODY_MAX ? `${body.slice(0, CARD_BODY_MAX - 1).trimEnd()}…` : body;
651
+ if (pick) card.recommendation = { option: pick };
577
652
  return card;
578
653
  }
579
654
 
655
+ /** Whole local days from today to an item's due date (negative: overdue); null without one. */
656
+ function daysToDue(d, now = Date.now(), tz = timeZone()) {
657
+ const m = DUE_RE.test(String(d?.due || "")) ? String(d.due).split("-").map(Number) : null;
658
+ if (!m) return null;
659
+ const p = zonedParts(now, tz);
660
+ return Math.round((Date.UTC(m[0], m[1] - 1, m[2]) - Date.UTC(p.year, p.month - 1, p.day)) / DAY_MS);
661
+ }
662
+ const dueSoon = (d, now, tz) => { const n = daysToDue(d, now, tz); return n !== null && n <= DUE_SOON_DAYS; };
663
+ const dueUrgent = (d, now, tz) => { const n = daysToDue(d, now, tz); return n !== null && n <= DUE_URGENT_DAYS; };
664
+ /** Among items waiting to go out: due soon first (soonest date first), then oldest first. */
665
+ function byDueThenSeq(now, tz) {
666
+ return (a, b) => {
667
+ const sa = dueSoon(a, now, tz), sb = dueSoon(b, now, tz);
668
+ if (sa !== sb) return sa ? -1 : 1;
669
+ if (sa && a.due !== b.due) return a.due < b.due ? -1 : 1;
670
+ return (a.seq || 0) - (b.seq || 0);
671
+ };
672
+ }
673
+
674
+ /** Close an undated card that a deadline pushed aside; the item waits again. */
675
+ const PUT_BACK_NOTE = "⏰ Back on your list: something with a deadline goes first.";
676
+ async function putBack(d, { updateCard = null } = {}) {
677
+ const fresh = loadDecisions();
678
+ const target = fresh.find((x) => x.id === d.id);
679
+ if (!target || target.status !== "asked") return false;
680
+ const oldId = target.askedMessageId;
681
+ target.status = "queued";
682
+ target.putBackAt = new Date().toISOString();
683
+ for (const k of ["askedMessageId", "askedAt", "staleAskedAt", "oldCardId", "remindedAt", "keptCard", "notBefore"]) delete target[k];
684
+ saveDecisions(fresh);
685
+ if (oldId && updateCard) {
686
+ try {
687
+ const view = await updateCard(oldId, { status: "closed", note: PUT_BACK_NOTE });
688
+ noteCardUpdate(oldId, { status: "closed", note: PUT_BACK_NOTE }, view);
689
+ } catch (e) { console.error(`[cards] closing ${oldId} for a deadline failed:`, e.message); }
690
+ }
691
+ return true;
692
+ }
693
+
694
+ /** Queue order: what is on a card first, then snoozed, then waiting (due soon first); each oldest first. */
695
+ function queueOrder(ds, now = Date.now(), tz = timeZone()) {
696
+ const rank = { asked: 0, snoozed: 1, queued: 2 };
697
+ const order = byDueThenSeq(now, tz);
698
+ return [...ds].sort((a, b) => (rank[a.status] ?? 3) - (rank[b.status] ?? 3) || order(a, b));
699
+ }
700
+
701
+ function homeKeyOf(d) {
702
+ return `${d.home?.adapter}|${d.home?.channelId}`;
703
+ }
704
+
705
+ /** Open items per home chat (adapter|channelId), in queue order. */
706
+ function openByHome(list, homeKey = null, now = Date.now()) {
707
+ const homes = new Map();
708
+ for (const d of list) {
709
+ if (!isOpenDecision(d) || !d.home) continue;
710
+ const key = homeKeyOf(d);
711
+ if (homeKey && key !== homeKey) continue;
712
+ if (!homes.has(key)) homes.set(key, []);
713
+ homes.get(key).push(d);
714
+ }
715
+ for (const [key, ds] of homes) homes.set(key, queueOrder(ds, now));
716
+ return homes;
717
+ }
718
+
719
+ /** How long an item has waited on him: since it was queued. */
720
+ function waitedWords(d, now) {
721
+ return ageWords(Math.max(0, now - Date.parse(d.createdAt || d.askedAt || now)));
722
+ }
723
+
580
724
  /** The one reply that brings an open decision back to the top of the chat. */
581
725
  function reminderText(title) {
582
726
  return `⏳ Still waiting on you: ${title || "a decision"}`;
@@ -587,22 +731,18 @@ function reminderText(title) {
587
731
  * and returns its message id: (home, card) => Promise<string>. `remind`
588
732
  * posts a plain reply: (home, text, { threadId, replyTo }) => Promise. A
589
733
  * snoozed decision keeps its card open; when its time is up it gets one
590
- * reply on that card (not a new card) and counts as asked again.
734
+ * reply on that card (not a new card) and counts as asked again. A card
735
+ * asked STALE_MS ago and still unanswered is asked once more on a fresh card
736
+ * (09:00-18:00 local); that is the same item, not another card out.
591
737
  */
592
- async function pumpDecisions({ send, remind = null, updateCard = null, now = Date.now(), homeKey = null } = {}) {
593
- const list = loadDecisions();
594
- const homes = new Map();
595
- for (const d of list) {
596
- if (!["queued", "asked", "snoozed"].includes(d.status)) continue;
597
- const key = `${d.home.adapter}|${d.home.channelId}`;
598
- if (homeKey && key !== homeKey) continue;
599
- if (!homes.has(key)) homes.set(key, []);
600
- homes.get(key).push(d);
601
- }
738
+ async function pumpDecisions({ send, remind = null, updateCard = null, now = Date.now(), homeKey = null, tz = timeZone() } = {}) {
739
+ const homes = openByHome(loadDecisions(), homeKey, now);
602
740
  const asked = [];
741
+ const localHour = zonedParts(now, tz).hour;
603
742
  for (const [key, ds] of homes) {
604
743
  const isDue = (d) => d.status === "snoozed" && Date.parse(d.notBefore || 0) <= now;
605
744
  let remindedHere = false;
745
+ const remindedNow = new Set();
606
746
  for (const d of ds.filter((x) => isDue(x) && x.keptCard && x.askedMessageId)) {
607
747
  if (!remind) continue;
608
748
  try {
@@ -627,15 +767,34 @@ async function pumpDecisions({ send, remind = null, updateCard = null, now = Dat
627
767
  }
628
768
  d.status = "asked";
629
769
  remindedHere = true;
770
+ remindedNow.add(d.id);
630
771
  asked.push({ key, decisionId: d.id, messageId: d.askedMessageId, reminded: true });
631
772
  }
773
+ if (localHour >= STALE_FIRST_HOUR && localHour < STALE_LAST_HOUR) {
774
+ const stale = ds.filter((d) => d.status === "asked" && d.askedAt && d.askedMessageId && !d.staleAskedAt
775
+ && !remindedNow.has(d.id) && now - Date.parse(d.askedAt) >= STALE_MS());
776
+ for (const d of stale) {
777
+ const messageId = await askAgain(d, ds.length, { send, updateCard, now });
778
+ if (messageId) asked.push({ key, decisionId: d.id, messageId, stale: true });
779
+ }
780
+ }
781
+ // Something needed within DUE_URGENT_DAYS takes the place of a card out
782
+ // without a date: that card closes and goes back on the list, keeping
783
+ // its place by age.
784
+ const out = ds.find((d) => d.status === "asked");
785
+ const urgent = ds.find((d) => d.status === "queued" && dueUrgent(d, now, tz));
786
+ if (out && urgent && !remindedHere && !dueSoon(out, now, tz) && !remindedNow.has(out.id)
787
+ && !asked.some((x) => x.decisionId === out.id)) {
788
+ if (await putBack(out, { updateCard })) out.status = "queued";
789
+ }
632
790
  if (remindedHere || ds.some((d) => d.status === "asked")) continue;
633
791
  // A decision snoozed before cards stayed open had its card closed: ask it again as a new card.
634
792
  const due = ds.filter((d) => d.status === "queued" || (isDue(d) && !d.keptCard));
635
793
  if (!due.length) continue;
636
- const next = due.sort((a, b) => (a.status === "queued") - (b.status === "queued") || a.seq - b.seq)[0];
637
- const open = ds.filter((d) => d.status !== "snoozed" || d === next).length;
638
- const card = decisionCard(next, 1, open);
794
+ const order = byDueThenSeq(now, tz);
795
+ const next = due.sort((a, b) => (dueUrgent(b, now, tz) - dueUrgent(a, now, tz)) || (a.status === "queued") - (b.status === "queued") || order(a, b))[0];
796
+ // N counts every open item in this home chat: on a card, snoozed or waiting.
797
+ const card = decisionCard(next, 1, ds.length, { now });
639
798
  const messageId = await send(next.home, card);
640
799
  if (!messageId) continue;
641
800
  const fresh = loadDecisions();
@@ -659,6 +818,50 @@ async function pumpDecisions({ send, remind = null, updateCard = null, now = Dat
659
818
  return asked;
660
819
  }
661
820
 
821
+ /**
822
+ * Ask a stale item again: post the fresh card (my pick, Something else,
823
+ * Later, Drop; all options if the server refuses that), then close the old card. The
824
+ * item keeps its first askedAt, so its age and a late answer still count
825
+ * from the first ask. Returns the new card's id, or null (tried next tick).
826
+ */
827
+ async function askAgain(d, total, { send, updateCard, now }) {
828
+ let messageId = null;
829
+ try {
830
+ messageId = await send(d.home, decisionCard(d, 1, total, { stale: true, now }));
831
+ } catch (e) {
832
+ console.error(`[cards] fresh card for stale decision ${d.id} refused (${e.message}); trying with every option`);
833
+ }
834
+ if (!messageId && d.pick && d.options.some((o) => o.id === d.pick)) {
835
+ try { messageId = await send(d.home, decisionCard(d, 1, total, { stale: true, allOptions: true, now })); }
836
+ catch (e) { console.error(`[cards] fresh card for stale decision ${d.id} failed:`, e.message); }
837
+ }
838
+ if (!messageId) return null;
839
+ const fresh = loadDecisions();
840
+ const target = fresh.find((x) => x.id === d.id);
841
+ if (!target) return null;
842
+ const oldCardId = target.askedMessageId;
843
+ target.oldCardId = oldCardId;
844
+ target.askedMessageId = String(messageId);
845
+ target.staleAskedAt = new Date(now).toISOString();
846
+ saveDecisions(fresh);
847
+ recordSentCard({
848
+ messageId,
849
+ decisionId: d.id,
850
+ title: d.title,
851
+ kind: "decision",
852
+ wake: "each",
853
+ origin: d.origin,
854
+ home: d.home,
855
+ staleAsk: true,
856
+ });
857
+ if (oldCardId && updateCard) {
858
+ const patch = { status: "closed", note: "Moved to a fresh card." };
859
+ try { noteCardUpdate(oldCardId, patch, await updateCard(oldCardId, patch)); }
860
+ catch (e) { console.error(`[cards] closing the old card ${oldCardId} failed:`, e.message); }
861
+ }
862
+ return String(messageId);
863
+ }
864
+
662
865
  async function handleDecisionResponse(rec, payload, envelope, deps = {}) {
663
866
  const list = loadDecisions();
664
867
  const d = list.find((x) => x.id === rec.decisionId);
@@ -667,6 +870,27 @@ async function handleDecisionResponse(rec, payload, envelope, deps = {}) {
667
870
  const send = deps.sendCard || ((home, card) => envelope.adapter?.sendCard?.(home.channelId, card, { threadId: home.threadId || null }));
668
871
  const remind = deps.remind || ((home, text, opts) => envelope.adapter?.send?.(home.channelId, text, opts));
669
872
  const pump = () => pumpDecisions({ send, remind, updateCard, homeKey: `${d.home.adapter}|${d.home.channelId}` });
873
+ // Only he can answer these cards: a tap is him being here.
874
+ try { noteOwnerActivity({ adapter: d.home.adapter, channelId: d.home.channelId }); } catch (_) {}
875
+
876
+ if (payload.action === "drop") {
877
+ if (!isOpenDecision(d)) return { handled: true, dropped: false, reason: "already settled" };
878
+ d.status = "dropped";
879
+ d.droppedAt = new Date().toISOString();
880
+ saveDecisions(list);
881
+ const patch = { status: "closed", note: "Dropped." };
882
+ try { noteCardUpdate(rec.messageId, patch, await updateCard(rec.messageId, patch)); } catch (e) { console.error("[cards] closing a dropped card failed:", e.message); }
883
+ try {
884
+ wakeOrigin(
885
+ d.origin,
886
+ `[Decision ${d.id}] Sumeet dropped "${d.title}". Don't raise it again unless something changes. If nothing needs saying, reply exactly [quiet].`,
887
+ `Dropped: ${d.title}`,
888
+ { ...deps, quiet: true },
889
+ );
890
+ } catch (e) { console.error(`[cards] dropped decision ${d.id}: origin not told:`, e.message); }
891
+ await pump();
892
+ return { handled: true, dropped: true };
893
+ }
670
894
 
671
895
  if (payload.action === "later") {
672
896
  // The card stays open where it is (no closing it and posting a new one
@@ -702,10 +926,13 @@ async function handleDecisionResponse(rec, payload, envelope, deps = {}) {
702
926
  if (wasSnoozed) {
703
927
  try { noteCardUpdate(rec.messageId, { note: "" }, await updateCard(rec.messageId, { note: "" })); } catch (_) { /* only clears the "remind you at" note */ }
704
928
  }
705
- const late = lateAnswerLine(rec.sentAt);
929
+ // From the first ask: a stale item's fresh card is not a fresh question.
930
+ const late = lateAnswerLine([rec.sentAt, d.askedAt].filter(Boolean).sort()[0]);
706
931
  wakeOrigin(
707
932
  d.origin,
708
- `[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}` : ""}`,
933
+ payload.answer.option === SOMETHING_ELSE && !d.options.some((o) => o.id === SOMETHING_ELSE)
934
+ ? `[Decision ${d.id}] On "${d.title}" Sumeet tapped "Something else": not your pick (${(d.options.find((o) => o.id === d.pick) || {}).label || d.pick}). Ask him in one short voice note what he wants instead, then carry on with his answer.${late ? `\n${late}` : ""}`
935
+ : `[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}` : ""}`,
709
936
  `Decided: ${d.title}`,
710
937
  deps,
711
938
  );
@@ -719,7 +946,7 @@ async function cancelDecision(id, { updateCard } = {}) {
719
946
  const list = loadDecisions();
720
947
  const d = list.find((x) => x.id === id);
721
948
  if (!d) return null;
722
- if (!["queued", "asked", "snoozed"].includes(d.status)) return d;
949
+ if (!isOpenDecision(d)) return d;
723
950
  const cardOpen = d.status === "asked" || (d.status === "snoozed" && d.keptCard);
724
951
  d.status = "cancelled";
725
952
  d.cancelledAt = new Date().toISOString();
@@ -731,6 +958,195 @@ async function cancelDecision(id, { updateCard } = {}) {
731
958
  return d;
732
959
  }
733
960
 
961
+ /**
962
+ * He did it himself, or answered in words elsewhere: tick it off. Its open
963
+ * card is closed with what happened; with `send`, the next item in that
964
+ * home chat goes out at once.
965
+ */
966
+ async function doneDecision(id, note, { updateCard = null, send = null, remind = null, now = Date.now() } = {}) {
967
+ const list = loadDecisions();
968
+ const d = list.find((x) => x.id === id);
969
+ if (!d) return null;
970
+ if (!isOpenDecision(d)) return d;
971
+ const cardOpen = d.status === "asked" || (d.status === "snoozed" && d.keptCard);
972
+ const what = note == null ? "" : String(note).replace(/\s+/g, " ").trim().slice(0, 300);
973
+ d.status = "done";
974
+ d.doneAt = new Date(now).toISOString();
975
+ d.doneNote = what || null;
976
+ saveDecisions(list);
977
+ if (cardOpen && d.askedMessageId && updateCard) {
978
+ const patch = { status: "closed", note: what ? `✅ Done: ${what}` : "✅ Done" };
979
+ try { noteCardUpdate(d.askedMessageId, patch, await updateCard(d.askedMessageId, patch)); } catch (e) { console.error(`[cards] closing done decision ${d.id}'s card failed:`, e.message); }
980
+ }
981
+ if (send && d.home) {
982
+ try { await pumpDecisions({ send, remind, updateCard, now, homeKey: homeKeyOf(d) }); }
983
+ catch (e) { console.error("[cards] asking the next decision failed:", e.message); }
984
+ }
985
+ return d;
986
+ }
987
+
988
+ /**
989
+ * The owner's list for `decide list`: every open item in queue order (home
990
+ * by home) with how long it has waited and its home thread (short), and
991
+ * with `all` the last 20 settled with their outcome.
992
+ */
993
+ function listDecisions({ all = false, now = Date.now() } = {}) {
994
+ const list = loadDecisions();
995
+ const open = [...openByHome(list, null, now).values()].flat().map((d) => ({
996
+ ...d,
997
+ age: waitedWords(d, now),
998
+ thread: d.home?.threadId ? String(d.home.threadId).slice(-6) : "main",
999
+ }));
1000
+ const res = { decisions: open, waiting: open.length };
1001
+ if (all) {
1002
+ const at = (d) => Date.parse(d.decidedAt || d.droppedAt || d.doneAt || d.cancelledAt || d.createdAt || 0) || 0;
1003
+ const outcome = (d) => (d.status === "decided" ? (d.answerLabel || "decided")
1004
+ : d.status === "done" ? (d.doneNote ? `done: ${d.doneNote}` : "done")
1005
+ : d.status === "cancelled" ? "withdrawn" : d.status);
1006
+ res.settled = list.filter((d) => !isOpenDecision(d))
1007
+ .sort((a, b) => at(b) - at(a))
1008
+ .slice(0, 20)
1009
+ .map((d) => ({ ...d, outcome: outcome(d) }));
1010
+ }
1011
+ return res;
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // When he was last here, per home chat (decisions-meta.json)
1016
+
1017
+ function loadDecisionsMeta() {
1018
+ const m = readJson(DECISIONS_META_FILE(), {});
1019
+ const meta = m && typeof m === "object" && !Array.isArray(m) ? m : {};
1020
+ return { ...meta, homes: meta.homes && typeof meta.homes === "object" && !Array.isArray(meta.homes) ? meta.homes : {} };
1021
+ }
1022
+ function saveDecisionsMeta(meta) {
1023
+ writeJson(DECISIONS_META_FILE(), meta);
1024
+ }
1025
+
1026
+ function updateHomeMeta(key, patch) {
1027
+ const meta = loadDecisionsMeta();
1028
+ meta.homes[key] = { ...(meta.homes[key] || {}), ...patch };
1029
+ saveDecisionsMeta(meta);
1030
+ return meta.homes[key];
1031
+ }
1032
+
1033
+ /** The owner wrote (or tapped a card) in this chat: a new quiet stretch starts from here. */
1034
+ function noteOwnerActivity({ adapter, channelId, at = Date.now() } = {}) {
1035
+ if (!adapter || !channelId) return null;
1036
+ const key = `${adapter}|${channelId}`;
1037
+ const meta = loadDecisionsMeta();
1038
+ const cur = meta.homes[key] || {};
1039
+ const t = typeof at === "number" ? at : Date.parse(at);
1040
+ if (!Number.isFinite(t) || (cur.lastOwnerAt && Date.parse(cur.lastOwnerAt) >= t)) return cur;
1041
+ return updateHomeMeta(key, { lastOwnerAt: new Date(t).toISOString() });
1042
+ }
1043
+
1044
+ /**
1045
+ * Every inbound message (core/router.js): when it is the owner writing in a
1046
+ * chat his decisions are asked in, note it. Another bot's message is not him.
1047
+ */
1048
+ function noteInboundForDecisions(envelope, now = Date.now()) {
1049
+ if (!envelope || envelope.botHandoff) return false;
1050
+ const adapter = envelope.adapter?.id;
1051
+ const channelId = envelope.channelId == null ? "" : String(envelope.channelId);
1052
+ const userId = envelope.userId == null ? "" : String(envelope.userId);
1053
+ if (!adapter || !channelId || !userId) return false;
1054
+ const home = loadDecisions().some((d) => d.home && d.home.adapter === adapter
1055
+ && String(d.home.channelId) === channelId && String(d.home.ownerKazeeUserId || "") === userId);
1056
+ if (!home) return false;
1057
+ noteOwnerActivity({ adapter, channelId, at: now });
1058
+ return true;
1059
+ }
1060
+
1061
+ /**
1062
+ * One catch-up message per quiet stretch: when items wait in a home chat and
1063
+ * he has not written there for CATCHUP_MS, post one plain message in the chat
1064
+ * itself (no thread), 09:00-21:00 local. A chat never seen starts its clock.
1065
+ * `send(home, text, { threadId })`.
1066
+ */
1067
+ async function catchUpOwner({ send, now = Date.now(), tz = timeZone() } = {}) {
1068
+ const homes = openByHome(loadDecisions());
1069
+ if (!homes.size) return [];
1070
+ const localHour = zonedParts(now, tz).hour;
1071
+ const meta = loadDecisionsMeta();
1072
+ const out = [];
1073
+ for (const [key, ds] of homes) {
1074
+ const h = meta.homes[key] || {};
1075
+ if (!h.lastOwnerAt) {
1076
+ updateHomeMeta(key, { lastOwnerAt: new Date(now).toISOString() });
1077
+ continue;
1078
+ }
1079
+ const last = Date.parse(h.lastOwnerAt);
1080
+ if (now - last < CATCHUP_MS()) continue;
1081
+ if (localHour < CATCHUP_FIRST_HOUR || localHour >= CATCHUP_LAST_HOUR) continue;
1082
+ if (h.catchupAt && Date.parse(h.catchupAt) >= last) continue;
1083
+ // Claimed before sending: never twice for one quiet stretch.
1084
+ updateHomeMeta(key, { catchupAt: new Date(now).toISOString() });
1085
+ const n = ds.length;
1086
+ const text = [
1087
+ `While you were away, ${n === 1 ? "1 thing waits" : `${n} things wait`} on you:`,
1088
+ ...ds.slice(0, CATCHUP_LINES).map((d, i) => `${i + 1}. ${shortTitle(d.title, 70)} (${waitedWords(d, now)})`),
1089
+ "The first one is on its card.",
1090
+ ].join("\n");
1091
+ try {
1092
+ await send({ ...ds[0].home, threadId: null }, text, { threadId: null });
1093
+ out.push(key);
1094
+ } catch (e) {
1095
+ console.error(`[cards] catch-up for ${key} failed:`, e.message);
1096
+ }
1097
+ }
1098
+ return out;
1099
+ }
1100
+
1101
+ function walkAt() {
1102
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(process.env.OC_DECISION_WALK_AT || "09:45").trim());
1103
+ const h = m ? Number(m[1]) : 9;
1104
+ const min = m ? Number(m[2]) : 45;
1105
+ return h < 24 && min < 60 ? { h, min } : { h: 9, min: 45 };
1106
+ }
1107
+
1108
+ /**
1109
+ * The morning walk: once a local day, from OC_DECISION_WALK_AT (09:45) for
1110
+ * a few hours (a restart at night never walks), each origin conversation
1111
+ * with items waiting over 20 h gets one quiet wake to re-check them: done,
1112
+ * cancel, or leave. The day is recorded first, so restarts never walk twice.
1113
+ */
1114
+ async function morningWalk({ scheduler = null, now = Date.now(), tz = timeZone() } = {}) {
1115
+ if (String(process.env.OC_DECISION_WALK || "").trim().toLowerCase() === "off") return [];
1116
+ const p = zonedParts(now, tz);
1117
+ const { h, min } = walkAt();
1118
+ const start = zonedTime(p.year, p.month, p.day, h, tz) + min * 60 * 1000;
1119
+ if (now < start || now >= start + WALK_WINDOW_MS) return [];
1120
+ const today = `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
1121
+ const meta = loadDecisionsMeta();
1122
+ if (meta.walkedOn === today) return [];
1123
+ meta.walkedOn = today;
1124
+ saveDecisionsMeta(meta);
1125
+ const groups = new Map();
1126
+ for (const d of [...openByHome(loadDecisions()).values()].flat()) {
1127
+ if (!d.origin || now - Date.parse(d.createdAt || now) <= WALK_MIN_AGE_MS) continue;
1128
+ const key = `${d.origin.project || ""}|${d.origin.threadId || ""}`;
1129
+ if (!groups.has(key)) groups.set(key, []);
1130
+ groups.get(key).push(d);
1131
+ }
1132
+ const woke = [];
1133
+ let i = 0;
1134
+ for (const [key, ds] of groups) {
1135
+ const prompt = [
1136
+ "Morning check of what's waiting on Sumeet from this conversation.",
1137
+ ...ds.map((d) => `- ${d.id} · ${shortTitle(d.title, 100)} · asked ${waitedWords(d, now)} ago`),
1138
+ "For each item: if he did it himself or it was settled elsewhere, close it with open-claudia decide done <id> \"<what happened>\"; if it no longer applies, decide cancel <id>; otherwise leave it. Don't message him about this check. If nothing changed, reply exactly [quiet].",
1139
+ ].join("\n");
1140
+ try {
1141
+ wakeOrigin(ds[0].origin, prompt, "Morning check: waiting on Sumeet", { scheduler, quiet: true, delayMs: 1500 + 30 * 1000 * i++ });
1142
+ woke.push(key);
1143
+ } catch (e) {
1144
+ console.error(`[cards] morning walk for ${key} failed:`, e.message);
1145
+ }
1146
+ }
1147
+ return woke;
1148
+ }
1149
+
734
1150
  // ---------------------------------------------------------------------------
735
1151
  // One reminder for a decision left waiting overnight
736
1152
 
@@ -754,6 +1170,12 @@ function zonedTime(year, month, day, hour, tz) {
754
1170
  return guess - offset;
755
1171
  }
756
1172
 
1173
+ function sameLocalDay(a, b, tz) {
1174
+ const x = zonedParts(typeof a === "number" ? a : Date.parse(a), tz);
1175
+ const y = zonedParts(b, tz);
1176
+ return x.year === y.year && x.month === y.month && x.day === y.day;
1177
+ }
1178
+
757
1179
  /** The first 09:00 local at least 8 hours after the decision was asked. */
758
1180
  function reminderDueAt(sentAt, tz = timeZone()) {
759
1181
  const earliest = Date.parse(sentAt) + REMIND_MIN_WAIT_MS;
@@ -780,7 +1202,9 @@ async function remindOpenDecisions({ send, readState = null, now = Date.now(), t
780
1202
  && now - Date.parse(c.sentAt) <= REMIND_MAX_AGE_MS
781
1203
  && now >= reminderDueAt(c.sentAt, tz)
782
1204
  // a snoozed decision comes back through the queue at its own time
783
- && !(c.decisionId && queue.get(c.decisionId)?.status === "snoozed"));
1205
+ && !(c.decisionId && queue.get(c.decisionId)?.status === "snoozed")
1206
+ // asked again on a fresh card today: that was today's nudge
1207
+ && !(c.decisionId && queue.get(c.decisionId)?.staleAskedAt && sameLocalDay(queue.get(c.decisionId).staleAskedAt, now, tz)));
784
1208
  const reminded = [];
785
1209
  for (const c of due) {
786
1210
  if (readState) {
@@ -836,6 +1260,8 @@ function startDecisionPump(registry, intervalMs = 5 * 60 * 1000) {
836
1260
  const tick = async () => {
837
1261
  await pumpDecisions({ send, remind, updateCard }).catch((e) => console.error("[cards] decision pump failed:", e.message));
838
1262
  await remindOpenDecisions({ send: remind, readState }).catch((e) => console.error("[cards] decision reminders failed:", e.message));
1263
+ await catchUpOwner({ send: remind }).catch((e) => console.error("[cards] catch-up failed:", e.message));
1264
+ await morningWalk({}).catch((e) => console.error("[cards] morning walk failed:", e.message));
839
1265
  };
840
1266
  pumpTimer = setInterval(tick, intervalMs);
841
1267
  if (pumpTimer.unref) pumpTimer.unref();
@@ -845,6 +1271,8 @@ function startDecisionPump(registry, intervalMs = 5 * 60 * 1000) {
845
1271
  }
846
1272
 
847
1273
  module.exports = {
1274
+ queueOrder,
1275
+ daysToDue,
848
1276
  CARDS_FILE,
849
1277
  startDecisionPump,
850
1278
  DECISIONS_FILE,
@@ -874,4 +1302,13 @@ module.exports = {
874
1302
  pumpDecisions,
875
1303
  handleDecisionResponse,
876
1304
  cancelDecision,
1305
+ doneDecision,
1306
+ listDecisions,
1307
+ DECISIONS_META_FILE,
1308
+ loadDecisionsMeta,
1309
+ noteOwnerActivity,
1310
+ noteInboundForDecisions,
1311
+ catchUpOwner,
1312
+ morningWalk,
1313
+ OPEN_STATUSES,
877
1314
  };
package/core/loopback.js CHANGED
@@ -107,7 +107,7 @@ const JSON_KINDS = new Set([
107
107
  "approval-request",
108
108
  "tool-gate",
109
109
  "say", "call-say", "call", "tts", "transcribe",
110
- "card-send", "card-update", "card-show", "decide-add", "decide-list", "decide-cancel",
110
+ "card-send", "card-update", "card-show", "decide-add", "decide-list", "decide-cancel", "decide-done",
111
111
  "owner-setup",
112
112
  "view-send", "view-push",
113
113
  ]);
@@ -254,14 +254,30 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
254
254
 
255
255
  // Decisions: the owner's own queue, asked one at a time in this room.
256
256
  if (caller.owner !== true) return reply(res, 403, { error: "owner only" });
257
+ const decisionDeps = {
258
+ send: (home, card) => ((registry.findAdapter(home.adapter) || own).sendCard(home.channelId, card, { threadId: home.threadId || null })),
259
+ remind: (home, text, opts) => ((registry.findAdapter(home.adapter) || own).send(home.channelId, text, opts)),
260
+ updateCard: typeof own.updateCard === "function" ? (messageId, patch) => own.updateCard(messageId, patch) : null,
261
+ };
257
262
  if (kind === "decide-list") {
258
- const all = cards.loadDecisions();
259
- return reply(res, 200, { ok: true, decisions: all.filter((d) => ["queued", "asked", "snoozed"].includes(d.status) || Date.now() - Date.parse(d.decidedAt || d.cancelledAt || 0) < 7 * 86400000) });
263
+ return reply(res, 200, { ok: true, ...cards.listDecisions({ all: payload.all === true }) });
260
264
  }
261
265
  if (kind === "decide-cancel") {
262
- const target = own;
263
- const d = await cards.cancelDecision(String(payload.id || ""), {
264
- updateCard: typeof target.updateCard === "function" ? (id, patch) => target.updateCard(id, patch) : null,
266
+ const d = await cards.cancelDecision(String(payload.id || ""), { updateCard: decisionDeps.updateCard });
267
+ if (!d) return reply(res, 404, { error: "no such decision" });
268
+ // Withdrawn: the next item in that chat goes out at once.
269
+ if (d.home && typeof own.sendCard === "function") {
270
+ await cards.pumpDecisions({ ...decisionDeps, homeKey: `${d.home.adapter}|${d.home.channelId}` }).catch((e) => console.error("[loopback] asking the next decision failed:", e.message));
271
+ }
272
+ return reply(res, 200, { ok: true, decision: d });
273
+ }
274
+ if (kind === "decide-done") {
275
+ // He did it himself, or answered in words elsewhere.
276
+ const note = payload.note === undefined || payload.note === null ? null : String(payload.note);
277
+ const d = await cards.doneDecision(String(payload.id || ""), note, {
278
+ updateCard: decisionDeps.updateCard,
279
+ send: typeof own.sendCard === "function" ? decisionDeps.send : null,
280
+ remind: decisionDeps.remind,
265
281
  });
266
282
  if (!d) return reply(res, 404, { error: "no such decision" });
267
283
  return reply(res, 200, { ok: true, decision: d });
@@ -269,20 +285,34 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
269
285
  if (kind === "decide-add") {
270
286
  if (typeof own.sendCard !== "function") return reply(res, 501, { error: "decision cards need Kazee Chat" });
271
287
  if (!caller.userId) return reply(res, 400, { error: "cannot tell your Kazee user id from this turn" });
272
- const title = String(payload.title || "").trim();
273
- if (!title || title.length > 140) return reply(res, 400, { error: "a decision needs a title of at most 140 characters" });
288
+ // Too long is shortened with a warning, not refused (Mail thread, 14 Nov:
289
+ // an 81-character label failed the whole call).
290
+ const shortened = [];
291
+ const fit = (v, max, what) => {
292
+ const t = String(v || "").trim();
293
+ if (t.length <= max) return t;
294
+ shortened.push(what);
295
+ return `${t.slice(0, max - 1).trimEnd()}…`;
296
+ };
297
+ const title = fit(payload.title, 140, "the title (140 characters)");
298
+ if (!title) return reply(res, 400, { error: "a decision needs a title" });
274
299
  const options = Array.isArray(payload.options) ? payload.options : [];
275
300
  if (options.length < 2 || options.length > 4) return reply(res, 400, { error: "a decision needs 2-4 options" });
276
301
  for (const o of options) {
277
- if (!o || !DECISION_ID_RE.test(String(o.id || "")) || !String(o.label || "").trim() || String(o.label).length > 80) {
278
- return reply(res, 400, { error: "each option needs an id (letters, digits, - or _) and a label of at most 80 characters" });
302
+ if (!o || !DECISION_ID_RE.test(String(o.id || "")) || !String(o.label || "").trim()) {
303
+ return reply(res, 400, { error: "each option needs an id (letters, digits, - or _) and a label" });
279
304
  }
280
305
  }
306
+ const due = payload.due ? String(payload.due).trim() : null;
307
+ if (due && (!/^\d{4}-\d{2}-\d{2}$/.test(due) || Number.isNaN(Date.parse(`${due}T00:00:00Z`)))) {
308
+ return reply(res, 400, { error: `--due needs a date as YYYY-MM-DD (got ${due})` });
309
+ }
281
310
  const body = payload.body ? String(payload.body).slice(0, 600) : null;
282
311
  const d = cards.addDecision({
283
312
  title,
284
313
  body,
285
- options: options.map((o) => ({ id: String(o.id), label: String(o.label).trim() })),
314
+ options: options.map((o) => ({ id: String(o.id), label: fit(o.label, 80, `option ${o.id} (80 characters)`) })),
315
+ due,
286
316
  pick: payload.pick ? String(payload.pick) : null,
287
317
  tag: payload.tag ? String(payload.tag).slice(0, 24) : null,
288
318
  // Asked where the conversation is: the thread that queued it (Sumeet,
@@ -293,17 +323,14 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
293
323
  });
294
324
  let asked = [];
295
325
  try {
296
- asked = await cards.pumpDecisions({
297
- send: (home, card) => ((registry.findAdapter(home.adapter) || own).sendCard(home.channelId, card, { threadId: home.threadId || null })),
298
- remind: (home, text, opts) => ((registry.findAdapter(home.adapter) || own).send(home.channelId, text, opts)),
299
- updateCard: (messageId, patch) => (typeof own.updateCard === "function" ? own.updateCard(messageId, patch) : null),
300
- });
326
+ asked = await cards.pumpDecisions({ ...decisionDeps, updateCard: decisionDeps.updateCard || (() => null) });
301
327
  } catch (e) {
302
- const waiting = cards.loadDecisions().filter((x) => ["queued", "asked", "snoozed"].includes(x.status)).length;
303
- return reply(res, 200, { ok: true, decision: d, askedNow: false, waiting, warning: `queued, but asking failed: ${e.message}` });
328
+ const waiting = cards.loadDecisions().filter((x) => cards.OPEN_STATUSES.includes(x.status)).length;
329
+ return reply(res, 200, { ok: true, decision: d, askedNow: false, waiting, warning: [shortened.length ? `shortened ${shortened.join(", ")}` : null, `queued, but asking failed: ${e.message}`].filter(Boolean).join("; ") });
304
330
  }
305
- const open = cards.loadDecisions().filter((x) => ["queued", "asked", "snoozed"].includes(x.status));
306
- return reply(res, 200, { ok: true, decision: d, askedNow: asked.some((a) => a.decisionId === d.id), waiting: open.length });
331
+ const open = cards.loadDecisions().filter((x) => cards.OPEN_STATUSES.includes(x.status));
332
+ const warning = shortened.length ? `shortened ${shortened.join(", ")}` : undefined;
333
+ return reply(res, 200, { ok: true, decision: d, askedNow: asked.some((a) => a.decisionId === d.id), waiting: open.length, warning });
307
334
  }
308
335
  return reply(res, 400, { error: `unknown card command ${kind}` });
309
336
  }
package/core/router.js CHANGED
@@ -111,6 +111,10 @@ async function onMessage(envelope) {
111
111
  // owner's canonical bucket on first contact, before any state is read.
112
112
  try { autoLinkOwnerChannel(envelope.adapter?.type, envelope.channelId); } catch (e) {}
113
113
 
114
+ // The owner writing in a chat his decisions wait in: the catch-up after
115
+ // 24 h of him quiet counts from here (core/cards.js).
116
+ try { require("./cards").noteInboundForDecisions(envelope); } catch (e) {}
117
+
114
118
  // Safe mode: only the diagnosis/rescue command set runs; everything else
115
119
  // gets a short banner pointing at /safemode. Preempts wizards, media and
116
120
  // agent turns — nothing may write memory or spawn a provider here.
@@ -235,6 +235,7 @@ function captureRunContext({
235
235
  attachSession: !liveCall && opts.attachSession !== false,
236
236
  occurrenceId: opts.occurrenceId || null,
237
237
  scheduledJobId: opts.scheduledJobId || null,
238
+ quiet: opts.quiet === true,
238
239
  originRunId: opts.originRunId || null,
239
240
  // Set on a turn that core/limit-pickup.js re-runs after a usage limit
240
241
  // reset: { originalPrompt, firstHitAt, attempts }.
package/core/runner.js CHANGED
@@ -2175,6 +2175,9 @@ async function finalizeStreamPreview(lane, result, runContext = null) {
2175
2175
  // A model that later sees them in its history can answer a wake-up with the same
2176
2176
  // words. That is never an answer, so it is never posted.
2177
2177
  const PLACEHOLDER_REPLY = /^no response requested\.?$/i;
2178
+ // A quiet scheduled run (core/cards.js: a dropped decision, the morning walk)
2179
+ // says "[quiet]" when there is nothing to tell; that is never posted.
2180
+ const QUIET_REPLY = /^\[quiet\]$/i;
2178
2181
 
2179
2182
  // "⚠️ Claude: 82% of the 5-hour limit is used. It resets at 17:20." goes out
2180
2183
  // after the answer that crossed it. Never to an outside contact (plan
@@ -2249,6 +2252,11 @@ async function deliverForegroundResult(result, runContext, state = currentState(
2249
2252
  return { ok: true, held: true, suppressed: "placeholder" };
2250
2253
  }
2251
2254
 
2255
+ if (result.ok && runContext.quiet === true && QUIET_REPLY.test(finalText.trim())) {
2256
+ console.log(`[runner] quiet run ${runContext.runId || "?"}: nothing to say, nothing posted`);
2257
+ return { ok: true, held: true, suppressed: "quiet" };
2258
+ }
2259
+
2252
2260
  if (result.ok) {
2253
2261
  let guarded = false;
2254
2262
  try { guarded = require("./relationship").isCurrentSpeakerGuarded(); } catch (error) {
package/core/scheduler.js CHANGED
@@ -1017,6 +1017,9 @@ function createSchedulerService(dependencies = {}) {
1017
1017
  occurrenceId: job.occurrenceId,
1018
1018
  scheduledJobId: job.id,
1019
1019
  originRunId: job.originRunId || null,
1020
+ // A quiet job (a dropped decision, the morning walk) posts no
1021
+ // "Wakeup:" line, and its reply of exactly [quiet] is not posted.
1022
+ ...(job.quiet === true ? { quiet: true } : {}),
1020
1023
  // A run that waited for a limit's reset: a new hit says "still used up"
1021
1024
  // and counts towards the most waits (limit-pickup MAX_ATTEMPTS).
1022
1025
  ...(Number(job.limitWaitCount) > 0 ? {
@@ -1054,7 +1057,7 @@ function createSchedulerService(dependencies = {}) {
1054
1057
  ? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway`
1055
1058
  : job.watchCheckTimingOut ? `⚠️ Watch "${job.label}": its check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row`
1056
1059
  : `🔔 Watch "${job.label}" triggered`)
1057
- : job.handoff ? "" : `Wakeup: ${job.label}`;
1060
+ : job.handoff || job.quiet === true ? "" : `Wakeup: ${job.label}`;
1058
1061
  // Say so when the run is NOT on the provider this room is set to: an
1059
1062
  // unexplained error from a provider the room never selected is worse than
1060
1063
  // the outage itself.
@@ -320,7 +320,7 @@ Reply normally in your final answer. To send a file, image, or voice clip back t
320
320
  - \`open-claudia say "<text>"\` — synthesize the text and send it as a voice note (use this when asked for a voice reply)
321
321
  - \`open-claudia card send <spec.json|->\` — (Kazee Chat) a decision, choice (poll), rating, slider, form or report card; answers wake this conversation. Cards are built from pieces: blocks (text, stats, bars, facts, table) and form questions (choice, rating, text, tick, date, slots, rows with a tick/date/option per row, board). When a reply would list items for someone to tick, date, pick, approve or move, or asks one person 2+ questions, send ONE card built from pieces instead of a text list plus a separate question; \`open-claudia card pieces\` lists them, \`card example <name>\` prints a spec, \`card update <id> --close | --note "..."\` changes one you sent.
322
322
  - \`open-claudia view send <page.html> --title "<title>" [--handler <file>]\` — (Kazee Chat) an interactive web page, sent as a card with an Open button; it opens in a walled-off panel. The page can load libraries from cdn.jsdelivr.net, unpkg.com, cdnjs.cloudflare.com or esm.sh but cannot fetch data from the internet, so put the data in the page. It talks to you with \`kazee.send(name, data)\` (wakes this conversation, or a --handler script answers fast) and \`kazee.on(fn)\` (gets what you \`view push\`); \`open-claudia view example\` shows a page and a handler.
323
- - \`open-claudia decide "<question>" --option yes="..." --option no="..." [--pick yes] [--body "why"]\` — (owner, Kazee Chat) queue a decision for the owner as a card: one is out at a time, his answer wakes this conversation. Prefer it to asking a yes/no question in text when work waits on his decision.
323
+ - \`open-claudia decide "<question>" --option yes="..." --option no="..." [--pick yes] [--body "why"] [--due YYYY-MM-DD]\` — (owner, Kazee Chat) queue a decision for the owner as a card: one is out at a time, his answer wakes this conversation; give --due when it is needed by a date (a vote, a deadline) so it moves up as the date nears; items wait until he answers, snoozes or drops them; tick off what he did himself with \`open-claudia decide done <id>\`. Information with nothing to decide is told once, never queued. Prefer it to asking a yes/no question in text when work waits on his decision.
324
324
  Never print or embed bot tokens in prompts, commands, logs, or messages.
325
325
  ${hasVoice || hasEars ? `
326
326
  ## Speech services (TTS / STT)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.5.81",
3
+ "version": "3.5.82",
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-output-schema.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-effort-reset.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 && node test-call-flags.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-test-run-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-owner-cards-dm.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-stop-queue.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-quiet-defaults.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-thread-handoff.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-cost-by-part.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-card-blocks.js && node test-decision-upkeep.js && node test-passive-replies.js && node test-kazee-bot-loops.js && node test-kazee-bot-handoffs.js && node test-placeholder-reply.js && node test-media-speech.js && node test-speech-voices.js && node test-tts-voice.js && node test-passive-replies-router.js && node test-loopback-cards.js && node test-task-injection.js && node test-speaker-extras.js && node test-token-diet.js && node test-dream-tasks.js && node test-task-archive.js && node test-cron-tolerance.js && node test-dream-schedule.js && node test-job-failures.js && node test-scheduled-placeholder.js && node test-lessons-since.js && node test-pack-reinject.js && node test-turn-journal.js && node test-kazee-catchup.js && node test-usage-limits.js && node test-limit-waits.js && node test-signin-race-retry.js && node test-auth-recheck.js && node test-signin-pickup.js && node test-limit-pickup.js && node test-browser-view.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-view.js && node test-browser-address.js && node test-browser-control.js && node test-browser-drive.js && node test-browser-teach.js && node test-kazee-bot-apps.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-app.js && node test-safe-filename.js && node test-media-filename-paths.js && node test-router-document-guard.js && OPEN_CLAUDIA_TEST=1 node test-web-dashboard-escape.js && OPEN_CLAUDIA_TEST=1 node test-web-bad-requests.js && OPEN_CLAUDIA_TEST=1 node test-kazee-download-limit.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-auth-routes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-otp-link-preview.js && node test-call-quick-voice.js && node test-call-run-stop.js && node test-call-stopped-transcript.js && node test-call-greeting-context.js && node test-call-camera.js && node test-switchboard-calls.js && node test-switchboard-supervisor.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-topic-lessons.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-env-pins.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-session-core.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-related.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-decisions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-external.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-introspection-retry.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-every-cron.js && node test-rust-cli-parity.js && OPEN_CLAUDIA_TEST=1 node test-bot-acts-for-owner.js && node test-walker-idle-stop.js && node test-usage-history-lazy.js && node test-node-flags-entrypoint.js && node test-recall-engine-env.js && node test-review-queue.js && node test-lazy-heavy-libs.js && node test-lessons-meta-keep.js && node test-views.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-test-run-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-owner-cards-dm.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-stop-queue.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-quiet-defaults.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-thread-handoff.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-cost-by-part.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-card-blocks.js && node test-decision-upkeep.js && node test-waiting-on-you.js && node test-passive-replies.js && node test-kazee-bot-loops.js && node test-kazee-bot-handoffs.js && node test-placeholder-reply.js && node test-media-speech.js && node test-speech-voices.js && node test-tts-voice.js && node test-passive-replies-router.js && node test-loopback-cards.js && node test-task-injection.js && node test-speaker-extras.js && node test-token-diet.js && node test-dream-tasks.js && node test-task-archive.js && node test-cron-tolerance.js && node test-dream-schedule.js && node test-job-failures.js && node test-scheduled-placeholder.js && node test-lessons-since.js && node test-pack-reinject.js && node test-turn-journal.js && node test-kazee-catchup.js && node test-usage-limits.js && node test-limit-waits.js && node test-signin-race-retry.js && node test-auth-recheck.js && node test-signin-pickup.js && node test-limit-pickup.js && node test-browser-view.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-view.js && node test-browser-address.js && node test-browser-control.js && node test-browser-drive.js && node test-browser-teach.js && node test-kazee-bot-apps.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-app.js && node test-safe-filename.js && node test-media-filename-paths.js && node test-router-document-guard.js && OPEN_CLAUDIA_TEST=1 node test-web-dashboard-escape.js && OPEN_CLAUDIA_TEST=1 node test-web-bad-requests.js && OPEN_CLAUDIA_TEST=1 node test-kazee-download-limit.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-auth-routes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-otp-link-preview.js && node test-call-quick-voice.js && node test-call-run-stop.js && node test-call-stopped-transcript.js && node test-call-greeting-context.js && node test-call-camera.js && node test-switchboard-calls.js && node test-switchboard-supervisor.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-topic-lessons.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-env-pins.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-session-core.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-related.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-decisions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-external.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-introspection-retry.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/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-every-cron.js && node test-rust-cli-parity.js && OPEN_CLAUDIA_TEST=1 node test-bot-acts-for-owner.js && node test-walker-idle-stop.js && node test-usage-history-lazy.js && node test-node-flags-entrypoint.js && node test-recall-engine-env.js && node test-review-queue.js && node test-lazy-heavy-libs.js && node test-lessons-meta-keep.js && node test-views.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
@@ -671,6 +671,8 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
671
671
  // (Sumeet 12 Nov 07:53 EAT, card 6af54622: "Build 3.5.77 with the 3 pieces, test on one bot, roll on your word").
672
672
  // 3.5.80 = 3.5.79 + the owner switch-on card lands in the thread that asked for it
673
673
  // (Sumeet 13 Nov 14:05 EAT: "why was that on the main chat not on the thread? Is that a bug?").
674
+ // 3.5.82 = 3.5.81 + "Waiting on you" for every thread: Drop, decide done, fresh card after 3 days, catch-up, morning walk, decide list, --due
675
+ // (Sumeet 14 Nov 15:18 EAT, card 6af83e0b: "Yes, release it").
674
676
  // 3.5.81 = 3.5.80 + a personal bot may send a Kazee Chat message as its owner when the owner asks (Chat shows "Owner via Bot" with the AI mark)
675
677
  // 3.5.79 = 3.5.78 + a reply refused as signed out is tried again every minute, quietly,
676
678
  // up to 30 times (Sumeet 13 Nov 13:17 EAT, during Anthropic's sign-in incident:
@@ -684,7 +686,7 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
684
686
  // 3.5.72 = 3.5.71 + the Mac memory fixes of 22-28 Oct:
685
687
  // topic lessons ride with their pack, .env recall pins, prompt fixes, past
686
688
  // chats reach recall, settled decisions, /engine x|mem0, nightly retry.
687
- assert.strictEqual(pkg.version, "3.5.81", "release version must remain unchanged without explicit approval");
689
+ assert.strictEqual(pkg.version, "3.5.82", "release version must remain unchanged without explicit approval");
688
690
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
689
691
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
690
692
  }
@@ -516,6 +516,20 @@ async function firetimeSelectionProbe(createSchedulerService) {
516
516
  assert.match(switched.runCalls[0].prompt, /claude/i);
517
517
  }
518
518
 
519
+ // A quiet job (core/cards.js: a dropped decision, the morning walk) posts no
520
+ // "Wakeup:" line and runs as a quiet run (its [quiet] reply is not posted).
521
+ async function quietJobProbe(createSchedulerService) {
522
+ const ok = { ok: true, status: "succeeded", persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } }, delivery: { ok: true } };
523
+ const quiet = schedulerHarness(createSchedulerService, [baseJob({ id: "quiet-wake", quiet: true })], { outcomes: [ok] });
524
+ assert.strictEqual((await quiet.service.fireJob("quiet-wake")).ok, true);
525
+ assert.deepStrictEqual(quiet.sends, [], "no Wakeup: line");
526
+ assert.strictEqual(quiet.admissions[0].opts.quiet, true);
527
+ const loud = schedulerHarness(createSchedulerService, [baseJob({ id: "loud-wake" })], { outcomes: [ok] });
528
+ await loud.service.fireJob("loud-wake");
529
+ assert.deepStrictEqual(loud.sends, ["Wakeup: fixture wakeup"]);
530
+ assert.strictEqual(loud.admissions[0].opts.quiet, undefined);
531
+ }
532
+
519
533
  async function duplicateFireProbe(createSchedulerService) {
520
534
  const terminal = deferred();
521
535
  const h = schedulerHarness(createSchedulerService, [baseJob({ id: "single-flight" })], {
@@ -1758,6 +1772,7 @@ async function main() {
1758
1772
  migrationIntegrationProbe(jobsApi, migrationApi);
1759
1773
  await firetimeSelectionProbe(schedulerApi.createSchedulerService);
1760
1774
  await duplicateFireProbe(schedulerApi.createSchedulerService);
1775
+ await quietJobProbe(schedulerApi.createSchedulerService);
1761
1776
  await failureAndRetryProbe(schedulerApi.createSchedulerService);
1762
1777
  await detachedHistoricalBranchProbe(schedulerApi.createSchedulerService);
1763
1778
  await historyLineageProbe(schedulerApi.createSchedulerService);