sphica 0.6.3 → 0.6.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "sphica",
4
- "version": "0.6.3",
4
+ "version": "0.6.5",
5
5
  "description": "Records Claude Code and Codex sessions on your machine and keeps past implementation and decisions, with their sources, for your agent to find.",
6
6
  "author": {
7
7
  "name": "iroha924",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Records Claude Code and Codex sessions on your machine and keeps past implementation and decisions, with their sources, for your agent to find.",
5
5
  "author": {
6
6
  "name": "iroha924",
package/README.md CHANGED
@@ -26,6 +26,8 @@ The database is a single SQLite file on your machine.
26
26
  - **Shown when it matters.** At session start, the current work; before the agent reads or edits a file, or runs a shell command that names it, the decisions tied to that file; when your prompt names a recorded option or code symbol, that record. Works in both Claude Code and Codex.
27
27
  - **Search in Japanese and English.** Records are made with search words in both languages, so a question in either language is more likely to find them.
28
28
  - **Find what you asked before.** Ask the agent whether you asked something like this before: `search` with `asked: true` shows your earlier messages in other sessions, the records that quote them (with what replaced them), and says "no recorded decision" when none was recorded, including a matter you raised in several sessions.
29
+ - **See what is live, and what needs a look.** Ask for the overview: `view: "live"` lists every active decision and constraint by the directory it applies to; `view: "look"` lists records whose file is gone or whose symbol is not found, conditions you said would bring a rejected option back, and lines in your instruction files whose record was replaced. Nothing is expired or changed on its own.
30
+ - **Rule text from records.** `/sphica:rules` drafts lines for CLAUDE.md, AGENTS.md, or `.claude/rules` from the constraints and decisions you pick, each marked with its record key so the overview flags it once the record changes. It never edits the file.
29
31
  - **Reviews check past decisions.** `/sphica:review` runs a reviewer per focus (correctness, security, written conventions, and past decisions by default; redundancy with `full`), and checks the diff against the records it touches.
30
32
 
31
33
  Records are never rewritten: a correction is a new record that supersedes the old one, and the history stays.
@@ -1,4 +1,4 @@
1
- -- Revision 1 → 2 of generation 2: the owner can forget chosen sources (issue #187).
1
+ -- Revision 1 → 2 of generation 2: the owner can forget chosen sources.
2
2
  -- `sphica init` runs this in one transaction with foreign keys off (set outside the transaction), then checks foreign_key_check before
3
3
  -- committing. Every statement matches db/schema.sql at revision 2; server/test/migrate.test.ts compares a migrated database with a fresh one.
4
4
 
@@ -0,0 +1,193 @@
1
+ -- Revision 2 → 3 of generation 2: a rejected option can carry a reconsider condition the owner stated, quoted as `reconsiders` evidence.
2
+ -- `sphica init` runs this in one transaction with foreign keys off (set outside the transaction), then checks foreign_key_check before
3
+ -- committing. Every statement matches db/schema.sql at revision 3; server/test/migrate.test.ts compares a migrated database with a fresh one.
4
+
5
+ -- Rebuild unit_option and unit_evidence (https://www.sqlite.org/lang_altertable.html#otheralter). What names them from elsewhere is dropped
6
+ -- first, or the renames fail on it: the state rules, the search view, and the triggers that read the view. All are created again after.
7
+ drop trigger unit_state_rules;
8
+ drop trigger unit_fts_ai;
9
+ drop trigger unit_fts_anchor_i;
10
+ drop trigger unit_fts_anchor_u;
11
+ drop trigger unit_fts_anchor_d;
12
+ drop trigger unit_fts_alias_i;
13
+ drop trigger unit_fts_alias_d;
14
+ drop view unit_search_text;
15
+
16
+ create table unit_option_new (
17
+ id integer primary key autoincrement not null,
18
+ unit_id integer not null references unit (id) on delete cascade,
19
+ position integer not null check (position > 0),
20
+ text text not null check (text <> ''),
21
+ outcome text not null check (outcome in ('chosen', 'rejected', 'deferred', 'proposed')),
22
+ why text,
23
+ -- For a rejected option, what the owner said would make it worth reconsidering; its words are a `reconsiders` evidence row on the option
24
+ reconsider_when text check (reconsider_when <> ''),
25
+ unique (unit_id, position),
26
+ check (reconsider_when is null or outcome = 'rejected'),
27
+ unique (unit_id, id)
28
+ ) strict;
29
+ insert into unit_option_new (id, unit_id, position, text, outcome, why)
30
+ select id, unit_id, position, text, outcome, why from unit_option;
31
+ -- AUTOINCREMENT never reuses an id: carry the counter over
32
+ delete from sqlite_sequence where name = 'unit_option_new';
33
+ insert into sqlite_sequence (name, seq) select 'unit_option_new', seq from sqlite_sequence where name = 'unit_option';
34
+ create table unit_evidence_new (
35
+ id integer primary key autoincrement not null,
36
+ unit_id integer not null references unit (id) on delete cascade,
37
+ option_id integer,
38
+ source_id integer not null references source (id) on delete cascade,
39
+ span_start integer not null check (span_start >= 0),
40
+ span_end integer not null check (span_end > span_start),
41
+ role text not null check (role in ('states', 'proposes', 'rejects', 'explains', 'implements', 'reconsiders')),
42
+ -- A third party the owner reported ("X said ..."): hearsay by the owner, never X's own statement
43
+ reported_speaker text,
44
+ run_id integer not null references extraction_run (id),
45
+ added_at text not null check (strftime('%Y-%m-%dT%H:%M:%fZ', added_at) is added_at),
46
+ retracted_at text check (strftime('%Y-%m-%dT%H:%M:%fZ', retracted_at) is retracted_at),
47
+ retraction_reason text,
48
+ retraction_source_id integer references source (id),
49
+ retraction_span_start integer,
50
+ retraction_span_end integer,
51
+ foreign key (unit_id, option_id) references unit_option (unit_id, id) on delete cascade,
52
+ check ((retracted_at is null) = (retraction_reason is null)),
53
+ check ((retracted_at is null) = (retraction_source_id is null)),
54
+ check ((retraction_source_id is null) = (retraction_span_start is null)),
55
+ check ((retraction_source_id is null) = (retraction_span_end is null)),
56
+ check (retraction_span_end is null or retraction_span_end > retraction_span_start)
57
+ ) strict;
58
+ insert into unit_evidence_new (id, unit_id, option_id, source_id, span_start, span_end, role, reported_speaker, run_id, added_at, retracted_at, retraction_reason, retraction_source_id, retraction_span_start, retraction_span_end)
59
+ select id, unit_id, option_id, source_id, span_start, span_end, role, reported_speaker, run_id, added_at, retracted_at, retraction_reason, retraction_source_id, retraction_span_start, retraction_span_end from unit_evidence;
60
+ -- AUTOINCREMENT never reuses an id: carry the counter over
61
+ delete from sqlite_sequence where name = 'unit_evidence_new';
62
+ insert into sqlite_sequence (name, seq) select 'unit_evidence_new', seq from sqlite_sequence where name = 'unit_evidence';
63
+ drop table unit_evidence;
64
+ drop table unit_option;
65
+ alter table unit_option_new rename to unit_option;
66
+ alter table unit_evidence_new rename to unit_evidence;
67
+
68
+ create trigger unit_option_sealed before insert on unit_option
69
+ when exists (select 1 from unit_state where unit_id = new.unit_id) or exists (select 1 from unit_alias where unit_id = new.unit_id) begin
70
+ select raise(abort, 'options are written with the unit, before its first state; record a successor instead');
71
+ end;
72
+ create trigger unit_option_frozen before update on unit_option begin
73
+ select raise(abort, 'options are never rewritten; record a successor');
74
+ end;
75
+ create trigger unit_option_no_delete before delete on unit_option
76
+ when exists (select 1 from unit where id = old.unit_id) begin
77
+ select raise(abort, 'options are never removed on their own');
78
+ end;
79
+ create unique index unit_evidence_unit_once on unit_evidence (unit_id, source_id, span_start, span_end, role) where option_id is null;
80
+ create unique index unit_evidence_option_once on unit_evidence (option_id, source_id, span_start, span_end, role) where option_id is not null;
81
+ create index unit_evidence_source on unit_evidence (source_id);
82
+ create trigger unit_evidence_check before insert on unit_evidence begin
83
+ select raise(abort, 'evidence and unit belong to different projects')
84
+ where (select project_id from unit where id = new.unit_id) is not (select project_id from source where id = new.source_id)
85
+ or (select project_id from unit where id = new.unit_id) is not (select project_id from extraction_run where id = new.run_id);
86
+ select raise(abort, 'evidence span is outside the source text')
87
+ where new.span_end > (select length(cast(text as blob)) from source where id = new.source_id);
88
+ select raise(abort, 'a reported speaker is the owner reporting someone else, so it must cite an owner session message')
89
+ where new.reported_speaker is not null and (trim(new.reported_speaker) = '' or not exists (select 1 from source
90
+ where id = new.source_id and kind = 'session_message' and author_kind = 'owner'));
91
+ select raise(abort, 'reconsiders quotes the owner on a rejected option that has a reconsider condition')
92
+ where new.role = 'reconsiders' and (new.option_id is null
93
+ or not exists (select 1 from unit_option where id = new.option_id and reconsider_when is not null)
94
+ or not exists (select 1 from source where id = new.source_id and author_kind = 'owner'));
95
+ end;
96
+ create trigger unit_evidence_retract before update on unit_evidence begin
97
+ select raise(abort, 'evidence is only ever retracted, once')
98
+ where old.retracted_at is not null or new.unit_id is not old.unit_id or new.option_id is not old.option_id
99
+ or new.source_id is not old.source_id or new.span_start is not old.span_start or new.span_end is not old.span_end
100
+ or new.role is not old.role or new.reported_speaker is not old.reported_speaker or new.run_id is not old.run_id
101
+ or new.added_at is not old.added_at or new.retracted_at is null;
102
+ select raise(abort, 'the retraction must cite an owner span of the same project')
103
+ where not exists (select 1 from source s where s.id = new.retraction_source_id and s.author_kind = 'owner'
104
+ and s.project_id = (select project_id from unit where id = new.unit_id)
105
+ and new.retraction_span_end <= length(cast(s.text as blob)));
106
+ end;
107
+ create trigger unit_evidence_no_delete before delete on unit_evidence
108
+ when exists (select 1 from unit where id = old.unit_id) and exists (select 1 from source where id = old.source_id)
109
+ and not (old.retracted_at is not null and exists (select 1 from source_forgotten where source_id = old.retraction_source_id)) begin
110
+ select raise(abort, 'evidence is retracted, never deleted');
111
+ end;
112
+ create trigger unit_evidence_retract_support after update of retracted_at on unit_evidence
113
+ when exists (select 1 from unit where id = new.unit_id and lifecycle = 'active')
114
+ and not exists (select 1 from unit_evidence where unit_id = new.unit_id and retracted_at is null) begin
115
+ select raise(abort, 'move the unit back to candidate before retracting its last evidence');
116
+ end;
117
+ create trigger unit_rev_evidence_i after insert on unit_evidence begin update unit set revision = revision + 1 where id = new.unit_id; end;
118
+ create trigger unit_rev_evidence_u after update on unit_evidence begin update unit set revision = revision + 1 where id = new.unit_id; end;
119
+ create trigger unit_rev_evidence_d after delete on unit_evidence when exists (select 1 from unit where id = old.unit_id) begin
120
+ update unit set revision = revision + 1 where id = old.unit_id;
121
+ end;
122
+ create trigger unit_state_rules before insert on unit_state begin
123
+ select raise(abort, 'from_state must be the current lifecycle')
124
+ where new.from_state is not (select lifecycle from unit where id = new.unit_id)
125
+ and exists (select 1 from unit_state where unit_id = new.unit_id);
126
+ select raise(abort, 'a quarantined or unsourced unit cannot become active')
127
+ where new.to_state = 'active' and exists (select 1 from unit where id = new.unit_id and (extraction <> 'supported' or unsourced = 1));
128
+ select raise(abort, 'an active decision or constraint needs unretracted evidence and adoption')
129
+ where new.to_state = 'active' and exists (select 1 from unit u where u.id = new.unit_id and u.kind in ('decision', 'constraint') and (
130
+ 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)
131
+ or not exists (select 1 from unit_adoption a where a.unit_id = u.id and a.retracted_at is null)));
132
+ select raise(abort, 'an active implementation needs code or commit evidence')
133
+ where new.to_state = 'active' and exists (select 1 from unit u where u.id = new.unit_id and u.kind = 'implementation' and not (
134
+ 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
135
+ and e.retracted_at is null and e.role = 'implements' and s.kind in ('commit_message', 'file_excerpt'))
136
+ or exists (select 1 from unit_anchor a where a.unit_id = u.id and a.retired_at is null and a.role = 'evidence'
137
+ and (a.commit_sha is not null or (a.edit_observation_id is not null and exists (select 1 from unit_evidence e
138
+ join source s on s.id = e.source_id join edit_observation o on o.id = a.edit_observation_id
139
+ where e.unit_id = u.id and e.option_id is null and e.retracted_at is null and e.role = 'implements'
140
+ and s.session_id = o.session_id))))));
141
+ select raise(abort, 'an active unit needs unretracted evidence')
142
+ where new.to_state = 'active' and exists (select 1 from unit u where u.id = new.unit_id and u.kind in ('finding', 'dead_end', 'question')
143
+ and 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));
144
+ -- 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
145
+ -- forgotten (forget's recheck is exempt, since the row is gone), leaves the unit as it was, and readers show the condition as unsupported
146
+ select raise(abort, 'a reconsider condition needs a quote of the owner')
147
+ 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
148
+ 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
149
+ where e.option_id = o.id and e.role = 'reconsiders' and s.author_kind = 'owner'));
150
+ select raise(abort, 'superseded needs a supersedes link from its successor')
151
+ where new.to_state = 'superseded' and not exists (select 1 from unit_link where to_unit = new.unit_id and kind = 'supersedes');
152
+ end;
153
+ create view unit_search_text as
154
+ select u.id,
155
+ sphica_terms(u.text || char(10) || coalesce(u.why, '') || char(10) || coalesce(u.scope_note, '') || char(10)
156
+ || coalesce(u.revisit_when, '') || char(10)
157
+ || coalesce((select group_concat(o.text || ' ' || coalesce(o.why, '') || ' ' || coalesce(o.reconsider_when, ''), char(10))
158
+ from (select text, why, reconsider_when from unit_option where unit_id = u.id order by position) o), '')) as body,
159
+ sphica_terms(coalesce((select group_concat(a.path || ' ' || coalesce(a.symbol, ''), char(10))
160
+ from (select path, symbol from unit_anchor where unit_id = u.id and retired_at is null order by id) a), '')) as ident,
161
+ sphica_terms(coalesce((select group_concat(j.value, ' ')
162
+ from json_each((select terms from unit_alias where unit_id = u.id and content_hash = u.content_hash order by id desc limit 1)) j), ''))
163
+ as alias
164
+ from unit u;
165
+ create trigger unit_fts_ai after insert on unit begin
166
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.id;
167
+ end;
168
+ create trigger unit_fts_option_i after insert on unit_option begin
169
+ delete from unit_fts where rowid = new.unit_id;
170
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
171
+ end;
172
+ create trigger unit_fts_anchor_i after insert on unit_anchor begin
173
+ delete from unit_fts where rowid = new.unit_id;
174
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
175
+ end;
176
+ create trigger unit_fts_anchor_u after update on unit_anchor begin
177
+ delete from unit_fts where rowid = new.unit_id;
178
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
179
+ end;
180
+ create trigger unit_fts_anchor_d after delete on unit_anchor when exists (select 1 from unit where id = old.unit_id) begin
181
+ delete from unit_fts where rowid = old.unit_id;
182
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = old.unit_id;
183
+ end;
184
+ create trigger unit_fts_alias_i after insert on unit_alias begin
185
+ delete from unit_fts where rowid = new.unit_id;
186
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = new.unit_id;
187
+ end;
188
+ create trigger unit_fts_alias_d after delete on unit_alias when exists (select 1 from unit where id = old.unit_id) begin
189
+ delete from unit_fts where rowid = old.unit_id;
190
+ insert into unit_fts (rowid, body, ident, alias) select id, body, ident, alias from unit_search_text where id = old.unit_id;
191
+ end;
192
+
193
+ pragma user_version = 3;
package/db/schema.sql CHANGED
@@ -1,5 +1,5 @@
1
1
  -- The source of truth for Sphica's database (SQLite, `node:sqlite`): the memory of past implementation and decisions for one owner on one machine.
