sphica 0.6.28 → 0.6.30

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/db/schema.sql CHANGED
@@ -213,6 +213,42 @@ create index edit_observation_path on edit_observation (path);
213
213
 
214
214
  -- One extraction by trace, harvest, or glean. target names what it read: `session:<uuid>`, `pr:<n>`, or `glean`.
215
215
  -- A migration that changes lifecycles records itself as a run too (origin migration, target `revision:<n>`), so each change names where it came from.
216
+ -- Each record tool call as the record server saw it, written and committed before the call does anything, so a failed call keeps its row.
217
+ -- Who called comes from what the host passes (caller.ts): Codex's session and turn, or Claude Code's tool use id, which the PreToolUse
218
+ -- hook's tool_call_observation joins to a session and turn.
219
+ create table record_call (
220
+ id integer primary key autoincrement not null,
221
+ project_id integer not null references project (id) on delete cascade,
222
+ tool text not null check (tool <> ''),
223
+ host text check (host in ('claude-code', 'codex')),
224
+ caller_session text,
225
+ caller_turn text,
226
+ tool_use_id text,
227
+ mode text not null check (mode in ('interactive', 'headless', 'sdk', 'unknown')),
228
+ mode_raw text,
229
+ called_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', called_at) is called_at)
230
+ ) strict;
231
+ create index record_call_project on record_call (project_id, host, called_at);
232
+ create index record_call_caller on record_call (host, caller_session, caller_turn);
233
+ create index record_call_tool_use on record_call (tool_use_id) where tool_use_id is not null;
234
+ create trigger record_call_frozen before update on record_call begin
235
+ select raise(abort, 'record tool calls are never changed');
236
+ end;
237
+
238
+ -- Record tool calls as Claude Code's PreToolUse hook saw them, written before the tool runs: the hook input names the session and turn
239
+ -- the MCP call itself does not carry.
240
+ create table tool_call_observation (
241
+ id integer primary key autoincrement not null,
242
+ host text not null check (host in ('claude-code', 'codex')),
243
+ session_external text not null check (session_external <> ''),
244
+ turn_id text,
245
+ tool_use_id text not null check (tool_use_id <> ''),
246
+ tool_name text not null check (tool_name <> ''),
247
+ owner_turn integer not null check (owner_turn in (0, 1)),
248
+ observed_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', observed_at) is observed_at),
249
+ unique (host, tool_use_id)
250
+ ) strict;
251
+
216
252
  create table extraction_run (
217
253
  id integer primary key autoincrement not null,
218
254
  project_id integer not null references project (id) on delete cascade,
@@ -223,18 +259,25 @@ create table extraction_run (
223
259
  input_bytes integer check (input_bytes >= 0),
224
260
  -- The CLI-issued draft this run saves. A saved run's draft saves nothing again; the draft is bound to this run's project and target
225
261
  draft_id text unique,
262
+ -- The record tool call that began this run; save compares its own caller with it
263
+ begin_call_id integer references record_call (id),
226
264
  started_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', started_at) is started_at),
227
265
  finished_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', finished_at) is finished_at),
228
266
  check (finished_at >= started_at)
229
267
  ) strict;
230
268
  create index extraction_run_project on extraction_run (project_id);
231
269
  create index extraction_run_session on extraction_run (session_id) where session_id is not null;
270
+ create index extraction_run_begin_call on extraction_run (begin_call_id) where begin_call_id is not null;
232
271
 
233
272
  -- A run changes once, when it finishes: only a running run takes a status and a finish time. The one other update is the foreign key
234
273
  -- action clearing session_id after its session was deleted
274
+ create trigger extraction_run_call_project before insert on extraction_run when new.begin_call_id is not null begin
275
+ select raise(abort, 'a run begins from a record tool call of its own project')
276
+ where (select project_id from record_call where id = new.begin_call_id) is not new.project_id;
277
+ end;
235
278
  create trigger extraction_run_frozen before update on extraction_run
236
279
  when new.id is not old.id or new.project_id is not old.project_id or new.origin is not old.origin or new.target is not old.target
237
- or new.input_bytes is not old.input_bytes or new.draft_id is not old.draft_id
280
+ or new.input_bytes is not old.input_bytes or new.draft_id is not old.draft_id or new.begin_call_id is not old.begin_call_id
238
281
  or new.started_at is not old.started_at
239
282
  or (new.session_id is not old.session_id and (new.session_id is not null or exists (select 1 from session where id = old.session_id)))
240
283
  or ((new.status is not old.status or new.finished_at is not old.finished_at) and old.status <> 'running') begin
@@ -341,7 +384,7 @@ create table unit_evidence (
341
384
  source_id integer not null references source (id) on delete cascade,
342
385
  span_start integer not null check (span_start >= 0),
343
386
  span_end integer not null check (span_end > span_start),
344
- role text not null check (role in ('states', 'proposes', 'rejects', 'explains', 'implements', 'reconsiders')),
387
+ role text not null check (role in ('states', 'proposes', 'rejects', 'explains', 'implements', 'reconsiders', 'decides')),
345
388
  -- A third party the owner reported ("X said ..."): hearsay by the owner, never X's own statement
346
389
  reported_speaker text,
347
390
  run_id integer not null references extraction_run (id),
@@ -367,12 +410,13 @@ create index unit_evidence_option on unit_evidence (unit_id, option_id);
367
410
  create index unit_evidence_retraction on unit_evidence (retraction_source_id) where retraction_source_id is not null;
368
411
  create index unit_evidence_run on unit_evidence (run_id);
369
412
 
370
- -- Evidence that the project adopted a decision or constraint. route: owner_statement (an owner-kind source span) or
371
- -- explicit (an explicit disposition in a source, such as a maintainer's reply saying it is adopted). A merge or a resolved thread is never adoption.
413
+ -- Evidence that the project adopted a decision or constraint. route: owner_statement (an owner-kind source span),
414
+ -- explicit (an explicit disposition in a source, such as a maintainer's reply saying it is adopted), or agent (the AI deciding in its own
415
+ -- reply, paired with the same span's decides evidence, saved by an interactive session's run). A merge or a resolved thread is never adoption.
372
416
  create table unit_adoption (
373
417
  id integer primary key autoincrement not null,
374
418
  unit_id integer not null references unit (id) on delete cascade,
375
- route text not null check (route in ('owner_statement', 'explicit')),
419
+ route text not null check (route in ('owner_statement', 'explicit', 'agent')),
376
420
  source_id integer not null references source (id) on delete cascade,
377
421
  span_start integer not null check (span_start >= 0),
378
422
  span_end integer not null check (span_end > span_start),
@@ -405,6 +449,20 @@ create trigger unit_adoption_route before insert on unit_adoption begin
405
449
  where exists (select 1 from source where id = new.source_id and kind = 'pr_event');
406
450
  select raise(abort, 'adoption applies to decisions and constraints')
407
451
  where not exists (select 1 from unit where id = new.unit_id and kind in ('decision', 'constraint'));
452
+ select raise(abort, 'agent adoption pairs with live decides evidence on the same span of the unit')
453
+ where new.route = 'agent' and not exists (select 1 from unit_evidence e where e.unit_id = new.unit_id and e.option_id is null
454
+ and e.role = 'decides' and e.source_id = new.source_id and e.span_start = new.span_start and e.span_end = new.span_end
455
+ and e.retracted_at is null);
456
+ select raise(abort, 'agent adoption needs a trace run')
457
+ where new.route = 'agent' and not exists (select 1 from extraction_run where id = new.run_id and origin = 'trace');
458
+ select raise(abort, 'agent adoption needs a run begun by an interactive session')
459
+ where new.route = 'agent' and not exists (select 1 from extraction_run r join record_call c on c.id = r.begin_call_id
460
+ where r.id = new.run_id and c.mode = 'interactive');
461
+ select raise(abort, 'agent adoption cannot cite a reply from a turn that ran a record tool, or one no record tool call can be placed away from')
462
+ where new.route = 'agent' and exists (select 1 from agent_ineligible_source where source_id = new.source_id);
463
+ select raise(abort, 'agent adoption cites a reply of the session the trace reads')
464
+ where new.route = 'agent' and not exists (select 1 from source s join extraction_run r on r.id = new.run_id
465
+ where s.id = new.source_id and s.session_id = r.session_id);
408
466
  end;
409
467
 
410
468
  create table unit_link (
@@ -423,6 +481,8 @@ create table unit_link (
423
481
  ) strict;
424
482
  -- Lookups by the unit a link points at: its successors, and its conflicts from either side
425
483
  create index unit_link_to on unit_link (to_unit, kind);
484
+ -- A record means to replace at most one other (the record input has one supersedes)
485
+ create unique index unit_link_one_intent on unit_link (from_unit) where kind = 'supersedes';
426
486
  create index unit_link_run on unit_link (run_id);
427
487
  create trigger unit_link_frozen before update on unit_link begin
428
488
  select raise(abort, 'links are frozen; only an unresolved conflict can be resolved, once')
@@ -441,6 +501,91 @@ create trigger unit_link_supersedes_acyclic before insert on unit_link when new.
441
501
  select 1 from chain where id = new.from_unit);
442
502
  end;
443
503
 
504
+ -- When a replacement is in effect: a supersedes link is the intent, and a row here says it took effect, from when, and until when. One open
505
+ -- row per replaced record is its successor place. Saves write rows only as judge decides (server/src/judge.ts); a row ends once and is
506
+ -- never rewritten, and a replacement that takes effect again is a new row.
507
+ create table unit_replacement (
508
+ id integer primary key autoincrement not null,
509
+ from_unit integer not null references unit (id) on delete cascade,
510
+ to_unit integer not null references unit (id) on delete cascade,
511
+ run_id integer references extraction_run (id),
512
+ forget_id integer references forget_batch (id),
513
+ started_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', started_at) is started_at),
514
+ ended_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', ended_at) is ended_at),
515
+ end_reason text,
516
+ end_run_id integer references extraction_run (id),
517
+ end_forget_id integer references forget_batch (id),
518
+ check ((run_id is null) <> (forget_id is null)),
519
+ check ((ended_at is null) = (end_reason is null)),
520
+ check (ended_at is null or (end_run_id is null) <> (end_forget_id is null)),
521
+ check (ended_at is not null or (end_run_id is null and end_forget_id is null)),
522
+ check (ended_at >= started_at),
523
+ check (from_unit <> to_unit)
524
+ ) strict;
525
+ create unique index unit_replacement_place on unit_replacement (to_unit) where ended_at is null;
526
+ create index unit_replacement_from on unit_replacement (from_unit);
527
+ create index unit_replacement_to on unit_replacement (to_unit);
528
+ create index unit_replacement_run on unit_replacement (run_id) where run_id is not null;
529
+ create index unit_replacement_forget on unit_replacement (forget_id) where forget_id is not null;
530
+ create index unit_replacement_end_run on unit_replacement (end_run_id) where end_run_id is not null;
531
+ create index unit_replacement_end_forget on unit_replacement (end_forget_id) where end_forget_id is not null;
532
+ create trigger unit_replacement_check before insert on unit_replacement begin
533
+ select raise(abort, 'a replacement''s cause belongs to the project of the records it joins')
534
+ where exists (select 1 from extraction_run where id = new.run_id
535
+ and project_id is not (select project_id from unit where id = new.to_unit))
536
+ or exists (select 1 from forget_batch where id = new.forget_id
537
+ and project_id is not (select project_id from unit where id = new.to_unit));
538
+ select raise(abort, 'a replacement takes effect only from the record''s own intent to replace that one')
539
+ where not exists (select 1 from unit_link where from_unit = new.from_unit and to_unit = new.to_unit and kind = 'supersedes');
540
+ select raise(abort, 'a replacement starts open')
541
+ where new.ended_at is not null;
542
+ -- A record whose source is gone can be replaced (that is how it is fixed); a quarantined one, never active, cannot
543
+ select raise(abort, 'a replacement takes effect only from a sound successor into a record not quarantined nor withdrawn')
544
+ where exists (select 1 from unit where id = new.from_unit and (extraction <> 'supported' or unsourced = 1 or lifecycle = 'withdrawn'))
545
+ or exists (select 1 from unit where id = new.to_unit and (extraction <> 'supported' or lifecycle = 'withdrawn'));
546
+ -- Of a decision or constraint, only the owner's or a maintainer's adoption lets a replacement take effect, whatever it replaces
547
+ select raise(abort, 'a decision or constraint replaces another only with the owner''s or a maintainer''s adoption')
548
+ where exists (select 1 from unit where id = new.from_unit and kind in ('decision', 'constraint'))
549
+ and not exists (select 1 from unit_adoption where unit_id = new.from_unit and route in ('owner_statement', 'explicit') and retracted_at is null);
550
+ select raise(abort, 'a replacement takes effect only from a sound successor with full support')
551
+ where exists (select 1 from unit_support where unit_id = new.from_unit and missing is not null);
552
+ end;
553
+ create trigger unit_replacement_end before update on unit_replacement begin
554
+ select raise(abort, 'a replacement''s cause belongs to the project of the records it joins')
555
+ where exists (select 1 from extraction_run where id = new.end_run_id
556
+ and project_id is not (select project_id from unit where id = new.to_unit))
557
+ or exists (select 1 from forget_batch where id = new.end_forget_id
558
+ and project_id is not (select project_id from unit where id = new.to_unit));
559
+ select raise(abort, 'a replacement only ever ends, once')
560
+ where old.ended_at is not null or new.ended_at is null or new.id is not old.id or new.from_unit is not old.from_unit
561
+ or new.to_unit is not old.to_unit or new.run_id is not old.run_id or new.forget_id is not old.forget_id
562
+ or new.started_at is not old.started_at;
563
+ end;
564
+ create trigger unit_replacement_no_delete before delete on unit_replacement
565
+ when exists (select 1 from unit where id = old.from_unit) and exists (select 1 from unit where id = old.to_unit) begin
566
+ select raise(abort, 'replacements are history and are never removed');
567
+ end;
568
+ create trigger unit_rev_replacement_i after insert on unit_replacement begin
569
+ update unit set revision = revision + 1 where id in (new.from_unit, new.to_unit);
570
+ end;
571
+ create trigger unit_rev_replacement_u after update on unit_replacement begin
572
+ update unit set revision = revision + 1 where id in (new.from_unit, new.to_unit);
573
+ end;
574
+
575
+ -- An intent whose earlier effect the update to revision 10 could not date: read says the history was not recorded, rather than calling it
576
+ -- a proposal that never took effect. Written only by that migration.
577
+ create table unit_replacement_gap (
578
+ from_unit integer not null references unit (id) on delete cascade,
579
+ to_unit integer not null references unit (id) on delete cascade,
580
+ run_id integer not null references extraction_run (id),
581
+ primary key (from_unit, to_unit)
582
+ ) strict;
583
+ create index unit_replacement_gap_to on unit_replacement_gap (to_unit);
584
+ create index unit_replacement_gap_run on unit_replacement_gap (run_id);
585
+ create trigger unit_replacement_gap_frozen before update on unit_replacement_gap begin
586
+ select raise(abort, 'a gap in replacement history is never changed');
587
+ end;
588
+
444
589
  -- Lifecycle history and the only route for lifecycle changes. The trigger checks the rules and then sets unit.lifecycle.
445
590
  -- A change comes from an extraction run, or from the owner forgetting sources (forget_id). source_id becomes null when its source is forgotten.
446
591
  create table unit_state (
@@ -466,28 +611,27 @@ create trigger unit_state_rules before insert on unit_state begin
466
611
  select raise(abort, 'from_state must be the current lifecycle')
467
612
  where new.from_state is not (select lifecycle from unit where id = new.unit_id)
468
613
  and exists (select 1 from unit_state where unit_id = new.unit_id);
469
- -- A successor's state is read from its history, not its lifecycle column: a row written in the same statement may not be applied yet
470
- select raise(abort, 'not a lifecycle change a unit can make: withdrawn is final, and a superseded unit only returns to candidate once every successor is withdrawn')
614
+ select raise(abort, 'not a lifecycle change a unit can make: withdrawn is final, and a superseded unit comes back only once nothing replaces it')
471
615
  where exists (select 1 from unit_state where unit_id = new.unit_id) and not (
472
616
  (new.from_state = 'candidate' and new.to_state in ('active', 'superseded', 'withdrawn'))
473
617
  or (new.from_state = 'active' and new.to_state in ('candidate', 'superseded', 'withdrawn'))
474
- or (new.from_state = 'superseded' and new.to_state = 'candidate' and not exists (
475
- select 1 from unit_link l join unit s on s.id = l.from_unit where l.to_unit = new.unit_id and l.kind = 'supersedes'
476
- and s.extraction = 'supported' and s.unsourced = 0
477
- and (select to_state from unit_state where unit_id = l.from_unit order by id desc limit 1) is not 'withdrawn')));
618
+ or (new.from_state = 'superseded' and new.to_state in ('candidate', 'active')
619
+ and not exists (select 1 from unit_replacement where to_unit = new.unit_id and ended_at is null)));
478
620
  select raise(abort, 'a quarantined or unsourced unit cannot become active')
479
621
  where new.to_state = 'active' and exists (select 1 from unit where id = new.unit_id and (extraction <> 'supported' or unsourced = 1));
480
622
  select raise(abort, (select missing from unit_support where unit_id = new.unit_id))
481
623
  where new.to_state = 'active' and (select missing from unit_support where unit_id = new.unit_id) is not null;
482
- -- A reconsider condition is the owner's: each needs a quote of the owner, written when the unit is saved. A quote retracted later, or
483
- -- forgotten (forget's recheck is exempt, since the row is gone), leaves the unit as it was, and readers show the condition as unsupported
624
+ -- A reconsider condition is the owner's: each needs a quote of the owner before the unit first becomes active. A quote retracted or
625
+ -- forgotten later leaves the unit as it was (it may come back to active), and readers show the condition as unsupported
484
626
  select raise(abort, 'a reconsider condition needs a quote of the owner')
485
- where new.to_state = 'active' and new.forget_id is null and exists (select 1 from unit_option o where o.unit_id = new.unit_id
627
+ where new.to_state = 'active' and new.forget_id is null
628
+ and not exists (select 1 from unit_state where unit_id = new.unit_id and to_state = 'active') and exists (select 1 from unit_option o where o.unit_id = new.unit_id
486
629
  and o.reconsider_when is not null and not exists (select 1 from unit_evidence e join source s on s.id = e.source_id
487
630
  where e.option_id = o.id and e.role = 'reconsiders' and s.author_kind = 'owner'));
488
- select raise(abort, 'superseded needs a supersedes link from an active successor')
489
- where new.to_state = 'superseded' and not exists (select 1 from unit_link l join unit s on s.id = l.from_unit
490
- where l.to_unit = new.unit_id and l.kind = 'supersedes' and s.lifecycle = 'active');
631
+ select raise(abort, 'superseded needs a replacement in effect into it')
632
+ where new.to_state = 'superseded' and not exists (select 1 from unit_replacement where to_unit = new.unit_id and ended_at is null);
633
+ select raise(abort, 'a unit something replaces is superseded, not active')
634
+ where new.to_state = 'active' and exists (select 1 from unit_replacement where to_unit = new.unit_id and ended_at is null);
491
635
  end;
492
636
  -- The one update allowed is the foreign key action clearing source_id after its source was forgotten
493
637
  create trigger unit_state_append_only before update on unit_state
@@ -502,17 +646,6 @@ end;
502
646
  create trigger unit_state_apply after insert on unit_state begin
503
647
  update unit set lifecycle = new.to_state, revision = revision + 1 where id = new.unit_id;
504
648
  end;
505
- -- Withdrawing a record's last live successor brings the record back to candidate, to be judged again. Successors are read from history:
506
- -- the withdrawal just written may not be applied to its unit yet
507
- create trigger unit_state_restore after insert on unit_state when new.to_state = 'withdrawn' begin
508
- insert into unit_state (unit_id, from_state, to_state, at, reason, source_id, run_id, forget_id)
509
- select o.id, 'superseded', 'candidate', new.at, 'its successor was withdrawn', new.source_id, new.run_id, new.forget_id
510
- from unit_link l join unit o on o.id = l.to_unit
511
- where l.from_unit = new.unit_id and l.kind = 'supersedes' and o.lifecycle = 'superseded'
512
- and not exists (select 1 from unit_link k join unit s on s.id = k.from_unit where k.to_unit = o.id and k.kind = 'supersedes'
513
- and k.from_unit <> new.unit_id and s.extraction = 'supported' and s.unsourced = 0
514
- and (select to_state from unit_state where unit_id = k.from_unit order by id desc limit 1) is not 'withdrawn');
515
- end;
516
649
 
517
650
  -- Where a unit applies in code, or code cited as evidence. Validated against the working tree when served, never cached here.
518
651
  create table unit_anchor (
@@ -561,11 +694,32 @@ end;
561
694
  -- What an active unit must have, in one place: missing says what it lacks, and is null when it lacks nothing. Activating reads it, and so
562
695
  -- does every change that can take support away (a retraction, retiring an anchor), so the two never disagree.
563
696
  -- Evidence on an option supports the option, never the unit.
697
+ -- Assistant replies that never carry agent adoption: from a turn that ran a record tool (a trace report must not become a decision), from a
698
+ -- session whose call named no turn, and, for a call no hook or host placed, every reply of its project and host from that call on.
699
+ create view agent_ineligible_source as
700
+ select s.id as source_id from source s join session se on se.id = s.session_id
701
+ where s.kind = 'session_message' and s.author_kind = 'assistant' and (
702
+ -- A reply with no turn cannot be placed apart from a turn that ran a record tool
703
+ s.turn_id is null
704
+ or exists (select 1 from record_call c where c.host = 'codex' and se.host = 'codex' and c.caller_session = se.external_id
705
+ and (c.caller_turn is null or c.caller_turn = s.turn_id))
706
+ -- The hook's row alone counts: the MCP SDK refuses a malformed call before the server can log it
707
+ or exists (select 1 from tool_call_observation o where o.host = 'claude-code' and se.host = 'claude-code'
708
+ and o.session_external = se.external_id
709
+ -- An observation with no turn places the call only in time, like an unplaced call: replies from then on
710
+ and (o.turn_id = s.turn_id or (o.turn_id is null and s.created_at >= o.observed_at)))
711
+ or exists (select 1 from record_call c where c.project_id = se.project_id and (c.host is null or c.host = se.host)
712
+ and (c.host is null or (c.host = 'codex' and c.caller_session is null)
713
+ or (c.host = 'claude-code' and not exists (select 1 from tool_call_observation o where o.host = 'claude-code' and o.tool_use_id = c.tool_use_id)))
714
+ and s.created_at >= c.called_at));
715
+
564
716
  create view unit_support as
565
717
  select u.id as unit_id, case
566
718
  when u.kind in ('decision', 'constraint') and (
567
719
  not exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.retracted_at is null)
568
- or not exists (select 1 from unit_adoption a where a.unit_id = u.id and a.retracted_at is null))
720
+ or not exists (select 1 from unit_adoption a where a.unit_id = u.id and a.retracted_at is null
721
+ and (a.route <> 'agent' or exists (select 1 from unit_evidence e where e.unit_id = u.id and e.option_id is null and e.role = 'decides'
722
+ and e.source_id = a.source_id and e.span_start = a.span_start and e.span_end = a.span_end and e.retracted_at is null))))
569
723
  then 'an active decision or constraint needs unretracted evidence and adoption'
570
724
  when u.kind = 'implementation' and not (
571
725
  exists (select 1 from unit_evidence e join source s on s.id = e.source_id where e.unit_id = u.id and e.option_id is null
@@ -735,6 +889,10 @@ create trigger unit_evidence_check before insert on unit_evidence begin
735
889
  where new.role = 'reconsiders' and (new.option_id is null
736
890
  or not exists (select 1 from unit_option where id = new.option_id and reconsider_when is not null)
737
891
  or not exists (select 1 from source where id = new.source_id and author_kind = 'owner'));
892
+ -- The AI stating its own choice in a reply: never a question it asked (AskUserQuestion's questions are recorded as its message)
893
+ select raise(abort, 'decides quotes the AI choosing in its own reply, never a question it asked or an option')
894
+ where new.role = 'decides' and (new.option_id is not null or not exists (select 1 from source where id = new.source_id
895
+ and kind = 'session_message' and author_kind = 'assistant' and external_id not glob '*:ask:*:q:*'));
738
896
  end;
739
897
  create trigger unit_evidence_retract before update on unit_evidence begin
740
898
  select raise(abort, 'evidence is only ever retracted, once')
@@ -762,22 +920,6 @@ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 fro
762
920
  and not (old.retracted_at is not null and not exists (select 1 from source where id = old.retraction_source_id)) begin
763
921
  select raise(abort, 'adoption is retracted, never deleted');
764
922
  end;
765
- -- A retraction, or retiring an anchor, that would leave an active unit without its required support must first move it back to candidate
766
- create trigger unit_evidence_retract_support after update of retracted_at on unit_evidence
767
- when exists (select 1 from unit u join unit_support s on s.unit_id = u.id
768
- where u.id = new.unit_id and u.lifecycle = 'active' and s.missing is not null) begin
769
- select raise(abort, 'move the unit back to candidate before retracting its last evidence');
770
- end;
771
- create trigger unit_adoption_retract_support after update of retracted_at on unit_adoption
772
- when exists (select 1 from unit u join unit_support s on s.unit_id = u.id
773
- where u.id = new.unit_id and u.lifecycle = 'active' and s.missing is not null) begin
774
- select raise(abort, 'move the unit back to candidate before retracting its last adoption');
775
- end;
776
- create trigger unit_anchor_retire_support after update of retired_at on unit_anchor
777
- when exists (select 1 from unit u join unit_support s on s.unit_id = u.id
778
- where u.id = new.unit_id and u.lifecycle = 'active' and s.missing is not null) begin
779
- select raise(abort, 'move the unit back to candidate before retiring its last code anchor');
780
- end;
781
923
  create trigger unit_adoption_check before insert on unit_adoption begin
782
924
  select raise(abort, 'adoption and unit belong to different projects')
783
925
  where (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id)
@@ -810,14 +952,6 @@ create trigger unit_link_check before insert on unit_link begin
810
952
  select raise(abort, 'a record supersedes one of its own kind; a decision and a constraint can replace each other')
811
953
  where new.kind = 'supersedes' and not exists (select 1 from unit a join unit b on b.id = new.to_unit where a.id = new.from_unit
812
954
  and (a.kind = b.kind or (a.kind in ('decision', 'constraint') and b.kind in ('decision', 'constraint'))));
813
- -- One live successor at a time. A withdrawn one gives its place up, and a quarantined or unsourced one never takes it: it can never
814
- -- become active, nor be withdrawn. States are read from history, as in unit_state_rules
815
- select raise(abort, 'the record already has a successor that is not withdrawn')
816
- where new.kind = 'supersedes'
817
- and exists (select 1 from unit n where n.id = new.from_unit and n.extraction = 'supported' and n.unsourced = 0)
818
- and exists (select 1 from unit_link l join unit s on s.id = l.from_unit
819
- where l.to_unit = new.to_unit and l.kind = 'supersedes' and s.extraction = 'supported' and s.unsourced = 0
820
- and (select to_state from unit_state where unit_id = l.from_unit order by id desc limit 1) is not 'withdrawn');
821
955
  end;
822
956
  create trigger unit_state_project before insert on unit_state begin
823
957
  select raise(abort, 'a state comes after its unit was created')
@@ -1032,6 +1166,13 @@ create trigger capture_edit_insert instead of insert on capture_edit begin
1032
1166
  select new.session_id, new.turn_id, new.tool_event_id, new.path, new.via, new.observed_at
1033
1167
  where exists (select 1 from session where id = new.session_id) on conflict do nothing;
1034
1168
  end;
1169
+ create view capture_tool_call as
1170
+ select host, session_external, turn_id, tool_use_id, tool_name, owner_turn, observed_at from tool_call_observation;
1171
+ create trigger capture_tool_call_insert instead of insert on capture_tool_call begin
1172
+ insert into tool_call_observation (host, session_external, turn_id, tool_use_id, tool_name, owner_turn, observed_at)
1173
+ values (new.host, new.session_external, new.turn_id, new.tool_use_id, new.tool_name, new.owner_turn, new.observed_at)
1174
+ on conflict do nothing;
1175
+ end;
1035
1176
  -- units is a JSON array of the unit ids delivered; each must belong to the delivered session's project
1036
1177
  create view capture_delivery as
1037
1178
  select session_id, event, outcome, reason, path, eligible, omitted, chars, at, null as units from delivery;
@@ -1070,4 +1211,4 @@ create trigger capture_delivery_prune_insert instead of insert on capture_delive
1070
1211
  order by d.at, d.id limit 200);
1071
1212
  end;
1072
1213
 
1073
- pragma user_version = 9;
1214
+ pragma user_version = 10;
package/dist/capture.js CHANGED
@@ -11129,7 +11129,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
11129
11129
 
11130
11130
  // server/src/sqlite.ts
11131
11131
  var SCHEMA_GENERATION = 2;
11132
- var SCHEMA_REVISION = 9;
11132
+ var SCHEMA_REVISION = 10;
11133
11133
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
11134
11134
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
11135
11135
  function requireRuntime() {
@@ -11269,6 +11269,7 @@ var CAPTURE_VIEWS = new Set([
11269
11269
  "capture_session",
11270
11270
  "capture_message",
11271
11271
  "capture_edit",
11272
+ "capture_tool_call",
11272
11273
  "capture_delivery",
11273
11274
  "capture_delivery_scoped",
11274
11275
  "capture_delivery_prune"
@@ -11277,6 +11278,7 @@ var TRIGGER_WRITES = {
11277
11278
  capture_session_insert: new Set(["session"]),
11278
11279
  capture_message_insert: new Set(["source"]),
11279
11280
  capture_edit_insert: new Set(["edit_observation"]),
11281
+ capture_tool_call_insert: new Set(["tool_call_observation"]),
11280
11282
  capture_delivery_insert: new Set(["delivery", "delivery_unit"]),
11281
11283
  capture_delivery_scoped_insert: new Set(["delivery", "delivery_unit"]),
11282
11284
  source_fts_ai: new Set(["source_fts"])
@@ -11324,6 +11326,7 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
11324
11326
  var writes = (action) => action === C2.SQLITE_INSERT || action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE;
11325
11327
  var INGEST_INSERTS = new Set([
11326
11328
  "project",
11329
+ "record_call",
11327
11330
  "ingest_source",
11328
11331
  "extraction_run",
11329
11332
  "source_processing",
@@ -11333,6 +11336,7 @@ var INGEST_INSERTS = new Set([
11333
11336
  "unit_adoption",
11334
11337
  "unit_link",
11335
11338
  "unit_state",
11339
+ "unit_replacement",
11336
11340
  "unit_anchor",
11337
11341
  "unit_alias",
11338
11342
  "field_def",
@@ -11353,6 +11357,7 @@ var INGEST_UPDATES = {
11353
11357
  unit_link: new Set(["resolved_at", "resolution"]),
11354
11358
  unit_evidence: new Set(RETRACTION),
11355
11359
  unit_adoption: new Set(RETRACTION),
11360
+ unit_replacement: new Set(["ended_at", "end_reason", "end_run_id"]),
11356
11361
  work: new Set(["title", "goal", "current", "next", "status", "branch", "run_id", "updated_at"])
11357
11362
  };
11358
11363
  var INGEST_DELETES = new Set(["artifact_link"]);
@@ -11361,7 +11366,6 @@ var INGEST_TRIGGER_WRITES = {
11361
11366
  source_fts_ai: ["insert source_fts"],
11362
11367
  source_fts_ad: ["delete source_fts"],
11363
11368
  unit_state_apply: ["update unit"],
11364
- unit_state_restore: ["insert unit_state"],
11365
11369
  delivery_ad: ["delete delivery_unit"],
11366
11370
  ...Object.fromEntries([
11367
11371
  "evidence_i",
@@ -11372,6 +11376,8 @@ var INGEST_TRIGGER_WRITES = {
11372
11376
  "adoption_d",
11373
11377
  "link_i",
11374
11378
  "link_u",
11379
+ "replacement_i",
11380
+ "replacement_u",
11375
11381
  "anchor_i",
11376
11382
  "anchor_u",
11377
11383
  "alias_i",
@@ -11406,7 +11412,14 @@ function ingestAuthorizer(action, p1, p2, trigger) {
11406
11412
  return C2.SQLITE_DENY;
11407
11413
  }
11408
11414
  var FORGET_WRITES = {
11409
- [C2.SQLITE_INSERT]: new Set(["forget_batch", "source_forgotten", "unit_state", "source_fts", "unit_fts"]),
11415
+ [C2.SQLITE_INSERT]: new Set([
11416
+ "forget_batch",
11417
+ "source_forgotten",
11418
+ "unit_state",
11419
+ "unit_replacement",
11420
+ "source_fts",
11421
+ "unit_fts"
11422
+ ]),
11410
11423
  [C2.SQLITE_DELETE]: new Set([
11411
11424
  "source",
11412
11425
  "unit_evidence",
@@ -11420,7 +11433,8 @@ var FORGET_WRITES = {
11420
11433
  };
11421
11434
  var FORGET_UPDATES = {
11422
11435
  unit: new Set(["lifecycle", "revision"]),
11423
- unit_state: new Set(["source_id"])
11436
+ unit_state: new Set(["source_id"]),
11437
+ unit_replacement: new Set(["ended_at", "end_reason", "end_forget_id"])
11424
11438
  };
11425
11439
  function forgetAuthorizer(action, p1, p2) {
11426
11440
  if (SHADOW.test(p1 ?? "") && writes(action))
@@ -12265,6 +12279,30 @@ async function readInput(stream) {
12265
12279
  raw += chunk;
12266
12280
  return JSON.parse(raw || "{}");
12267
12281
  }
12282
+ var RECORD_TOOLS = "mcp__plugin_sphica_record__";
12283
+ var OBSERVE_WAIT_MS = 5000;
12284
+ var hostId = (v) => v && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
12285
+ async function observeRecordCall(file, input, owner) {
12286
+ const session = hostId(input.session_id);
12287
+ const toolUse = hostId(input.tool_use_id);
12288
+ const tool = hostId(input.tool_name);
12289
+ if (!session || !toolUse || !tool?.startsWith(RECORD_TOOLS))
12290
+ return;
12291
+ const cap = openWriter("capture", file, OBSERVE_WAIT_MS);
12292
+ try {
12293
+ await cap.insertInto("capture_tool_call").values({
12294
+ host: "claude-code",
12295
+ session_external: session,
12296
+ turn_id: hostId(input.prompt_id),
12297
+ tool_use_id: toolUse,
12298
+ tool_name: tool,
12299
+ owner_turn: owner ? 1 : 0,
12300
+ observed_at: iso(Date.now())
12301
+ }).execute();
12302
+ } finally {
12303
+ await cap.destroy().catch(() => {});
12304
+ }
12305
+ }
12268
12306
  async function main() {
12269
12307
  if (process.argv[2] === "--flush") {
12270
12308
  await flush();
@@ -12272,6 +12310,10 @@ async function main() {
12272
12310
  }
12273
12311
  const input = await readInput(process.stdin);
12274
12312
  const host = process.argv[2] === "codex" ? "codex" : "claude-code";
12313
+ if (host === "claude-code" && input.hook_event_name === "PreToolUse") {
12314
+ await observeRecordCall(dbFile(), input, isOwnerTurn(input));
12315
+ return;
12316
+ }
12275
12317
  if (host === "codex" && input.hook_event_name === "Stop")
12276
12318
  process.stdout.write("{}");
12277
12319
  const { flush: send, notice } = onHook(host, input);
@@ -12300,6 +12342,7 @@ export {
12300
12342
  fit,
12301
12343
  flush,
12302
12344
  isOwnerTurn,
12345
+ observeRecordCall,
12303
12346
  onHook,
12304
12347
  openTurn,
12305
12348
  readInput,