synartesis 0.6.19 → 0.6.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,107 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.6.22 — 2026-09-16
6
+
7
+ ### Added
8
+
9
+ - **The memory policy's undo is now proven, not just plausible.** It has always
10
+ declared `provenance: live` -- the tools were checked against the real server
11
+ and the shapes read off its own answers -- while the README said its recovery
12
+ guarantees were unproven. Both were true, and the gap between them is where a
13
+ policy can name every tool correctly, take exactly the arguments the server
14
+ wants, and still resolve an inverse that puts nothing back. That failure
15
+ looks like success: the drift check passes and the report says `rolled_back`.
16
+
17
+ `tests/adapter-memory.test.ts` makes each change against a real knowledge
18
+ graph, undoes it, and compares the file. What the agent added is gone and
19
+ what was already there is untouched; an entity the agent only *tried* to
20
+ create -- this server ignores a duplicate name -- is left alone rather than
21
+ deleted out from under its owner; a relation drawn or removed goes back; and
22
+ a delete of an entity is held for a person rather than approximated, because
23
+ one inverse cannot put back both the entity and the relations that went with
24
+ it. Every one of those was confirmed by breaking the policy and watching the
25
+ matching test fail.
26
+
27
+ Two of the four shipped policies are now round-tripped end to end.
28
+ `synartesis check`, the README and both guides say which, and `git` is still
29
+ named plainly as untested.
30
+
31
+ - `check` also confirms the memory policy covers every tool that server offers,
32
+ so a server that grows one fails here rather than in front of somebody's
33
+ agent.
34
+
35
+ ## 0.6.21 — 2026-09-16
36
+
37
+ ### Fixed
38
+
39
+ - **`live` claimed more than it meant, and the docs contradicted each other
40
+ about it.** `provenance: live` says a policy has met its server and that the
41
+ tools take the arguments it passes them. `check` printed that as "checked
42
+ against the real server", which reads as a claim that undo works -- and it is
43
+ not one: a policy can be right about every tool name and still record an
44
+ inverse that restores nothing. Meanwhile the README said memory's recovery
45
+ guarantees were unproven while the user guide listed memory among the
46
+ policies that say `live`, so a reader who saw one came away with the opposite
47
+ of what the other meant. `check` now says "shapes read from the real server"
48
+ and adds, once under the server list, that `live` is not a recovery claim and
49
+ that only filesystem has been round-tripped. README and both guides say the
50
+ same thing.
51
+
52
+ ### Added
53
+
54
+ - **`check` names the tools no policy covers, instead of describing the rule.**
55
+ An unmatched tool has always been fail-closed -- irreversible, held for a
56
+ person the first time it is called -- and `check` said so as a sentence about
57
+ tools in general while the actual list sat one round trip away. It already
58
+ connects to every server and reads the whole tool list to verify the
59
+ policies, so the answer was in hand and thrown away, and the first anybody
60
+ learned a tool was ungoverned was an agent stopping on it mid-task. Now
61
+ named, per server, with a count. The proxy warns at startup for the same
62
+ reason, and `proxy ready` carries an `ungoverned` count so a policy with no
63
+ gaps and a build that forgot to look do not read alike.
64
+
65
+ For what almost everybody runs, the answer is none: the shipped filesystem
66
+ policy covers every tool that server offers, and there is a test that will
67
+ fail if that stops being true.
68
+
69
+ - The build targeted `node20` while the package requires `>=22`, which is the
70
+ floor better-sqlite3 sets -- on Node 20 it segfaults the moment a database
71
+ opens. Targeting lower was harmless but said the wrong thing about what this
72
+ supports, in the one place a reader could check. Now `node22`.
73
+
74
+ ## 0.6.20 — 2026-09-15
75
+
76
+ ### Fixed
77
+
78
+ - **The undo preview showed a write nobody confirmed as one that was
79
+ confirmed.** A tool call can land and then fail to say so -- the server
80
+ writes the record and times out, or answers with an error on its way out.
81
+ Synartesis already handled this correctly: it refuses to read `isError` as a
82
+ promise that nothing happened, reads the resource back, and records the
83
+ change so it stays recoverable. What it did not do was say so afterwards.
84
+ The row that came out of that path carried no trace of it at all -- the
85
+ refusal was written to the log, which nobody reads, at the moment it
86
+ happens, which is not the moment it matters -- so `undo --dry-run` printed
87
+ `revert ... state matches; applying inverse`, character for character what a
88
+ confirmed write prints. The row now records how it was established, and the
89
+ preview prints it under the step as `caveat`. Same in the app's
90
+ `preview_undo`, so a model cannot describe an inferred write to somebody as
91
+ a confirmed one.
92
+
93
+ - **A halt that named the consequence and hid the cause.** Where the transport
94
+ failed and the read-back proved the write had landed, undo correctly stopped
95
+ rather than reverting on an unverified assumption -- but said only "the
96
+ post-state was never captured, so drift could not be ruled out", which reads
97
+ as a missed reading. The reason there was no reading, which the row knew all
98
+ along, is now printed with it.
99
+
100
+ - An action whose outcome is unknown was told it got that way because "the
101
+ process died mid-call". A timeout, or an error the read-back could not
102
+ settle, arrives in exactly the same state and was told the same wrong story.
103
+ It now says what is actually known, and the row's own error says which of
104
+ them it was.
105
+
5
106
  ## 0.6.19 — 2026-09-15