2
- -- Generation 2 (the 0.5.0 rebuild). `sphica_generation` holds the generation; `pragma user_version` is the revision within it.
2
+ -- Generation 2. `sphica_generation` holds the generation; `pragma user_version` is the revision within it.
3
3
  -- A database of another generation is refused without being changed.
4
4
  --
5
5
  -- Four boundaries:
@@ -265,7 +265,10 @@ create table unit_option (
265
265
  text text not null check (text <> ''),
266
266
  outcome text not null check (outcome in ('chosen', 'rejected', 'deferred', 'proposed')),
267
267
  why text,
268
+ -- For a rejected option, what the owner said would make it worth reconsidering; its words are a `reconsiders` evidence row on the option
269
+ reconsider_when text check (reconsider_when <> ''),
268
270
  unique (unit_id, position),
271
+ check (reconsider_when is null or outcome = 'rejected'),
269
272
  unique (unit_id, id)
270
273
  ) strict;
271
274
  create trigger unit_option_sealed before insert on unit_option
@@ -288,7 +291,7 @@ create table unit_evidence (
288
291
  source_id integer not null references source (id) on delete cascade,
289
292
  span_start integer not null check (span_start >= 0),
290
293
  span_end integer not null check (span_end > span_start),
291
- role text not null check (role in ('states', 'proposes', 'rejects', 'explains', 'implements')),
294
+ role text not null check (role in ('states', 'proposes', 'rejects', 'explains', 'implements', 'reconsiders')),
292
295
  -- A third party the owner reported ("X said ..."): hearsay by the owner, never X's own statement
293
296
  reported_speaker text,
294
297
  run_id integer not null references extraction_run (id),
@@ -412,6 +415,12 @@ create trigger unit_state_rules before insert on unit_state begin
412
415
  select raise(abort, 'an active unit needs unretracted evidence')
413
416
  where new.to_state = 'active' and exists (select 1 from unit u where u.id = new.unit_id and u.kind in ('finding', 'dead_end', 'question')
414
417
  and 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));
