synartesis 0.7.0 → 0.8.1

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,69 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.8.1 — 2026-09-19
6
+
7
+ Documentation only. No code changed, and the published package behaves exactly
8
+ as 0.8.0 did.
9
+
10
+ ### Changed
11
+
12
+ - **The README is laid out as a page rather than a document.** Somebody
13
+ arriving from a link wants to know within one screen what this is, that it
14
+ works, and which of the four ways in is theirs. None of that was visible
15
+ without scrolling past four screens of prose. The top is now a centred hero,
16
+ a badge row and a contents bar, then **Choose your path** — four cards, one
17
+ per way in, each carrying the command that starts it — and a short list of
18
+ first results that take the least time.
19
+
20
+ - **Two things came out of hiding, in the README and on the site.** The
21
+ interactive screen was a footnote inside Install despite being the interface
22
+ most people will use, and there was nothing anywhere about writing a policy
23
+ for a server that does not ship with one — which is the case for every server
24
+ but four, and the contribution most worth asking for. Both are now sections
25
+ of their own: `synartesis.online/install.html#write-a-policy` covers `init`
26
+ drafting fail-closed and `check` refusing a policy that names a tool the
27
+ server no longer has.
28
+
29
+ - **A contributing section, and the star asked for out loud.** The README
30
+ explained the tool thoroughly and then stopped, with no issue link and
31
+ nothing a reader who agreed with it could do next.
32
+
33
+ - `SECURITY.md` said the 0.6.x line was the supported one, two minors after it
34
+ stopped being.
35
+
36
+ ## 0.8.0 — 2026-09-16
37
+
38
+ ### Added
39
+
40
+ - **`expect: absent`, and `move_file` becomes undoable.** A pre-read normally
41
+ captures what a call is about to replace, so a read that finds nothing means
42
+ there is nothing to put back. For a few calls that is exactly backwards:
43
+ moving a file onto a free path is undone by moving it back, and it is finding
44
+ *something* there that puts the call beyond undo, because one inverse cannot
45
+ both move your file back and restore what it landed on.
46
+
47
+ Both halves were measured before anything was written. Declared plainly
48
+ `reversible`, the safe move came back `partial` with the file still moved —
49
+ the trivially reversible case was the one that could not be undone — and the
50
+ dangerous one came back `rolled_back` with the overwritten file gone, which
51
+ is the confident wrong undo this exists to prevent. `expect: absent` on the
52
+ snapshot swaps the two: finding nothing is reversible and the inverse runs;
53
+ finding something is held for a person and recorded with **no inverse**, so
54
+ undo says it cannot be undone rather than putting half of it back.
55
+
56
+ The shipped filesystem policy uses it, so moving a file to a fresh path is
57
+ now an ordinary undoable action instead of an approval prompt. A policy that
58
+ declares it on a non-`reversible` rule, on a `verify` read, or with an
59
+ inverse reading `$snapshot.` — which can never resolve, since nothing is
60
+ captured — is refused at load.
61
+
62
+ Worth recording: this server's own description of `move_file` says "If the
63
+ destination exists, the operation will fail." It does not; it renames over
64
+ the top, because that is what `rename(2)` does. Taking the documentation at
65
+ its word would have made the rule unconditionally reversible and the
66
+ overwrite both unrecoverable and unremarked.
67
+
5
68
  ## 0.7.0 — 2026-09-16
6
69
 
7
70
  A pass over everything, after an audit of the desktop app, the core and the
package/README.md CHANGED
@@ -1,27 +1,79 @@
1
1
  <!-- Absolute URLs, not relative paths: this README is also the npm package
2
2
  page, and npm does not resolve relative image paths against the repo. -->
