sphica 0.6.32 → 0.6.34

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/dist/cli.js CHANGED
@@ -16472,6 +16472,8 @@ var spoolDir = () => path8.join(sphicaHome(), "spool");
16472
16472
  var stateFile = () => path8.join(sphicaHome(), "capture.json");
16473
16473
  var rejectedDir = () => path8.join(spoolDir(), "rejected");
16474
16474
  var unregisteredDir = () => path8.join(spoolDir(), "unregistered");
16475
+ var callsDir = () => path8.join(spoolDir(), "calls");
16476
+ var callsRejectedDir = () => path8.join(callsDir(), "rejected");
16475
16477
  var HOLD_DAYS = 30;
16476
16478
  var HOLD_MAX = 1000;
16477
16479
  var baselineDir = () => path8.join(sphicaHome(), "worktree");
@@ -16520,7 +16522,7 @@ ${z}`,
16520
16522
  originalBytes: all
16521
16523
  };
16522
16524
  }
16523
- function prune(held) {
16525
+ function prune(held, removed = () => {}) {
16524
16526
  let files;
16525
16527
  try {
16526
16528
  files = fs8.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
@@ -16530,16 +16532,21 @@ function prune(held) {
16530
16532
  const cutoff = Date.now() - HOLD_DAYS * 24 * 60 * 60 * 1000;
16531
16533
  const stale = files.filter((f) => Number(f.split("-")[0]) < cutoff);
16532
16534
  const over = files.slice(0, Math.max(0, files.length - HOLD_MAX));
16533
- for (const f of new Set([...stale, ...over]))
16535
+ for (const f of new Set([...stale, ...over])) {
16534
16536
  fs8.rmSync(path8.join(held, f), { force: true });
16537
+ removed();
16538
+ }
16535
16539
  }
16536
- function spool(record) {
16537
- const dir = spoolDir();
16540
+ function put(dir, record) {
16538
16541
  fs8.mkdirSync(dir, { recursive: true, mode: 448 });
16539
16542
  const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}.json`;
16540
16543
  const tmp = path8.join(dir, `.${name}`);
16541
16544
  fs8.writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
16542
16545
  fs8.renameSync(tmp, path8.join(dir, name));
16546
+ return path8.join(dir, name);
16547
+ }
16548
+ function spool(record) {
16549
+ put(spoolDir(), record);
16543
16550
  }