418
+ -- 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
419
+ -- forgotten (forget's recheck is exempt, since the row is gone), leaves the unit as it was, and readers show the condition as unsupported
420
+ select raise(abort, 'a reconsider condition needs a quote of the owner')
421
+ 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
422
+ 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
423
+ where e.option_id = o.id and e.role = 'reconsiders' and s.author_kind = 'owner'));
415
424
  select raise(abort, 'superseded needs a supersedes link from its successor')
416
425
  where new.to_state = 'superseded' and not exists (select 1 from unit_link where to_unit = new.unit_id and kind = 'supersedes');
417
426
  end;
@@ -494,6 +503,10 @@ create trigger unit_evidence_check before insert on unit_evidence begin
494
503
  select raise(abort, 'a reported speaker is the owner reporting someone else, so it must cite an owner session message')
495
504
  where new.reported_speaker is not null and (trim(new.reported_speaker) = '' or not exists (select 1 from source
496
505
  where id = new.source_id and kind = 'session_message' and author_kind = 'owner'));
506
+ select raise(abort, 'reconsiders quotes the owner on a rejected option that has a reconsider condition')
507
+ where new.role = 'reconsiders' and (new.option_id is null
508
+ or not exists (select 1 from unit_option where id = new.option_id and reconsider_when is not null)
509
+ or not exists (select 1 from source where id = new.source_id and author_kind = 'owner'));
497
510
  end;
