sphica 0.6.35 → 0.6.37

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.35",
4
+ "version": "0.6.37",
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.35",
3
+ "version": "0.6.37",
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
@@ -74,7 +74,7 @@ In Codex, open `/hooks` and mark Sphica's hooks as trusted. Automatic recording
74
74
  **3. Set up in your repository**
75
75
 
76
76
  ```bash
77
- cd ~/Projects/your-repo
77
+ cd path/to/your-repo
78
78
  sphica init
79
79
  ```
80
80
 
@@ -0,0 +1,26 @@
1
+ -- Revision 10 → 11 of generation 2: a harvest run keeps the sources it may cite, chosen when it begins (harvest_run_source). A running
2
+ -- harvest has none, so it is removed and its session gets "Begin again"; nothing cites a running run (a save also finishes it).
3
+ -- Every statement that stays is the same as in the schema at revision 11.
4
+
5
+ create temp table sphica_migration_note (rule text, item text, action text);
6
+
7
+ create table harvest_run_source (
8
+ run_id integer not null references extraction_run (id) on delete cascade,
9
+ source_id integer not null references source (id) on delete cascade,
10
+ primary key (run_id, source_id)
11
+ ) strict;
12
+ create index harvest_run_source_source on harvest_run_source (source_id);
13
+
14
+ create trigger harvest_run_source_run before insert on harvest_run_source begin
15
+ select raise(abort, 'only a harvest run keeps sources, from its own project')
16
+ where (select origin from extraction_run where id = new.run_id) is not 'harvest'
17
+ or (select project_id from source where id = new.source_id) is not (select project_id from extraction_run where id = new.run_id);
18
+ end;
19
+
20
+ insert into sphica_migration_note
21
+ select 'a harvest run still running, begun before its sources were kept', 'run ' || id || ' ' || target || ' begun ' || started_at,
22
+ 'removed; begin the harvest again'
23
+ from extraction_run where origin = 'harvest' and status = 'running' order by id;
24
+ delete from extraction_run where origin = 'harvest' and status = 'running';
25
+
26
+ pragma user_version = 11;
package/db/schema.sql CHANGED
@@ -6,7 +6,7 @@
6
6
  -- captured sources session, source, artifact_link, edit_observation: what was said or written, never rewritten
7
7
  -- (the owner can forget chosen sources: forget_batch and source_forgotten keep what was removed, without its text)
8
8
  -- extracted units unit and its option, evidence, adoption, link, state, anchor, alias tables: what was decided or implemented
9
- -- processing extraction_run, source_processing: what has been looked at and saved, so gaps are counted
9
+ -- processing extraction_run, source_processing, harvest_run_source: what has been looked at and saved, so gaps are counted
10
10
  -- work and delivery work, delivery, delivery_unit: the current work status and what the hooks injected
11
11
  -- Every table is STRICT and every primary key is not null. Times are ISO 8601 UTC (`Date#toISOString()`); `strftime(...) is column` rejects others.
12
12
  -- Paths (a source's, an edit observation's, an anchor's) are repository-relative with forward slashes, in one form, so one place
@@ -293,6 +293,15 @@ create table source_processing (
293
293
  ) strict;
294
294
  create index source_processing_run on source_processing (run_id);
295
295
 
296
+ -- The sources a harvest run may show and cite, chosen when it begins, so another harvest of the same pull request cannot change them.
297
+ -- Ids only; a source the owner forgets leaves with its row
298
+ create table harvest_run_source (
299
+ run_id integer not null references extraction_run (id) on delete cascade,
300
+ source_id integer not null references source (id) on delete cascade,
301
+ primary key (run_id, source_id)
302
+ ) strict;
303
+ create index harvest_run_source_source on harvest_run_source (source_id);
304
+
296
305
  -- An extracted unit. Its text is never rewritten: corrections are successors, withdrawals, retractions, and anchor replacements.
297
306
  -- extraction: supported (every evidence span was found in retained text) or quarantined (with reason).
298
307
  -- lifecycle changes only through unit_state (its trigger sets this column). revision rises with every change to the unit's relations,
@@ -981,6 +990,11 @@ create trigger source_processing_project before insert on source_processing begi
981
990
  select raise(abort, 'source and run belong to different projects')
982
991
  where (select project_id from source where id = new.source_id) is not (select project_id from extraction_run where id = new.run_id);
983
992
  end;
993
+ create trigger harvest_run_source_run before insert on harvest_run_source begin
994
+ select raise(abort, 'only a harvest run keeps sources, from its own project')
995
+ where (select origin from extraction_run where id = new.run_id) is not 'harvest'
996
+ or (select project_id from source where id = new.source_id) is not (select project_id from extraction_run where id = new.run_id);
997
+ end;
984
998
 
985
999
  -- Every change to a unit's relations raises its revision (stale drafts are refused against it)
986
1000
  create trigger unit_rev_evidence_i after insert on unit_evidence begin update unit set revision = revision + 1 where id = new.unit_id; end;
@@ -1211,4 +1225,4 @@ create trigger capture_delivery_prune_insert instead of insert on capture_delive
1211
1225
  order by d.at, d.id limit 200);
1212
1226
  end;
1213
1227
 
1214
- pragma user_version = 10;
1228
+ pragma user_version = 11;
package/dist/capture.js CHANGED
@@ -11396,7 +11396,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
11396
11396
 
11397
11397
  // server/src/sqlite.ts
11398
11398
  var SCHEMA_GENERATION = 2;
