synartesis 0.6.20 → 0.7.0

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,180 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.7.0 — 2026-09-16
6
+
7
+ A pass over everything, after an audit of the desktop app, the core and the
8
+ release. Most of what it found was not broken code but things that stated
9
+ something untrue.
10
+
11
+ ### Fixed
12
+
13
+ - **Every desktop binary since 0.6.12 carried the wrong version.**
14
+ electron-builder reads the version from `app/package.json`, which nothing
15
+ else reads, so it stayed put while the root moved. The nine installers
16
+ attached to v0.6.23 were all named `0.6.12`, byte-identical in name to the
17
+ ones on v0.6.12 — two people on different releases could not tell their
18
+ builds apart. `app-packaging.test.ts` already checked seven agreements
19
+ between the packaging files; the absence of the eighth is why this ran for
20
+ eleven releases.
21
+
22
+ - **A spent approval read as "refused" in `watch` and the console.** `labelFor`
23
+ was written for exactly this and `list` and `show` were taught to use it;
24
+ `plainly` was not — and `plainly` is what the two live screens render, so a
25
+ person was told their own yes had been a no, seconds after giving it.
26
+
27
+ - **`undo --dry-run --force` printed no plan.** It showed what forcing would
28
+ write over and returned, so the flag whose whole job is to print the plan
29
+ printed none. A dry run no longer needs `--yes` either: the two-step ask
30
+ protects a write, and a preview writes nothing.
31
+
32
+ - **A `verify:` read was never validated.** `snapshot` and `inverse` are
33
+ checked against the servers at load; `verify` was not, and it is the one
34
+ whose failure is silent — the proxy turns it into a soft warning and carries
35
+ on, so a typo meant that tool had no drift detection at all while both the
36
+ policy and `check` said it was fine.
37
+
38
+ - **A failed inverse on the lost-answer path was swallowed whole.** An inverse
39
+ reading `$result.` cannot be rebuilt when the answer is what went missing,
40
+ and undo reported "cannot be undone" without saying why — on the path where
41
+ undo matters most.
42
+
43
+ - **`--client` with a typo blamed the machine**, reporting "No MCP client
44
+ config was found on this machine". On `uninstall` it said "Nothing was
45
+ covered, so nothing was changed", which reads as reassurance.
46
+
47
+ - **`undo --to` always exited 1.** A floor makes a run `partial` by
48
+ construction, correctly, and the exit code was read off that — so an undo
49
+ that did exactly what it was told could not be told from one that halted on
50
+ somebody's edit.
51
+
52
+ - **`status` read "when was this server last used" off the newest five hundred
53
+ actions**, so a server whose last use had scrolled out of that window came
54
+ back as never used: "covered, nothing through it yet". Asked exactly now,
55
+ from a covering index — 75ms to 3.5ms on sixty thousand actions.
56
+
57
+ ### Fixed — the desktop window
58
+
59
+ - **With no policy the window could not be quit.** The branch that drew the
60
+ no-policy screen returned before the IPC handlers were registered and before
61
+ the quit handlers were attached; on Windows and Linux that left a process
62
+ running with no window. Everything after `whenReady` is now one path in
63
+ `boot.ts`, which imports no Electron — which is also how the key-page
64
+ allowlist became testable.
65
+
66
+ - **Two spurious errors before the no-policy screen.** The window asked the
67
+ engine two questions the moment it mounted, which with no policy came back as
68
+ "No handler registered" and were drawn as errors. The push is now a pull.
69
+
70
+ - **`Bridge` was declared twice and neither copy was connected to the
71
+ preload** — the only one that was true. They had already drifted. The shared
72
+ one survives, the exposed object is annotated rather than inferred, and both
73
+ the compiler and a test now catch a future drift.
74
+
75
+ - **A server that would not start vanished**: no event, no log, nothing in the
76
+ window, and a briefing that went on announcing it as connected. It now
77
+ reaches the transcript with the server's own words, and the model is told not
78
+ to plan around tools that are not there.
79
+
80
+ ### Changed
81
+
82
+ - `pnpm check` runs build, typecheck, lint and the suite in one command.
83
+ - The release workflow runs that gate before building installers, builds the
84
+ desktop app in CI, runs the memory demo, and takes its release notes from
85
+ `CHANGELOG.md` instead of a list of commit subjects.
86
+ - `npm publish` runs the whole gate rather than only a build.
87
+ - `list --json` and `show --json` both carry `actionCount`, so one name means
88
+ one thing in both. The existing `actions` shape is untouched.
89
+ - The site points at `/releases/latest` rather than a pinned version.
90
+ - The user guide covers `install`, `uninstall`, `status` and `desktop`, and is
91
+ no longer stamped twenty releases back. `CONTRIBUTING.md` no longer shows a
92
+ manifest syntax that throws on load, or asks for a C toolchain the project
93
+ disables.
94
+
95
+ ## 0.6.23 — 2026-09-16
96
+
97
+ ### Docs
98
+
99
+ - **What to do with a tool that does two things at once.** A call that both
100
+ writes something recoverable and does something that is not -- saves a file
101
+ and posts to an API, updates a record and sends the email about it -- takes
102
+ the class of its least recoverable part, because a rule matches a tool name
103
+ and gives it one class. The guide now says so, says why the alternative is
104
+ worse (undo puts the file back, reports `rolled_back`, and says nothing about
105
+ the request still out there), points at `move_file` as the shipped example of
106
+ the same shape, and shows the one way out: a `compensable` rule whose inverse
107
+ neutralises the unrecoverable half. Asked often enough to be worth writing
108
+ down rather than answering again.
109
+
110
+ ## 0.6.22 — 2026-09-16
111
+
112
+ ### Added
113
+
114
+ - **The memory policy's undo is now proven, not just plausible.** It has always
115
+ declared `provenance: live` -- the tools were checked against the real server
116
+ and the shapes read off its own answers -- while the README said its recovery
117
+ guarantees were unproven. Both were true, and the gap between them is where a
118
+ policy can name every tool correctly, take exactly the arguments the server
119
+ wants, and still resolve an inverse that puts nothing back. That failure
120
+ looks like success: the drift check passes and the report says `rolled_back`.
121
+
122
+ `tests/adapter-memory.test.ts` makes each change against a real knowledge
123
+ graph, undoes it, and compares the file. What the agent added is gone and
124
+ what was already there is untouched; an entity the agent only *tried* to
125
+ create -- this server ignores a duplicate name -- is left alone rather than
126
+ deleted out from under its owner; a relation drawn or removed goes back; and
127
+ a delete of an entity is held for a person rather than approximated, because
128
+ one inverse cannot put back both the entity and the relations that went with
129
+ it. Every one of those was confirmed by breaking the policy and watching the
130
+ matching test fail.
131
+
132
+ Two of the four shipped policies are now round-tripped end to end.
133
+ `synartesis check`, the README and both guides say which, and `git` is still
134
+ named plainly as untested.
135
+
136
+ - `check` also confirms the memory policy covers every tool that server offers,
137
+ so a server that grows one fails here rather than in front of somebody's
138
+ agent.
139
+
140
+ ## 0.6.21 — 2026-09-16
141
+
142
+ ### Fixed
143
+
144
+ - **`live` claimed more than it meant, and the docs contradicted each other
145
+ about it.** `provenance: live` says a policy has met its server and that the
146
+ tools take the arguments it passes them. `check` printed that as "checked
147
+ against the real server", which reads as a claim that undo works -- and it is
148
+ not one: a policy can be right about every tool name and still record an
149
+ inverse that restores nothing. Meanwhile the README said memory's recovery
150
+ guarantees were unproven while the user guide listed memory among the
151
+ policies that say `live`, so a reader who saw one came away with the opposite
152
+ of what the other meant. `check` now says "shapes read from the real server"
153
+ and adds, once under the server list, that `live` is not a recovery claim and
154
+ that only filesystem has been round-tripped. README and both guides say the
155
+ same thing.
156
+
157
+ ### Added
158
+
159
+ - **`check` names the tools no policy covers, instead of describing the rule.**
160
+ An unmatched tool has always been fail-closed -- irreversible, held for a
161
+ person the first time it is called -- and `check` said so as a sentence about
162
+ tools in general while the actual list sat one round trip away. It already
163
+ connects to every server and reads the whole tool list to verify the
164
+ policies, so the answer was in hand and thrown away, and the first anybody
165
+ learned a tool was ungoverned was an agent stopping on it mid-task. Now
166
+ named, per server, with a count. The proxy warns at startup for the same
167
+ reason, and `proxy ready` carries an `ungoverned` count so a policy with no
168
+ gaps and a build that forgot to look do not read alike.
169
+
170
+ For what almost everybody runs, the answer is none: the shipped filesystem
171
+ policy covers every tool that server offers, and there is a test that will
172
+ fail if that stops being true.
173
+
174
+ - The build targeted `node20` while the package requires `>=22`, which is the
175
+ floor better-sqlite3 sets -- on Node 20 it segfaults the moment a database
176
+ opens. Targeting lower was harmless but said the wrong thing about what this
177
+ supports, in the one place a reader could check. Now `node22`.
178
+
5
179
  ## 0.6.20 — 2026-09-15