498
511
  create trigger unit_evidence_retract before update on unit_evidence begin
499
512
  select raise(abort, 'evidence is only ever retracted, once')
@@ -602,14 +615,14 @@ create trigger unit_rev_anchor_i after insert on unit_anchor begin update unit s
602
615
  create trigger unit_rev_anchor_u after update on unit_anchor begin update unit set revision = revision + 1 where id = new.unit_id; end;
603
616
  create trigger unit_rev_alias_i after insert on unit_alias begin update unit set revision = revision + 1 where id = new.unit_id; end;
604
617
 
605
- -- Unit search text: body (text, reason, scope, revisit condition, options), identifiers (live anchors), and the newest matching alias set.
618
+ -- Unit search text: body (text, reason, scope, revisit condition, options and their reconsider conditions), identifiers (live anchors), and the newest matching alias set.
606
619
  -- search.ts weighs body and identifiers above aliases. Lifecycle is not indexed; queries filter it.
607
620
  create view unit_search_text as
608
621
  select u.id,
609
622
  sphica_terms(u.text || char(10) || coalesce(u.why, '') || char(10) || coalesce(u.scope_note, '') || char(10)
610
623
  || coalesce(u.revisit_when, '') || char(10)
611
- || coalesce((select group_concat(o.text || ' ' || coalesce(o.why, ''), char(10))
612
- from (select text, why from unit_option where unit_id = u.id order by position) o), '')) as body,
624
+ || coalesce((select group_concat(o.text || ' ' || coalesce(o.why, '') || ' ' || coalesce(o.reconsider_when, ''), char(10))
625
+ from (select text, why, reconsider_when from unit_option where unit_id = u.id order by position) o), '')) as body,
613
626
  sphica_terms(coalesce((select group_concat(a.path || ' ' || coalesce(a.symbol, ''), char(10))
614
627
  from (select path, symbol from unit_anchor where unit_id = u.id and retired_at is null order by id) a), '')) as ident,