3
+ <div align="center">
4
+
3
5
  [![Synartesis — an undo layer for AI agents](https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-banner.png)](https://synartesis.online)
4
6
 
7
+ **Give AI agents a way back.**
8
+ Synartesis sits between your MCP client and the servers it talks to, records
9
+ every tool call with the state that call replaced, and can put that state back.
10
+ What cannot be put back, it refuses to let an agent do unsupervised.
11
+
12
+ ### [Install it, then read the five minutes ahead of you →](docs/synartesis-user-guide.md#the-five-minutes-ahead-of-you)
13
+
5
14
  [![check](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml/badge.svg)](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml)
6
15
  [![npm](https://img.shields.io/npm/v/synartesis?color=5e1420&label=npm)](https://www.npmjs.com/package/synartesis)
7
16
  [![downloads](https://img.shields.io/npm/dm/synartesis?color=5e1420&label=downloads)](https://www.npmjs.com/package/synartesis)
8
17
  [![node](https://img.shields.io/node/v/synartesis?color=5e1420)](https://nodejs.org)
9
18
  [![MIT](https://img.shields.io/badge/licence-MIT-5e1420.svg)](LICENSE)
19
+ [![site](https://img.shields.io/badge/synartesis.online-5e1420)](https://synartesis.online)
20
+ [![star this repo](https://img.shields.io/badge/★_star_this_repo-2c080f)](https://github.com/ArhaanDev24/Synartesis)
21
+
22
+ [Choose your path](#choose-your-path) · [See it in action](#see-it-in-action) ·
23
+ [Install](#install) · [The screen](#the-screen) ·
24
+ [The desktop window](#the-desktop-window) ·
25
+ [The four classes](#what-it-can-and-cannot-do) ·
26
+ [Your own server](#writing-a-policy-for-your-own-server) ·
27
+ [Commands](#commands) · [What it does not do](#what-it-does-not-do) ·
28
+ [Trust](#trust) · [Contributing](#contributing)
29
+
30
+ </div>
10
31
 
11
32
  An agent with write access to a real system runs twenty steps, misreads step
12
33
  seven, and applies the rest to the wrong records. Today your options are to
13
34
  reverse it by hand from the transcript, restore a backup and lose every
14
35
  legitimate change made in the same window, or accept the damage.
15
36
 
16
- Synartesis sits between your MCP client and the servers it talks to. It records
17
- every tool call with the state that call replaced, and it can put that state
18
- back. What cannot be put back, it refuses to let an agent do unsupervised.
19
-
20
37
  It is not a sandbox: the container your agent runs in is disposable, but the
21
38
  CRM row it updated over the network is not. It is not a tracing tool: a trace
22
39
  tells you `update_customer` ran forty times, not what the values were before.
23
40
 
24
- ## What it looks like
41
+ **If this is a problem you have, [a star](https://github.com/ArhaanDev24/Synartesis) helps other people find it.**
42
+ It is a young project, and that is most of how anybody learns it exists.
43
+
44
+ ---
45
+
46
+ ## Choose your path
47
+
48
+ <table>
49
+ <tr>
50
+ <td width="50%"><a href="#install"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-install.png" alt="Cover what you already have — one command, every client. Finds what Claude Code, Claude Desktop, Cursor and Codex already list and points every entry at the proxy. $ synartesis install"></a></td>
51
+ <td width="50%"><a href="#the-screen"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-screen.png" alt="The screen — everything, on the arrow keys. What your agents have done, what is held for approval, and the undo for any of it. $ synartesis"></a></td>
52
+ </tr>
53
+ <tr>
54
+ <td width="50%"><a href="#the-desktop-window"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-desktop.png" alt="The desktop window — the same engine, with a conversation. Talk to any model. Every tool it calls goes out through the proxy on its way. $ synartesis desktop"></a></td>
55
+ <td width="50%"><a href="#writing-a-policy-for-your-own-server"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-policy.png" alt="Your own server — four ship with a policy, hundreds do not. Introspect a server, draft a manifest from it, then correct it where you know better. $ synartesis init crm -- ./crm-mcp"></a></td>
56
+ </tr>
57
+ </table>
58
+
59
+ **Your first result**, in the order they take the least time:
60
+
61
+ - **Undo a real write:** [run the filesystem demo](demo/filesystem-demo.sh) — it
62
+ makes a file, has an agent overwrite it, and puts it back, in about a minute
63
+ and without touching anything of yours.
64
+ - **Cover the agents on this machine:** [`synartesis install`](#install), then
65
+ `synartesis status` to see what is now covered and what is not.
66
+ - **Watch one happen:** [`synartesis watch`](#commands) prints each call as it
67
+ goes out, and lets you answer an approval where you are standing.
68
+ - **Ask what an agent already did:** [`synartesis show <id> --live`](#has-anybody-touched-it-since)
69
+ reads every resource a session touched, as it is now, and says which still match.
70
+
71
+ Nothing above sends anything anywhere. The journal is a SQLite file on your
72
+ machine, and the proxy talks only to the servers your policy names.
73
+
74
+ ---
75
+
76
+ ## See it in action
25
77
 
26
78
  Both shots are real output from [`./demo/filesystem-demo.sh`](demo/filesystem-demo.sh),
27
79
  pasted rather than typeset. An agent overwrote a file and tried to move another.
@@ -41,6 +93,9 @@ It stops at the record that moved and exits non-zero. Anything already put back
41
93
  stays put back, and it prints the three ways on: leave it, restore the resource
42
94
  and `--replan`, or `--force` to overwrite deliberately.
43
95
 
96
+ [The desktop window](#the-desktop-window) · [What it can and cannot do](#what-it-can-and-cannot-do) · [What it does not do](#what-it-does-not-do) · [User guide](docs/synartesis-user-guide.md)
97
+
98
+ ---
44
99
  ## Install
45
100
 
46
101
  ```bash
@@ -67,6 +122,12 @@ installed.**
67
122
  Needs Node 22 or newer. npm ships a prebuilt SQLite binding, so no toolchain is
68
123
  required unless you build from a clone.
69
124
 
125
+ [User guide](docs/synartesis-user-guide.md#install-it) · [Claude Desktop](docs/synartesis-user-guide.md#claude-desktop) · [Claude Code](docs/synartesis-user-guide.md#claude-code) · [Any other client](docs/synartesis-user-guide.md#any-other-mcp-client)
126
+
127
+ ---
128
+
129
+ ## The screen
130
+
70
131
  Then just:
71
132
 
72
133
  ```bash
@@ -74,7 +135,17 @@ synartesis
74
135
  ```
75
136
 
76
137
  One screen: what agents have done, what is held for approval, every AI on the
77
- machine, and undo — all on the arrow keys.
138
+ machine, and undo — all on the arrow keys. `enter` opens a session, `u` undoes
139
+ it, `p` previews that undo without running it, `l` reads the world as it is now,
140
+ `f` expands every argument, `c` lists every AI on the machine, `g` shows what is
141
+ held. Nothing in it is a second implementation: it is the same journal and the
142
+ same planner the [commands](#commands) below use, so a session undone in the
143
+ screen and one undone in a script end the same way.
144
+
145
+ There is no mode in it that acts without telling you what it is about to do. An
146
+ undo shows you the plan first, and a held call shows you why it is held.
147
+
148
+ ---
78
149
 
79
150
  ## The desktop window
80
151
 
@@ -143,6 +214,8 @@ want. The `release` workflow builds all three platforms on their own machines
143
214
  and attaches the installers to the release for a tag. Both the window and the terminal share
144
215
  one journal, so either can undo what the other did.
145
216
 
217
+ ---
218
+
146
219
  ## What it can and cannot do
147
220
 
148
221
  Every tool gets one of four classifications, written down in a manifest:
@@ -156,7 +229,54 @@ Every tool gets one of four classifications, written down in a manifest:
156
229
 
157
230
  A tool your manifest does not mention is treated as `irreversible`. That is
158
231
  deliberate: silently forwarding an unknown destructive call is the one failure
159
- worth avoiding most.
232
+ worth avoiding most. `synartesis check` names them, so you meet that decision
233
+ before your agent does.
234
+
235
+ A few calls are reversible only when nothing is in the way — moving a file onto
236
+ a free path is undone by moving it back, moving it onto an existing file
237
+ destroys what was there. For those, `expect: absent` on the pre-read swaps the
238
+ two: finding nothing is the reversible case, finding something is held for a
239
+ person and recorded with no inverse, so undo says it cannot be undone rather
240
+ than putting half of it back and calling that success.
241
+
242
+ ---
243
+
244
+ ## Writing a policy for your own server
245
+
246
+ Four servers ship with a policy. For anything else — a database, a ticketing
247
+ system, the MCP server you wrote last week — start by asking the server what it
248
+ has:
249
+
250
+ ```bash
251
+ synartesis init crm -- ./crm-mcp
252
+ ```
253
+
254
+ That connects, lists every tool, and writes a manifest drafted from what the
255
+ server says about itself. If it recognises the server as one that already ships
256
+ with a policy, it adopts that one instead — but only when every tool the policy
257
+ calls is actually there, because a policy whose inverses cannot be called is
258
+ worse than a file of TODOs: it looks finished.
259
+
260
+ Drafted, not decided. A tool the server marks read-only is written `readonly`
261
+ with a comment telling you to check, since that hint is a statement of intent
262
+ and not a guarantee; everything else lands `irreversible` and gated. The draft
263
+ is fail-closed on purpose, and you open it up one tool at a time as you work
264
+ out how each of them is undone.
265
+
266
+ Then check it against the running server:
267
+
268
+ ```bash
269
+ synartesis check
270
+ ```
271
+
272
+ It loads the manifest, connects to every server it names, and says which tools
273
+ your policy covers, which it does not, and where the two disagree. A policy
274
+ that names a tool the server no longer has is an error, not a warning.
275
+
276
+ [Writing a manifest](docs/synartesis-user-guide.md#open-the-file) · [The four classes](docs/synartesis-user-guide.md#the-four-classes) · [Checking it](docs/synartesis-user-guide.md#check-that-it-worked) · [Pinning a tool's shape](#when-the-server-changes-underneath-you)
277
+
278
+
279
+ ---
160
280
 
161
281
  ## Has anybody touched it since?
162
282
 
@@ -179,6 +299,8 @@ answers. `l` in the screen does the same.
179
299
  If you decide the recorded value is the one worth keeping, `undo --force` prints
180
300
  every line it would write over and stops; `--force --yes` goes ahead.
181
301
 
302
+ ---
303
+
182
304
  ## What each policy has actually been tested against
183
305
 
184
306
  A policy that has met a real server and one written from its documentation are
@@ -204,6 +326,8 @@ wrote yourself: the tool has no business grading your work. Nothing is inferred
204
326
  from silence, and all three states are printed, because if silence meant "fine"
205
327
  then an ungraded policy and a known-untested one would look identical.
206
328
 
329
+ ---
330
+
207
331
  ## Undoing something that was never read first
208
332
 
209
333
  Most undo rides on a pre-read: the value before the write is captured, and
@@ -237,6 +361,8 @@ It is consulted only where there is no read already, so it can never displace a
237
361
  working pre-read with a differently shaped one — which would make the post-state
238
362
  and the snapshot incomparable and every later comparison meaningless.
239
363
 
364
+ ---
365
+
240
366
  ## When the server changes underneath you
241
367
 
242
368
  A policy is a claim about what a tool does, and a tool's name is a weak place to
@@ -276,6 +402,8 @@ reads as protected and is not. Tools that no policy matches need no pin: they ar
276
402
  already fail-closed as irreversible and gated, so there is no classification for a
277
403
  schema change to corrupt.
278
404
 
405
+ ---
406
+
279
407
  ## Commands
280
408
 
281
409
  `synartesis desktop` opens [the window](#the-desktop-window), and says where to
@@ -301,8 +429,8 @@ get it if it is not installed.
301
429
  | `prune` | Delete sessions older than 30 days and reclaim the space |
302
430
  | `close [id]` | End a session a killed proxy left open |
303
431
 
304
- In the screen: `enter` opens, `u` undoes, `p` previews, `l` checks the world
305
- now, `f` expands, `c` shows every AI on the machine, `g` shows what is held.
432
+ Every one of those can also be done from [the screen](#the-screen), on the
433
+ arrow keys.
306
434
 
307
435
  `--manifest` and `--journal` are found rather than typed, from the current
308
436
  directory upwards the way a version control tool finds its root, then from
@@ -312,6 +440,8 @@ and `gates`. Exit codes: `0` succeeded, `1` halted or refused, `2` bad usage.
312
440
  **Full walkthrough, writing a manifest, and serving over HTTP for clients that
313
441
  cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
314
442
 
443
+ ---
444
+
315
445
  ## What it does not do
316
446
 
317
447
  - **It cannot un-send what has been seen.** An email that has been read, a
@@ -353,6 +483,8 @@ means an agent stopping mid-task on a call nobody expected. `synartesis check`
353
483
  names every such tool, and the proxy warns about them at startup, so you can
354
484
  write a policy before meeting one rather than after.
355
485
 
486
+ ---
487
+
356
488
  ## Trust
357
489
 
358
490
  A manifest names commands and Synartesis runs them. Treat one you did not write
@@ -378,6 +510,8 @@ or of the CLI mid-undo loses nothing; only the machine losing power can cost the
378
510
  tail of the write-ahead log. `SYNARTESIS_SYNC=full` asks for an fsync per commit
379
511
  instead — worth it where fsync is cheap, and measurably not where it is not.
380
512
 
513
+ ---
514
+
381
515
  ## Development
382
516
 
383
517
  ```bash
@@ -397,6 +531,44 @@ is expected to work, the code has no platform-specific paths outside
397
531
  `src/locate.ts` and `src/install/clients.ts`, and nobody has proved it. If you
398
532
  run Windows and something is wrong there, that is worth an issue.
399
533
 
534
+ ---
535
+
536
+ ## Contributing
537
+
538
+ The most useful thing you can send is a policy. Four servers ship with one;
539
+ there are hundreds that do not. If you run one — a database, a ticketing
540
+ system, your own — `synartesis init <name> -- <command>` drafts a
541
+ manifest by introspecting it, and a pull request adding that draft under
542
+ [`manifests/`](manifests) makes the tool cover a server it could not cover
543
+ before. Say in the description whether you ran it against the real thing; that
544
+ is what `provenance:` records, and an honest `documented` is worth more than an
545
+ optimistic `live`.
546
+
547
+ Also wanted, in rough order of how much they help:
548
+
549
+ - **Windows.** The installer is built and never tested — see
550
+ [Development](#development). A report either way is worth an issue.
551
+ - **A case where undo got it wrong.** The failure this project cares about most
552
+ is a confident wrong answer. If undo told you it reverted something and it had
553
+ not, that is the bug report to open.
554
+ - **A server whose tools do not fit the four classes.** The model has held so
555
+ far, and the first case it cannot express is worth knowing about.
556
+
557
+ [`CONTRIBUTING.md`](CONTRIBUTING.md) has the setup, and one command runs the
558
+ same gate a pull request has to pass:
559
+
560
+ ```bash
561
+ pnpm check
562
+ ```
563
+
564
+ Bugs and questions go in [issues](https://github.com/ArhaanDev24/Synartesis/issues).
565
+ Security reports do not — [`SECURITY.md`](SECURITY.md) says where those go
566
+ instead. And if it saved you an afternoon, **[star the
567
+ repo](https://github.com/ArhaanDev24/Synartesis)**; it costs you a click and is
568
+ most of how a project this size gets found.
569
+
570
+ ---
571
+
400
572
  ## Licence
401
573
 
402
574
  MIT. See [LICENSE](LICENSE).
package/SECURITY.md CHANGED
@@ -20,8 +20,8 @@ patches to older ones.
20
20
 
21
21
  | Version | Supported |
22
22
  |---|---|
23
- | 0.6.x | yes |
24
- | < 0.6 | no |
23
+ | 0.8.x | yes |
24
+ | < 0.8 | no |
25
25
 
26
26
  ## What the journal holds, and why it matters
27
27
 
@@ -1155,7 +1155,11 @@ var templateValue = z2.lazy(
1155
1155
  var callTemplate = z2.strictObject({
1156
1156
  tool: z2.string().min(1),
1157
1157
  args: z2.record(z2.string(), templateValue).default({}),
1158
- absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional()
1158
+ absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional(),
1159
+ // Only `absent` for now. `present` would mean "refuse unless something is
1160
+ // already here", which is a different feature nobody has asked for, and a
1161
+ // value with no meaning behind it is worse than one that is missing.
1162
+ expect: z2.literal("absent").optional()
1159
1163
  });
1160
1164
  var toolPolicy = z2.strictObject({
1161
1165
  match: z2.string().min(1),
@@ -1304,12 +1308,25 @@ function validate(source, manifest) {
1304
1308
  if (policy.class === "readonly" && policy.snapshot !== void 0) {
1305
1309
  source.fail([...path, "snapshot"], "a readonly tool must not declare a snapshot");
1306
1310
  }
1311
+ const expectsAbsent = policy.snapshot?.expect === "absent";
1312
+ if (policy.verify?.expect !== void 0) {
1313
+ source.fail(
1314
+ [...path, "verify", "expect"],
1315
+ "expect belongs on a snapshot; a verify read runs after the call, when there is nothing left to expect"
1316
+ );
1317
+ }
1318
+ if (expectsAbsent && policy.class !== "reversible") {
1319
+ source.fail(
1320
+ [...path, "snapshot", "expect"],
1321
+ `expect: absent says this call is reversible exactly when the read finds nothing, which only means something for a reversible tool, not a ${policy.class} one`
1322
+ );
1323
+ }
1307
1324
  if (policy.snapshot !== void 0) {
1308
1325
  checkCall(source, [...path, "snapshot"], policy.snapshot, servers, ["$."]);
1309
1326
  }
1310
1327
  if (policy.inverse !== void 0) {
1311
1328
  const allowed = ["$.", "$result."];
1312
- if (policy.snapshot !== void 0) {
1329
+ if (policy.snapshot !== void 0 && !expectsAbsent) {
1313
1330
  allowed.push("$snapshot.");
1314
1331
  }
1315
1332
  checkCall(source, [...path, "inverse"], policy.inverse, servers, allowed);
@@ -1329,7 +1346,8 @@ function withGate(policy) {
1329
1346
  args: call.args,
1330
1347
  ...call.absent_when === void 0 ? {} : {
1331
1348
  absentWhen: typeof call.absent_when === "string" ? [call.absent_when] : [...call.absent_when]
1332
- }
1349
+ },
1350
+ ...call.expect === void 0 ? {} : { expect: call.expect }
1333
1351
  });
1334
1352
  const gate = policy.gate ?? (policy.class === "irreversible" ? "always" : "never");
1335
1353
  return {
@@ -1993,4 +2011,4 @@ export {
1993
2011
  observeState,
1994
2012
  connectStdioUpstream
1995
2013
  };
1996
- //# sourceMappingURL=chunk-FXSKWMNG.js.map
2014
+ //# sourceMappingURL=chunk-O3QDPYEL.js.map
package/dist/cli.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  verifyAgainstServers,
39
39
  warnUntested,
40
40
  wasRefused
41
- } from "./chunk-FXSKWMNG.js";
41
+ } from "./chunk-O3QDPYEL.js";
42
42
  import {
43
43
  DriftConflict,
44
44
  ManifestError,
package/dist/proxy.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  verifyAgainstServers,
26
26
  warnUntested,
27
27
  withIdempotencyKey
28
- } from "./chunk-FXSKWMNG.js";
28
+ } from "./chunk-O3QDPYEL.js";
29
29
  import {
30
30
  SnapshotError,
31
31
  UpstreamError,
@@ -709,18 +709,39 @@ function createProxyServer(options) {
709
709
  let snapshot;
710
710
  let verify;
711
711
  let probe;
712
- let missingPriorState;
712
+ let noWayBack;
713
+ let foundNothing = false;
714
+ const expectsAbsent = policy.snapshot?.expect === "absent";
713
715
  if (policy.snapshot !== void 0) {
714
716
  try {
715
717
  verify = planRead(policy.snapshot, { args });
716
718
  probe = verify;
717
719
  snapshot = await runRead(router, verify, extra.signal);
718
- journal.attachSnapshot(pending.actionId, snapshot);
720
+ if (expectsAbsent) {
721
+ noWayBack = {
722
+ asked: `something is already there, so this cannot be undone \u2014 putting back what this call moves would leave nothing where the old contents were`,
723
+ recorded: `the pre-read expected nothing and found something, so this call overwrote it and no single inverse can put both back`
724
+ };
725
+ verify = void 0;
726
+ } else {
727
+ journal.attachSnapshot(pending.actionId, snapshot);
728
+ }
719
729
  } catch (error) {
720
730
  const reason = describe(error);
721
731
  if (error instanceof SnapshotError && error.absent) {
722
- missingPriorState = reason;
723
- verify = void 0;
732
+ foundNothing = true;
733
+ if (expectsAbsent) {
734
+ log?.debug(
735
+ { seq: pending.seq, tool: route.tool },
736
+ "pre-read found nothing, which is what makes this reversible"
737
+ );
738
+ } else {
739
+ noWayBack = {
740
+ asked: `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${reason}`,
741
+ recorded: `no prior state existed, so there is nothing to restore: ${reason}`
742
+ };
743
+ verify = void 0;
744
+ }
724
745
  } else {
725
746
  journal.markFailed(pending.actionId, reason);
726
747
  log?.error(
@@ -734,8 +755,8 @@ function createProxyServer(options) {
734
755
  }
735
756
  }
736
757
  }
737
- const priorState = probe === void 0 || missingPriorState !== void 0 ? { present: false } : { present: true, value: snapshot };
738
- if (missingPriorState !== void 0 && !askedAlready) {
758
+ const priorState = probe === void 0 || foundNothing ? { present: false } : { present: true, value: snapshot };
759
+ if (noWayBack !== void 0 && !askedAlready) {
739
760
  const standing = journal.findApproval({
740
761
  server: route.upstream.name,
741
762
  tool: route.tool,
@@ -743,18 +764,14 @@ function createProxyServer(options) {
743
764
  notBefore: new Date(Date.now() - APPROVAL_WINDOW_MS).toISOString()
744
765
  });
745
766
  if (standing === void 0) {
746
- await decide(
747
- `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
748
- );
767
+ await decide(noWayBack.asked);
749
768
  } else if (journal.adoptApproval(pending.actionId, standing)) {
750
769
  log?.info(
751
770
  { action: pending.actionId, by: standing.approvedBy, from: standing.runId },
752
771
  "proceeding on a standing approval"
753
772
  );
754
773
  } else {
755
- await decide(
756
- `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
757
- );
774
+ await decide(noWayBack.asked);
758
775
  }
759
776
  }
760
777
  const forwarded = {
@@ -800,13 +817,11 @@ function createProxyServer(options) {
800
817
  if (inferred !== void 0) {
801
818
  warnings.push(inferred);
802
819
  }
803
- if (missingPriorState !== void 0) {
804
- warnings.push(
805
- `no prior state existed, so there is nothing to restore: ${missingPriorState}`
806
- );
820
+ if (noWayBack !== void 0) {
821
+ warnings.push(noWayBack.recorded);
807
822
  }
808
823
  let inverse;
809
- if (policy.inverse !== void 0 && missingPriorState === void 0) {
824
+ if (policy.inverse !== void 0 && noWayBack === void 0) {
810
825
  try {
811
826
  inverse = planInverse(policy.inverse, context);
812
827
  } catch (error) {
@@ -857,7 +872,7 @@ function createProxyServer(options) {
857
872
  const recovered = recoverInverse(
858
873
  policy,
859
874
  { args, snapshot },
860
- missingPriorState !== void 0
875
+ noWayBack !== void 0
861
876
  );
862
877
  journal.markApplied(pending.actionId, {
863
878
  result: void 0,
@@ -88,16 +88,46 @@ tools:
88
88
  path: "$.path"
89
89
  content: "$snapshot.content"
90
90
 
91
- # Only reversible when the destination did not exist. Moving onto a file
92
- # that did overwrites it, and moving back afterwards restores the source and
93
- # leaves nothing where the destination's contents were: undo reports success
94
- # while the file it destroyed stays destroyed. There is no pre-read that
95
- # would tell the two cases apart -- a snapshot that finds nothing is how this
96
- # policy says "cannot be undone", which is backwards here, since finding
97
- # nothing is the safe case. So it asks.
91
+ # Reversible exactly when the destination did not exist, which is what
92
+ # `expect: absent` says.
93
+ #
94
+ # Moving onto a path where nothing is, is undone by moving the file back:
95
+ # the state this replaces is absence, and moving it off restores absence
96
+ # exactly. Moving onto a file that does exist overwrites it, and one inverse
97
+ # cannot both move the file back and restore what it landed on -- undo would
98
+ # report success over a file it had destroyed.
99
+ #
100
+ # Both cases were gated before, because the machinery read "the pre-read
101
+ # found nothing" as "there is nothing to put back" -- true of a write, and
102
+ # precisely backwards here, where finding nothing is the safe case. Measured
103
+ # both ways against the real server: as a plain reversible rule the safe move
104
+ # came back `partial` with the file still moved, and the dangerous one came
105
+ # back `rolled_back` with the overwritten file gone.
106
+ #
107
+ # And the pre-read is load-bearing, not belt and braces. This server's own
108
+ # description of move_file says "If the destination exists, the operation
109
+ # will fail" -- it does not. It renames over the top, silently, because
110
+ # that is what rename(2) does on POSIX. Taking the description at its word
111
+ # would have made this rule unconditionally reversible and the overwrite
112
+ # unrecoverable and unremarked. tests/expect-absent.test.ts pins the real
113
+ # behaviour so a version that starts matching its documentation is a
114
+ # failing test rather than a surprise.
98
115
  - match: "fs.move_file"
99
- class: irreversible
100
- gate: always
116
+ class: reversible
117
+ snapshot:
118
+ tool: "fs.read_text_file"
119
+ args:
120
+ path: "$.destination"
121
+ # Same reasoning as the two writes above: anything that is not absence
122
+ # is a failed read, not an empty destination, and a destination that
123
+ # exists but cannot be read must not be treated as a free space.
124
+ absent_when: ["ENOENT", "no such file"]
125
+ expect: absent
126
+ inverse:
127
+ tool: "fs.move_file"
128
+ args:
129
+ source: "$.destination"
130
+ destination: "$.source"
101
131
 
102
132
  # No rmdir exists on this server, so a directory once created stays.
103
133
  - match: "fs.create_directory"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,