6
107
 
7
108
  ### Fixed
package/README.md CHANGED
@@ -333,10 +333,25 @@ cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
333
333
  resolved when the call happens, so a mistake in a manifest is baked into every
334
334
  run made under it. `undo --replan` rebuilds them from a corrected one.
335
335
 
336
- The bundled **filesystem** policy is tested against the real server: exact
337
- byte-for-byte restoration, drift refusal, and absence told apart from a read
338
- that failed. The **memory, git and github** policies are checked only for tool
339
- existence — their recovery guarantees are not yet proven.
336
+ Two bundled policies are tested against the real server, by making the change
337
+ and undoing it. **filesystem**: exact byte-for-byte restoration, drift refusal,
338
+ and absence told apart from a read that failed. **memory**: the graph is put
339
+ back as it was, entities the agent only tried to create are left alone, and a
340
+ delete of an entity is held rather than approximated. **git and github** are
341
+ checked only for tool existence — their recovery guarantees are not yet proven.
342
+
343
+ All three of filesystem, memory and git declare `provenance: live`, and that
344
+ word is narrower than it looks: it says the policy has met its server and the
345
+ tools take the arguments it passes them, not that undo has been round-tripped.
346
+ `synartesis check` says so under the server list rather than leaving `live` to
347
+ stand for both.
348
+
349
+ **A tool no policy mentions is irreversible and held for a person** the first
350
+ time it is called. That is the safe end of the trade, and it means a server
351
+ that gains a tool in an update does not quietly get a free pass — but it also
352
+ means an agent stopping mid-task on a call nobody expected. `synartesis check`
353
+ names every such tool, and the proxy warns about them at startup, so you can
354
+ write a policy before meeting one rather than after.
340
355
 
341
356
  ## Trust
342
357
 