6
180
 
7
181
  ### 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
 
@@ -370,11 +385,17 @@ pnpm test
370
385
  ```
371
386
 
372
387
  ```bash
373
- pnpm typecheck && pnpm lint
388
+ pnpm check
374
389
  ```
375
390
 
376
- Every push runs those on Linux and macOS across Node 22 and 24, plus the demo
377
- and the installer.
391
+ Every push runs that on Linux and macOS across Node 22 and 24, plus both demos,
392
+ the installer, and a build of the desktop app.
393
+
394
+ **Windows is built and not tested.** The release attaches a Windows installer,
395
+ and no CI job compiles or exercises it — the test matrix is Linux and macOS. It
396
+ is expected to work, the code has no platform-specific paths outside
397
+ `src/locate.ts` and `src/install/clients.ts`, and nobody has proved it. If you
398
+ run Windows and something is wrong there, that is worth an issue.
378
399
 
379
400
  ## Licence
380
401
 
package/SECURITY.md CHANGED
@@ -20,8 +20,8 @@ patches to older ones.
20
20
 
21
21
  | Version | Supported |
22
22
  |---|---|
23
- | 0.3.x | yes |
24
- | < 0.3 | no |
23
+ | 0.6.x | yes |
24
+ | < 0.6 | no |
25
25
 
26
26
  ## What the journal holds, and why it matters
27
27
 
@@ -325,6 +325,20 @@ CREATE INDEX IF NOT EXISTS actions_unresolved ON actions(run_id, server, tool)
325
325
  -- ordering it asks for, so the index both filters and sorts.
326
326
  CREATE INDEX IF NOT EXISTS actions_writes ON actions(run_id, seq)
327
327
  WHERE class <> 'readonly';
328
+
329
+ -- Covering, like actions_run_status and for the same reason. status asks when
330
+ -- each server was last used; without this the group-by walks the table, and
331
+ -- the table carries the snapshots, so the cost of drawing a connection list
332
+ -- grew with the size of the data those connections had touched rather than
333
+ -- with how many there were. Both columns the query reads are here, so sqlite
334
+ -- never reaches into a row -- the plan says COVERING INDEX, and it is the
335
+ -- covering half that does the work. Measured on sixty thousand actions across
336
+ -- twelve servers with two-kilobyte snapshots: 75ms to 3.5ms.
337
+ --
338
+ -- Added the same way as the ones above and for the same reason: no row
339
+ -- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
340
+ -- build opening the same file afterwards neither notices nor cares.
341
+ CREATE INDEX IF NOT EXISTS actions_seen ON actions(server, ts);
328
342
  `;