615
628
  sphica_terms(coalesce((select group_concat(j.value, ' ')
@@ -739,4 +752,4 @@ create trigger capture_delivery_insert instead of insert on capture_delivery beg
739
752
  select last_insert_rowid(), j.value from json_each(coalesce(new.units, '[]')) j where true on conflict do nothing;
740
753
  end;
741
754
 
742
- pragma user_version = 2;
755
+ pragma user_version = 3;
package/dist/capture.js CHANGED
@@ -10813,7 +10813,7 @@ import os from "node:os";
10813
10813
  import path from "node:path";
10814
10814
  import { constants as C, DatabaseSync } from "node:sqlite";
10815
10815
  var SCHEMA_GENERATION = 2;
10816
- var SCHEMA_REVISION = 2;
10816
+ var SCHEMA_REVISION = 3;
10817
10817
  var sphicaHome = () => process.env.SPHICA_HOME || path.join(os.homedir(), ".sphica");
10818
10818
  var dbFile = () => process.env.SPHICA_DB || path.join(sphicaHome(), "sphica.db");
10819
10819
  function requireRuntime() {
package/dist/cli.js CHANGED
@@ -14187,7 +14187,7 @@ import os from "node:os";
14187
14187
  import path2 from "node:path";
14188
14188
  import { constants as C, DatabaseSync } from "node:sqlite";
14189
14189
  var SCHEMA_GENERATION = 2;
14190
- var SCHEMA_REVISION = 2;
14190
+ var SCHEMA_REVISION = 3;
14191
14191
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
14192
14192
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
14193
14193
  function requireRuntime() {
package/dist/deliver.js CHANGED
@@ -11086,7 +11086,7 @@ import os from "node:os";
11086
11086
  import path2 from "node:path";
11087
11087
  import { constants as C, DatabaseSync } from "node:sqlite";
11088
11088
  var SCHEMA_GENERATION = 2;
11089
- var SCHEMA_REVISION = 2;
11089
+ var SCHEMA_REVISION = 3;
11090
11090
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
11091
11091
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
11092
11092
  function requireRuntime() {
@@ -31689,10 +31689,28 @@ function localChange(root, args) {
31689
31689
  return { base, files, digest: sha256(read.join("\x00")).toString("hex") };
31690
31690
  }
31691
31691
 
31692
+ // server/src/trace.ts
31693
+ var PENDING_DAYS = 30;
31694
+ var pendingCutoff = (now) => new Date(now.getTime() - PENDING_DAYS * 86400000).toISOString();
31695
+ var untracedOwner = (eb) => eb.selectFrom("source as m").whereRef("m.session_id", "=", "s.id").where("m.author_kind", "=", "owner").where(({ not, exists, selectFrom }) => not(exists(selectFrom("source_processing as p").whereRef("p.source_id", "=", "m.id").select(sql`1`.as("x")))));
31696
+ var untracedSessions = (db, projectId2) => db.selectFrom("session as s").where("s.project_id", "=", projectId2).where((eb) => eb.exists(untracedOwner(eb).select(sql`1`.as("x")))).select((eb) => [
31697
+ "s.id",
31698
+ "s.host",
31699
+ "s.started_at",
31700
+ "s.branch",
31701
+ eb.selectFrom("source as o").whereRef("o.session_id", "=", "s.id").where("o.author_kind", "=", "owner").select((o) => o.fn.max("o.created_at").as("last")).as("last"),
31702
+ untracedOwner(eb).select((m) => m.fn.countAll().as("waiting")).as("waiting"),
31703
+ untracedOwner(eb).select((m) => m.fn.min("m.id").as("first")).as("first")
31704
+ ]);
31705
+
31692
31706
  // server/src/status.ts
31693
- async function pendingCount(db, projectId2) {
31694
- const r = await db.selectFrom("session as s").where("s.project_id", "=", projectId2).where((eb) => eb.exists(eb.selectFrom("source as m").whereRef("m.session_id", "=", "s.id").where("m.author_kind", "=", "owner").where((eb2) => eb2.not(eb2.exists(eb2.selectFrom("source_processing as p").whereRef("p.source_id", "=", "m.id").select(sql`1`.as("x"))))).select(sql`1`.as("x")))).select((eb) => eb.fn.countAll().as("n")).executeTakeFirst();
31695
- return Number(r?.n ?? 0);
31707
+ async function pendingCount(db, projectId2, now = new Date) {
31708
+ const cutoff = pendingCutoff(now);
31709
+ const r = await db.selectFrom(untracedSessions(db, projectId2).as("w")).select([
31710
+ sql`count(case when w.last >= ${cutoff} then 1 end)`.as("recent"),
31711
+ sql`count(case when w.last < ${cutoff} then 1 end)`.as("older")
31712
+ ]).executeTakeFirst();
31713
+ return { recent: Number(r?.recent ?? 0), older: Number(r?.older ?? 0) };
31696
31714
  }
31697
31715
 
31698
31716
  // server/src/deliver.ts
@@ -31887,7 +31905,7 @@ async function onPrompt(db, projectId2, prompt) {
31887
31905
  async function waiting(db, projectId2, place) {
31888
31906
  if (!place.owner)
31889
31907
  return "";
31890
- const n = await pendingCount(db, projectId2);
31908
+ const n = (await pendingCount(db, projectId2)).recent;
31891
31909
  if (!n)
31892
31910
  return "";
31893
31911
  const day = new Date().toLocaleDateString("sv-SE");
@@ -45527,7 +45527,7 @@ import os from "node:os";
45527
45527
  import path from "node:path";
45528
45528
  import { constants as C, DatabaseSync } from "node:sqlite";
45529
45529
  var SCHEMA_GENERATION = 2;
45530
- var SCHEMA_REVISION = 2;
45530
+ var SCHEMA_REVISION = 3;
45531
45531
  var sphicaHome = () => process.env.SPHICA_HOME || path.join(os.homedir(), ".sphica");
45532
45532
  var dbFile = () => process.env.SPHICA_DB || path.join(sphicaHome(), "sphica.db");
45533
45533
  function requireRuntime() {
@@ -46091,7 +46091,14 @@ var UNIT_KINDS = [
46091
46091
  ];
46092
46092
  var STANCES = ["do", "dont", "defer"];
46093
46093
  var OPTION_OUTCOMES = ["chosen", "rejected", "deferred", "proposed"];
46094
- var EVIDENCE_ROLES = ["states", "proposes", "rejects", "explains", "implements"];
46094
+ var EVIDENCE_ROLES = [
46095
+ "states",
46096
+ "proposes",
46097
+ "rejects",
46098
+ "explains",
46099
+ "implements",
46100
+ "reconsiders"
46101
+ ];
46095
46102
  var WORK_STATUSES = ["active", "blocked", "paused", "done", "abandoned"];
46096
46103
  var HOSTS = ["claude-code", "codex"];
46097
46104
  var sessionId = (projectId, host, externalId) => uuidFrom(String(projectId), host, externalId);
@@ -46131,11 +46138,15 @@ function readText(root, rel) {
46131
46138
  return null;
46132
46139
  if (!st.isFile() || st.size > MAX_BYTES)
46133
46140
  return;
46134
- const inside = path2.relative(fs2.realpathSync(root), fs2.realpathSync(abs));
46135
- if (leaves(inside))
46141
+ try {
46142
+ const inside = path2.relative(fs2.realpathSync(root), fs2.realpathSync(abs));
46143
+ if (leaves(inside))
46144
+ return;
46145
+ const buf = fs2.readFileSync(abs);
46146
+ return buf.includes(0) ? undefined : buf.toString("utf8");
46147
+ } catch {
46136
46148
  return;
46137
- const buf = fs2.readFileSync(abs);
46138
- return buf.includes(0) ? undefined : buf.toString("utf8");
46149
+ }
46139
46150
  }
46140
46151
  function findSymbol(text, symbol2) {
46141
46152
  const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`);
@@ -47221,7 +47232,7 @@ var MAINTAINERS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
47221
47232
  var text = (max) => exports_external.string().trim().min(1).max(max);
47222
47233
  var Quote = exports_external.object({ source: exports_external.string().regex(SOURCE_REF, "cite a source ref such as s12"), quote: exports_external.string() }).strict();
47223
47234
  var Evidence = Quote.extend({
47224
- role: exports_external.enum(EVIDENCE_ROLES),
47235
+ role: exports_external.enum(EVIDENCE_ROLES).exclude(["reconsiders"]),
47225
47236
  reported_speaker: text(100).optional()
47226
47237
  }).strict();
47227
47238
  var Unit = exports_external.object({
@@ -47237,6 +47248,8 @@ var Unit = exports_external.object({
47237
47248
  text: text(500),
47238
47249
  outcome: exports_external.enum(OPTION_OUTCOMES),
47239
47250
  why: text(2000).optional(),
47251
+ reconsider_when: text(1000).optional(),
47252
+ reconsider_quote: Quote.optional(),
47240
47253
  evidence: exports_external.array(Evidence.partial({ role: true })).max(10).default([])
47241
47254
  }).strict()).max(12).default([]),
47242
47255
  evidence: exports_external.array(Evidence).max(20).default([]),
@@ -47300,10 +47313,20 @@ async function checkRecord(db, target, raw) {
47300
47313
  errors3.push(`${at}: stance is required for decisions and constraints, and only for them`);
47301
47314
  if (u.revisit_when !== undefined && u.stance !== "defer")
47302
47315
  errors3.push(`${at}: revisit_when goes only with stance defer`);
47316
+ for (const o of u.options) {
47317
+ if (o.reconsider_when === undefined !== (o.reconsider_quote === undefined))
47318
+ errors3.push(`${at}: option "${head(o.text, 60)}": reconsider_when and reconsider_quote go together`);
47319
+ else if (o.reconsider_when !== undefined && o.outcome !== "rejected")
47320
+ errors3.push(`${at}: option "${head(o.text, 60)}": a reconsider condition goes only on a rejected option`);
47321
+ }
47303
47322
  }
47304
47323
  const refs = new Set;
47305
47324
  for (const u of record3.units)
47306
- for (const q of [...u.evidence, ...u.adoption, ...u.options.flatMap((o) => o.evidence)])
47325
+ for (const q of [
47326
+ ...u.evidence,
47327
+ ...u.adoption,
47328
+ ...u.options.flatMap((o) => [...o.evidence, ...o.reconsider_quote ? [o.reconsider_quote] : []])
47329
+ ])
47307
47330
  refs.add(Number(q.source.slice(1)));
47308
47331
  const sources = new Map((refs.size ? await db.selectFrom("source").select(["id", "kind", "author_kind", "author_login", "author_association", "text"]).where("project_id", "=", target.projectId).where("id", "in", [...refs]).execute() : []).map((s) => [s.id, s]));
47309
47332
  for (const r of refs) {
@@ -47342,12 +47365,30 @@ async function checkRecord(db, target, raw) {
47342
47365
  ];
47343
47366
  });
47344
47367
  const evidence = spans(u.evidence);
47368
+ const reconsider = (o) => {
47369
+ const q = o.reconsider_quote;
47370
+ const s = q && sources.get(Number(q.source.slice(1)));
47371
+ if (!q || !s || o.reconsider_when === undefined)
47372
+ return null;
47373
+ cites.add(s.id);
47374
+ if (s.author_kind !== "owner") {
47375
+ errors3.push(`${key}: option "${head(o.text, 60)}": reconsider_quote must quote the owner; ${q.source} is by ${s.author_login ?? s.author_kind}`);
47376
+ return null;
47377
+ }
47378
+ const span = locate2(s.text, q.quote);
47379
+ if (!span) {
47380
+ errors3.push(`${key}: option "${head(o.text, 60)}": reconsider_quote not found in ${q.source}: "${head(q.quote, 80)}"`);
47381
+ return null;
47382
+ }
47383
+ return { source: s.id, start: span[0], end: span[1] };
47384
+ };
47345
47385
  const options = u.options.map((o) => ({
47346
47386
  input: o,
47347
47387
  evidence: spans(o.evidence.map((e) => ({
47348
47388
  ...e,
47349
47389
  role: e.role ?? (o.outcome === "rejected" ? "rejects" : o.outcome === "chosen" ? "states" : "explains")
47350
- })))
47390
+ }))),
47391
+ reconsider: reconsider(o)
47351
47392
  }));
47352
47393
  if (u.evidence.length === 0)
47353
47394
  quarantine.push("no evidence cited");
@@ -47465,7 +47506,7 @@ var contentHash = (u) => sha256(JSON.stringify([
47465
47506
  u.why ?? null,
47466
47507
  u.scope_note ?? null,
47467
47508
  u.revisit_when ?? null,
47468
- u.options.map((o) => [o.text, o.outcome, o.why ?? null])
47509
+ u.options.map((o) => o.reconsider_when === undefined ? [o.text, o.outcome, o.why ?? null] : [o.text, o.outcome, o.why ?? null, o.reconsider_when])
47469
47510
  ]));
47470
47511
  var ACTIVATION = /needs|cannot become active/;
47471
47512
  async function saveRecord(trx, target, runId, checked, looked) {
@@ -47520,9 +47561,12 @@ ${checked.errors.map((e) => ` ${e}`).join(`
47520
47561
  position: i + 1,
47521
47562
  text: o.input.text,
47522
47563
  outcome: o.input.outcome,
47523
- why: o.input.why ?? null
47564
+ why: o.input.why ?? null,
47565
+ reconsider_when: o.input.reconsider_when ?? null
47524
47566
  }).returning("id").executeTakeFirstOrThrow();
47525
47567
  await evidence(o.evidence, option.id);
47568
+ if (o.reconsider)
47569
+ await evidence([{ ...o.reconsider, role: "reconsiders", reported: null }], option.id);
47526
47570
  }
47527
47571
  await evidence(p.evidence, null);
47528
47572
  for (const a of p.adoption) {
@@ -47656,7 +47700,7 @@ var Op = exports_external.discriminatedUnion("op", [
47656
47700
  source: SOURCE_REF2.optional(),
47657
47701
  file: File2.optional(),
47658
47702
  quote,
47659
- role: exports_external.enum(EVIDENCE_ROLES),
47703
+ role: exports_external.enum(EVIDENCE_ROLES).exclude(["reconsiders"]),
47660
47704
  reported_speaker: exports_external.string().trim().min(1).max(100).optional()
47661
47705
  }).strict(),
47662
47706
  exports_external.object({ op: exports_external.literal("adopt"), unit, revision, source: SOURCE_REF2, quote }).strict(),
@@ -48128,15 +48172,26 @@ ${c.errors.map((e) => ` ${e}`).join(`
48128
48172
  }
48129
48173
 
48130
48174
  // server/src/trace.ts
48131
- async function pendingSessions(db, projectId2, limit = 20) {
48132
- return db.selectFrom("session as s").innerJoin("source as m", "m.session_id", "s.id").where("s.project_id", "=", projectId2).where("m.author_kind", "=", "owner").where((eb) => eb.not(eb.exists(eb.selectFrom("source_processing as p").whereRef("p.source_id", "=", "m.id").select(sql`1`.as("x"))))).groupBy("s.id").select((eb) => [
48133
- "s.id",
48134
- "s.host",
48135
- "s.started_at",
48136
- "s.branch",
48137
- eb.fn.countAll().as("waiting"),
48138
- eb.fn.min("m.id").as("first")
48139
- ]).orderBy("s.started_at", "desc").limit(limit).execute();
48175
+ var PENDING_DAYS = 30;
48176
+ var pendingCutoff = (now) => new Date(now.getTime() - PENDING_DAYS * 86400000).toISOString();
48177
+ var untracedOwner = (eb) => eb.selectFrom("source as m").whereRef("m.session_id", "=", "s.id").where("m.author_kind", "=", "owner").where(({ not, exists, selectFrom }) => not(exists(selectFrom("source_processing as p").whereRef("p.source_id", "=", "m.id").select(sql`1`.as("x")))));
48178
+ var untracedSessions = (db, projectId2) => db.selectFrom("session as s").where("s.project_id", "=", projectId2).where((eb) => eb.exists(untracedOwner(eb).select(sql`1`.as("x")))).select((eb) => [
48179
+ "s.id",
48180
+ "s.host",
48181
+ "s.started_at",
48182
+ "s.branch",
48183
+ eb.selectFrom("source as o").whereRef("o.session_id", "=", "s.id").where("o.author_kind", "=", "owner").select((o) => o.fn.max("o.created_at").as("last")).as("last"),
48184
+ untracedOwner(eb).select((m) => m.fn.countAll().as("waiting")).as("waiting"),
48185
+ untracedOwner(eb).select((m) => m.fn.min("m.id").as("first")).as("first")
48186
+ ]);
48187
+ async function pendingSessions(db, projectId2, group, now = new Date, limit = 20) {
48188
+ const cutoff = pendingCutoff(now);
48189
+ const base = db.selectFrom(untracedSessions(db, projectId2).as("w")).where("w.last", group === "recent" ? ">=" : "<", cutoff);
48190
+ const [rows, total] = await Promise.all([
48191
+ base.selectAll("w").orderBy("w.last", "desc").limit(limit).execute(),
48192
+ base.select((eb) => eb.fn.countAll().as("n")).executeTakeFirst()
48193
+ ]);
48194
+ return { rows, total: Number(total?.n ?? 0) };
48140
48195
  }
48141
48196
  async function openRun(db, v) {
48142
48197
  const r = await db.insertInto("extraction_run").values({
@@ -48175,15 +48230,30 @@ async function liveUnits(db, projectId2, limit = 40) {
48175
48230
 
48176
48231
  // server/src/extract.ts
48177
48232
  var newRunId = () => crypto2.randomBytes(9).toString("base64url");
48178
- async function pendingText(db, projectId2) {
48233
+ async function pendingText(db, projectId2, now = new Date) {
48179
48234
  await flush(undefined, TOOL_FLUSH_BUDGET_MS).catch(() => {});
48180
- const rows = await pendingSessions(db, projectId2);
48181
- if (!rows.length)
48235
+ const [recent, older] = await Promise.all([
48236
+ pendingSessions(db, projectId2, "recent", now),
48237
+ pendingSessions(db, projectId2, "older", now)
48238
+ ]);
48239
+ if (!recent.total && !older.total)
48182
48240
  return "Every captured session has been traced.";
48183
- const firsts = new Map((await db.selectFrom("source").select(["id", "text"]).where("id", "in", rows.map((r) => Number(r.first))).execute()).map((m) => [m.id, m.text]));
48241
+ const ids = [...recent.rows, ...older.rows].map((r) => Number(r.first));
48242
+ const firsts = new Map(ids.length ? (await db.selectFrom("source").select(["id", "text"]).where("id", "in", ids).execute()).map((m) => [
48243
+ m.id,
48244
+ m.text
48245
+ ]) : []);
48246
+ const group = (g) => [
48247
+ ...g.rows.map((r) => `- ${r.id} ${r.host} ${r.started_at}: ${plural2(Number(r.waiting), "message")} waiting, starting "${inline(firsts.get(Number(r.first)) ?? "").slice(0, 100)}"`),
48248
+ ...g.total > g.rows.length ? [`- and ${g.total - g.rows.length} more`] : []
48249
+ ];
48184
48250
  return [
48185
- `${plural2(rows.length, "session")} to trace (pass the id to trace_begin):`,
48186
- ...rows.map((r) => `- ${r.id} ${r.host} ${r.started_at}: ${plural2(Number(r.waiting), "message")} waiting, starting "${inline(firsts.get(Number(r.first)) ?? "").slice(0, 100)}"`)
48251
+ recent.total ? `${plural2(recent.total, "session")} to trace (pass the id to trace_begin):` : "No recent session waits to be traced.",
48252
+ ...group(recent),
48253
+ ...older.total ? [
48254
+ `Older than ${PENDING_DAYS} days (not counted at session start), ${plural2(older.total, "session")}; trace_begin takes these ids too:`,
48255
+ ...group(older)
48256
+ ] : []
48187
48257
  ].join(`
48188
48258
  `);
48189
48259
  }
@@ -48593,7 +48663,7 @@ var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, o
48593
48663
  var RECORD = exports_external.record(exports_external.string(), exports_external.unknown()).describe("The record, as the Skill describes");
48594
48664
  server.registerTool("trace_pending", {
48595
48665
  title: "Sessions not traced yet",
48596
- description: "Lists this project's captured sessions with owner messages no trace has looked at.",
48666
+ description: "Lists this project's captured sessions with owner messages no trace has looked at, then apart those whose last owner message is over 30 days old.",
48597
48667
  inputSchema: { cwd: CWD },
48598
48668
  annotations: READ
48599
48669
  }, async (a, extra) => tool(async () => pendingText(conn(), (await projectOf(a.cwd, extra._meta)).projectId)));