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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +11 -5
- package/db/migrations/0010.check.sql +11 -0
- package/db/migrations/0010.sql +931 -0
- package/db/schema.sql +195 -54
- package/dist/capture.js +47 -4
- package/dist/cli.js +277 -4
- package/dist/deliver.js +187 -49
- package/dist/mcp-record.js +859 -308
- package/dist/mcp.js +277 -183
- package/hooks/hooks.json +13 -0
- package/package.json +1 -1
- package/skills/export/SKILL.md +3 -2
- package/skills/glean/SKILL.md +1 -1
- package/skills/review/reviewers/precedent.md +6 -1
- package/skills/rules/SKILL.md +5 -2
- package/skills/trace/SKILL.md +43 -7
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)
|
|
371
|
-
-- explicit (an explicit disposition in a source, such as a maintainer's reply saying it is adopted)
|
|
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
|
-
|
|
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
|
|
475
|
-
select 1 from
|
|
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
|
|
483
|
-
-- forgotten
|
|
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
|
|
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
|
|
489
|
-
where new.to_state = 'superseded' and not exists (select 1 from
|
|
490
|
-
|
|
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 =
|
|
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 =
|
|
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([
|
|
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,
|