synartesis 0.6.12 → 0.6.15

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,314 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.6.15 — 2026-09-15
6
+
7
+ One gap, named by a stranger on Reddit and real.
8
+
9
+ ### Security
10
+
11
+ - **A retry of a call whose outcome nobody could establish is now held for a
12
+ person.** The tri-state was already there -- a write that times out is not
13
+ called `failed`, the resource is re-read to see which way it went, and what
14
+ cannot be settled is left `pending`, which undo refuses to step over. What
15
+ was missing was the other half: nothing stopped the agent from simply making
16
+ the call again.
17
+
18
+ The idempotency key cannot close this. It is `runId:seq`, so a retry is a new
19
+ row under a different key and no upstream can tell the two attempts were one
20
+ intention -- checked against the real proxy, which records `seq=1 key=…:1`
21
+ and `seq=2 key=…:2`. A comment beside the forward call claimed otherwise and
22
+ has been corrected.
23
+
24
+ So the person who can go and look is asked before a second side effect
25
+ exists. It reuses the gate rather than inventing a second mechanism: the call
26
+ appears in `synartesis gates` with a reason that says what happened, rather
27
+ than the generic policy text.
28
+
29
+ Three things it deliberately does not do. It never holds a **read** -- a read
30
+ that timed out is left `pending` like anything else, but reading twice
31
+ changes nothing, and an agent may always look freely. It holds only the
32
+ **identical** call, matched on server, tool and arguments: the uncertainty is
33
+ about one call, not about the tool. And approving the retry **does not
34
+ resolve the first attempt** -- that row stays `pending` and undo still stops
35
+ on it, because saying yes to going forward is not a claim about what already
36
+ happened.
37
+
38
+ ### Performance
39
+
40
+ - `findPending` runs on the way in to every write, and left to choose sqlite
41
+ walked every action in the run -- rows that carry the snapshots -- to find
42
+ the handful that are pending. A partial index over exactly those rows, named
43
+ explicitly, on a single run of twenty thousand actions with two-kilobyte
44
+ snapshots: **24ms a call to 0.004ms**.
45
+
46
+ ## 0.6.14 — 2026-09-15 (tagged, never published)
47
+
48
+ The terminal now says what to do next, and the argument parser stopped
49
+ disagreeing with itself.
50
+
51
+ ### Added
52
+
53
+ - **Most commands end by naming the one thing worth doing next**, with the
54
+ session id already filled in:
55
+
56
+ ```
57
+ fs.create_directory is held, and the agent is waiting on you: synartesis approve f0c9935d
58
+ ```
59
+
60
+ It is worked out from the journal rather than from what was typed, so it
61
+ changes as the state does. A call held for approval outranks everything else
62
+ -- that is an agent stopped mid-task waiting on a person who may not know it
63
+ is waiting. Below that: the session that changed something, what is not
64
+ pinned, and `watch`. There is at most one, there is none when nothing
65
+ applies, and `--json` never carries it. `SYNARTESIS_NO_HINTS` turns it off.
66
+
67
+ The session it names is not simply the newest one. A client that connects
68
+ and reads opens a session like any other, so "the last thing that happened"
69
+ is regularly a session in which nothing happened; a session that only read,
70
+ or was already undone, or was held and never ran, is never offered for undo.
71
+
72
+ - **A mistyped command gets the word that was meant.** `synartesis lst` used to
73
+ answer with forty lines of help, thirty-nine of them about something the
74
+ person was not doing. It now says `did you mean list?` and lists five
75
+ commands. Flags too: `--jounral`, `--dryrun`, `--forse` all resolve.
76
+
77
+ - **A flag that belongs to `proxy` is named as such.** `--http`, `--token`,
78
+ `--server` and `--log-level` are in the help page, and answering one of them
79
+ with "unknown flag" sent people hunting through that page for a flag already
80
+ in it.
81
+
82
+ ### Fixed
83
+
84
+ - **A wrapped server's own flags were read as ours.** `synartesis init db --
85
+ some-server --manifest audit.yaml` handed `--manifest` to the server
86
+ correctly and *also* took it, writing the policy to a path nobody asked for
87
+ -- on a machine where that path is real, one they already had. `positional`
88
+ and the unknown-flag check had always stopped at the bare `--`; the flag
89
+ reader had not.
90
+
91
+ - **The value of a flag was read as a flag.** `deny --reason "-see ticket 42"`
92
+ answered `unknown flag -see ticket 42`, and `prune --older-than -5` answered
93
+ `unknown flag -5` instead of the message that knows what `--older-than` is
94
+ for. Three separate lists had to agree about which flags take a value and
95
+ did not; there is one now, and a test holds it against the help page.
96
+
97
+ - **`undo --to 0` announced the session before refusing.** It printed "no
98
+ session named, so the most recent: a42bf93a" and only then rejected the
99
+ flag, which reads as though that session had been acted on.
100
+
101
+ - **A hint that could not be pasted.** `list --journal ./bench.db` named a
102
+ session, and the `show` it offered read the *default* journal and answered
103
+ "no run matches". Hints now carry `--journal` and `--manifest` when those
104
+ were not the obvious ones, and `undo --replan --dry-run` offers a command
105
+ that still replans.
106
+
107
+ - **Guessing at a typo was too eager.** A ceiling of half the word answered
108
+ `--server` with `--live` and `--token` with `--to`, neither of which anybody
109
+ meant. Two edits at most now, never for a word under three characters, and a
110
+ swapped pair counts as the one mistake it feels like -- which is what keeps
111
+ `shwo`, `pruen` and `--jounral` reachable at that ceiling.
112
+
113
+ - **Colour was decided for both streams from stdout**, so `synartesis nonsense
114
+ 2> errors.log` in a terminal wrote escape sequences into the file.
115
+
116
+ ### Performance
117
+
118
+ - **`newestUndoable` runs on every `list`** and could only be answered by
119
+ reading the action rows, which carry the snapshots -- so the cost of one
120
+ screen of sessions grew with the size of the data rather than the number of
121
+ sessions. A partial index over exactly the rows it wants fixes that, but only
122
+ when named: left to choose, sqlite takes `actions_gated` and reads the rows
123
+ anyway. Measured on a 246MB journal: **56ms to 0.01ms**.
124
+
125
+ ## 0.6.13 — 2026-09-14
126
+
127
+ Four things, all of them from one comment by a stranger on Reddit who had
128
+ clearly built something like this before. Every one was real, and none of them
129
+ would have announced itself.
130
+
131
+ ### Security
132
+
133
+ - **A server that would not start said nothing useful about why, and could
134
+ hang the proxy outright.** Two faults in the same few lines, found by hitting
135
+ the first one in real use.
136
+
137
+ What it printed was `code: 'MODULE_NOT_FOUND', requireStack: [], },
138
+ Node.js v22.16.0` -- the last four lines of node's output, which are the four
139
+ least informative. `Error: Cannot find module '/path/to/thing'` sits in the
140
+ middle and was thrown away, so the message named neither the file nor
141
+ anything to act on. A line that states a fault now leads, stack frames are
142
+ dropped, and the tail is only the fallback when nothing states one.
143
+
144
+ The second was worse and was found while testing the first: nothing read the
145
+ server's stderr until after the connection failed. A pipe nobody reads fills
146
+ at around 64kB and blocks the writer, so a server that said more than that
147
+ before dying never exited, the connect never rejected, and `check` sat there
148
+ for ever with no output at all. Measured against a server writing 2.4MB to
149
+ stderr: **killed at 30 seconds, against 0.8 seconds and the right error**.
150
+ Stderr is now read as it arrives, capped so a logging loop cannot make the
151
+ proxy the thing that runs out of memory.
152
+
153
+ - **The one remaining advisory is gone.** `esbuild` reached `pnpm audit`
154
+ through vite, in the renderer build only -- never in anything published, and
155
+ `--prod` was already clean. Pinned anyway, the same way as 0.6.12's three:
156
+ a low advisory nobody can reach is still a line of output that trains you to
157
+ ignore the tool.
158
+
159
+ - **A server upgrade could silently invalidate the policy written for it.** A
160
+ policy is a claim about what a tool does, anchored to the tool's name -- and
161
+ a name is a weak anchor. A server can keep `write_file` and change what it
162
+ takes. The policy still says reversible, the snapshot still reads a field
163
+ that has moved, and the before-image captured no longer matches the write.
164
+ Nothing failed. The undo was produced later, on request, confidently, and was
165
+ wrong, which is worse than having no undo because somebody acted on it.
166
+
167
+ Startup already checked that the tools a policy names exist. It did not check
168
+ that they still have the shape the policy was written for.
169
+
170
+ A manifest may now pin that shape:
171
+
172
+ ```yaml
173
+ pins:
174
+ fs:
175
+ write_file: "sha256:ce17c85e8a58835..."
176
+ ```
177
+
178
+ `synartesis pin` prints the block for the servers you actually have. With it
179
+ in place, a tool whose shape has moved stops the proxy at startup and names
180
+ both fingerprints, instead of being quietly trusted.
181
+
182
+ It prints rather than rewriting the manifest: pinning is a person vouching
183
+ for what a tool does today, and a command that edited the policy for them
184
+ would let that happen with nobody reading it.
185
+
186
+ Pinning is per server and all-or-nothing. No pins means no checking, so every
187
+ manifest written before this keeps working. Any pins means that server is
188
+ checked in full -- a half-pinned server is the worst of both, because it
189
+ reads as protected and is not. Tools no policy matches need no pin; they are
190
+ already fail-closed as irreversible and gated.
191
+
192
+ ### Fixed
193
+
194
+ - **Forward calls carried no idempotency key.** The key was minted for every
195
+ action and stored, and it was presented on the inverse -- but not on the
196
+ call going out. So a write that timed out in flight left the agent free to
197
+ retry, with nothing telling the server that the retry was the same intention.
198
+ Two side effects would sit behind one journal row, and undo would reverse one
199
+ of them and report success.
200
+
201
+ The key now rides out with the forward call as well, merged into `_meta`
202
+ rather than replacing it, so a client's own `progressToken` survives. It
203
+ remains advisory -- a server that ignores it gives no protection, which is
204
+ why the journal's state transitions are still the real guard.
205
+
206
+ - **A compensable action could be undone over somebody's work, silently.** An
207
+ action whose undo is a compensating call -- a create offset by a delete --
208
+ has no before-image, because the thing did not exist before the call. So
209
+ there was nothing for undo to compare against: it compensated regardless and
210
+ marked the step `[unverified]`.
211
+
212
+ A policy may now declare a `verify` read, resolved *after* the call so it can
213
+ name a resource the call itself created:
214
+
215
+ ```yaml
216
+ verify:
217
+ tool: "memory.open_nodes"
218
+ args: { names: "$result.entities[].name" }
219
+ ```
220
+
221
+ Undo then halts on drift the way it does everywhere else. Measured against
222
+ the real `@modelcontextprotocol/server-memory`: an agent creates an entity, a
223
+ person adds an observation to it by hand, and undo is asked for. Before, it
224
+ deleted the entity, took the hand-written observation with it, and reported
225
+ `rolled_back`. Now it halts, prints the added line, and writes nothing.
226
+
227
+ It is consulted only where no read exists already, so it can never displace a
228
+ working pre-read with a differently shaped one -- which would leave the
229
+ post-state and the snapshot describing different things and make every later
230
+ comparison meaningless.
231
+
232
+ `memory.create_entities` gets one. Deleting an entity takes its observations
233
+ and relations with it, which is exactly the case worth refusing.
234
+
235
+ - **Nothing said which policies had actually been run against a real server.**
236
+ Three of the four that ship were written against the live server; `github`
237
+ never has been, and its own header has said so in plain words since it was
238
+ written. Nothing in the code read that header. `check` did not mention it,
239
+ `install` adopted the policy without a word, and a held GitHub call looked
240
+ exactly as confident as a held filesystem one.
241
+
242
+ A server may now state it, and the four that ship do:
243
+
244
+ ```yaml
245
+ servers:
246
+ gh:
247
+ command: github-mcp-server
248
+ provenance: documented # or: live
249
+ ```
250
+
251
+ Surfaced by `check`, by the proxy at every start -- before it connects, since
252
+ the untried adapter is the one whose server is least likely to be installed
253
+ -- and by `install` at the moment the policy is adopted. `init` writes the
254
+ claim into the manifest it generates, or the warning would go quiet exactly
255
+ when the policy starts being used.
256
+
257
+ Absent means no claim either way, which is right for a policy somebody wrote
258
+ themselves. All three states are printed: if silence meant "fine", an
259
+ ungraded policy and a known-untested one would look identical from here.
260
+
261
+ - **A call somebody approved was reported as one they had refused.** When an
262
+ agent retries a held call, the approval moves onto the row that actually
263
+ runs and the original is retired -- stored as `denied`, with an error saying
264
+ where its approval went. `labelFor` existed to keep that out of the UI, and
265
+ three readers did not use it. `show` printed "denied by <name>" beside a call
266
+ that person had just approved and which had gone through; its footer counted
267
+ the row as a refusal; and `show --live` called it "never applied (denied)".
268
+ All three now go through `labelFor`, and the live view says what actually
269
+ happened: "its approval moved to the call that ran".
270
+
271
+ - **Arguments are summarised for a terminal again.** A value with a newline in
272
+ it was printed verbatim, so writing two lines to a file broke the timeline
273
+ and left the second at column zero. A value over 48 characters was reduced to
274
+ a byte count -- which for a path is the one summary that answers nothing, so
275
+ every row of a filesystem session read `path 120 B` and named no file.
276
+ Values are flattened to one line, a long path keeps its end, and long prose
277
+ keeps its size, because the tail of a file tells you nothing.
278
+
279
+ The undo plan used raw truncated json for the same job and cut off mid-path;
280
+ it uses the same summary now, so a step reads
281
+ `would call fs.write_file path …/work/ledger.csv content north,412800`.
282
+
283
+ - **`synartesis close` treated a tidy journal as a usage error.** Nothing left
284
+ open is the ordinary state and the thing somebody runs the command to check.
285
+ It answered with exit 2 and forty lines of unrelated help. It now says
286
+ "nothing is open" and succeeds.
287
+
288
+ Separately, "there is no run to act on" and "no run matches <id>" stopped
289
+ reciting every command. They are facts about the journal rather than about
290
+ what was typed, and the command list buried the one sentence that mattered.
291
+ A mistyped command still gets the full list.
292
+
293
+ - **`-v`, `version` and `help` answer.** `--version` worked and `synartesis
294
+ version` said "unknown command", which is a riddle rather than an answer.
295
+
296
+ - **Listing sessions read every action of every run.** It needs a count and
297
+ three status tallies per run, and it was getting them by materialising every
298
+ row -- snapshots, results and inverses included, which is the bulk of the
299
+ table. So the cost of listing sessions grew with the size of the data those
300
+ sessions had touched rather than with how many there were. It is one grouped
301
+ query now, over a covering index. Measured on forty runs of five hundred
302
+ actions with two-kilobyte snapshots, a hundred-megabyte journal: **76ms to
303
+ 2ms**.
304
+
305
+ The index is added the same way as the two in 0.6.12 and for the same reason:
306
+ no row changes, no meaning changes, and an older build opening the same file
307
+ neither notices nor cares.
308
+
309
+ - `synartesis check` now says whether anything is pinned, either way. Silence
310
+ when nothing was would have left the safer state and the unchecked one
311
+ looking identical.
312
+
5
313
  ## 0.6.12 — 2026-09-13
6
314
 
7
315
  ### Security
package/README.md CHANGED
@@ -179,6 +179,103 @@ answers. `l` in the screen does the same.
179
179
  If you decide the recorded value is the one worth keeping, `undo --force` prints
180
180
  every line it would write over and stops; `--force --yes` goes ahead.
181
181
 
182
+ ## What each policy has actually been tested against
183
+
184
+ A policy that has met a real server and one written from its documentation are
185
+ not the same kind of claim, and the difference only shows up at the moment
186
+ somebody needs undo to work. So a policy can say which it is:
187
+
188
+ ```yaml
189
+ servers:
190
+ gh:
191
+ command: github-mcp-server
192
+ provenance: documented # or: live
193
+ ```
194
+
195
+ Of the four that ship, three say `live` — they were written against the real
196
+ server and corrected where it disagreed with its own docs. **`github` says
197
+ `documented`**: it has never been run against a real account, and its own header
198
+ has always said so. Now `check` says it, the proxy says it at every start, and
199
+ `install` says it at the moment the policy is adopted — rather than leaving it in
200
+ a file for you to find afterwards.
201
+
202
+ Absent means no claim either way, which is the right default for a policy you
203
+ wrote yourself: the tool has no business grading your work. Nothing is inferred
204
+ from silence, and all three states are printed, because if silence meant "fine"
205
+ then an ungraded policy and a known-untested one would look identical.
206
+
207
+ ## Undoing something that was never read first
208
+
209
+ Most undo rides on a pre-read: the value before the write is captured, and
210
+ before putting it back, undo reads the world again and refuses if it has moved.
211
+
212
+ A compensable action has no such read. `create_entities` makes something that
213
+ did not exist a moment earlier, so there is nothing to capture — it has a
214
+ compensating action instead, a delete that offsets the create. Which means undo
215
+ had nothing to compare against and compensated regardless. If you had added to
216
+ that record in the meantime, the delete took your work with it and the run
217
+ reported success.
218
+
219
+ A policy can now declare a read used only for that check:
220
+
221
+ ```yaml
222
+ - match: "memory.create_entities"
223
+ class: compensable
224
+ inverse:
225
+ tool: "memory.delete_entities"
226
+ args: { entityNames: "$result.entities[].name" }
227
+ verify:
228
+ tool: "memory.open_nodes"
229
+ args: { names: "$result.entities[].name" }
230
+ ```
231
+
232
+ It is resolved *after* the call, so `$result` is available and it can name a
233
+ resource the call itself created. Undo then halts on drift the same way it does
234
+ everywhere else, and shows you the diff.
235
+
236
+ It is consulted only where there is no read already, so it can never displace a
237
+ working pre-read with a differently shaped one — which would make the post-state
238
+ and the snapshot incomparable and every later comparison meaningless.
239
+
240
+ ## When the server changes underneath you
241
+
242
+ A policy is a claim about what a tool does, and a tool's name is a weak place to
243
+ anchor that claim. A server upgrade can keep `write_file` and add an argument to
244
+ it. The policy still says reversible, the snapshot still reads a field that has
245
+ moved, and the before-image captured no longer matches the write. Nothing fails.
246
+ The undo is produced on request, confidently, and is wrong — which is worse than
247
+ having no undo, because somebody acted on it.
248
+
249
+ So you can pin the shape a tool had when you wrote its policy:
250
+
251
+ ```bash
252
+ synartesis pin
253
+ ```
254
+
255
+ It prints a block. Paste it into the manifest:
256
+
257
+ ```yaml
258
+ pins:
259
+ fs:
260
+ write_file: "sha256:ce17c85e8a5883552a11555f9b893de497fadab965a5c7935c0cb8f3c55b91d6"
261
+ edit_file: "sha256:88459ef670b139a12a3e0335ae0a4584dd892f60f45f565b545e1004d7565dd5"
262
+ ```
263
+
264
+ From then on, a tool whose shape has moved stops the proxy at startup and names
265
+ both fingerprints, instead of quietly serving the old policy. Re-run `pin` when
266
+ you have looked at what changed and decided the policy still holds.
267
+
268
+ It prints rather than writes on purpose: pinning is you vouching for what a tool
269
+ does today, and a command that silently rewrote your policy would let that happen
270
+ without anyone reading it.
271
+
272
+ Pinning is per server and all-or-nothing. A server with no pins is not checked,
273
+ so every manifest written before this existed keeps working. A server with any
274
+ pins is checked in full — a half-pinned server is the worst of both, because it
275
+ reads as protected and is not. Tools that no policy matches need no pin: they are
276
+ already fail-closed as irreversible and gated, so there is no classification for a
277
+ schema change to corrupt.
278
+
182
279
  ## Commands
183
280
 
184
281
  `synartesis desktop` opens [the window](#the-desktop-window), and says where to
@@ -190,6 +287,7 @@ get it if it is not installed.
190
287
  | `install` / `uninstall` / `status` | Cover the clients on this machine, put them back, say what is covered |
191
288
  | `init <server> -- <cmd>` | Introspect a server and draft a manifest |
192
289
  | `check` | Load a manifest and verify it against the servers it names |
290
+ | `pin` | Print the `pins:` block for the servers you have now |
193
291
  | `list` | Every recorded session |
194
292
  | `show <id>` | One session's timeline, with the undo for each step |
195
293
  | `show <id> --live` | The same, plus what has changed in the world since |
@@ -218,8 +316,11 @@ cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
218
316
 
219
317
  - **It cannot un-send what has been seen.** An email that has been read, a
220
318
  posted message, a file deleted with no backup. This is why the gate exists.
221
- - **Compensable actions cannot be checked for drift.** They declare no pre-read,
222
- so undo compensates them and marks them `[unverified]`.
319
+ - **Compensable actions can only be checked for drift if their policy declares a
320
+ `verify` read.** They have no pre-read — the thing they made did not exist
321
+ before the call — so without one, undo compensates them and marks them
322
+ `[unverified]`. With one, the resource is read back after the write and undo
323
+ halts rather than compensating over somebody else's edit.
223
324
  - **Undo halts on uncertainty, and steps over the merely permanent.** Drift, an
224
325
  unknown outcome, or a failed reversing call stop it. An action that simply
225
326
  cannot be undone is reported and left in place while everything else is