sphica 0.6.36 → 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.36",
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.36",
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",
@@ -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()))
@@ -48691,9 +48705,7 @@ async function checkRecord(db, target, raw, facts = prepareRecord(target.root, r
48691
48705
  ...new Set(record4.units.flatMap((u) => [...u.supersedes ? [u.supersedes] : [], ...u.conflicts]))
48692
48706
  ];
48693
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]));
48694
- 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]));
48695
48708
  const units = [];
48696
- const claimed = new Set;
48697
48709
  for (const [i, u] of record4.units.entries()) {
48698
48710
  const key = keys[i] ?? "";
48699
48711
  const quarantine = [];
@@ -48851,23 +48863,14 @@ async function checkRecord(db, target, raw, facts = prepareRecord(target.root, r
48851
48863
  let supersedes = null;
48852
48864
  if (u.supersedes) {
48853
48865
  const old = others.get(u.supersedes);
48854
- 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);
48855
- const takes = ["decision", "constraint"].includes(u.kind) ? adoption.some((x) => x.route !== "agent") : u.kind !== "implementation" || implemented();
48856
48866
  if (!old)
48857
48867
  errors3.push(`${key}: supersedes ${u.supersedes}, which is not a record of this project`);
48858
48868
  else if (!["active", "candidate"].includes(old.lifecycle))
48859
48869
  errors3.push(`${key}: ${u.supersedes} is already ${old.lifecycle}`);
48860
- else if (claimed.has(old.id) && !quarantine.length && takes)
48861
- errors3.push(`${key}: another record in this save already supersedes ${u.supersedes}`);
48862
48870
  else if (!replaceable(u.kind, old.kind))
48863
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)`);
48864
- else if (holders.has(old.id) && !quarantine.length && takes) {
48865
- const h = holders.get(old.id);
48866
- errors3.push(`${key}: ${u.supersedes} already has a successor, ${h?.key} (${h?.lifecycle}); withdraw it first, or supersede it instead`);
48867
- } else
48872
+ else
48868
48873
  supersedes = old.id;
48869
- if (supersedes !== null && !quarantine.length && takes)
48870
- claimed.add(supersedes);
48871
48874
  }
48872
48875
  const conflicts = u.conflicts.flatMap((k) => {
48873
48876
  const other = others.get(k);
@@ -49001,12 +49004,20 @@ var contentHash = (u) => sha256(JSON.stringify([
49001
49004
  u.revisit_when ?? null,
49002
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])
49003
49006
  ]));
49007
+
49008
+ class SaveRefused extends Error {
49009
+ }
49004
49010
  async function settleSaved(trx, runId, saved, more = {}) {
49005
49011
  const written = new Map(saved.written.map((w) => [w.id, w]));
49006
49012
  const settled2 = await reconcile(trx, [...written.keys(), ...more.seeds ?? []], { runId }, {
49007
49013
  hints: new Map(saved.written.map((w) => [w.id, w.hint])),
49008
49014
  withdraw: more.withdraw
49009
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`);
49010
49021
  const now = new Map(settled2.changes.map((c) => [c.id, c.after]));
49011
49022
  for (const w of saved.written) {
49012
49023
  if (saved.quarantined.some((q) => q.startsWith(`${w.key} (`)))
@@ -49179,7 +49190,6 @@ ${checked.errors.map((e) => ` ${e}`).join(`
49179
49190
  saved.written.push({
49180
49191
  id,
49181
49192
  key: p.key,
49182
- adopted: p.adoption.length > 0,
49183
49193
  hint: {
49184
49194
  reason: p.adoption.length ? "evidence and adoption found" : "evidence found",
49185
49195
  source: p.adoption[0]?.source ?? p.evidence[0]?.source ?? null
@@ -49477,9 +49487,6 @@ async function checkGlean(db, target, raw, facts = prepareGlean(target.root, raw
49477
49487
  errors3.push(`${what}: the merge does not adopt a proposal`);
49478
49488
  else if (got && got.s.author_kind !== "owner" && !MAINTAINERS2.has(got.s.author_association ?? ""))
49479
49489
  errors3.push(`${what}: only the owner or a maintainer can adopt`);
49480
- 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();
49481
- if (held && !parsed.data.ops.some((o) => o.op === "withdraw" && o.unit === held.holder))
49482
- errors3.push(`${what}: ${held.replaced} already has a successor, ${held.holder} (in effect); withdraw it first, or supersede it instead`);
49483
49490
  }
49484
49491
  if (op.op === "anchor" && !repoPath(op.path))
49485
49492
  errors3.push(`${what}: the path is not inside the repository`);
@@ -49773,16 +49780,11 @@ ${c.errors.map((e) => ` ${e}`).join(`
49773
49780
  }
49774
49781
  const settled2 = await settleSaved(trx, runId, units, {
49775
49782
  seeds: [...touched.keys(), ...withdraw.keys()],
49776
- withdraw
49783
+ withdraw,
49784
+ adopted
49777
49785
  });
49778
49786
  for (const id of settled2.redundant)
49779
49787
  changed2.push(`${settled2.keys.get(id)}: superseded by a record of this save, so not withdrawn`);
49780
- for (const w of units.written)
49781
- if (w.adopted)
49782
- adopted.add(w.id);
49783
- for (const [id, [to, holder]] of settled2.held)
49784
- if (adopted.has(id))
49785
- 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`);
49786
49788
  const written = new Set(units.written.map((w) => w.id));
49787
49789
  for (const ch of settled2.changes) {
49788
49790
  if (written.has(ch.id))
@@ -50243,42 +50245,43 @@ function contextOnly(id, items, c) {
50243
50245
  ...c.work && !c.units.length ? ["work: an automatic run updates work only beside a record of the messages it traces"] : []
50244
50246
  ];
50245
50247
  }
50246
- async function checkText(db, id, projectId2, root, record4, call) {
50247
- const run = await bound(db, id, projectId2);
50248
- const { target, items } = await scopeOf(db, run, root);
50249
- target.agent = await agentRun(db, run, call);
50250
- const c = run.origin === "glean" ? await checkGlean(db, target, record4) : await checkRecord(db, target, record4);
50251
- if (!("ops" in c))
50252
- c.errors.push(...contextOnly(id, items, c));
50253
- const units = "ops" in c ? c.units.units : c.units;
50254
- const lines = [
50255
- ...c.errors.map((e) => `✗ ${e}`),
50256
- ...c.problems.map((p) => `△ ${p}`),
50257
- ...units.filter((u) => u.quarantine.length).map((u) => `△ ${u.key} will be quarantined: ${u.quarantine.join("; ")}`)
50258
- ];
50259
- 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`;
50260
- return { ok: c.errors.length === 0, text: [...lines, summary].join(`
50261
- `) };
50262
- }
50263
- async function saveText(db, id, projectId2, root, record4, probe, call) {
50248
+ async function prepare2(db, id, projectId2, root, record4, probe, call) {
50264
50249
  const begun = await bound(db, id, projectId2);
50265
50250
  if (call !== undefined && begun.begin_call_id !== null)
50266
50251
  await sameCaller(db, begun.begin_call_id, call);
50267
- const glean = begun.origin === "glean";
50268
- const gleanFacts = glean ? prepareGlean(root, record4, probe) : undefined;
50269
- const facts = gleanFacts ?? prepareRecord(root, record4, probe);
50270
- const text3 = await inTransaction(db, async (trx) => {
50271
- const run = await bound(trx, id, projectId2);
50272
- const scope = await scopeOf(trx, run, root);
50273
- scope.target.agent = await agentRun(trx, run, call);
50274
- const lines = [];
50275
- const notes = [];
50276
- const saved = run.origin === "glean" ? await saveGlean(trx, scope.target, run.id, await checkGlean(trx, scope.target, record4, gleanFacts)).then((g) => {
50277
- lines.push(...g.changed.map((c) => `✓ ${c}`));
50278
- return g.units;
50279
- }) : await checkRecord(trx, scope.target, record4, facts).then((checked) => {
50280
- checked.errors.push(...contextOnly(id, scope.items, checked));
50281
- 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) => {
50282
50285
  const shown = shownTo.get(id)?.sources ?? new Set;
50283
50286
  const cited = new Set([
50284
50287
  ...checked.units.flatMap((u) => [
@@ -50289,17 +50292,62 @@ async function saveText(db, id, projectId2, root, record4, probe, call) {
50289
50292
  ]),
50290
50293
  ...checked.fieldDefs
50291
50294
  ].map((q) => q.source));
50292
- return saveRecord(trx, scope.target, run.id, checked, scope.looked.filter((s) => shown.has(s) || cited.has(s)));
50293
- });
50294
- 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);
50295
50343
  return [
50296
50344
  ...saved.active.map((k) => `✓ ${k} active`),
50297
50345
  ...saved.superseded.map((k) => `✓ ${k} superseded`),
50298
50346
  ...saved.candidates.map((c) => `△ ${c.key} candidate: ${c.why}`),
50299
50347
  ...saved.quarantined.map((q) => `△ ${q} quarantined`),
50300
50348
  ...saved.anchorProblems.map((a) => `△ ${a}`),
50301
- ...notes.map((n) => `△ ${n}`),
50302
- ...lines,
50349
+ ...v.ops === null ? v.problems.map((n) => `△ ${n}`) : [],
50350
+ ...changed2.map((c) => `✓ ${c}`),
50303
50351
  "✓ saved"
50304
50352
  ].join(`
50305
50353
  `);
@@ -50598,7 +50646,7 @@ server.registerTool("record_context", {
50598
50646
  }));
50599
50647
  server.registerTool("record_check", {
50600
50648
  title: "Check a record",
50601
- 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.",
50602
50650
  inputSchema: exports_external.object({ run: RUN, record: RECORD, cwd: CWD }).strict(),
50603
50651
  annotations: READ
50604
50652
  }, async (a, extra) => tool(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.6.36",
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