329
343
 
330
344
  // src/journal/journal.ts
@@ -336,6 +350,7 @@ var runSchema = z.object({
336
350
  ended_at: z.string().nullable(),
337
351
  status: z.enum(["active", "complete", "rolled_back", "partial"])
338
352
  });
353
+ var seenSchema = z.object({ server: z.string(), ts: z.string() });
339
354
  var tallySchema = z.object({
340
355
  run_id: z.string(),
341
356
  actions: z.number(),
@@ -847,6 +862,33 @@ var SqliteJournal = class {
847
862
  return raw === void 0 ? void 0 : toAction(raw);
848
863
  });
849
864
  }
865
+ /**
866
+ * When each server was last used, over everything, not over a window.
867
+ *
868
+ * This was a scan of the newest five hundred actions, which answers a
869
+ * different question: on a busy journal a server whose last use had fallen
870
+ * out of that window came back as never used at all, and `status` reported
871
+ * "covered, nothing through it yet" about a server somebody uses daily.
872
+ * Wrong, and reassuringly so -- it reads as though the connection were
873
+ * merely new.
874
+ *
875
+ * Exact instead, and answered entirely from actions_seen: the query reads
876
+ * only the two columns that index holds, so it never touches the rows
877
+ * carrying snapshots.
878
+ */
879
+ lastSeenPerServer() {
880
+ return this.#run("lastSeenPerServer", () => {
881
+ const rows = this.#db.prepare("SELECT server, MAX(ts) AS ts FROM actions GROUP BY server").all();
882
+ const seen = /* @__PURE__ */ new Map();
883
+ for (const row of rows) {
884
+ const read2 = seenSchema.safeParse(row);
885
+ if (read2.success) {
886
+ seen.set(read2.data.server, read2.data.ts);
887
+ }
888
+ }
889
+ return seen;
890
+ });
891
+ }
850
892
  recentActions(limit) {
851
893
  return this.#run(
852
894
  "recentActions",
@@ -1272,6 +1314,13 @@ function validate(source, manifest) {
1272
1314
  }
1273
1315
  checkCall(source, [...path, "inverse"], policy.inverse, servers, allowed);
1274
1316
  }