16544
16551
  var branchOf = (root) => {
16545
16552
  try {
@@ -16594,9 +16601,11 @@ function captureNotice(file = dbFile()) {
16594
16601
  if (!fs8.existsSync(file))
16595
16602
  return panel("sphica: no database, so conversations are not recorded", [file], "Create it with sphica init");
16596
16603
  const s = readState();
16604
+ if (s.unreadable)
16605
+ return panel("sphica: cannot read the recording queue", [spoolDir(), s.unreadable], "Check with sphica doctor");
16597
16606
  if (s.stuck)
16598
16607
  return panel("sphica: cannot send recordings", [`${s.pending} pending / failed: ${plain(s.stuck.slice(0, 120))}`], "Check with sphica doctor");
16599
- if (s.rejected > 0)
16608
+ if (s.rejected)
16600
16609
  return panel(`sphica: the database rejected ${plural(s.rejected, "record")}`, [rejectedDir(), `Move them back to ${spoolDir()} to resend`], "Fix them first, then check with sphica doctor");
16601
16610
  return null;
16602
16611
  }
@@ -16787,37 +16796,116 @@ function onHook(host, input) {
16787
16796
  }
16788
16797
  return { flush: false };
16789
16798
  }
16799
+ function readStateFile() {
16800
+ let raw = {};
16801
+ try {
16802
+ const parsed = JSON.parse(fs8.readFileSync(stateFile(), "utf8"));
16803
+ if (parsed && typeof parsed === "object")
16804
+ raw = parsed;
16805
+ } catch {}
16806
+ const p = raw.pruned;
16807
+ return {
16808
+ flushedAt: typeof raw.flushedAt === "string" ? raw.flushedAt : undefined,
16809
+ error: typeof raw.error === "string" ? raw.error || "unknown failure" : null,
16810
+ deferred: typeof raw.deferred === "number" ? raw.deferred : undefined,
16811
+ pruned: p && typeof p.at === "string" && typeof p.count === "number" ? { at: p.at, count: p.count } : undefined
16812
+ };
16813
+ }
16790
16814
  function writeState(s) {
16791
16815
  try {
16792
- fs8.writeFileSync(stateFile(), JSON.stringify(s));
16816
+ fs8.writeFileSync(stateFile(), JSON.stringify({ ...s, pruned: s.pruned ?? readStateFile().pruned }));
16793
16817
  } catch {}
16794
16818
  }
16795
16819
  function readState() {
16796
- const count = (dir) => {
16820
+ const queue = listQueue(spoolDir());
16821
+ const counts = {
16822
+ pending: queue.files?.length ?? null,
16823
+ rejected: listQueue(rejectedDir()).files?.length ?? null,
16824
+ unregistered: listQueue(unregisteredDir()).files?.length ?? null
16825
+ };
16826
+ const state = readStateFile();
16827
+ const stuck = state.error && (counts.pending ?? 0) > 0 ? state.error : null;
16828
+ return { ...state, ...counts, unreadable: queue.code, stuck };
16829
+ }
16830
+ function listQueue(dir) {
16831
+ try {
16832
+ const names = fs8.readdirSync(dir);
16833
+ return {
16834
+ files: names.filter((f) => f.endsWith(".json") && !f.startsWith(".")),
16835
+ temp: names.filter((f) => f.endsWith(".json") && f.startsWith(".")),
16836
+ code: null
16837
+ };
16838
+ } catch (e) {
16839
+ const code = e.code ?? "unknown";
16840
+ return code === "ENOENT" ? { files: [], temp: [], code: null } : { files: null, temp: [], code };
16841
+ }
16842
+ }
16843
+ var TEMP_AGE_MS = 60000;
16844
+ var REASON = /^[a-z]+(-[a-z]+)?(:[A-Z]+)?$/;
16845
+ function queueReport(now = Date.now()) {
16846
+ const dirs = {
16847
+ queue: spoolDir(),
16848
+ calls: callsDir(),
16849
+ rejected: rejectedDir(),
16850
+ unregistered: unregisteredDir(),
16851
+ callsRejected: callsRejectedDir()
16852
+ };
16853
+ const listed = Object.fromEntries(Object.entries(dirs).map(([k, d]) => [k, listQueue(d)]));
16854
+ const report2 = (k) => {
16855
+ const temp = { count: 0, oldestMs: 0, bytes: 0 };
16856
+ for (const name of listed[k].temp)
16857
+ try {
16858
+ const st = fs8.statSync(path8.join(dirs[k], name));
16859
+ const age = now - st.mtimeMs;
16860
+ if (age < TEMP_AGE_MS)
16861
+ continue;
16862
+ temp.count++;
16863
+ temp.bytes += st.size;
16864
+ temp.oldestMs = Math.max(temp.oldestMs, age);
16865
+ } catch {}
16866
+ return { files: listed[k].files?.length ?? null, code: listed[k].code, temp };
16867
+ };
16868
+ const reasons = (k) => {
16869
+ const out = {};
16870
+ for (const name of listed[k].files ?? []) {
16871
+ let why = "unknown";
16872
+ try {
16873
+ const text = fs8.readFileSync(path8.join(dirs[k], `${name}.reason`), "utf8");
16874
+ if (REASON.test(text))
16875
+ why = text;
16876
+ } catch {}
16877
+ out[why] = (out[why] ?? 0) + 1;
16878
+ }
16879
+ return out;
16880
+ };
16881
+ const read = (dir, name) => {
16797
16882
  try {
16798
- return fs8.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length;
16883
+ return JSON.parse(fs8.readFileSync(path8.join(dir, name), "utf8"));
16799
16884
  } catch {
16800
- return 0;
16885
+ return null;
16801
16886
  }
16802
16887
  };
16803
- const counts = {
16804
- pending: count(spoolDir()),
16805
- rejected: count(rejectedDir()),
16806
- unregistered: count(unregisteredDir())
16807
- };
16808
- let raw = {};
16809
- try {
16810
- const parsed = JSON.parse(fs8.readFileSync(stateFile(), "utf8"));
16811
- if (parsed && typeof parsed === "object")
16812
- raw = parsed;
16813
- } catch {}
16814
- const error = typeof raw.error === "string" ? raw.error || "unknown failure" : null;
16888
+ const held = new Map;
16889
+ for (const name of listed.unregistered.files ?? []) {
16890
+ const key = read(dirs.unregistered, name)?.project;
16891
+ const project = typeof key === "string" ? key : "unreadable";
16892
+ const age = Math.max(0, now - (Number(name.split("-")[0]) || now));
16893
+ const h2 = held.get(project) ?? { count: 0, oldestMs: 0 };
16894
+ held.set(project, { count: h2.count + 1, oldestMs: Math.max(h2.oldestMs, age) });
16895
+ }
16896
+ const waiting = new Set;
16897
+ for (const name of listed.calls.files ?? []) {
16898
+ const o = observation(read(dirs.calls, name));
16899
+ if (o)
16900
+ waiting.add(o.toolUse);
16901
+ }
16815
16902
  return {
16816
- flushedAt: typeof raw.flushedAt === "string" ? raw.flushedAt : undefined,
16817
- error,
16818
- deferred: typeof raw.deferred === "number" ? raw.deferred : undefined,
16819
- ...counts,
16820
- stuck: error && counts.pending > 0 ? error : null
16903
+ dirs: Object.fromEntries(Object.keys(dirs).map((k) => [k, report2(k)])),
16904
+ rejected: reasons("rejected"),
16905
+ callsRejected: reasons("callsRejected"),
16906
+ held: [...held].map(([project, h2]) => ({ project, ...h2 })).sort((a2, b) => a2.project.localeCompare(b.project)),
16907
+ waiting,
16908
+ pruned: readStateFile().pruned
16821
16909
  };
16822
16910
  }
16823
16911
  function lock() {
@@ -16956,16 +17044,36 @@ var queued = (from) => {
16956
17044
  return [];
16957
17045
  }
16958
17046
  };
17047
+ var CODE_NAMES = { 18: "TOOBIG", 19: "CONSTRAINT", 20: "MISMATCH", 25: "RANGE" };
17048
+ function setAside(from, name, dir, reason2) {
17049
+ fs8.mkdirSync(dir, { recursive: true, mode: 448 });
17050
+ try {
17051
+ fs8.renameSync(path8.join(from, name), path8.join(dir, name));
17052
+ } catch (e) {
17053
+ if (e.code === "ENOENT")
17054
+ return;
17055
+ throw e;
17056
+ }
17057
+ try {
17058
+ fs8.writeFileSync(path8.join(dir, `${name}.reason`), reason2, { mode: 384 });
17059
+ } catch {}
17060
+ }
16959
17061
  async function sendBatch(db, names) {
16960
17062
  const held = unregisteredDir();
16961
17063
  const records = [];
16962
17064
  for (const { name, from } of names) {
17065
+ let raw;
16963
17066
  let r2;
16964
17067
  try {
16965
- r2 = current(JSON.parse(fs8.readFileSync(path8.join(from, name), "utf8")));
17068
+ raw = JSON.parse(fs8.readFileSync(path8.join(from, name), "utf8"));
17069
+ } catch {
17070
+ setAside(from, name, rejectedDir(), "unreadable");
17071
+ continue;
17072
+ }
17073
+ try {
17074
+ r2 = current(raw);
16966
17075
  } catch {
16967
- fs8.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
16968
- fs8.renameSync(path8.join(from, name), path8.join(rejectedDir(), name));
17076
+ setAside(from, name, rejectedDir(), typeof raw?.project === "string" ? "version" : "no-project");
16969
17077
  continue;
16970
17078
  }
16971
17079
  if (r2)
@@ -16992,21 +17100,12 @@ async function sendBatch(db, names) {
16992
17100
  } catch (e2) {
16993
17101
  if (!rejected(e2))
16994
17102
  throw e2;
16995
- bad.push(x);
16996
- }
16997
- }
16998
- }
16999
- if (bad.length) {
17000
- fs8.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
17001
- for (const x of bad) {
17002
- try {
17003
- fs8.renameSync(path8.join(x.from, x.name), path8.join(rejectedDir(), x.name));
17004
- } catch (e) {
17005
- if (e.code !== "ENOENT")
17006
- throw e;
17103
+ bad.push({ ...x, why: `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}` });
17007
17104
  }
17008
17105
  }
17009
17106
  }
17107
+ for (const x of bad)
17108
+ setAside(x.from, x.name, rejectedDir(), x.why);
17010
17109
  if (strayed.length) {
17011
17110
  fs8.mkdirSync(held, { recursive: true, mode: 448 });
17012
17111
  for (const x of strayed) {
@@ -17026,6 +17125,60 @@ async function sendBatch(db, names) {
17026
17125
  fs8.rmSync(path8.join(x.from, x.name), { force: true });
17027
17126
  return { sent, rejected: bad.length };
17028
17127
  }
17128
+ async function sendObservations(db, names) {
17129
+ const aside = (name, why) => {
17130
+ try {
17131
+ setAside(callsDir(), name, callsRejectedDir(), why);
17132
+ } catch {}
17133
+ };
17134
+ const rows = [];
17135
+ for (const { name, from } of names) {
17136
+ let text;
17137
+ try {
17138
+ text = fs8.readFileSync(path8.join(from, name), "utf8");
17139
+ } catch {
17140
+ continue;
17141
+ }
17142
+ let o = null;
17143
+ let why = "unreadable";
17144
+ try {
17145
+ o = observation(JSON.parse(text));
17146
+ why = "shape";
17147
+ } catch {}
17148
+ if (o)
17149
+ rows.push({ name, o });
17150
+ else
17151
+ aside(name, why);
17152
+ }
17153
+ if (!rows.length)
17154
+ return true;
17155
+ const insert = (part) => db.insertInto("capture_tool_call").values(part.map((x) => observationRow(x.o))).execute();
17156
+ const bad = new Map;
17157
+ try {
17158
+ await insert(rows);
17159
+ } catch (e) {
17160
+ if (!rejected(e))
17161
+ return false;
17162
+ for (const x of rows)
17163
+ try {
17164
+ await insert([x]);
17165
+ } catch (e2) {
17166
+ if (!rejected(e2))
17167
+ return false;
17168
+ bad.set(x.name, `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}`);
17169
+ }
17170
+ }
17171
+ for (const x of rows) {
17172
+ const why = bad.get(x.name);
17173
+ if (why)
17174
+ aside(x.name, why);
17175
+ else
17176
+ try {
17177
+ fs8.rmSync(path8.join(callsDir(), x.name), { force: true });
17178
+ } catch {}
17179
+ }
17180
+ return true;
17181
+ }
17029
17182
  async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
17030
17183
  const deadline = Date.now() + budgetMs;
17031
17184
  const dir = spoolDir();
@@ -17033,6 +17186,8 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
17033
17186
  const total = { sent: 0, deferred: 0, rejected: 0 };
17034
17187
  let batches = 0;
17035
17188
  let queueBatches = 0;
17189
+ let removed = 0;
17190
+ const pruned = () => removed ? { at: new Date().toISOString(), count: removed } : undefined;
17036
17191
  for (let first = true;; first = false) {
17037
17192
  let unlock = lock();
17038
17193
  while (!unlock && Date.now() < deadline) {
@@ -17054,12 +17209,20 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
17054
17209
  const sentBefore = batches;
17055
17210
  try {
17056
17211
  if (first) {
17057
- prune(held);
17212
+ prune(held, () => removed++);
17058
17213
  for (const part of chunks(queued(held), BATCH)) {
17059
17214
  if (late())
17060
17215
  break;
17061
17216
  await send(part);
17062
17217
  }
17218
+ for (const part of chunks(queued(callsDir()), BATCH)) {
17219
+ if (late())
17220
+ break;
17221
+ client ??= openWriter("capture", file);
17222
+ if (!await sendObservations(client, part))
17223
+ break;
17224
+ batches++;
17225
+ }
17063
17226
  }
17064
17227
  while (queueBatches === 0 || !late()) {
17065
17228
  const part = queued(dir).slice(0, BATCH);
@@ -17068,12 +17231,19 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
17068
17231
  await send(part);
17069
17232
  queueBatches++;
17070
17233
  }
17071
- prune(held);
17234
+ prune(held, () => removed++);
17072
17235
  total.deferred = queued(held).length;
17073
17236
  if (batches > sentBefore)
17074
- writeState({ flushedAt: new Date().toISOString(), error: null, deferred: total.deferred });
17237
+ writeState({
17238
+ flushedAt: new Date().toISOString(),
17239
+ error: null,
17240
+ deferred: total.deferred,
17241
+ pruned: pruned()
17242
+ });
17243
+ else if (removed)
17244
+ writeState({ ...readStateFile(), pruned: pruned() });
17075
17245
  } catch (e) {
17076
- writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
17246
+ writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300), pruned: pruned() });
17077
17247
  throw e;
17078
17248
  } finally {
17079
17249
  await client?.destroy().catch(() => {});
@@ -17092,27 +17262,63 @@ async function readInput(stream) {
17092
17262
  }
17093
17263
  var RECORD_TOOLS = "mcp__plugin_sphica_record__";
17094
17264
  var OBSERVE_WAIT_MS = 5000;
17095
- var hostId = (v) => v && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
17096
- async function observeRecordCall(file, input, owner) {
17097
- const session = hostId(input.session_id);
17098
- const toolUse = hostId(input.tool_use_id);
17099
- const tool = hostId(input.tool_name);
17100
- if (!session || !toolUse || !tool?.startsWith(RECORD_TOOLS))
17265
+ var hostId = (v) => typeof v === "string" && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
17266
+ var OBSERVATION_KEYS = ["at", "host", "owner", "session", "tool", "toolUse", "turn", "v"].join();
17267
+ function observation(raw) {
17268
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
17269
+ return null;
17270
+ const o = raw;
17271
+ if (Object.keys(o).sort().join() !== OBSERVATION_KEYS)
17272
+ return null;
17273
+ if (o.v !== 1 || o.host !== "claude-code" || !hostId(o.session) || !hostId(o.toolUse))
17274
+ return null;
17275
+ if (!hostId(o.tool)?.startsWith(RECORD_TOOLS))
17276
+ return null;
17277
+ if (o.turn !== null && !hostId(o.turn))
17278
+ return null;
17279
+ if (o.owner !== 0 && o.owner !== 1)
17280
+ return null;
17281
+ const at = typeof o.at === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(o.at) ? o.at : null;
17282
+ if (!at || Number.isNaN(Date.parse(at)) || new Date(at).toISOString() !== at)
17283
+ return null;
17284
+ return o;
17285
+ }
17286
+ var observationRow = (o) => ({
17287
+ host: o.host,
17288
+ session_external: o.session,
17289
+ turn_id: o.turn,
17290
+ tool_use_id: o.toolUse,
17291
+ tool_name: o.tool,
17292
+ owner_turn: o.owner,
17293
+ observed_at: o.at
17294
+ });
17295
+ async function observeRecordCall(file, input, owner, waitMs = OBSERVE_WAIT_MS) {
17296
+ const o = observation({
17297
+ v: 1,
17298
+ host: "claude-code",
17299
+ session: hostId(input.session_id),
17300
+ turn: hostId(input.prompt_id),
17301
+ toolUse: hostId(input.tool_use_id),
17302
+ tool: hostId(input.tool_name),
17303
+ owner: owner ? 1 : 0,
17304
+ at: iso(Date.now())
17305
+ });
17306
+ if (!o)
17101
17307
  return;
17102
- const cap = openWriter("capture", file, OBSERVE_WAIT_MS);
17308
+ let kept = null;
17309
+ try {
17310
+ kept = put(callsDir(), o);
17311
+ } catch {}
17312
+ const cap = openWriter("capture", file, waitMs);
17103
17313
  try {
17104
- await cap.insertInto("capture_tool_call").values({
17105
- host: "claude-code",
17106
- session_external: session,
17107
- turn_id: hostId(input.prompt_id),
17108
- tool_use_id: toolUse,
17109
- tool_name: tool,
17110
- owner_turn: owner ? 1 : 0,
17111
- observed_at: iso(Date.now())
17112
- }).execute();
17314
+ await cap.insertInto("capture_tool_call").values(observationRow(o)).execute();
17113
17315
  } finally {
17114
17316
  await cap.destroy().catch(() => {});
17115
17317
  }
17318
+ if (kept)
17319
+ try {
17320
+ fs8.rmSync(kept, { force: true });
17321
+ } catch {}
17116
17322
  }
17117
17323
  async function main() {
17118
17324
  if (process.argv[2] === "--flush") {
@@ -18433,6 +18639,46 @@ Run --help for usage`),
18433
18639
  exceptionWhileRunningCommand: (e) => failed(reason(e)),
18434
18640
  commandErrorResult: (e) => failed(e.message)
18435
18641
  };
18642
+ async function adoptionStops(db, waiting, say2) {
18643
+ const calls = await db.selectFrom("record_call as c").innerJoin("project as p", "p.id", "c.project_id").select(["p.id", "p.name", "p.key", "c.host", "c.tool_use_id", "c.called_at"]).where((eb) => eb.or([
18644
+ eb("c.host", "is", null),
18645
+ eb.and([eb("c.host", "=", "codex"), eb("c.caller_session", "is", null)]),
18646
+ eb.and([
18647
+ eb("c.host", "=", "claude-code"),
18648
+ eb.not(eb.exists(eb.selectFrom("tool_call_observation as o").select("o.id").where("o.host", "=", "claude-code").whereRef("o.tool_use_id", "=", "c.tool_use_id")))
18649
+ ])
18650
+ ])).orderBy("p.name").orderBy("c.called_at").execute();
18651
+ const turnless = await db.selectFrom("tool_call_observation as o").innerJoin("session as se", (j) => j.on("se.host", "=", "claude-code").onRef("se.external_id", "=", "o.session_external")).innerJoin("project as p", "p.id", "se.project_id").select((eb) => [
18652
+ "p.id",
18653
+ "p.name",
18654
+ "p.key",
18655
+ eb.fn.count("se.id").distinct().as("sessions"),
18656
+ eb.fn.min("o.observed_at").as("first")
18657
+ ]).where("o.host", "=", "claude-code").where("o.turn_id", "is", null).groupBy(["p.id", "p.name", "p.key"]).orderBy("p.name").execute();
18658
+ const groups = new Map;
18659
+ for (const c of calls) {
18660
+ const k = `${c.id}\x00${c.host ?? ""}`;
18661
+ groups.set(k, [...groups.get(k) ?? [], c]);
18662
+ }
18663
+ const keys = new Map;
18664
+ for (const c of [...calls, ...turnless])
18665
+ keys.set(c.name, (keys.get(c.name) ?? new Set).add(c.key));
18666
+ const named = (p) => inline((keys.get(p.name)?.size ?? 0) > 1 ? `${p.name} (${p.key})` : p.name);
18667
+ for (const group2 of groups.values()) {
18668
+ const [first] = group2;
18669
+ if (!first)
18670
+ continue;
18671
+ const from = new Date(first.called_at).toLocaleString("sv-SE");
18672
+ const n2 = group2.length;
18673
+ const text = first.host === "claude-code" ? (() => {
18674
+ const resend = group2.filter((c) => c.tool_use_id && waiting.has(c.tool_use_id)).length;
18675
+ return `Claude Code replies from ${from} on are not adopted as AI decisions: ${plural(n2, "record tool call")} without a hook observation (${resend} waiting to be resent, ${n2 - resend} that cannot be)`;
18676
+ })() : first.host === "codex" ? `Codex replies from ${from} on are not adopted as AI decisions: ${plural(n2, "record tool call")} that named no session` : `replies of every host from ${from} on are not adopted as AI decisions: ${plural(n2, "record tool call")} that named no host`;
18677
+ say2("warn", "AI adoption", `${named(first)}: ${text}`);
18678
+ }
18679
+ for (const t2 of turnless)
18680
+ say2("warn", "AI adoption", `${named(t2)}: Claude Code replies in ${plural(Number(t2.sessions), "session")} from ${new Date(t2.first ?? "").toLocaleString("sv-SE")} on are not adopted as AI decisions: a record tool's hook did not know the turn`);
18681
+ }
18436
18682
  async function doctor(cwd) {
18437
18683
  const issues = [];
18438
18684
  const count = (m, label) => {
@@ -18484,7 +18730,36 @@ async function doctor(cwd) {
18484
18730
  }
18485
18731
  }
18486
18732
  const s = readState();
18487
- say2(s.stuck ? "fail" : s.rejected ? "warn" : "ok", "Recording", `${s.pending} pending${s.flushedAt ? ` / last sent ${new Date(s.flushedAt).toLocaleString("sv-SE")}` : ""}${s.stuck ? ` / failed: ${plain(s.stuck)} (sent again after the next turn)` : ""}${s.unregistered ? ` / ${s.unregistered} set aside for unregistered projects (${unregisteredDir()})` : ""}${s.rejected ? ` / ${s.rejected} rejected by the database (${rejectedDir()})` : ""}`);
18733
+ if (s.unreadable)
18734
+ say2("fail", "Recording", `the queue cannot be read (${inline(s.unreadable)}) at ${inline(spoolDir())}`);
18735
+ else
18736
+ say2(s.stuck ? "fail" : "ok", "Recording", `${s.pending} pending${s.flushedAt ? ` / last sent ${new Date(s.flushedAt).toLocaleString("sv-SE")}` : ""}${s.stuck ? ` / failed: ${plain(s.stuck)} (sent again after the next turn)` : ""}`);
18737
+ const q = queueReport();
18738
+ const dirOf = {
18739
+ queue: spoolDir(),
18740
+ calls: callsDir(),
18741
+ rejected: rejectedDir(),
18742
+ unregistered: unregisteredDir(),
18743
+ callsRejected: callsRejectedDir()
18744
+ };
18745
+ const minutes = (ms) => ms < 2 * 3600000 ? `${Math.round(ms / 60000)} min` : ms < 2 * 86400000 ? `${Math.round(ms / 3600000)} h` : `${Math.round(ms / 86400000)} days`;
18746
+ const reasons = (r2) => Object.entries(r2).sort(([a2], [b]) => a2.localeCompare(b)).map(([why, n2]) => `${inline(why)} ${n2}`).join(", ");
18747
+ for (const [k, d] of Object.entries(q.dirs)) {
18748
+ if (d.code && k !== "queue")
18749
+ say2("fail", "Queue directory", `${inline(dirOf[k])} cannot be read (${inline(d.code)})`);
18750
+ if (d.temp.count)
18751
+ say2("warn", "Left-behind files", `${plural(d.temp.count, "temporary file")} older than a minute in ${inline(dirOf[k])} (oldest ${minutes(d.temp.oldestMs)}, ${d.temp.bytes} bytes): written but never put in the queue`);
18752
+ }
18753
+ if (q.dirs.rejected.files)
18754
+ say2("warn", "Rejected records", `${plural(q.dirs.rejected.files, "record")} in ${inline(rejectedDir())}: ${reasons(q.rejected)}. Fix them, then move them back to ${inline(spoolDir())} to resend`);
18755
+ if (q.held.length)
18756
+ say2("none", "Held recordings", `for projects not registered, in ${inline(unregisteredDir())}: ${q.held.map((h2) => `${inline(h2.project)} ${h2.count} (oldest ${minutes(h2.oldestMs)})`).join(" / ")}. Register a project with sphica init to send its recordings`);
18757
+ if (q.pruned)
18758
+ say2("none", "Last prune", `${plural(q.pruned.count, "held recording")} expired at ${new Date(q.pruned.at).toLocaleString("sv-SE")}`);
18759
+ if (q.dirs.calls.files)
18760
+ say2("none", "Observations to resend", `${plural(q.dirs.calls.files, "record tool observation")} ${q.dirs.calls.files === 1 ? "waits" : "wait"} in ${inline(callsDir())} for the next send`);
18761
+ if (q.dirs.callsRejected.files)
18762
+ say2("warn", "Observations not sent", `${plural(q.dirs.callsRejected.files, "file")} in ${inline(callsRejectedDir())}: ${reasons(q.callsRejected)}`);
18488
18763
  if (usable2) {
18489
18764
  try {
18490
18765
  await withDb("reader", async (db) => {
@@ -18493,6 +18768,7 @@ async function doctor(cwd) {
18493
18768
  const twice = await db.selectFrom("unit").select("project_id").where("lifecycle", "in", ["active", "candidate"]).groupBy(["project_id", "content_hash"]).having((eb) => eb.fn.countAll(), ">", 1).execute();
18494
18769
  if (twice.length)
18495
18770
  say2("warn", "Records", `${plural(twice.length, "set")} of live records hold the same words. Read them with Sphica's read and withdraw the extra ones with /sphica:glean`);
18771
+ await adoptionStops(db, q.waiting, say2);
18496
18772
  const { found, ambiguous } = localRoots();
18497
18773
  const homes = [os4.homedir()];
18498
18774
  try {