synartesis 0.1.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/LICENSE +21 -0
- package/README.md +501 -0
- package/dist/chunk-K3QIPVBY.js +85 -0
- package/dist/chunk-K3QIPVBY.js.map +1 -0
- package/dist/chunk-X4VQNEP5.js +1383 -0
- package/dist/chunk-X4VQNEP5.js.map +1 -0
- package/dist/cli.js +1708 -0
- package/dist/cli.js.map +1 -0
- package/dist/demo-agent.js +67 -0
- package/dist/demo-agent.js.map +1 -0
- package/dist/proxy.js +913 -0
- package/dist/proxy.js.map +1 -0
- package/dist/toy-crm.js +293 -0
- package/dist/toy-crm.js.map +1 -0
- package/manifests/filesystem.yaml +97 -0
- package/manifests/git.yaml +77 -0
- package/manifests/github.yaml +175 -0
- package/manifests/memory.yaml +96 -0
- package/manifests/toy-crm.yaml +66 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arhaan Khan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Synartesis
|
|
2
|
+
|
|
3
|
+
An undo layer for AI agents.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
An agent with write access to a real system runs twenty steps, misreads step
|
|
9
|
+
seven, and applies the rest to the wrong records. Today your options are to
|
|
10
|
+
reverse it by hand from the transcript, restore a backup and lose every
|
|
11
|
+
legitimate change made in the same window, or accept the damage.
|
|
12
|
+
|
|
13
|
+
Synartesis sits between your MCP client and the servers it talks to. It records
|
|
14
|
+
every tool call with the state that call replaced, and it can put that state
|
|
15
|
+
back. What cannot be put back, it refuses to let an agent do unsupervised.
|
|
16
|
+
|
|
17
|
+
It is not a sandbox: the container your agent runs in is disposable, but the
|
|
18
|
+
CRM row it updated over the network is not. It is not a tracing tool: a trace
|
|
19
|
+
tells you `update_customer` ran forty times, not what the values were before.
|
|
20
|
+
|
|
21
|
+
## What it can and cannot do
|
|
22
|
+
|
|
23
|
+
Every tool gets one of four classifications, which you write down in a manifest:
|
|
24
|
+
|
|
25
|
+
| Class | Meaning | Example | What happens |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| `readonly` | Changes nothing | `get_customer` | Recorded, forwarded |
|
|
28
|
+
| `reversible` | Prior state can be restored exactly | `update_customer` | State captured before the write; written back on undo |
|
|
29
|
+
| `compensable` | Cannot be reversed, but can be offset | `create_charge` | A different call neutralises it |
|
|
30
|
+
| `irreversible` | Neither | `send_email` | **Suspended until a human approves it** |
|
|
31
|
+
|
|
32
|
+
A tool your manifest does not mention is treated as `irreversible`. That is
|
|
33
|
+
deliberate: silently forwarding an unknown destructive call is the one failure
|
|
34
|
+
worth avoiding most.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
| Tool | Version | Check with |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Node | 22 or newer | `node --version` |
|
|
41
|
+
| pnpm | 9 or newer | `pnpm --version` |
|
|
42
|
+
| A C toolchain | any | `cc --version` |
|
|
43
|
+
|
|
44
|
+
`pnpm` comes with Node via corepack:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
corepack enable pnpm
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The C toolchain is needed once, to compile SQLite's native bindings. On macOS
|
|
51
|
+
run `xcode-select --install`; on Debian or Ubuntu, `apt install build-essential`.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl -fsSL https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/install.sh | bash
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or from a clone, if you would rather read it first:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/ArhaanDev24/Synartesis.git && cd Synartesis && ./install.sh
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The script checks your Node version, builds, and links `synartesis` and
|
|
66
|
+
`synartesis-proxy` into the first writable directory already on your PATH. It
|
|
67
|
+
edits no shell profile and needs no sudo. Pass `--no-link` to build only.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
synartesis --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
If nothing could be linked, nothing breaks: every command Synartesis prints
|
|
74
|
+
spells itself out in whichever form actually runs on your machine.
|
|
75
|
+
|
|
76
|
+
## Walkthrough
|
|
77
|
+
|
|
78
|
+
This uses a toy CRM that ships with the repo, so you can see the whole loop
|
|
79
|
+
without pointing anything at real data. Run it from a scratch directory.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
mkdir -p /tmp/synartesis-demo && cd /tmp/synartesis-demo
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 1. Write a policy
|
|
86
|
+
|
|
87
|
+
`init` starts a server, asks it what tools it has, and writes a manifest.
|
|
88
|
+
Replace `SYNARTESIS` with the path you cloned into.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
node SYNARTESIS/dist/cli.js init crm -- node SYNARTESIS/dist/toy-crm.js --state ./crm.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`init` prints the path it wrote to. Unless a policy already sits above the
|
|
95
|
+
directory you are standing in, that is `~/.synartesis/synartesis.yaml`. Open it:
|
|
96
|
+
every tool that isn't a self-declared read starts as `irreversible` with a
|
|
97
|
+
`TODO`. **Working through those TODOs is the job.** A finished policy for this
|
|
98
|
+
fixture ships in the repo, so copy it into this directory rather than typing it
|
|
99
|
+
out — a policy here takes precedence over the one in your home:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cp SYNARTESIS/manifests/toy-crm.yaml ./synartesis.yaml
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then edit the `args` line so it points at your clone and keeps its data in
|
|
106
|
+
this directory. The copy ships with `args: ["dist/toy-crm.js"]`; it needs both
|
|
107
|
+
the path to your clone and the `--state` file:
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
servers:
|
|
111
|
+
crm:
|
|
112
|
+
command: node
|
|
113
|
+
args: ["SYNARTESIS/dist/toy-crm.js", "--state", "./crm.json"]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 2. Point your agent at the proxy
|
|
117
|
+
|
|
118
|
+
Wherever your MCP client lists servers, replace the entry for the server you
|
|
119
|
+
want covered with the proxy. For Claude Desktop or Claude Code that is a
|
|
120
|
+
`mcpServers` block:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"mcpServers": {
|
|
125
|
+
"crm": {
|
|
126
|
+
"command": "node",
|
|
127
|
+
"args": ["SYNARTESIS/dist/proxy.js", "--manifest", "/tmp/synartesis-demo/synartesis.yaml"]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The agent sees the same tools with the same names and the same results. That is
|
|
134
|
+
the point: nothing about your agent changes.
|
|
135
|
+
|
|
136
|
+
For this walkthrough you do not need a real agent. This does the same thing:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"demo-agent","version":"0"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"update_customer","arguments":{"id":"c_001","plan":"free","notes":"wrong edit"}}}' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"delete_customer","arguments":{"id":"c_002"}}}' | node SYNARTESIS/dist/proxy.js --manifest ./synartesis.yaml --journal ./journal.db > /dev/null
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Look at the damage:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
cat crm.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Ada is on the wrong plan with the wrong notes, and Grace is gone.
|
|
149
|
+
|
|
150
|
+
### 3. See what it did
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
node SYNARTESIS/dist/cli.js list --journal ./journal.db
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
node SYNARTESIS/dist/cli.js show RUN_ID --journal ./journal.db
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`show` prints each call with its class, its status, and the exact call that
|
|
161
|
+
would undo it, already resolved to literal values.
|
|
162
|
+
|
|
163
|
+
### 4. Undo it
|
|
164
|
+
|
|
165
|
+
Look before you leap:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
node SYNARTESIS/dist/cli.js undo RUN_ID --dry-run --journal ./journal.db
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Then do it:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
node SYNARTESIS/dist/cli.js undo RUN_ID --journal ./journal.db
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
cat crm.json
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Grace is back and Ada is on her original plan, with her original notes.
|
|
182
|
+
|
|
183
|
+
### 5. Watch it refuse
|
|
184
|
+
|
|
185
|
+
Undo is not a blunt instrument. If something else changed a record after the
|
|
186
|
+
agent touched it, writing the old value back would destroy that work, so
|
|
187
|
+
Synartesis stops and shows you both values.
|
|
188
|
+
|
|
189
|
+
Run the damage command from step 2 again. That creates a second run, so take
|
|
190
|
+
the run id from the top of `list`, which is ordered most recent first. Then
|
|
191
|
+
edit the record by hand:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
node -e 'const f="./crm.json",s=JSON.parse(require("fs").readFileSync(f));s.customers.c_001.notes="a human wrote this";require("fs").writeFileSync(f,JSON.stringify(s,null,2))'
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
node SYNARTESIS/dist/cli.js undo RUN_ID --journal ./journal.db
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
It halts at the record that moved, prints the expected and actual state, and
|
|
202
|
+
exits non-zero. It is not all-or-nothing: undo works newest first, so anything
|
|
203
|
+
it had already put back before reaching the drifted record stays put back —
|
|
204
|
+
here `delete_customer` is reversed and `c_002` comes back, and then it stops.
|
|
205
|
+
`show` afterwards tells you which actions are still outstanding. Once you have
|
|
206
|
+
resolved the conflict yourself, `undo --replan` rebuilds the plan against the
|
|
207
|
+
world as it now is and carries on.
|
|
208
|
+
|
|
209
|
+
## Approving what cannot be undone
|
|
210
|
+
|
|
211
|
+
`send_email` is classified `irreversible`, so the agent cannot send one on its
|
|
212
|
+
own. The call is **refused immediately** with an action id and the command that
|
|
213
|
+
would approve it. The agent tells you, you decide, and it tries again.
|
|
214
|
+
|
|
215
|
+
It does not hold the call open while waiting. That was the first design and it
|
|
216
|
+
does not survive contact with a real client: every useful window for a person
|
|
217
|
+
to notice, open a terminal and decide is longer than a client will wait for a
|
|
218
|
+
tool, so the two cannot be reconciled by picking a better timeout.
|
|
219
|
+
|
|
220
|
+
Approval also does not happen on the terminal the agent is using: the proxy
|
|
221
|
+
talks MCP over stdin and stdout, so there is nothing there to prompt on, and a
|
|
222
|
+
desktop client has no terminal at all. The request goes to the journal, and you
|
|
223
|
+
answer it from anywhere:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
node SYNARTESIS/dist/cli.js gates --journal ./journal.db
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
node SYNARTESIS/dist/cli.js approve ACTION_ID --by your-name --journal ./journal.db
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
node SYNARTESIS/dist/cli.js deny ACTION_ID --by your-name --reason "not this one" --journal ./journal.db
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
An approval is **single use** and expires after an hour, so it covers the retry
|
|
238
|
+
it was granted for and cannot quietly authorise the same call tomorrow. It is
|
|
239
|
+
not tied to one session, because people restart their client and an approval
|
|
240
|
+
stranded in a dead session would be no approval at all.
|
|
241
|
+
|
|
242
|
+
Nothing is ever approved by silence. An unanswered request simply stays
|
|
243
|
+
unanswered, visible in `synartesis gates` until someone decides.
|
|
244
|
+
|
|
245
|
+
The agent is told all of this when it connects, so it can explain itself rather
|
|
246
|
+
than reporting an opaque failure.
|
|
247
|
+
|
|
248
|
+
## Real servers
|
|
249
|
+
|
|
250
|
+
If you would rather follow steps than read about it, there is a
|
|
251
|
+
[guide to running this against your own files](TESTING-ON-A-REAL-SYSTEM.md),
|
|
252
|
+
with the gate and the drift check as the two things worth testing on purpose.
|
|
253
|
+
|
|
254
|
+
Synartesis has nothing to do with email in particular. It sits on the MCP
|
|
255
|
+
protocol, so its subject is whatever the servers you have connected can do:
|
|
256
|
+
your files, your repositories, your database, your tickets, your agent's own
|
|
257
|
+
memory. What it can undo depends entirely on what those servers expose, and
|
|
258
|
+
each manifest below says plainly where that runs out.
|
|
259
|
+
|
|
260
|
+
| Manifest | Server | State it governs |
|
|
261
|
+
| --- | --- | --- |
|
|
262
|
+
| [`filesystem.yaml`](manifests/filesystem.yaml) | `@modelcontextprotocol/server-filesystem` | real files on disk |
|
|
263
|
+
| [`memory.yaml`](manifests/memory.yaml) | `@modelcontextprotocol/server-memory` | the knowledge graph an agent keeps about you |
|
|
264
|
+
| [`git.yaml`](manifests/git.yaml) | `mcp-server-git` | a real repository's index and history |
|
|
265
|
+
| [`github.yaml`](manifests/github.yaml) | `github/github-mcp-server` | issues, pull requests, file contents |
|
|
266
|
+
| [`toy-crm.yaml`](manifests/toy-crm.yaml) | the fixture in this repo | the worked example of every class |
|
|
267
|
+
|
|
268
|
+
Every one of those but `github.yaml` was checked against the server actually
|
|
269
|
+
running. Two demos run the whole loop for real:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
./demo/filesystem-demo.sh
|
|
273
|
+
./demo/memory-demo.sh
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The filesystem demo overwrites a file and moves another, restores both, then
|
|
277
|
+
shows undo refusing when a human edited the file in between, and the gate
|
|
278
|
+
refusing to create a directory this server has no way to remove.
|
|
279
|
+
|
|
280
|
+
The memory demo is the sharper one. The agent adds two people to the graph, one
|
|
281
|
+
of whom was already there, and the server quietly ignores the duplicate. Undo
|
|
282
|
+
therefore has to remove exactly one of them: the inverse is built from what the
|
|
283
|
+
server said it created, not from what the agent asked for, so the person who
|
|
284
|
+
was there first survives being undone. The same session then tries to delete an
|
|
285
|
+
entity and is held, because deleting an entity also deletes every relation
|
|
286
|
+
touching it and one inverse call cannot put back both.
|
|
287
|
+
|
|
288
|
+
### Where each one runs out
|
|
289
|
+
|
|
290
|
+
The limits are the interesting part, and they are properties of the servers
|
|
291
|
+
rather than of Synartesis.
|
|
292
|
+
|
|
293
|
+
- **filesystem**: `move_file` is reversible from its arguments alone, so no
|
|
294
|
+
pre-read is declared and drift cannot be checked for it. `create_directory`
|
|
295
|
+
is `irreversible` not because directories are precious but because this
|
|
296
|
+
server exposes no way to remove one.
|
|
297
|
+
- **memory**: `add_observations` and `delete_observations` are exact opposites
|
|
298
|
+
that disagree about what to call the same field. A path can read a field and
|
|
299
|
+
cannot rename one, so that inverse cannot be written at all and the call is
|
|
300
|
+
gated instead.
|
|
301
|
+
- **git**: nearly every read this server offers answers in prose meant for a
|
|
302
|
+
person, so almost nothing can be inverted from a captured state however
|
|
303
|
+
reversible the underlying git operation is. Commits are gated because this
|
|
304
|
+
server exposes no reset, no revert, and no way to move a branch.
|
|
305
|
+
|
|
306
|
+
Two things worth knowing if you write your own, both found by running these
|
|
307
|
+
against live servers rather than by reading documentation:
|
|
308
|
+
|
|
309
|
+
`$result` is the structured block, and it need not match the text one. The
|
|
310
|
+
memory server answers `create_entities` with a bare list in its text block and
|
|
311
|
+
`{"entities": [...]}` in `structuredContent`. Synartesis walks the structured
|
|
312
|
+
one, because that is the machine-readable contract.
|
|
313
|
+
|
|
314
|
+
And `synartesis check` proves a tool exists, not that a path resolves. It
|
|
315
|
+
cannot: no call has been made, so there is no result to walk. Run the thing
|
|
316
|
+
once and read `synartesis show` before you rely on an inverse.
|
|
317
|
+
|
|
318
|
+
## Writing a manifest
|
|
319
|
+
|
|
320
|
+
The manifest is the whole product. It should take fifteen minutes for an API
|
|
321
|
+
you know.
|
|
322
|
+
|
|
323
|
+
```yaml
|
|
324
|
+
version: 1
|
|
325
|
+
|
|
326
|
+
servers:
|
|
327
|
+
crm:
|
|
328
|
+
command: node
|
|
329
|
+
args: ["./crm-server.js"]
|
|
330
|
+
|
|
331
|
+
tools:
|
|
332
|
+
- match: "crm.get_customer"
|
|
333
|
+
class: readonly
|
|
334
|
+
|
|
335
|
+
# Read the record before overwriting it, then write that record back.
|
|
336
|
+
- match: "crm.update_customer"
|
|
337
|
+
class: reversible
|
|
338
|
+
snapshot:
|
|
339
|
+
tool: "crm.get_customer"
|
|
340
|
+
args:
|
|
341
|
+
id: "$.id"
|
|
342
|
+
inverse:
|
|
343
|
+
tool: "crm.update_customer"
|
|
344
|
+
args:
|
|
345
|
+
id: "$.id"
|
|
346
|
+
name: "$snapshot.name"
|
|
347
|
+
plan: "$snapshot.plan"
|
|
348
|
+
|
|
349
|
+
# Nothing to read beforehand; the id only exists once the call returns.
|
|
350
|
+
- match: "crm.create_customer"
|
|
351
|
+
class: compensable
|
|
352
|
+
inverse:
|
|
353
|
+
tool: "crm.delete_customer"
|
|
354
|
+
args:
|
|
355
|
+
id: "$result.id"
|
|
356
|
+
|
|
357
|
+
- match: "crm.send_*"
|
|
358
|
+
class: irreversible
|
|
359
|
+
gate: always
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
There are exactly three things a value can refer to:
|
|
363
|
+
|
|
364
|
+
| Prefix | Refers to | Available in |
|
|
365
|
+
|---|---|---|
|
|
366
|
+
| `$.` | the arguments the agent sent | `snapshot` and `inverse` |
|
|
367
|
+
| `$snapshot.` | what the pre-read captured | `inverse` |
|
|
368
|
+
| `$result.` | what the forward call returned | `inverse` |
|
|
369
|
+
|
|
370
|
+
Anything else is a literal. A reference can stand alone, in which case the
|
|
371
|
+
value keeps its type, or sit inside a sentence, in which case it is substituted
|
|
372
|
+
as text:
|
|
373
|
+
|
|
374
|
+
```yaml
|
|
375
|
+
sha: "$result.content.sha" # the value itself
|
|
376
|
+
message: "Revert agent change to $.path" # text with the path substituted
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Write `$$` for a literal dollar sign. There are no expressions, conditionals or
|
|
380
|
+
functions, and there will not be: the moment this becomes a language it stops
|
|
381
|
+
being something you can write in fifteen minutes.
|
|
382
|
+
|
|
383
|
+
Paths can index a list with `[0]` and read one field from every element with
|
|
384
|
+
`[]`:
|
|
385
|
+
|
|
386
|
+
```yaml
|
|
387
|
+
labels: "$snapshot.labels[].name" # [{name: "bug"}, ...] becomes ["bug", ...]
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
That covers the common case where an API hands a field back richer than it
|
|
391
|
+
takes it, which is what GitHub does with issue labels. `[]` reads the same key
|
|
392
|
+
from each element and nothing else: it is still a path, not a transform. A
|
|
393
|
+
reference copies values, it cannot compute them, so an API needing a genuinely
|
|
394
|
+
different shape is one the inverse should leave that field out of, and say so.
|
|
395
|
+
|
|
396
|
+
Other things to know:
|
|
397
|
+
|
|
398
|
+
- `match` supports `*`, which matches within one segment: `crm.send_*` matches
|
|
399
|
+
`crm.send_email` but not `crm.a.b`. The most specific pattern wins regardless
|
|
400
|
+
of the order rules are written in.
|
|
401
|
+
- The inverse of a patch should restore **every** field, not re-apply a patch.
|
|
402
|
+
If the same record is edited twice in one run, a partial inverse leaves the
|
|
403
|
+
fields the second edit touched behind.
|
|
404
|
+
- `gate: on_write` is a heuristic for tools like a raw SQL runner, where
|
|
405
|
+
destructiveness cannot be read from the tool name. Anything it cannot
|
|
406
|
+
confidently read as a single read statement is gated. Use `gate: always`
|
|
407
|
+
wherever certainty matters.
|
|
408
|
+
- A malformed manifest stops the proxy from starting, with the file and line to
|
|
409
|
+
fix. It will never run with a policy it could not understand.
|
|
410
|
+
|
|
411
|
+
## Commands
|
|
412
|
+
|
|
413
|
+
| Command | Does |
|
|
414
|
+
|---|---|
|
|
415
|
+
| `init <server> -- <cmd>` | Introspect a server and draft a manifest |
|
|
416
|
+
| `list` | Every recorded run |
|
|
417
|
+
| `show <runId>` | One run's timeline, with the undo for each step |
|
|
418
|
+
| `gates` | What is waiting for a decision |
|
|
419
|
+
| `approve <actionId>` | Allow a suspended call |
|
|
420
|
+
| `deny <actionId>` | Refuse one |
|
|
421
|
+
| `undo <runId>` | Reverse a run, newest action first |
|
|
422
|
+
| `undo <runId> --replan` | Same, but rebuild each undo from the current manifest |
|
|
423
|
+
| `check` | Load a manifest and verify it against the servers it names |
|
|
424
|
+
|
|
425
|
+
`--manifest` and `--journal` are found rather than typed. Both are looked for
|
|
426
|
+
from the current directory upwards, the way a version control tool finds its
|
|
427
|
+
root, so inside a project that has a `synartesis.yaml` every command works with
|
|
428
|
+
no flags at all. When there is nothing above you either, both come from
|
|
429
|
+
`~/.synartesis` — most of what anyone guards is not part of a project, and
|
|
430
|
+
should not need a directory of its own. `SYNARTESIS_HOME` moves that. A journal
|
|
431
|
+
that does not exist yet is placed beside the policy, so the proxy that creates
|
|
432
|
+
it and the CLI that reads it agree without either being told.
|
|
433
|
+
|
|
434
|
+
Other flags: `--dry-run`, `--to <seq>` and `--replan` on `undo`, `--all` on
|
|
435
|
+
`approve` and `deny`, `--json` on `list`, `show` and `gates`.
|
|
436
|
+
|
|
437
|
+
Exit codes: `0` succeeded, `1` halted or refused, `2` bad usage or
|
|
438
|
+
configuration.
|
|
439
|
+
|
|
440
|
+
The proxy takes `--manifest`, `--journal`, `--gate-timeout <seconds>` and
|
|
441
|
+
`--log-level`. It logs structured JSON to stderr; stdout is reserved for
|
|
442
|
+
protocol traffic.
|
|
443
|
+
|
|
444
|
+
## What it does not do
|
|
445
|
+
|
|
446
|
+
- **It cannot un-send what has been seen.** An email that has been read, a
|
|
447
|
+
posted message, a file deleted with no backup. This is why the gate exists.
|
|
448
|
+
- **Compensable actions cannot be checked for drift.** They declare no pre-read,
|
|
449
|
+
so undo compensates them and marks them `[unverified]` in its report.
|
|
450
|
+
- **Undo halts on uncertainty, and steps over the merely permanent.** Drift, an
|
|
451
|
+
unknown outcome, or a failed reversing call stop it, because continuing past
|
|
452
|
+
those could destroy something. An action that simply cannot be undone, like a
|
|
453
|
+
sent email, is reported and left in place while everything else is reverted:
|
|
454
|
+
no amount of stopping un-sends it, and stopping would only leave the rest
|
|
455
|
+
wrong too. Either way the run is marked `partial`.
|
|
456
|
+
- **A call interrupted mid-flight is recorded as unknown**, not as failed. Undo
|
|
457
|
+
refuses to walk past it, because whether it applied cannot be determined.
|
|
458
|
+
- **An undo is only as good as the policy that recorded it.** Inverses are
|
|
459
|
+
resolved when the call happens, not when you undo, so a mistake in a manifest
|
|
460
|
+
is baked into every run made under it. `undo --replan` rebuilds them from a
|
|
461
|
+
corrected manifest using the state already captured, which is the way out.
|
|
462
|
+
|
|
463
|
+
## Watching it work
|
|
464
|
+
|
|
465
|
+
Synartesis is not a daemon and cannot be one. An MCP client spawns a stdio
|
|
466
|
+
server itself and owns its lifetime, so nothing long-running could sit in
|
|
467
|
+
between and see those calls. What a person wants from a daemon is usually the
|
|
468
|
+
reassurance that it is there and doing something, and that needs somewhere to
|
|
469
|
+
look rather than a background process:
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
synartesis watch
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
It redraws as the agent works: what has been called, what class each call was,
|
|
476
|
+
and anything waiting on a decision, with the command to approve it. Ctrl-C
|
|
477
|
+
stops it. Piped rather than run in a terminal, it prints the state once and
|
|
478
|
+
exits.
|
|
479
|
+
|
|
480
|
+
## Trust
|
|
481
|
+
|
|
482
|
+
A manifest names commands and Synartesis runs them. Treat one you did not write
|
|
483
|
+
the way you would treat a shell script from the same source: read it first.
|
|
484
|
+
There is no sandbox here, and there is not meant to be.
|
|
485
|
+
|
|
486
|
+
## Development
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
pnpm test
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
pnpm typecheck && pnpm lint
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Every push runs those on Linux and macOS across Node 22 and 24, plus the demo
|
|
497
|
+
and the installer.
|
|
498
|
+
|
|
499
|
+
## Licence
|
|
500
|
+
|
|
501
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var SynartesisError = class extends Error {
|
|
3
|
+
constructor(message, options) {
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = new.target.name;
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
var ManifestError = class extends SynartesisError {
|
|
9
|
+
constructor(message, location) {
|
|
10
|
+
super(
|
|
11
|
+
location === void 0 ? message : `${location.file}:${String(location.line)}:${String(location.column)}: ${message}`
|
|
12
|
+
);
|
|
13
|
+
this.location = location;
|
|
14
|
+
}
|
|
15
|
+
location;
|
|
16
|
+
code = "MANIFEST_ERROR";
|
|
17
|
+
};
|
|
18
|
+
var UpstreamError = class extends SynartesisError {
|
|
19
|
+
constructor(server, operation, cause) {
|
|
20
|
+
super(`upstream ${server} failed during ${operation}: ${describe(cause)}`, { cause });
|
|
21
|
+
this.server = server;
|
|
22
|
+
this.operation = operation;
|
|
23
|
+
}
|
|
24
|
+
server;
|
|
25
|
+
operation;
|
|
26
|
+
code = "UPSTREAM_ERROR";
|
|
27
|
+
};
|
|
28
|
+
var SnapshotError = class extends SynartesisError {
|
|
29
|
+
constructor(tool, reason, options) {
|
|
30
|
+
super(`snapshot via ${tool} failed: ${reason}`, options);
|
|
31
|
+
this.tool = tool;
|
|
32
|
+
this.absent = options?.absent ?? false;
|
|
33
|
+
}
|
|
34
|
+
tool;
|
|
35
|
+
code = "SNAPSHOT_ERROR";
|
|
36
|
+
absent;
|
|
37
|
+
};
|
|
38
|
+
var DriftConflict = class extends SynartesisError {
|
|
39
|
+
constructor(seq, expected, actual) {
|
|
40
|
+
super(
|
|
41
|
+
`drift at sequence ${String(seq)}: the resource is not in the state this run left it in.
|
|
42
|
+
expected: ${JSON.stringify(expected)}
|
|
43
|
+
actual: ${JSON.stringify(actual)}`
|
|
44
|
+
);
|
|
45
|
+
this.seq = seq;
|
|
46
|
+
this.expected = expected;
|
|
47
|
+
this.actual = actual;
|
|
48
|
+
}
|
|
49
|
+
seq;
|
|
50
|
+
expected;
|
|
51
|
+
actual;
|
|
52
|
+
code = "DRIFT_CONFLICT";
|
|
53
|
+
};
|
|
54
|
+
var RollbackHalted = class extends SynartesisError {
|
|
55
|
+
constructor(seq, reason, options) {
|
|
56
|
+
super(`rollback halted at sequence ${String(seq)}: ${reason}`, options);
|
|
57
|
+
this.seq = seq;
|
|
58
|
+
}
|
|
59
|
+
seq;
|
|
60
|
+
code = "ROLLBACK_HALTED";
|
|
61
|
+
};
|
|
62
|
+
var JournalError = class extends SynartesisError {
|
|
63
|
+
code = "JOURNAL_ERROR";
|
|
64
|
+
constructor(operation, cause) {
|
|
65
|
+
super(`journal ${operation} failed: ${describe(cause)}`, { cause });
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
function describe(cause) {
|
|
69
|
+
if (cause instanceof Error) {
|
|
70
|
+
return cause.message;
|
|
71
|
+
}
|
|
72
|
+
return typeof cause === "string" ? cause : JSON.stringify(cause);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
SynartesisError,
|
|
77
|
+
ManifestError,
|
|
78
|
+
UpstreamError,
|
|
79
|
+
SnapshotError,
|
|
80
|
+
DriftConflict,
|
|
81
|
+
RollbackHalted,
|
|
82
|
+
JournalError,
|
|
83
|
+
describe
|
|
84
|
+
};
|
|
85
|
+
//# sourceMappingURL=chunk-K3QIPVBY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts"],"sourcesContent":["/**\n * The taxonomy from spec 3.5. Classes are added as the phase that raises them\n * lands, so every class here has a live throw site.\n */\nexport abstract class SynartesisError extends Error {\n abstract readonly code: string;\n\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n this.name = new.target.name;\n }\n}\n\nexport interface SourceLocation {\n readonly file: string;\n readonly line: number;\n readonly column: number;\n}\n\n/**\n * An invalid or unmatched policy. Carries a source location wherever the\n * manifest is at fault, because \"never start with a broken policy\" is only\n * useful if the operator is told which line to fix.\n */\nexport class ManifestError extends SynartesisError {\n readonly code = \"MANIFEST_ERROR\";\n\n constructor(\n message: string,\n readonly location?: SourceLocation,\n ) {\n super(\n location === undefined\n ? message\n : `${location.file}:${String(location.line)}:${String(location.column)}: ${message}`,\n );\n }\n}\n\n/** The wrapped server failed, or could not be reached at all. */\nexport class UpstreamError extends SynartesisError {\n readonly code = \"UPSTREAM_ERROR\";\n\n constructor(\n readonly server: string,\n readonly operation: string,\n cause: unknown,\n ) {\n super(`upstream ${server} failed during ${operation}: ${describe(cause)}`, { cause });\n }\n}\n\n/**\n * The pre-read failed, so the action must not proceed. A reversible action\n * without a snapshot is silently irreversible, which is the one outcome this\n * product exists to prevent.\n */\nexport class SnapshotError extends SynartesisError {\n readonly code = \"SNAPSHOT_ERROR\";\n\n constructor(\n readonly tool: string,\n reason: string,\n options?: { cause?: unknown; absent?: boolean },\n ) {\n super(`snapshot via ${tool} failed: ${reason}`, options);\n /**\n * The read reached the server and the server said no such resource, as\n * opposed to the read not completing at all. Only the former tells us\n * anything about the resource itself.\n */\n this.absent = options?.absent ?? false;\n }\n\n readonly absent: boolean;\n}\n\n/**\n * The resource changed after the agent touched it. Writing the old value back\n * would silently destroy whatever happened in between, so both values are\n * carried here for a human to judge.\n */\nexport class DriftConflict extends SynartesisError {\n readonly code = \"DRIFT_CONFLICT\";\n\n constructor(\n readonly seq: number,\n readonly expected: unknown,\n readonly actual: unknown,\n ) {\n super(\n `drift at sequence ${String(seq)}: the resource is not in the state this run left it in.\\n` +\n ` expected: ${JSON.stringify(expected)}\\n` +\n ` actual: ${JSON.stringify(actual)}`,\n );\n }\n}\n\n/**\n * An inverse failed, so the run is partially reverted. Continuing past it would\n * produce a state that is neither the before nor the after (D6).\n */\nexport class RollbackHalted extends SynartesisError {\n readonly code = \"ROLLBACK_HALTED\";\n\n constructor(\n readonly seq: number,\n reason: string,\n options?: { cause?: unknown },\n ) {\n super(`rollback halted at sequence ${String(seq)}: ${reason}`, options);\n }\n}\n\n/**\n * Not in spec 3.5, which covers failures on the proxy's forward path. A journal\n * write failing is different in kind: it means the record of what the agent did\n * is incomplete, so the call must not proceed. Always fatal, never swallowed.\n */\nexport class JournalError extends SynartesisError {\n readonly code = \"JOURNAL_ERROR\";\n\n constructor(operation: string, cause: unknown) {\n super(`journal ${operation} failed: ${describe(cause)}`, { cause });\n }\n}\n\nexport function describe(cause: unknown): string {\n if (cause instanceof Error) {\n return cause.message;\n }\n return typeof cause === \"string\" ? cause : JSON.stringify(cause);\n}\n"],"mappings":";AAIO,IAAe,kBAAf,cAAuC,MAAM;AAAA,EAGlD,YAAY,SAAiB,SAA+B;AAC1D,UAAM,SAAS,OAAO;AACtB,SAAK,OAAO,WAAW;AAAA,EACzB;AACF;AAaO,IAAM,gBAAN,cAA4B,gBAAgB;AAAA,EAGjD,YACE,SACS,UACT;AACA;AAAA,MACE,aAAa,SACT,UACA,GAAG,SAAS,IAAI,IAAI,OAAO,SAAS,IAAI,CAAC,IAAI,OAAO,SAAS,MAAM,CAAC,KAAK,OAAO;AAAA,IACtF;AANS;AAAA,EAOX;AAAA,EAPW;AAAA,EAJF,OAAO;AAYlB;AAGO,IAAM,gBAAN,cAA4B,gBAAgB;AAAA,EAGjD,YACW,QACA,WACT,OACA;AACA,UAAM,YAAY,MAAM,kBAAkB,SAAS,KAAK,SAAS,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC;AAJ3E;AACA;AAAA,EAIX;AAAA,EALW;AAAA,EACA;AAAA,EAJF,OAAO;AASlB;AAOO,IAAM,gBAAN,cAA4B,gBAAgB;AAAA,EAGjD,YACW,MACT,QACA,SACA;AACA,UAAM,gBAAgB,IAAI,YAAY,MAAM,IAAI,OAAO;AAJ9C;AAUT,SAAK,SAAS,SAAS,UAAU;AAAA,EACnC;AAAA,EAXW;AAAA,EAHF,OAAO;AAAA,EAgBP;AACX;AAOO,IAAM,gBAAN,cAA4B,gBAAgB;AAAA,EAGjD,YACW,KACA,UACA,QACT;AACA;AAAA,MACE,qBAAqB,OAAO,GAAG,CAAC;AAAA,cACf,KAAK,UAAU,QAAQ,CAAC;AAAA,cACxB,KAAK,UAAU,MAAM,CAAC;AAAA,IACzC;AARS;AACA;AACA;AAAA,EAOX;AAAA,EATW;AAAA,EACA;AAAA,EACA;AAAA,EALF,OAAO;AAalB;AAMO,IAAM,iBAAN,cAA6B,gBAAgB;AAAA,EAGlD,YACW,KACT,QACA,SACA;AACA,UAAM,+BAA+B,OAAO,GAAG,CAAC,KAAK,MAAM,IAAI,OAAO;AAJ7D;AAAA,EAKX;AAAA,EALW;AAAA,EAHF,OAAO;AASlB;AAOO,IAAM,eAAN,cAA2B,gBAAgB;AAAA,EACvC,OAAO;AAAA,EAEhB,YAAY,WAAmB,OAAgB;AAC7C,UAAM,WAAW,SAAS,YAAY,SAAS,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC;AAAA,EACpE;AACF;AAEO,SAAS,SAAS,OAAwB;AAC/C,MAAI,iBAAiB,OAAO;AAC1B,WAAO,MAAM;AAAA,EACf;AACA,SAAO,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AACjE;","names":[]}
|