11399
- var SCHEMA_REVISION = 10;
11399
+ var SCHEMA_REVISION = 11;
11400
11400
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
11401
11401
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
11402
11402
  function requireRuntime() {
@@ -11597,6 +11597,7 @@ var INGEST_INSERTS = new Set([
11597
11597
  "ingest_source",
11598
11598
  "extraction_run",
11599
11599
  "source_processing",
11600
+ "harvest_run_source",
11600
11601
  "unit",
11601
11602
  "unit_option",
11602
11603
  "unit_evidence",
@@ -11692,6 +11693,7 @@ var FORGET_WRITES = {
11692
11693
  "unit_evidence",
11693
11694
  "unit_adoption",
11694
11695
  "source_processing",
11696
+ "harvest_run_source",
11695
11697
  "source_fts",
11696
11698
  "field_def",
11697
11699
  "unit_field",
package/dist/cli.js CHANGED
@@ -93,7 +93,6 @@ var require_src = __commonJS(function(exports, module) {
93
93
 
94
94
  // server/src/cli.ts
95
95
  import fs10 from "node:fs";
96
- import os4 from "node:os";
97
96
  import path10 from "node:path";
98
97
 
99
98
  // server/node_modules/@clack/core/dist/index.mjs
@@ -4912,7 +4911,7 @@ ${indent}${caution(note)}` : ""}`);
4912
4911
 
4913
4912
  // server/src/sqlite.ts
4914
4913
  var SCHEMA_GENERATION = 2;
4915
- var SCHEMA_REVISION = 10;
4914
+ var SCHEMA_REVISION = 11;
4916
4915
  var sphicaHome = () => process.env.SPHICA_HOME || path3.join(os2.homedir(), ".sphica");
4917
4916
  var dbFile = () => process.env.SPHICA_DB || path3.join(sphicaHome(), "sphica.db");
4918
4917
  function requireRuntime() {
@@ -15957,6 +15956,7 @@ var INGEST_INSERTS = new Set([
15957
15956
  "ingest_source",
15958
15957
  "extraction_run",
15959
15958
  "source_processing",
15959
+ "harvest_run_source",
15960
15960
  "unit",
15961
15961
  "unit_option",
15962
15962
  "unit_evidence",
@@ -16052,6 +16052,7 @@ var FORGET_WRITES = {
16052
16052
  "unit_evidence",
16053
16053
  "unit_adoption",
16054
16054
  "source_processing",
16055
+ "harvest_run_source",
16055
16056
  "source_fts",
16056
16057
  "field_def",
16057
16058
  "unit_field",
@@ -16120,7 +16121,6 @@ var sessionId = (projectId, host, externalId) => uuidFrom(String(projectId), hos
16120
16121
  // server/src/project.ts
16121
16122
  import { execFileSync as execFileSync2 } from "node:child_process";
16122
16123
  import fs6 from "node:fs";
16123
- import os3 from "node:os";
16124
16124
  import path6 from "node:path";
16125
16125
 
16126
16126
  // server/src/anchors.ts
@@ -16348,48 +16348,6 @@ function nameLocal(dir, name) {
16348
16348
  });
16349
16349
  return { key: `local:${name}`, root, name };
16350
16350
  }
16351
- function underHome(p, homes, api = path6) {
16352
- for (const h2 of homes) {
16353
- const rel = api.relative(h2, p);
16354
- if (rel === "")
16355
- return "~";
16356
- if (rel !== ".." && !rel.startsWith(`..${api.sep}`) && !api.isAbsolute(rel))
16357
- return `~${api.sep}${rel}`;
16358
- }
16359
- return p;
16360
- }
16361
- var projectsDir = () => path6.join(os3.homedir(), "Projects");
16362
- function localRoots(roots = [projectsDir()]) {
16363
- const seen = new Map;
16364
- const add = (p) => {
16365
- if (p)
16366
- seen.set(p.key, [...new Set([...seen.get(p.key) ?? [], p.root])]);
16367
- };
16368
- for (const r2 of roots) {
16369
- let entries = [];
16370
- try {
16371
- entries = fs6.readdirSync(r2, { withFileTypes: true });
16372
- } catch {
16373
- continue;
16374
- }
16375
- for (const e of entries)
16376
- if (e.isDirectory() && !e.name.startsWith("."))
16377
- add(identify(path6.join(r2, e.name)));
16378
- }
16379
- for (const [root, name] of Object.entries(localMap())) {
16380
- if (LOCAL_KEY.test(name) && fs6.existsSync(root))
16381
- add({ key: `local:${name}`, root, name });
16382
- }
16383
- const found = new Map;
16384
- const ambiguous = new Map;
16385
- for (const [key, dirs] of seen) {
16386
- if (dirs.length === 1 && dirs[0])
16387
- found.set(key, dirs[0]);
16388
- else
16389
- ambiguous.set(key, dirs);
16390
- }
16391
- return { found, ambiguous };
16392
- }
16393
16351
  function relativeTo(root, file, cwd = root) {
16394
16352
  const abs = path6.resolve(cwd, file);
16395
16353
  const rel = path6.relative(root, abs);
@@ -18769,14 +18727,7 @@ async function doctor(cwd) {
18769
18727
  if (twice.length)
18770
18728
  say2("warn", "Records", `${plural(twice.length, "set")} of live records hold the same words. Read them with Sphica's read and withdraw the extra ones with /sphica:glean`);
18771
18729
  await adoptionStops(db, q.waiting, say2);
18772
- const { found, ambiguous } = localRoots();
18773
- const homes = [os4.homedir()];
18774
- try {
18775
- homes.push(fs10.realpathSync.native(os4.homedir()));
18776
- } catch {}
18777
- const tilde = (p) => underHome(p, homes);
18778
18730
  const rows = await db.selectFrom("project as p").select((eb) => [
18779
- "p.key",
18780
18731
  "p.name",
18781
18732
  eb.selectFrom("unit as u").select((u3) => u3.fn.countAll().as("n")).whereRef("u.project_id", "=", "p.id").as("records"),
18782
18733
  eb.selectFrom("extraction_run as r").select((r2) => r2.fn.max("r.finished_at").as("at")).whereRef("r.project_id", "=", "p.id").where("r.status", "=", "saved").where("r.origin", "<>", "migration").as("extracted")
@@ -18784,11 +18735,8 @@ async function doctor(cwd) {
18784
18735
  if (rows.length)
18785
18736
  console.log(section("Projects", true));
18786
18737
  const column = Math.max(...rows.map((x) => width(inline(x.name)))) + 2;
18787
- for (const x of rows) {
18788
- const copies = ambiguous.get(x.key);
18789
- const where = copies ? ` (${copies.length} copies: ${copies.map((c) => inline(tilde(c))).sort().join(", ")})` : found.get(x.key) ? "" : ` (not found in ${inline(tilde(projectsDir()))} or the named projects)`;
18790
- console.log(indent(` ${mark("none")} ${pad(inline(x.name), column)}${plural(Number(x.records ?? 0), "record")}${x.extracted ? ` / last extraction ${new Date(x.extracted).toLocaleString("sv-SE")}` : ""}${where}`));
18791
- }
18738
+ for (const x of rows)
18739
+ console.log(indent(` ${mark("none")} ${pad(inline(x.name), column)}${plural(Number(x.records ?? 0), "record")}${x.extracted ? ` / last extraction ${new Date(x.extracted).toLocaleString("sv-SE")}` : ""}`));
18792
18740
  });
18793
18741
  } catch (e) {
18794
18742
  say2("fail", "DB", `cannot read: ${plain(reason(e))}`);
package/dist/deliver.js CHANGED
@@ -11427,7 +11427,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
11427
11427
 
11428
11428
  // server/src/sqlite.ts
11429
11429
  var SCHEMA_GENERATION = 2;
11430
- var SCHEMA_REVISION = 10;
11430
+ var SCHEMA_REVISION = 11;
11431
11431
  var sphicaHome = () => process.env.SPHICA_HOME || path3.join(os.homedir(), ".sphica");
11432
11432
  var dbFile = () => process.env.SPHICA_DB || path3.join(sphicaHome(), "sphica.db");
11433
11433
  function requireRuntime() {
@@ -11652,6 +11652,7 @@ var INGEST_INSERTS = new Set([
11652
11652
  "ingest_source",
11653
11653
  "extraction_run",
11654
11654
  "source_processing",
11655
+ "harvest_run_source",
11655
11656
  "unit",
11656
11657
  "unit_option",
11657
11658
  "unit_evidence",
@@ -11747,6 +11748,7 @@ var FORGET_WRITES = {
11747
11748
  "unit_evidence",
11748
11749
  "unit_adoption",
11749
11750
  "source_processing",
11751
+ "harvest_run_source",
11750
11752
  "source_fts",
11751
11753
  "field_def",
11752
11754
  "unit_field",
@@ -46183,7 +46183,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
46183
46183
 
46184
46184
  // server/src/sqlite.ts
46185
46185
  var SCHEMA_GENERATION = 2;
46186
- var SCHEMA_REVISION = 10;
46186
+ var SCHEMA_REVISION = 11;
46187
46187
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
46188
46188
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
46189
46189
  function requireRuntime() {
@@ -46274,6 +46274,20 @@ async function inTransaction(db, fn) {
46274
46274
  }
46275
46275
  });
46276
46276
  }
46277
+ async function inRolledBack(db, fn) {
46278
+ return db.connection().execute(async (c) => {
46279
+ await sql`begin immediate`.execute(c);
46280
+ let out;
46281
+ try {
46282
+ out = await fn(c);
46283
+ } catch (e) {
46284
+ await sql`rollback`.execute(c).catch(() => {});
46285
+ throw e;
46286
+ }
46287
+ await sql`rollback`.execute(c);
46288
+ return out;
46289
+ });
46290
+ }
46277
46291
  var iso = (d) => {
46278
46292
  const t = new Date(d);
46279
46293
  if (Number.isNaN(t.getTime()))
@@ -46384,6 +46398,7 @@ var INGEST_INSERTS = new Set([
46384
46398
  "ingest_source",
46385
46399
  "extraction_run",
46386
46400
  "source_processing",
46401
+ "harvest_run_source",
46387
46402
  "unit",
46388
46403
  "unit_option",
46389
46404
  "unit_evidence",
@@ -46479,6 +46494,7 @@ var FORGET_WRITES = {
46479
46494
  "unit_evidence",
46480
46495
  "unit_adoption",
46481
46496
  "source_processing",
46497
+ "harvest_run_source",
46482
46498
  "source_fts",
46483
46499
  "field_def",
46484
46500
  "unit_field",
@@ -47787,6 +47803,7 @@ var item = (v) => ({
47787
47803
  commit: null,
47788
47804
  ...v
47789
47805
  });
47806
+ var said = (body) => body?.trim() ? body : "";
47790
47807
  var sha = (s) => s && /^[0-9a-f]{40}$/.test(s) ? s : null;
47791
47808
  var cleanPath = (p) => p && !p.startsWith("/") && !p.includes("\\") && !/\p{Cc}/u.test(p) && !p.split("/").some((x) => x === "" || x === "." || x === "..") ? p : null;
47792
47809
  async function readPull(get, number5, repo = null) {
@@ -47807,52 +47824,49 @@ async function readPull(get, number5, repo = null) {
47807
47824
  association: p.author_association ?? null,
47808
47825
  url: p.html_url,
47809
47826
  createdAt: p.created_at,
47810
- text: p.body?.trim() ? p.body : ""
47827
+ text: said(p.body)
47811
47828
  }));
47812
47829
  for (const c of comments)
47813
- if (c.body?.trim())
47814
- items.push(item({
47815
- kind: "pr_comment",
47816
- artifact,
47817
- externalId: `comment:${c.id}`,
47818
- author: c.user ?? null,
47819
- association: c.author_association ?? null,
47820
- url: c.html_url,
47821
- createdAt: c.created_at,
47822
- text: c.body
47823
- }));
47830
+ items.push(item({
47831
+ kind: "pr_comment",
47832
+ artifact,
47833
+ externalId: `comment:${c.id}`,
47834
+ author: c.user ?? null,
47835
+ association: c.author_association ?? null,
47836
+ url: c.html_url,
47837
+ createdAt: c.created_at,
47838
+ text: said(c.body)
47839
+ }));
47824
47840
  for (const r of reviews)
47825
- if (r.body?.trim())
47826
- items.push(item({
47827
- kind: "review",
47828
- artifact,
47829
- externalId: `review:${r.id}`,
47830
- author: r.user ?? null,
47831
- association: r.author_association ?? null,
47832
- url: r.html_url,
47833
- createdAt: r.submitted_at ?? p.created_at,
47834
- text: r.body
47835
- }));
47836
- for (const c of reviewComments)
47837
- if (c.body?.trim()) {
47838
- const end = c.line ?? null;
47839
- const start = c.start_line && c.start_line <= (end ?? 0) && (c.start_side ?? c.side) === c.side ? c.start_line : end;
47840
- items.push(item({
47841
- kind: "review_comment",
47842
- artifact,
47843
- externalId: `review_comment:${c.id}`,
47844
- author: c.user ?? null,
47845
- association: c.author_association ?? null,
47846
- parent: c.in_reply_to_id ? `review_comment:${c.in_reply_to_id}` : null,
47847
- url: c.html_url,
47848
- createdAt: c.created_at,
47849
- text: c.body,
47850
- path: cleanPath(c.path),
47851
- lines: cleanPath(c.path) && end && start ? [start, end] : null,
47852
- hunk: c.diff_hunk ?? null,
47853
- commit: sha(c.commit_id)
47854
- }));
47855
- }
47841
+ items.push(item({
47842
+ kind: "review",
47843
+ artifact,
47844
+ externalId: `review:${r.id}`,
47845
+ author: r.user ?? null,
47846
+ association: r.author_association ?? null,
47847
+ url: r.html_url,
47848
+ createdAt: r.submitted_at ?? p.created_at,
47849
+ text: said(r.body)
47850
+ }));
47851
+ for (const c of reviewComments) {
47852
+ const end = c.line ?? null;
47853
+ const start = c.start_line && c.start_line <= (end ?? 0) && (c.start_side ?? c.side) === c.side ? c.start_line : end;
47854
+ items.push(item({
47855
+ kind: "review_comment",
47856
+ artifact,
47857
+ externalId: `review_comment:${c.id}`,
47858
+ author: c.user ?? null,
47859
+ association: c.author_association ?? null,
47860
+ parent: c.in_reply_to_id ? `review_comment:${c.in_reply_to_id}` : null,
47861
+ url: c.html_url,
47862
+ createdAt: c.created_at,
47863
+ text: said(c.body),
47864
+ path: cleanPath(c.path),
47865
+ lines: cleanPath(c.path) && end && start ? [start, end] : null,
47866
+ hunk: c.diff_hunk ?? null,
47867
+ commit: sha(c.commit_id)
47868
+ }));
47869
+ }
47856
47870
  for (const c of commits)
47857
47871
  items.push(item({
47858
47872
  kind: "commit_message",
@@ -47885,29 +47899,27 @@ async function readIssue(get, n) {
47885
47899
  const issue2 = await get(`issues/${n}`);
47886
47900
  if (issue2.pull_request)
47887
47901
  return items;
47888
- if (issue2.body?.trim())
47902
+ items.push(item({
47903
+ kind: "issue_body",
47904
+ artifact: `issue:${n}`,
47905
+ externalId: `issue:${n}`,
47906
+ author: issue2.user ?? null,
47907
+ association: issue2.author_association ?? null,
47908
+ url: issue2.html_url,
47909
+ createdAt: issue2.created_at,
47910
+ text: said(issue2.body)
47911
+ }));
47912
+ for (const c of await get(`issues/${n}/comments?per_page=100`, true))
47889
47913
  items.push(item({
47890
- kind: "issue_body",
47914
+ kind: "issue_comment",
47891
47915
  artifact: `issue:${n}`,
47892
- externalId: `issue:${n}`,
47893
- author: issue2.user ?? null,
47894
- association: issue2.author_association ?? null,
47895
- url: issue2.html_url,
47896
- createdAt: issue2.created_at,
47897
- text: issue2.body
47916
+ externalId: `comment:${c.id}`,
47917
+ author: c.user ?? null,
47918
+ association: c.author_association ?? null,
47919
+ url: c.html_url,
47920
+ createdAt: c.created_at,
47921
+ text: said(c.body)
47898
47922
  }));
47899
- for (const c of await get(`issues/${n}/comments?per_page=100`, true))
47900
- if (c.body?.trim())
47901
- items.push(item({
47902
- kind: "issue_comment",
47903
- artifact: `issue:${n}`,
47904
- externalId: `comment:${c.id}`,
47905
- author: c.user ?? null,
47906
- association: c.author_association ?? null,
47907
- url: c.html_url,
47908
- createdAt: c.created_at,
47909
- text: c.body
47910
- }));
47911
47923
  return items;
47912
47924
  }
47913
47925
  function githubTarget(repo, url2) {
@@ -47996,12 +48008,16 @@ async function linkIssues(db, projectId2, number5, closes) {
47996
48008
  kind: "closes"
47997
48009
  }).onConflict((oc) => oc.doNothing()).execute();
47998
48010
  }
47999
- async function pullSources(db, projectId2, number5) {
48011
+ async function pullSourceIds(db, projectId2, number5) {
48000
48012
  const artifacts = [
48001
48013
  `pr:${number5}`,
48002
48014
  ...(await db.selectFrom("artifact_link").select("to_artifact").where("project_id", "=", projectId2).where("from_artifact", "=", `pr:${number5}`).execute()).map((l) => l.to_artifact)
48003
48015
  ];
48004
- return db.selectFrom("source as s").where("s.project_id", "=", projectId2).where("s.artifact", "in", artifacts).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source as n").select("n.id").whereRef("n.project_id", "=", "s.project_id").whereRef("n.kind", "=", "s.kind").whereRef("n.external_id", "=", "s.external_id").whereRef("n.revision", ">", "s.revision")))).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source_forgotten as f").select("f.source_id").whereRef("f.project_id", "=", "s.project_id").whereRef("f.kind", "=", "s.kind").whereRef("f.external_id", "=", "s.external_id").whereRef("f.revision", ">", "s.revision")))).select([
48016
+ const rows = await db.selectFrom("source as s").where("s.project_id", "=", projectId2).where("s.artifact", "in", artifacts).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source as n").select("n.id").whereRef("n.project_id", "=", "s.project_id").whereRef("n.kind", "=", "s.kind").whereRef("n.external_id", "=", "s.external_id").whereRef("n.revision", ">", "s.revision")))).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source_forgotten as f").select("f.source_id").whereRef("f.project_id", "=", "s.project_id").whereRef("f.kind", "=", "s.kind").whereRef("f.external_id", "=", "s.external_id").whereRef("f.revision", ">", "s.revision")))).select("s.id").execute();
48017
+ return rows.map((r) => r.id);
48018
+ }
48019
+ async function runSources(db, runId) {
48020
+ return db.selectFrom("harvest_run_source as h").innerJoin("source as s", "s.id", "h.source_id").where("h.run_id", "=", runId).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source_forgotten as f").select("f.source_id").whereRef("f.project_id", "=", "s.project_id").whereRef("f.kind", "=", "s.kind").whereRef("f.external_id", "=", "s.external_id").whereRef("f.revision", ">", "s.revision")))).select([
48005
48021
  "s.id",
48006
48022
  "s.kind",
48007
48023
  "s.artifact",
@@ -48010,7 +48026,6 @@ async function pullSources(db, projectId2, number5) {
48010
48026
  "s.author_login",
48011
48027
  "s.author_association",
48012
48028
  "s.created_at",
48013
- "s.captured_at",
48014
48029
  "s.path",
48015
48030
  "s.line_start",
48016
48031
  "s.text"
@@ -48690,9 +48705,7 @@ async function checkRecord(db, target, raw, facts = prepareRecord(target.root, r
48690
48705
  ...new Set(record4.units.flatMap((u) => [...u.supersedes ? [u.supersedes] : [], ...u.conflicts]))
48691
48706
  ];
48692
48707
  const others = new Map((linked.length ? await db.selectFrom("unit as o").select(["o.id", "o.key", "o.kind", "o.lifecycle"]).where("o.project_id", "=", target.projectId).where("o.key", "in", linked).execute() : []).map((u) => [u.key, u]));
48693
- const holders = new Map((others.size ? await db.selectFrom("unit_replacement as h").innerJoin("unit as n", "n.id", "h.from_unit").select(["h.to_unit", "n.key", "n.lifecycle"]).where("h.ended_at", "is", null).where("h.to_unit", "in", [...others.values()].map((o) => o.id)).execute() : []).map((h) => [h.to_unit, h]));
48694
48708
  const units = [];
48695
- const claimed = new Set;
48696
48709
  for (const [i, u] of record4.units.entries()) {
48697
48710
  const key = keys[i] ?? "";
48698
48711
  const quarantine = [];
@@ -48850,23 +48863,14 @@ async function checkRecord(db, target, raw, facts = prepareRecord(target.root, r
48850
48863
  let supersedes = null;
48851
48864
  if (u.supersedes) {
48852
48865
  const old = others.get(u.supersedes);
48853
- const implemented = () => evidence.some((e) => e.role === "implements" && (["commit_message", "file_excerpt"].includes(sources.get(e.source)?.kind ?? "") || anchors.some((a) => a.role === "evidence" && (a.commit || a.observation !== null && sources.get(e.source)?.session_id === target.sessionId)))) || anchors.some((a) => a.role === "evidence" && a.commit);
48854
- const takes = ["decision", "constraint"].includes(u.kind) ? adoption.some((x) => x.route !== "agent") : u.kind !== "implementation" || implemented();
48855
48866
  if (!old)
48856
48867
  errors3.push(`${key}: supersedes ${u.supersedes}, which is not a record of this project`);
48857
48868
  else if (!["active", "candidate"].includes(old.lifecycle))
48858
48869
  errors3.push(`${key}: ${u.supersedes} is already ${old.lifecycle}`);
48859
- else if (claimed.has(old.id) && !quarantine.length && takes)
48860
- errors3.push(`${key}: another record in this save already supersedes ${u.supersedes}`);
48861
48870
  else if (!replaceable(u.kind, old.kind))
48862
48871
  errors3.push(`${key}: a ${u.kind} cannot supersede ${u.supersedes}, a ${old.kind} (a record supersedes one of its own kind; a decision and a constraint can replace each other)`);
48863
- else if (holders.has(old.id) && !quarantine.length && takes) {
48864
- const h = holders.get(old.id);
48865
- errors3.push(`${key}: ${u.supersedes} already has a successor, ${h?.key} (${h?.lifecycle}); withdraw it first, or supersede it instead`);
48866
- } else
48872
+ else
48867
48873
  supersedes = old.id;
48868
- if (supersedes !== null && !quarantine.length && takes)
48869
- claimed.add(supersedes);
48870
48874
  }
48871
48875
  const conflicts = u.conflicts.flatMap((k) => {
48872
48876
  const other = others.get(k);
@@ -49000,12 +49004,20 @@ var contentHash = (u) => sha256(JSON.stringify([
49000
49004
  u.revisit_when ?? null,
49001
49005
  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])
49002
49006
  ]));
49007
+
49008
+ class SaveRefused extends Error {
49009
+ }
49003
49010
  async function settleSaved(trx, runId, saved, more = {}) {
49004
49011
  const written = new Map(saved.written.map((w) => [w.id, w]));
49005
49012
  const settled2 = await reconcile(trx, [...written.keys(), ...more.seeds ?? []], { runId }, {
49006
49013
  hints: new Map(saved.written.map((w) => [w.id, w.hint])),
49007
49014
  withdraw: more.withdraw
49008
49015
  });
49016
+ const mine = new Set([...written.keys(), ...more.adopted ?? []]);
49017
+ const key = (id) => settled2.keys.get(id) ?? `u${id}`;
49018
+ for (const [id, [to, holder]] of settled2.held)
49019
+ if (mine.has(id))
49020
+ throw new SaveRefused(mine.has(holder) ? `${key(id)}: another record in this save already supersedes ${key(to)}` : `${key(id)}: ${key(to)} already has a successor, ${key(holder)} (in effect); withdraw it first, or supersede it instead`);
49009
49021
  const now = new Map(settled2.changes.map((c) => [c.id, c.after]));
49010
49022
  for (const w of saved.written) {
49011
49023
  if (saved.quarantined.some((q) => q.startsWith(`${w.key} (`)))
@@ -49178,7 +49190,6 @@ ${checked.errors.map((e) => ` ${e}`).join(`
49178
49190
  saved.written.push({
49179
49191
  id,
49180
49192
  key: p.key,
49181
- adopted: p.adoption.length > 0,
49182
49193
  hint: {
49183
49194
  reason: p.adoption.length ? "evidence and adoption found" : "evidence found",
49184
49195
  source: p.adoption[0]?.source ?? p.evidence[0]?.source ?? null
@@ -49476,9 +49487,6 @@ async function checkGlean(db, target, raw, facts = prepareGlean(target.root, raw
49476
49487
  errors3.push(`${what}: the merge does not adopt a proposal`);
49477
49488
  else if (got && got.s.author_kind !== "owner" && !MAINTAINERS2.has(got.s.author_association ?? ""))
49478
49489
  errors3.push(`${what}: only the owner or a maintainer can adopt`);
49479
- const held = await db.selectFrom("unit_link as l").innerJoin("unit_replacement as r", (j) => j.onRef("r.to_unit", "=", "l.to_unit").on("r.ended_at", "is", null)).innerJoin("unit as h", "h.id", "r.from_unit").innerJoin("unit as t", "t.id", "l.to_unit").where("l.from_unit", "=", u.id).where("l.kind", "=", "supersedes").where("r.from_unit", "!=", u.id).select(["h.key as holder", "t.key as replaced"]).executeTakeFirst();
49480
- if (held && !parsed.data.ops.some((o) => o.op === "withdraw" && o.unit === held.holder))
49481
- errors3.push(`${what}: ${held.replaced} already has a successor, ${held.holder} (in effect); withdraw it first, or supersede it instead`);
49482
49490
  }
49483
49491
  if (op.op === "anchor" && !repoPath(op.path))
49484
49492
  errors3.push(`${what}: the path is not inside the repository`);
@@ -49495,8 +49503,8 @@ async function checkGlean(db, target, raw, facts = prepareGlean(target.root, raw
49495
49503
  if (op.op === "replace_anchor") {
49496
49504
  if (!repoPath(op.to.path))
49497
49505
  errors3.push(`${what}: the path is not inside the repository`);
49498
- const said = await span(op.source, op.quote, what);
49499
- if (said && said.s.author_kind !== "owner")
49506
+ const said2 = await span(op.source, op.quote, what);
49507
+ if (said2 && said2.s.author_kind !== "owner")
49500
49508
  errors3.push(`${what}: only the owner's words can move an anchor`);
49501
49509
  const from = repoPath(op.from.path);
49502
49510
  if (!from)
@@ -49772,16 +49780,11 @@ ${c.errors.map((e) => ` ${e}`).join(`
49772
49780
  }
49773
49781
  const settled2 = await settleSaved(trx, runId, units, {
49774
49782
  seeds: [...touched.keys(), ...withdraw.keys()],
49775
- withdraw
49783
+ withdraw,
49784
+ adopted
49776
49785
  });
49777
49786
  for (const id of settled2.redundant)
49778
49787
  changed2.push(`${settled2.keys.get(id)}: superseded by a record of this save, so not withdrawn`);
49779
- for (const w of units.written)
49780
- if (w.adopted)
49781
- adopted.add(w.id);
49782
- for (const [id, [to, holder]] of settled2.held)
49783
- if (adopted.has(id))
49784
- throw new Error(`${settled2.keys.get(id)}: ${settled2.keys.get(to)} already has a successor, ${settled2.keys.get(holder)} (in effect); withdraw it first, or supersede it instead`);
49785
49788
  const written = new Set(units.written.map((w) => w.id));
49786
49789
  for (const ch of settled2.changes) {
49787
49790
  if (written.has(ch.id))
@@ -49974,7 +49977,7 @@ async function beginHarvest(db, projectId2, number5, get, beginCall) {
49974
49977
  const kept = (await storeItems(trx, projectId2, pull.items)).filter((id) => id !== null).length;
49975
49978
  await linkIssues(trx, projectId2, number5, pull.closes);
49976
49979
  const run = newRunId();
49977
- await openRun(trx, {
49980
+ const runId = await openRun(trx, {
49978
49981
  projectId: projectId2,
49979
49982
  origin: "harvest",
49980
49983
  target: `pr:${number5}`,
@@ -49982,6 +49985,8 @@ async function beginHarvest(db, projectId2, number5, get, beginCall) {
49982
49985
  draftId: run,
49983
49986
  beginCall
49984
49987
  });
49988
+ for (const sourceId of await pullSourceIds(trx, projectId2, number5))
49989
+ await trx.insertInto("harvest_run_source").values({ run_id: runId, source_id: sourceId }).execute();
49985
49990
  return { run, sources: kept };
49986
49991
  });
49987
49992
  }
@@ -50036,7 +50041,7 @@ async function agentRun(db, run, call) {
50036
50041
  async function scopeOf(db, run, root) {
50037
50042
  if (run.origin === "harvest") {
50038
50043
  const number5 = Number(run.target.slice("pr:".length));
50039
- const sources2 = (await pullSources(db, run.project_id, number5)).filter((x) => x.captured_at <= run.started_at);
50044
+ const sources2 = await runSources(db, run.id);
50040
50045
  return {
50041
50046
  target: {
50042
50047
  projectId: run.project_id,
@@ -50240,42 +50245,43 @@ function contextOnly(id, items, c) {
50240
50245
  ...c.work && !c.units.length ? ["work: an automatic run updates work only beside a record of the messages it traces"] : []
50241
50246
  ];
50242
50247
  }
50243
- async function checkText(db, id, projectId2, root, record4, call) {
50244
- const run = await bound(db, id, projectId2);
50245
- const { target, items } = await scopeOf(db, run, root);
50246
- target.agent = await agentRun(db, run, call);
50247
- const c = run.origin === "glean" ? await checkGlean(db, target, record4) : await checkRecord(db, target, record4);
50248
- if (!("ops" in c))
50249
- c.errors.push(...contextOnly(id, items, c));
50250
- const units = "ops" in c ? c.units.units : c.units;
50251
- const lines = [
50252
- ...c.errors.map((e) => `✗ ${e}`),
50253
- ...c.problems.map((p) => `△ ${p}`),
50254
- ...units.filter((u) => u.quarantine.length).map((u) => `△ ${u.key} will be quarantined: ${u.quarantine.join("; ")}`)
50255
- ];
50256
- const summary = c.errors.length ? `✗ ${plural2(c.errors.length, "error")}; fix the record and check again` : `✓ ${plural2(units.length, "record")}${"ops" in c ? ` and ${plural2(c.ops.length, "change")}` : ""} can be saved`;
50257
- return { ok: c.errors.length === 0, text: [...lines, summary].join(`
50258
- `) };
50259
- }
50260
- async function saveText(db, id, projectId2, root, record4, probe, call) {
50248
+ async function prepare2(db, id, projectId2, root, record4, probe, call) {
50261
50249
  const begun = await bound(db, id, projectId2);
50262
50250
  if (call !== undefined && begun.begin_call_id !== null)
50263
50251
  await sameCaller(db, begun.begin_call_id, call);
50264
- const glean = begun.origin === "glean";
50265
- const gleanFacts = glean ? prepareGlean(root, record4, probe) : undefined;
50266
- const facts = gleanFacts ?? prepareRecord(root, record4, probe);
50267
- const text3 = await inTransaction(db, async (trx) => {
50268
- const run = await bound(trx, id, projectId2);
50269
- const scope = await scopeOf(trx, run, root);
50270
- scope.target.agent = await agentRun(trx, run, call);
50271
- const lines = [];
50272
- const notes = [];
50273
- const saved = run.origin === "glean" ? await saveGlean(trx, scope.target, run.id, await checkGlean(trx, scope.target, record4, gleanFacts)).then((g) => {
50274
- lines.push(...g.changed.map((c) => `✓ ${c}`));
50275
- return g.units;
50276
- }) : await checkRecord(trx, scope.target, record4, facts).then((checked) => {
50277
- checked.errors.push(...contextOnly(id, scope.items, checked));
50278
- notes.push(...checked.problems);
50252
+ const gleanFacts = begun.origin === "glean" ? prepareGlean(root, record4, probe) : undefined;
50253
+ return { facts: gleanFacts ?? prepareRecord(root, record4, probe), gleanFacts };
50254
+ }
50255
+ async function validate2(trx, id, projectId2, root, record4, prepared, call) {
50256
+ const run = await bound(trx, id, projectId2);
50257
+ const scope = await scopeOf(trx, run, root);
50258
+ scope.target.agent = await agentRun(trx, run, call);
50259
+ const quarantine = (units) => units.filter((u) => u.quarantine.length).map((u) => `${u.key} will be quarantined: ${u.quarantine.join("; ")}`);
50260
+ if (run.origin === "glean") {
50261
+ const c = await checkGlean(trx, scope.target, record4, prepared.gleanFacts);
50262
+ return {
50263
+ run,
50264
+ errors: c.errors,
50265
+ problems: c.problems,
50266
+ quarantine: quarantine(c.units.units),
50267
+ units: c.units.units.length,
50268
+ ops: c.ops.length,
50269
+ write: async (w) => {
50270
+ const g = await saveGlean(w, scope.target, run.id, c);
50271
+ return { saved: g.units, changed: g.changed };
50272
+ }
50273
+ };
50274
+ }
50275
+ const checked = await checkRecord(trx, scope.target, record4, prepared.facts);
50276
+ checked.errors.push(...contextOnly(id, scope.items, checked));
50277
+ return {
50278
+ run,
50279
+ errors: checked.errors,
50280
+ problems: checked.problems,
50281
+ quarantine: quarantine(checked.units),
50282
+ units: checked.units.length,
50283
+ ops: null,
50284
+ write: async (w) => {
50279
50285
  const shown = shownTo.get(id)?.sources ?? new Set;
50280
50286
  const cited = new Set([
50281
50287
  ...checked.units.flatMap((u) => [
@@ -50286,17 +50292,62 @@ async function saveText(db, id, projectId2, root, record4, probe, call) {
50286
50292
  ]),
50287
50293
  ...checked.fieldDefs
50288
50294
  ].map((q) => q.source));
50289
- return saveRecord(trx, scope.target, run.id, checked, scope.looked.filter((s) => shown.has(s) || cited.has(s)));
50290
- });
50291
- await finishRun(trx, run.id);
50295
+ const saved = await saveRecord(w, scope.target, run.id, checked, scope.looked.filter((s) => shown.has(s) || cited.has(s)));
50296
+ return { saved, changed: [] };
50297
+ }
50298
+ };
50299
+ }
50300
+ async function checkText(db, id, projectId2, root, record4, call, probe) {
50301
+ const prepared = await prepare2(db, id, projectId2, root, record4, probe, call);
50302
+ const { v, done, refused } = await inRolledBack(db, async (trx) => {
50303
+ const v2 = await validate2(trx, id, projectId2, root, record4, prepared, call);
50304
+ if (v2.errors.length)
50305
+ return { v: v2, done: null, refused: null };
50306
+ try {
50307
+ const done2 = await v2.write(trx);
50308
+ await finishRun(trx, v2.run.id);
50309
+ return { v: v2, done: done2, refused: null };
50310
+ } catch (e) {
50311
+ if (!(e instanceof SaveRefused))
50312
+ throw e;
50313
+ return { v: v2, done: null, refused: e.message };
50314
+ }
50315
+ });
50316
+ const errors3 = [...v.errors, ...refused === null ? [] : [refused]];
50317
+ const lines = [
50318
+ ...errors3.map((e) => `✗ ${e}`),
50319
+ ...[...new Set(v.problems)].map((p) => `△ ${p}`),
50320
+ ...done ? [
50321
+ ...done.saved.active.map((k) => `✓ would be active: ${k}`),
50322
+ ...done.saved.superseded.map((k) => `✓ would be superseded: ${k}`),
50323
+ ...done.saved.candidates.map((c) => `△ would stay a candidate: ${c.key}: ${c.why}`),
50324
+ ...done.saved.quarantined.map((q) => `△ would be quarantined: ${q}`),
50325
+ ...[...new Set(done.saved.anchorProblems)].filter((a) => !v.problems.some((p) => p === a || p.endsWith(` ${a}`))).map((a) => `△ ${a}`),
50326
+ ...done.changed.map((c) => `✓ would: ${c}`)
50327
+ ] : v.quarantine.map((q) => `△ ${q}`)
50328
+ ];
50329
+ const summary = errors3.length ? `✗ ${plural2(errors3.length, "error")}; fix the record and check again` : `✓ ${plural2(v.units, "record")}${v.ops === null ? "" : ` and ${plural2(v.ops, "change")}`} can be saved`;
50330
+ return { ok: errors3.length === 0, text: [...lines, summary].join(`
50331
+ `) };
50332
+ }
50333
+ async function saveText(db, id, projectId2, root, record4, probe, call) {
50334
+ const prepared = await prepare2(db, id, projectId2, root, record4, probe, call);
50335
+ const text3 = await inTransaction(db, async (trx) => {
50336
+ const v = await validate2(trx, id, projectId2, root, record4, prepared, call);
50337
+ if (v.errors.length)
50338
+ throw new Error(`The record is not valid:
50339
+ ${v.errors.map((e) => ` ${e}`).join(`
50340
+ `)}`);
50341
+ const { saved, changed: changed2 } = await v.write(trx);
50342
+ await finishRun(trx, v.run.id);
50292
50343
  return [
50293
50344
  ...saved.active.map((k) => `✓ ${k} active`),
50294
50345
  ...saved.superseded.map((k) => `✓ ${k} superseded`),
50295
50346
  ...saved.candidates.map((c) => `△ ${c.key} candidate: ${c.why}`),
50296
50347
  ...saved.quarantined.map((q) => `△ ${q} quarantined`),
50297
50348
  ...saved.anchorProblems.map((a) => `△ ${a}`),
50298
- ...notes.map((n) => `△ ${n}`),
50299
- ...lines,
50349
+ ...v.ops === null ? v.problems.map((n) => `△ ${n}`) : [],
50350
+ ...changed2.map((c) => `✓ ${c}`),
50300
50351
  "✓ saved"
50301
50352
  ].join(`
50302
50353
  `);
@@ -50595,7 +50646,7 @@ server.registerTool("record_context", {
50595
50646
  }));
50596
50647
  server.registerTool("record_check", {
50597
50648
  title: "Check a record",
50598
- description: "Checks a record against the run's retained text without saving it.",
50649
+ description: "Checks a record by running the save in a transaction that rolls back: it refuses what record_save would refuse and says what would become of each record (would be active, would stay a candidate, would be superseded), writing nothing.",
50599
50650
  inputSchema: exports_external.object({ run: RUN, record: RECORD, cwd: CWD }).strict(),
50600
50651
  annotations: READ
50601
50652
  }, async (a, extra) => tool(async () => {
package/dist/mcp.js CHANGED
@@ -46004,7 +46004,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
46004
46004
 
46005
46005
  // server/src/sqlite.ts
46006
46006
  var SCHEMA_GENERATION = 2;
46007
- var SCHEMA_REVISION = 10;
46007
+ var SCHEMA_REVISION = 11;
46008
46008
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
46009
46009
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
46010
46010
  function requireRuntime() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
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
  "license": "MIT",
6
6
  "type": "module",
@@ -49,8 +49,9 @@ read tools `search` and `read`. Pass the repository root as `cwd` to every tool.
49
49
  the owner's message that quotes it
50
50
  5. **Read**: `record_context` with the run: the owner's messages in this session with their refs. When a page ends with
51
51
  `call record_context with after: "s<N>"`, call it again with that `after`
52
- 6. **Check**: `record_check` with the run and the record below. Fix errors and check again. A note to ask the owner for a source means
53
- step 2 is not done
52
+ 6. **Check**: `record_check` with the run and the record below. It runs the save in a transaction that rolls back, so it refuses what the
53
+ save would refuse and lists what would change (`would be active`, `would: <change>`) without writing it. Fix errors and check again. A
54
+ note to ask the owner for a source means step 2 is not done
54
55
  7. **Save**: `record_save`. **Report** what changed, copying save's lines
55
56
 
56
57
  ## The record
@@ -37,7 +37,8 @@ as `cwd` to every tool.
37
37
  sources an earlier harvest already looked at: what they decided may already be saved, so search before recording it again. Read all of it before writing:
38
38
  when a page ends with `call record_context with after: "s<N>"`, call it again with that `after`, until the last page
39
39
  4. **Check**: `record_check` with the run and the record as `record`. The shape and fields are trace's ([../trace/SKILL.md](../trace/SKILL.md),
40
- "The record"), with `work` left out. Keys are saved as `harvest:<number>/<key>`. Fix and check again until there are no errors.
40
+ "The record"), with `work` left out. Keys are saved as `harvest:<number>/<key>`. As in trace, check runs the save in a transaction that
41
+ rolls back and says what would become of each record (`would be active`, ...). Fix and check again until there are no errors.
41
42
  A file the pull request touched may have moved since: on a warning that a path is not in the working tree, give the commit that holds it,
42
43
  or anchor where the code is now
43
44
  5. **Save**: `record_save` with the same run and record
@@ -41,7 +41,9 @@ Everything goes through Sphica's `record` MCP server (its tools are `trace_pendi
41
41
  the last page (the one with the live records). Saving marks as looked at only the messages you were shown and those you quote; the
42
42
  rest stay pending.
43
43
  Use `search` and `read` to look at older records this session may replace
44
- 4. **Check**: `record_check` with the run and the record below as `record`. Errors refuse the save; fix and check again. Warnings say what will be
44
+ 4. **Check**: `record_check` with the run and the record below as `record`. It runs the save in a transaction that rolls back, so nothing is
45
+ written: it refuses what the save would refuse, and says what would become of each record (`would be active`, `would stay a candidate` with
46
+ the reason, `would be superseded`, `would be quarantined`). Errors refuse the save; fix and check again. Warnings say what will be
45
47
  left out, quarantined, or kept as a candidate, and why. An anchor warning (a path not in the working tree, with near paths; a directory; a
46
48
  symbol not in the file) means fix the anchor and check again; keep it only when you know it is right
47
49
  5. **Save**: `record_save` with the same run and record. A run saves once