1317
+ if (policy.verify !== void 0) {
1318
+ const allowed = ["$.", "$result."];
1319
+ if (policy.snapshot !== void 0) {
1320
+ allowed.push("$snapshot.");
1321
+ }
1322
+ checkCall(source, [...path, "verify"], policy.verify, servers, allowed);
1323
+ }
1275
1324
  });
1276
1325
  }
1277
1326
  function withGate(policy) {
@@ -1554,18 +1603,30 @@ function untested(manifest) {
1554
1603
  function describeStanding(entry) {
1555
1604
  switch (entry.provenance) {
1556
1605
  case "live":
1557
- return "checked against the real server";
1606
+ return "shapes read from the real server";
1558
1607
  case "documented":
1559
1608
  return "written from documentation, never run against the real server";
1560
1609
  case "unstated":
1561
1610
  return "no claim either way";
1562
1611
  }
1563
1612
  }
1613
+ var LIVE_IS_NOT_RECOVERY = "`live` means the policy has met its server, not that undo has been round-tripped against it. Whether it puts anything back is a separate question, and only a test against the real server answers it.";
1564
1614
  function warnUntested(servers) {
1565
1615
  const names = servers.join(", ");
1566
1616
  const these = servers.length === 1 ? "this policy has" : "these policies have";
1567
1617
  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
1618
  }
1619
+ function ungoverned(manifest, advertised) {
1620
+ const resolver = createPolicyResolver(manifest);
1621
+ const found = [];
1622
+ for (const server of [...advertised.keys()].sort()) {
1623
+ const tools = (advertised.get(server) ?? []).filter((tool) => !resolver.resolve(qualify(server, tool)).matched).sort();
1624
+ if (tools.length > 0) {
1625
+ found.push({ server, tools });
1626
+ }
1627
+ }
1628
+ return found;
1629
+ }
1569
1630
 
1570
1631
  // src/proxy/routing.ts
1571
1632
  var SEPARATOR = "__";
@@ -1915,7 +1976,9 @@ export {
1915
1976
  standing,
1916
1977
  untested,
1917
1978
  describeStanding,
1979
+ LIVE_IS_NOT_RECOVERY,
1918
1980
  warnUntested,
1981
+ ungoverned,
1919
1982
  IDEMPOTENCY_META_KEY,
1920
1983
  withIdempotencyKey,
1921
1984
  createRouter,
@@ -1930,4 +1993,4 @@ export {
1930
1993
  observeState,
1931
1994
  connectStdioUpstream
1932
1995
  };
1933
- //# sourceMappingURL=chunk-SZHVLCJR.js.map
1996
+ //# sourceMappingURL=chunk-FXSKWMNG.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-FXSKWMNG.js";
40
42
  import {
41
43
  DriftConflict,
42
44
  ManifestError,
@@ -1074,7 +1076,7 @@ function plainly(action) {
1074
1076
  case "rolling_back":
1075
1077
  return { text: "undoing", needs: false };
1076
1078
  case "denied":
1077
- return { text: "refused", needs: false };
1079
+ return labelFor(action) === "used" ? { text: "used", needs: false } : { text: "refused", needs: false };
1078
1080
  case "failed":
1079
1081
  return { text: "failed", needs: false };
1080
1082
  case "approved":
@@ -1524,6 +1526,12 @@ var LABELS = {
1524
1526
  cursor: "Cursor",
1525
1527
  codex: "Codex"
1526
1528
  };
1529
+ var CLIENT_IDS = Object.keys(LABELS).filter(
1530
+ (name) => name in LABELS
1531
+ );
1532
+ function isClientId(value) {
1533
+ return CLIENT_IDS.some((known) => known === value);
1534
+ }
1527
1535
  function claudeDesktopPath() {
1528
1536
  const home = homedir();
1529
1537
  switch (platform()) {
@@ -1944,14 +1952,7 @@ function applyUninstall(sites, manifestPath) {
1944
1952
  // src/install/connections.ts
1945
1953
  var ACTIVE_WITHIN_MS = 2 * 60 * 1e3;
1946
1954
  function lastSeenByServer(journal) {
1947
- const seen = /* @__PURE__ */ new Map();
1948
- for (const action of journal.recentActions(500)) {
1949
- const known = seen.get(action.server);
1950
- if (known === void 0 || action.ts > known) {
1951
- seen.set(action.server, action.ts);
1952
- }
1953
- }
1954
- return seen;
1955
+ return journal.lastSeenPerServer();
1955
1956
  }
1956
1957
  function commandMissing(command) {
1957
1958
  if (command === void 0) {
@@ -2846,7 +2847,8 @@ Most commands end by naming the one thing worth doing next, worked out from
2846
2847
  what is actually in the journal rather than from what was typed. Set
2847
2848
  SYNARTESIS_NO_HINTS to turn that off; --json never carries it.
2848
2849
 
2849
- Exit codes: 0 complete, 1 halted or partial, 2 bad usage or configuration.
2850
+ Exit codes: 0 did what was asked, 1 stopped or left something in place,
2851
+ 2 bad usage or configuration.
2850
2852
  `;
2851
2853
  var UsageError = class extends Error {
2852
2854
  /**
@@ -2938,6 +2940,20 @@ async function runPin(argv) {
2938
2940
  out("");
2939
2941
  return 0;
2940
2942
  }
2943
+ function namedClient(argv) {
2944
+ const typed = flag(argv, "--client");
2945
+ if (typed === void 0) {
2946
+ return void 0;
2947
+ }
2948
+ if (!isClientId(typed)) {
2949
+ const near = didYouMean(typed, CLIENT_IDS);
2950
+ throw new UsageError(
2951
+ `--client ${typed} is not a client this knows${near === void 0 ? "" : `; did you mean ${near}?`}
2952
+ It knows: ${CLIENT_IDS.join(", ")}`
2953
+ );
2954
+ }
2955
+ return typed;
2956
+ }
2941
2957
  async function runCheck(argv) {
2942
2958
  const path = findManifest(flag(argv, "--manifest"));
2943
2959
  const manifest = loadManifest(path);
@@ -2949,6 +2965,7 @@ async function runCheck(argv) {
2949
2965
  }
2950
2966
  }
2951
2967
  const upstreams = [];
2968
+ const offered = /* @__PURE__ */ new Map();
2952
2969
  try {
2953
2970
  for (const [name, spec] of Object.entries(manifest.servers)) {
2954
2971
  upstreams.push(
@@ -2962,6 +2979,9 @@ async function runCheck(argv) {
2962
2979
  );
2963
2980
  }
2964
2981
  await verifyAgainstServers(upstreams, manifest);
2982
+ for (const upstream of upstreams) {
2983
+ offered.set(upstream.name, (await toolShapes(upstream)).map((tool) => tool.name));
2984
+ }
2965
2985
  } finally {
2966
2986
  for (const upstream of upstreams) {
2967
2987
  await upstream.close();
@@ -2983,6 +3003,11 @@ async function runCheck(argv) {
2983
3003
  ` ${style.quiet(" ")} ${style.strong(entry.server)} ${entry.provenance === "documented" ? style.accent(said) : style.quiet(said)}`
2984
3004
  );
2985
3005
  }
3006
+ if (standing(manifest).some((entry) => entry.provenance === "live")) {
3007
+ for (const line2 of wrapped(LIVE_IS_NOT_RECOVERY, 66)) {
3008
+ out(` ${style.quiet(" ")} ${style.quiet(line2)}`);
3009
+ }
3010
+ }
2986
3011
  out(` ${style.quiet("policies")} ${[...counts].map(([k, v]) => `${String(v)} ${k}`).join(", ")}`);
2987
3012
  out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
2988
3013
  const pinned = Object.entries(manifest.pins ?? {});
@@ -2992,7 +3017,25 @@ async function runCheck(argv) {
2992
3017
  ` ${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(", ")}`))}`
2993
3018
  );
2994
3019
  out("");
2995
- out(` ${style.quiet("Anything not mentioned here is treated as irreversible and guarded.")}`);
3020
+ const uncovered = ungoverned(manifest, offered);
3021
+ if (uncovered.length === 0) {
3022
+ out(` ${style.quiet("Every tool these servers offer has a policy.")}`);
3023
+ } else {
3024
+ const total = uncovered.reduce((sum, entry) => sum + entry.tools.length, 0);
3025
+ out(
3026
+ ` ${style.accent("guarded by default")} ${style.quiet(
3027
+ `${String(total)} tool${total === 1 ? "" : "s"} here ${total === 1 ? "has" : "have"} no policy, so ${total === 1 ? "it is" : "they are"} treated as`
3028
+ )}`
3029
+ );
3030
+ out(` ${style.quiet("irreversible and held for a person the first time an agent calls")}`);
3031
+ out(` ${style.quiet(`${total === 1 ? "it" : "one"}. Write a policy for any you would rather it got on with.`)}`);
3032
+ out("");
3033
+ for (const entry of uncovered) {
3034
+ for (const line2 of wrapped(entry.tools.join(", "), 60)) {
3035
+ out(` ${style.quiet(entry.server.padEnd(8))} ${style.strong(line2)}`);
3036
+ }
3037
+ }
3038
+ }
2996
3039
  out("");
2997
3040
  hint(
2998
3041
  firstOf(
@@ -3008,7 +3051,7 @@ async function runCheck(argv) {
3008
3051
  }
3009
3052
  async function runInstall(argv) {
3010
3053
  const manifestPath = findManifest(flag(argv, "--manifest"));
3011
- const only = flag(argv, "--client");
3054
+ const only = namedClient(argv);
3012
3055
  const dryRun = argv.includes("--dry-run");
3013
3056
  const printOnly = argv.includes("--print");
3014
3057
  const sites = discover(process.cwd()).filter(
@@ -3093,7 +3136,7 @@ async function runInstall(argv) {
3093
3136
  }
3094
3137
  async function runUninstall(argv) {
3095
3138
  const manifestPath = findManifest(flag(argv, "--manifest"));
3096
- const only = flag(argv, "--client");
3139
+ const only = namedClient(argv);
3097
3140
  const sites = discover(process.cwd()).filter(
3098
3141
  (site) => only === void 0 || site.client === only
3099
3142
  );
@@ -3367,7 +3410,14 @@ function runList(journal, asJson, journalPath) {
3367
3410
  const tally2 = journal.tallyRuns();
3368
3411
  const counted = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
3369
3412
  if (asJson) {
3370
- out(JSON.stringify(runs.map((run) => ({ ...run, actions: counted(run.id).actions }))));
3413
+ out(
3414
+ JSON.stringify(
3415
+ runs.map((run) => {
3416
+ const actions = counted(run.id).actions;
3417
+ return { ...run, actions, actionCount: actions };
3418
+ })
3419
+ )
3420
+ );
3371
3421
  return 0;
3372
3422
  }
3373
3423
  if (runs.length === 0) {
@@ -3423,6 +3473,9 @@ async function runShow(argv, journal, asJson) {
3423
3473
  JSON.stringify({
3424
3474
  run,
3425
3475
  actions: journal.getActions(runId),
3476
+ // The same name means the same thing in `list --json`, where `actions`
3477
+ // has always been a count and cannot change.
3478
+ actionCount: journal.getActions(runId).length,
3426
3479
  ...inspection === void 0 ? {} : { live: inspection.resources }
3427
3480
  })
3428
3481
  );
@@ -3878,7 +3931,7 @@ function report(result, alreadyForcing = false, as = "") {
3878
3931
  needs: ["manifest", "journal"]
3879
3932
  });
3880
3933
  }
3881
- return result.status === "rolled_back" ? 0 : 1;
3934
+ return result.halted === void 0 && permanent.length === 0 ? 0 : 1;
3882
3935
  }
3883
3936
  async function withUpstreams(manifestPath, use, only) {
3884
3937
  const manifest = loadManifest(manifestPath);
@@ -3996,6 +4049,8 @@ async function runUndo(argv, journal) {
3996
4049
  if (said && !forcing) {
3997
4050
  process.stderr.write("synartesis: --yes only means anything with --force; ignoring it\n");
3998
4051
  }
4052
+ const dryRun = argv.includes("--dry-run");
4053
+ const forcePlan = forcing && (said || dryRun);
3999
4054
  if (forcing && !said) {
4000
4055
  const over = (await withUpstreams(
4001
4056
  manifestPath,
@@ -4019,25 +4074,30 @@ async function runUndo(argv, journal) {
4019
4074
  }
4020
4075
  }
4021
4076
  out("");
4022
- out(` ${style.quiet("nothing has been written. To go ahead and lose that:")}`);
4023
- out(` ${style.strong(`${cliCommand()} undo ${runId.slice(0, 8)} --force --yes`)}`);
4024
- out("");
4025
- return 1;
4077
+ if (!dryRun) {
4078
+ out(` ${style.quiet("nothing has been written. To go ahead and lose that:")}`);
4079
+ out(` ${style.strong(`${cliCommand()} undo ${runId.slice(0, 8)} --force --yes`)}`);
4080
+ out("");
4081
+ return 1;
4082
+ }
4026
4083
  }
4027
4084
  }
4028
4085
  const replan = argv.includes("--replan");
4029
4086
  return report(
4030
4087
  await performUndo(manifestPath, journal, runId, {
4031
- dryRun: argv.includes("--dry-run"),
4088
+ dryRun,
4032
4089
  ...toSeq === void 0 ? {} : { toSeq },
4033
4090
  replan,
4034
- ...forcing && said ? { force: true } : {}
4091
+ ...forcePlan ? { force: true } : {}
4035
4092
  }),
4036
4093
  forcing,
4037
4094
  // --to is deliberately absent, and cannot reach here: a floor leaves
4038
4095
  // actions below it alone, which makes the result `partial`, and the hint
4039
4096
  // is only offered on `rolled_back`.
4040
- `${replan ? " --replan" : ""}${forcing && said ? " --force --yes" : ""}`
4097
+ // forcePlan, not `forcing && said`: on a dry run the command that does
4098
+ // this for real is the forced one, and offering it without --force would
4099
+ // hand back something that halts on the drift the preview just showed.
4100
+ `${replan ? " --replan" : ""}${forcePlan ? " --force --yes" : ""}`
4041
4101
  );
4042
4102
  }
4043
4103
  var KNOWN_COMMANDS = [
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-FXSKWMNG.js";
27
29
  import {
28
30
  SnapshotError,
29
31
  UpstreamError,
@@ -397,6 +399,18 @@ function compatible(tool, seen) {
397
399
  }
398
400
  return { ...tool, outputSchema: withoutDialect(tool["outputSchema"], seen) };
399
401
  }
402
+ function recoverInverse(policy, captured, noPriorState) {
403
+ if (policy.inverse === void 0 || noPriorState) {
404
+ return {};
405
+ }
406
+ try {
407
+ return { inverse: planInverse(policy.inverse, { ...captured, result: void 0 }) };
408
+ } catch (error) {
409
+ return {
410
+ warning: `and its inverse could not be resolved without that answer: ${describe(error)}`
411
+ };
412
+ }
413
+ }
400
414
  function createProxyServer(options) {
401
415
  const { upstreams, manifest, journal } = options;
402
416
  const router = createRouter(upstreams, manifest);
@@ -840,19 +854,19 @@ function createProxyServer(options) {
840
854
  } else if (settled === "unknown") {
841
855
  journal.markUnknown(pending.actionId, describe(error));
842
856
  } else {
843
- let recovered;
844
- if (policy.inverse !== void 0 && missingPriorState === void 0) {
845
- try {
846
- recovered = planInverse(policy.inverse, { args, snapshot, result: void 0 });
847
- } catch {
848
- recovered = void 0;
849
- }
850
- }
857
+ const recovered = recoverInverse(
858
+ policy,
859
+ { args, snapshot },
860
+ missingPriorState !== void 0
861
+ );
851
862
  journal.markApplied(pending.actionId, {
852
863
  result: void 0,
853
- ...recovered === void 0 ? {} : { inverse: recovered },
864
+ ...recovered.inverse === void 0 ? {} : { inverse: recovered.inverse },
854
865
  ...verify === void 0 ? {} : { verify },
855
- warning: `the call applied but its answer never arrived: ${describe(error)}`
866
+ warning: [
867
+ `the call applied but its answer never arrived: ${describe(error)}`,
868
+ ...recovered.warning === void 0 ? [] : [recovered.warning]
869
+ ].join("; ")
856
870
  });
857
871
  }
858
872
  }
@@ -1170,12 +1184,26 @@ async function main() {
1170
1184
  tools: manifest.tools.filter((rule) => rule.match.startsWith(`${String(argv.server)}.`))
1171
1185
  }
1172
1186
  );
1187
+ const uncovered = ungoverned(
1188
+ manifest,
1189
+ new Map(await Promise.all(upstreams.map(async (upstream) => [
1190
+ upstream.name,
1191
+ (await toolShapes(upstream)).map((tool) => tool.name)
1192
+ ])))
1193
+ );
1194
+ for (const entry of uncovered) {
1195
+ log.warn(
1196
+ { server: entry.server, tools: entry.tools },
1197
+ "no policy covers these tools; they will be held for approval when called"
1198
+ );
1199
+ }
1173
1200
  log.info(
1174
1201
  {
1175
1202
  manifest: argv.manifest,
1176
1203
  journal: argv.journal,
1177
1204
  servers: upstreams.map((upstream) => upstream.name),
1178
- policies: manifest.tools.length
1205
+ policies: manifest.tools.length,
1206
+ ungoverned: uncovered.reduce((sum, entry) => sum + entry.tools.length, 0)
1179
1207
  },
1180
1208
  "proxy ready"
1181
1209
  );
@@ -1183,7 +1211,6 @@ async function main() {
1183
1211
  upstreams,
1184
1212
  manifest,
1185
1213
  journal,
1186
- gateTimeoutMs: argv.gateTimeoutMs,
1187
1214
  logger: log,
1188
1215
  // Absolute, because whoever approves may be in any directory at all.
1189
1216
  approveHint: (actionId) => `${cliCommandFrom(import.meta.url)} approve ${actionId.slice(0, 8)} --journal ${resolve(argv.journal)}`
@@ -88,9 +88,6 @@ tools:
88
88
  path: "$.path"
89
89
  content: "$snapshot.content"
90
90
 
91
- # Reversible from its arguments alone: no pre-read could say anything the
92
- # arguments do not already. Drift cannot be checked for the same reason, so
93
- # undo reports it as unverified.
94
91
  # Only reversible when the destination did not exist. Moving onto a file
95
92
  # that did overwrites it, and moving back afterwards restores the source and
96
93
  # leaves nothing where the destination's contents were: undo reports success
@@ -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
@@ -36,19 +42,14 @@ tools:
36
42
  # undoing by argument would delete a Grace who was there long before the
37
43
  # agent ran. The result carries only what was actually created.
38
44
  #
39
- # $result.entities, not $result: this server answers with both a text block
40
- # holding a bare array and a structuredContent object wrapping it, and
41
- # structured data wins over re-parsed text. Written against the text block,
42
- # the first version of this policy resolved to nothing and undo halted. It is
43
- # the same mistake the filesystem policy made, and the reason neither of them
44
- # can be trusted until it has met a real server.
45
- #
46
- # $result is the structured block, not the text one, and here the two
47
- # disagree: the text is a bare list of entities while structuredContent wraps
48
- # it as {"entities": [...]}. The structured block is the machine-readable
49
- # contract, so that is the one a path walks. Writing $result[].name here is
50
- # the natural mistake, and it was the first thing this policy got wrong
51
- # against the live server.
45
+ # $result.entities, not $result. $result is the structured block, not the
46
+ # text one, and here the two disagree: the text is a bare list of entities
47
+ # while structuredContent wraps it as {"entities": [...]}. The structured
48
+ # block is the machine-readable contract, so that is the one a path walks.
49
+ # Writing $result[].name is the natural mistake, and it was the first thing
50
+ # this policy got wrong against the live server -- the same mistake the
51
+ # filesystem policy made, and the reason neither can be trusted until it has
52
+ # met one.
52
53
  - match: "memory.create_entities"
53
54
  class: compensable
54
55
  inverse:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.6.20",
3
+ "version": "0.7.0",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -57,7 +57,8 @@
57
57
  "app:dist": "pnpm app:build && node app/build/pack.mjs",
58
58
  "demo": "tsup --silent && ./demo/filesystem-demo.sh",
59
59
  "demo:memory": "tsup --silent && ./demo/memory-demo.sh",
60
- "prepublishOnly": "tsup"
60
+ "check": "pnpm build && pnpm typecheck && pnpm lint && pnpm test",
61
+ "prepublishOnly": "pnpm check"
61
62
  },
62
63
  "dependencies": {
63
64
  "@modelcontextprotocol/sdk": "^1.30.0",
@@ -86,6 +87,7 @@
86
87
  "@fontsource/ibm-plex-sans": "^5.3.0",
87
88
  "@google/genai": "^2.22.0",
88
89
  "@modelcontextprotocol/server-filesystem": "^2026.7.10",
90
+ "@modelcontextprotocol/server-memory": "^2026.8.31",
89
91
  "@types/better-sqlite3": "^9.6.0",
90
92
  "@types/node": "^26.2.0",
91
93
  "@types/react": "^19.3.0",