@@ -1554,18 +1554,30 @@ function untested(manifest) {
1554
1554
  function describeStanding(entry) {
1555
1555
  switch (entry.provenance) {
1556
1556
  case "live":
1557
- return "checked against the real server";
1557
+ return "shapes read from the real server";
1558
1558
  case "documented":
1559
1559
  return "written from documentation, never run against the real server";
1560
1560
  case "unstated":
1561
1561
  return "no claim either way";
1562
1562
  }
1563
1563
  }
1564
+ var LIVE_IS_NOT_RECOVERY = "`live` means the policy has met its server, not that undo has been round-tripped against it. Of the policies that ship, filesystem and memory have been; git and github have not.";
1564
1565
  function warnUntested(servers) {
1565
1566
  const names = servers.join(", ");
1566
1567
  const these = servers.length === 1 ? "this policy has" : "these policies have";
1567
1568
  return `${names}: ${these} never been run against the real server. The classes and inverses here come from documentation, so undo may not work where it says it will. Run \`synartesis check\` against your own credentials, and expect to correct something.`;
1568
1569
  }
1570
+ function ungoverned(manifest, advertised) {
1571
+ const resolver = createPolicyResolver(manifest);
1572
+ const found = [];
1573
+ for (const server of [...advertised.keys()].sort()) {
1574
+ const tools = (advertised.get(server) ?? []).filter((tool) => !resolver.resolve(qualify(server, tool)).matched).sort();
1575
+ if (tools.length > 0) {
1576
+ found.push({ server, tools });
1577
+ }
1578
+ }
1579
+ return found;
1580
+ }
1569
1581
 
1570
1582
  // src/proxy/routing.ts
1571
1583
  var SEPARATOR = "__";
@@ -1915,7 +1927,9 @@ export {
1915
1927
  standing,
1916
1928
  untested,
1917
1929
  describeStanding,
1930
+ LIVE_IS_NOT_RECOVERY,
1918
1931
  warnUntested,
1932
+ ungoverned,
1919
1933
  IDEMPOTENCY_META_KEY,
1920
1934
  withIdempotencyKey,
1921
1935
  createRouter,
@@ -1930,4 +1944,4 @@ export {
1930
1944
  observeState,
1931
1945
  connectStdioUpstream
1932
1946
  };
1933
- //# sourceMappingURL=chunk-SZHVLCJR.js.map
1947
+ //# sourceMappingURL=chunk-JLLAK2QC.js.map
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  IDEMPOTENCY_META_KEY,
4
+ LIVE_IS_NOT_RECOVERY,
4
5
  NOTHING_RECORDED_YET,
5
6
  PROXY_FLAGS,
6
7
  WORDMARK,
@@ -32,11 +33,12 @@ import {
32
33
  toPayload,
33
34
  toResolvedRead,
34
35
  toolShapes,
36
+ ungoverned,
35
37
  untested,
36
38
  verifyAgainstServers,
37
39
  warnUntested,
38
40
  wasRefused
39
- } from "./chunk-SZHVLCJR.js";
41
+ } from "./chunk-JLLAK2QC.js";
40
42
  import {
41
43
  DriftConflict,
42
44
  ManifestError,
@@ -375,7 +377,12 @@ function classify(action, replanning, goAhead) {
375
377
  case "pending":
376
378
  return {
377
379
  kind: "halt",
378
- reason: "outcome unknown: the process died mid-call, so whether this applied cannot be determined",
380
+ // Not "the process died mid-call". That is one of the ways a row ends
381
+ // up here; a server that timed out, or answered with an error the
382
+ // read-back could not settle, arrives in exactly the same state and is
383
+ // told the same wrong story. The row's own error says which it was,
384
+ // and the halt prints it directly underneath.
385
+ reason: "outcome unknown: the call went out and its outcome was never established, so whether this applied cannot be determined",
379
386
  verified: false
380
387
  };
381
388
  case "gated":
@@ -452,7 +459,14 @@ ${seen}` : seen;
452
459
  if (!parsedPlan.success) {
453
460
  const approved = action.approvedBy === void 0 ? "" : `, approved by ${action.approvedBy}`;
454
461
  const reason = action.class === "irreversible" ? `cannot be undone${approved}; left in place` : `no usable inverse was recorded${action.error === void 0 ? "" : `: ${action.error}`}; left in place`;
455
- steps.push({ ...describeStep(action), kind: "permanent", reason, verified: false });
462
+ const unconfirmed = action.class === "irreversible" ? caveat(action) : void 0;
463
+ steps.push({
464
+ ...describeStep(action),
465
+ kind: "permanent",
466
+ reason,
467
+ verified: false,
468
+ ...unconfirmed === void 0 ? {} : { note: unconfirmed }
469
+ });
456
470
  leftInPlace = true;
457
471
  continue;
458
472
  }
@@ -517,13 +531,23 @@ ${seen}` : seen;
517
531
  }
518
532
  if (!verified && action.class === "reversible" && !force) {
519
533
  const reason = unverifiedBecause(action);
534
+ const because = caveat(action);
520
535
  halted = {
521
536
  seq: action.seq,
522
537
  reason,
523
- detail: "Without it there is no way to tell this resource from one somebody has edited since, so the recorded value was not written.",
538
+ detail: (because === void 0 ? "" : `${because}
539
+
540
+ `) + "Without it there is no way to tell this resource from one somebody has edited since, so the recorded value was not written.",
524
541
  conflict: true
525
542
  };
526
- steps.push({ ...describeStep(action), kind: "halt", reason, verified: false, plan });
543
+ steps.push({
544
+ ...describeStep(action),
545
+ kind: "halt",
546
+ reason,
547
+ verified: false,
548
+ plan,
549
+ ...because === void 0 ? {} : { note: because }
550
+ });
527
551
  break;
528
552
  }
529
553
  if (!verified && action.status === "rolling_back") {
@@ -539,13 +563,15 @@ ${seen}` : seen;
539
563
  const after = intendedAfterInverse(action);
540
564
  projected.set(resourceKey(toResolvedRead(verifyRead.data)), after ?? UNFORESEEABLE);
541
565
  }
566
+ const recordedWith = caveat(action);
542
567
  steps.push({
543
568
  ...describeStep(action),
544
569
  kind: "revert",
545
570
  reason: verified ? "state matches; applying inverse" : forcedOver ?? unverifiedBecause(action),
546
571
  verified,
547
572
  plan,
548
- ...rebuilt.inverse === void 0 ? {} : { replanned: true }
573
+ ...rebuilt.inverse === void 0 ? {} : { replanned: true },
574
+ ...recordedWith === void 0 ? {} : { note: recordedWith }
549
575
  });
550
576
  if (dryRun) {
551
577
  continue;
@@ -613,6 +639,9 @@ ${seen}` : seen;
613
639
  function unverifiedBecause(action) {
614
640
  return action.verify === void 0 ? "no read declared for this tool, so drift could not be ruled out -- a `verify` read in its policy would give it one" : "the post-state was never captured, so drift could not be ruled out";
615
641
  }
642
+ function caveat(action) {
643
+ return action.status === "applied" && action.error !== void 0 && action.error !== "" ? action.error : void 0;
644
+ }
616
645
  function describeStep(action) {
617
646
  return { seq: action.seq, server: action.server, tool: action.tool };
618
647
  }
@@ -2922,6 +2951,7 @@ async function runCheck(argv) {
2922
2951
  }
2923
2952
  }
2924
2953
  const upstreams = [];
2954
+ const offered = /* @__PURE__ */ new Map();
2925
2955
  try {
2926
2956
  for (const [name, spec] of Object.entries(manifest.servers)) {
2927
2957
  upstreams.push(
@@ -2935,6 +2965,9 @@ async function runCheck(argv) {
2935
2965
  );
2936
2966
  }
2937
2967
  await verifyAgainstServers(upstreams, manifest);
2968
+ for (const upstream of upstreams) {
2969
+ offered.set(upstream.name, (await toolShapes(upstream)).map((tool) => tool.name));
2970
+ }
2938
2971
  } finally {
2939
2972
  for (const upstream of upstreams) {
2940
2973
  await upstream.close();
@@ -2956,6 +2989,11 @@ async function runCheck(argv) {
2956
2989
  ` ${style.quiet(" ")} ${style.strong(entry.server)} ${entry.provenance === "documented" ? style.accent(said) : style.quiet(said)}`
2957
2990
  );
2958
2991
  }
2992
+ if (standing(manifest).some((entry) => entry.provenance === "live")) {
2993
+ for (const line2 of wrapped(LIVE_IS_NOT_RECOVERY, 66)) {
2994
+ out(` ${style.quiet(" ")} ${style.quiet(line2)}`);
2995
+ }
2996
+ }
2959
2997
  out(` ${style.quiet("policies")} ${[...counts].map(([k, v]) => `${String(v)} ${k}`).join(", ")}`);
2960
2998
  out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
2961
2999
  const pinned = Object.entries(manifest.pins ?? {});
@@ -2965,7 +3003,25 @@ async function runCheck(argv) {
2965
3003
  ` ${style.quiet("pinned ")} ${pinned.length === 0 ? style.quiet("nothing -- run `synartesis pin`") : pinned.map(([name, tools]) => `${name} (${String(Object.keys(tools).length)})`).join(", ") + (unpinned.length === 0 ? "" : style.quiet(`; not ${unpinned.join(", ")}`))}`
2966
3004
  );
2967
3005
  out("");
2968
- out(` ${style.quiet("Anything not mentioned here is treated as irreversible and guarded.")}`);
3006
+ const uncovered = ungoverned(manifest, offered);
3007
+ if (uncovered.length === 0) {
3008
+ out(` ${style.quiet("Every tool these servers offer has a policy.")}`);
3009
+ } else {
3010
+ const total = uncovered.reduce((sum, entry) => sum + entry.tools.length, 0);
3011
+ out(
3012
+ ` ${style.accent("guarded by default")} ${style.quiet(
3013
+ `${String(total)} tool${total === 1 ? "" : "s"} here ${total === 1 ? "has" : "have"} no policy, so ${total === 1 ? "it is" : "they are"} treated as`
3014
+ )}`
3015
+ );
3016
+ out(` ${style.quiet("irreversible and held for a person the first time an agent calls")}`);
3017
+ out(` ${style.quiet(`${total === 1 ? "it" : "one"}. Write a policy for any you would rather it got on with.`)}`);
3018
+ out("");
3019
+ for (const entry of uncovered) {
3020
+ for (const line2 of wrapped(entry.tools.join(", "), 60)) {
3021
+ out(` ${style.quiet(entry.server.padEnd(8))} ${style.strong(line2)}`);
3022
+ }
3023
+ }
3024
+ }
2969
3025
  out("");
2970
3026
  hint(
2971
3027
  firstOf(
@@ -3783,6 +3839,11 @@ function report(result, alreadyForcing = false, as = "") {
3783
3839
  out(
3784
3840
  ` ${style.quiet(String(step.seq).padStart(3))} ${kind} ${style.strong(`${step.server}.${step.tool}`)} ${style.quiet(step.reason)}${unverified}`
3785
3841
  );
3842
+ if (step.note !== void 0 && step.kind !== "halt") {
3843
+ for (const [at, line2] of wrapped(step.note, 62).entries()) {
3844
+ out(` ${at === 0 ? style.accent("caveat") : " "} ${style.quiet(line2)}`);
3845
+ }
3846
+ }
3786
3847
  if (step.plan !== void 0 && step.kind === "revert") {
3787
3848
  const verb = `${step.replanned === true ? "replanned, " : ""}${result.dryRun ? "would call" : "called"}`;
3788
3849
  out(
package/dist/proxy.js CHANGED
@@ -19,11 +19,13 @@ import {
19
19
  refusal,
20
20
  runRead,
21
21
  toPayload,
22
+ toolShapes,
23
+ ungoverned,
22
24
  untested,
23
25
  verifyAgainstServers,
24
26
  warnUntested,
25
27
  withIdempotencyKey
26
- } from "./chunk-SZHVLCJR.js";
28
+ } from "./chunk-JLLAK2QC.js";
27
29
  import {
28
30
  SnapshotError,
29
31
  UpstreamError,
@@ -755,6 +757,7 @@ function createProxyServer(options) {
755
757
  const result = await route.upstream.client.request(forwarded, PassthroughResult, {
756
758
  signal: extra.signal
757
759
  });
760
+ let inferred;
758
761
  const refused = refusal(result);
759
762
  if (refused !== void 0) {
760
763
  const settled = policy.refusal === "clean" ? "none" : await whatHappened(router, probe, priorState, extra.signal);
@@ -774,6 +777,7 @@ function createProxyServer(options) {
774
777
  );
775
778
  return result;
776
779
  }
780
+ inferred = `the upstream answered with an error and the change was established by reading the resource back, so nothing confirmed it: ${refused}`;
777
781
  log?.warn(
778
782
  { seq: pending.seq, tool: route.tool, reason: refused },
779
783
  "the upstream reported an error after changing the resource"
@@ -781,6 +785,9 @@ function createProxyServer(options) {
781
785
  }
782
786
  const context = { args, snapshot, result: toPayload(result) };
783
787
  const warnings = [];
788
+ if (inferred !== void 0) {
789
+ warnings.push(inferred);
790
+ }
784
791
  if (missingPriorState !== void 0) {
785
792
  warnings.push(
786
793
  `no prior state existed, so there is nothing to restore: ${missingPriorState}`
@@ -1165,12 +1172,26 @@ async function main() {
1165
1172
  tools: manifest.tools.filter((rule) => rule.match.startsWith(`${String(argv.server)}.`))
1166
1173
  }
1167
1174
  );
1175
+ const uncovered = ungoverned(
1176
+ manifest,
1177
+ new Map(await Promise.all(upstreams.map(async (upstream) => [
1178
+ upstream.name,
1179
+ (await toolShapes(upstream)).map((tool) => tool.name)
1180
+ ])))
1181
+ );
1182
+ for (const entry of uncovered) {
1183
+ log.warn(
1184
+ { server: entry.server, tools: entry.tools },
1185
+ "no policy covers these tools; they will be held for approval when called"
1186
+ );
1187
+ }
1168
1188
  log.info(
1169
1189
  {
1170
1190
  manifest: argv.manifest,
1171
1191
  journal: argv.journal,
1172
1192
  servers: upstreams.map((upstream) => upstream.name),
1173
- policies: manifest.tools.length
1193
+ policies: manifest.tools.length,
1194
+ ungoverned: uncovered.reduce((sum, entry) => sum + entry.tools.length, 0)
1174
1195
  },
1175
1196
  "proxy ready"
1176
1197
  );
@@ -1,8 +1,14 @@
1
1
  # Policy for @modelcontextprotocol/server-memory, the knowledge graph an agent
2
2
  # keeps about you between sessions.
3
3
  #
4
- # Verified against the real server: every tool named here was listed by it, and
5
- # the shapes below were read off its own responses rather than from docs.
4
+ # Verified against the real server twice over: every tool named here was listed
5
+ # by it and the shapes below were read off its own responses rather than from
6
+ # docs -- and then the undo itself was run. tests/adapter-memory.test.ts makes
7
+ # each change against a real graph, undoes it, and compares the file: what the
8
+ # agent added is gone, what was already there is untouched, an entity it only
9
+ # tried to create is left alone, and a delete of an entity is held rather than
10
+ # approximated. Every one of those was checked by breaking this policy and
11
+ # watching the test fail.
6
12
  #
7
13
  # This is the server the taxonomy fits best. Almost everything it does has an
8
14
  # exact opposite sitting next to it in the same toolbox, which is what
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.6.19",
3
+ "version": "0.6.22",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -86,6 +86,7 @@
86
86
  "@fontsource/ibm-plex-sans": "^5.3.0",
87
87
  "@google/genai": "^2.22.0",
88
88
  "@modelcontextprotocol/server-filesystem": "^2026.7.10",
89
+ "@modelcontextprotocol/server-memory": "^2026.8.31",
89
90
  "@types/better-sqlite3": "^9.6.0",
90
91
  "@types/node": "^26.2.0",
91
92
  "@types/react": "^19.3.0",