synartesis 0.8.1 → 0.8.5

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,270 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.8.5 — 2026-09-22
6
+
7
+ Three states you could get into and not get out of, two screens that read the
8
+ whole journal to draw one frame, and a gate that let two statements through.
9
+ Found by auditing rather than by using it, which is the difference between
10
+ this release and the last one.
11
+
12
+ ### Fixed
13
+
14
+ - **An undo killed halfway can now be resumed.** An action whose inverse was
15
+ being sent is marked `rolling_back`, and until now nothing could ever
16
+ reclaim one: no flag, no command, no amount of waiting. The claim was right
17
+ to refuse -- sending a second inverse is harmless for a restore and a second
18
+ real change to the world for a compensation -- but it was refusing because
19
+ it could not tell a process still working from one that had died, and there
20
+ was nothing to ask. One Ctrl-C during an undo put an action permanently
21
+ beyond the command whose whole job is getting back.
22
+
23
+ There is a lease now, in a `leases` table, holding the host and process id
24
+ that took each claim. A claim is reclaimed only on evidence, and only when
25
+ both questions have answers: the drift check has already proved the inverse
26
+ never landed (a `rolling_back` row that gets that far has a verified
27
+ pre-read, or it halts earlier), and the operating system says the process
28
+ that held it is gone. Anything less certain -- a lease from another machine,
29
+ a lease taken by a build from before the table existed, no lease at all --
30
+ still halts, and now says who held it and since when. There is no timeout to
31
+ tune, so there is no window in which an undo that is merely slow is mistaken
32
+ for one that died.
33
+
34
+ No schema version bump. `CREATE TABLE IF NOT EXISTS` changes no row and no
35
+ meaning, runs on every open, and an older build opening the same journal
36
+ neither notices nor cares -- the same argument the indexes have carried
37
+ since 0.6. A bump would refuse to open every journal already out there,
38
+ because there is still no migration path.
39
+
40
+ - **A call whose outcome was never established no longer blocks a whole run
41
+ for ever.** `pending` means the call went out and nobody can say whether it
42
+ landed, and undo stops at one rather than produce a state that is neither
43
+ the before nor the after. That was right and permanent: nothing in the CLI
44
+ could settle such a row, so one interrupted call blocked the undo of
45
+ everything older than it, and the only way past was `--to` above it, which
46
+ abandons the rest.
47
+
48
+ `synartesis resolve <action> --applied|--failed` records what a person
49
+ found, and who they were. It claims nothing else: an action resolved as
50
+ applied has no inverse, because none was ever resolved, so undo reports it
51
+ as something it cannot put back and carries on with the rest of the run.
52
+ Unblocking a run and undoing an action are different things and only the
53
+ first is on offer. A proxy that comes back and records what it actually saw
54
+ wins over a person's judgement, because it was there.
55
+
56
+ - **Ctrl-C during an undo stops between actions instead of wherever it lands.**
57
+ There was no signal handling in the CLI at all, so an interrupt was node's
58
+ default: die immediately, including with an inverse in flight, which is the
59
+ single best way to produce the state the first item above describes. The
60
+ first Ctrl-C now asks the rollback to stop before it claims the next action
61
+ and says so; the second is the operating system's. Deliberately not wired to
62
+ the abort signal that cancels a request in flight -- what is already in the
63
+ air is allowed to finish and be recorded.
64
+
65
+ - **A client disconnecting no longer erases that a session was undone.** The
66
+ proxy ends its run when its client goes away, which says nothing about
67
+ whether anything was put back -- and it was an unguarded write, so it
68
+ stamped `complete` straight over the `rolled_back` an undo had just
69
+ recorded. A session somebody had reversed came back looking untouched.
70
+
71
+ - **`undo` no longer defaults to a session an agent is still writing to.** The
72
+ newest run is, by definition, the one a running proxy is using, and `undo`
73
+ with no id pointed straight at it. A preview is always allowed, because it
74
+ writes nothing; doing it for real now needs `--yes`, and the refusal names
75
+ all three ways on. A session whose proxy exited normally is `complete`, so
76
+ this fires only where something really is unfinished.
77
+
78
+ - **Two writes the `on_write` gate read as reads.**
79
+ `WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x` is an ordinary
80
+ PostgreSQL data-modifying CTE, and `EXPLAIN ANALYZE DELETE FROM users` runs
81
+ the statement it claims to be explaining. Both begin with a word on the
82
+ read list, neither contains a semicolon, and both were passed through
83
+ ungated while emptying a table. The heuristic's promise is that anything it
84
+ cannot confidently read as a read is gated; these were read confidently and
85
+ wrongly. The leading word still decides whether a statement could be a read,
86
+ and a second pass over it -- with comments and string literals removed, so a
87
+ keyword cannot hide in either -- decides whether anything in it writes.
88
+
89
+ No shipped policy uses `on_write`, so nothing in the box was exposed. The
90
+ documentation invites you to use it for your own SQL server, which is
91
+ exactly who this was a hole for.
92
+
93
+ ### Changed
94
+
95
+ - **The console drew each frame by reading the entire journal.** `runsView`
96
+ called `getActions` for every session on screen -- every snapshot, result
97
+ and inverse in each of them, each through a schema parse -- to arrive at two
98
+ integers per row, eight times a second. `tallyRuns` was added for precisely
99
+ this and `list` was moved onto it; the console was missed. On a
100
+ hundred-megabyte journal that was 62ms a frame against a 120ms tick, so half
101
+ the event loop went on deciding what to draw and keypresses queued behind
102
+ renders. It is about 2ms now, and what each session still has waiting comes
103
+ from two partial indexes rather than from its rows.
104
+
105
+ - **`watch` scanned the whole table to show twelve lines.** `recentActions`
106
+ orders by time and there was no index on time alone, so every 120ms it read
107
+ every row in the table -- the ones carrying the snapshots -- and sorted them
108
+ in a temporary b-tree to return twelve. 7.4ms to 0.03ms on the same journal.
109
+
110
+ - **A large write no longer pays to canonicalise its own arguments for
111
+ nothing.** The three lookups that run on the way in to a call each built a
112
+ canonical form of the arguments before checking whether there was any
113
+ candidate row to compare it against -- and their indexes are partial
114
+ precisely so that there almost never is. 23.5ms of pure waste on a
115
+ ten-megabyte write, gone with an early return.
116
+
117
+ - **A budget test for both screens.** Every performance test here measured one
118
+ call against a journal a few hundred rows deep, which is the one shape in
119
+ which none of the above exists. `tests/screen-budget.test.ts` seeds four
120
+ thousand actions and holds a console frame and a watch frame to a fraction
121
+ of their tick, so the next regression is caught by CI rather than by
122
+ somebody's fan.
123
+
124
+ ## 0.8.4 — 2026-09-21
125
+
126
+ Bugs, all of them found by using the thing rather than by a test going red:
127
+ the suite was green before this and is green after it.
128
+
129
+ ### Fixed
130
+
131
+ - **A server that was briefly unreachable no longer turns an ordinary undo
132
+ into one that needs `--force`.** The drift check reads the resource as it is
133
+ now, and when that read failed the action was written back as
134
+ `unrecoverable`. Nothing had been learned about the resource -- the read
135
+ never happened -- but the row now said otherwise, and the next plain
136
+ `undo` refused with "halted here on an earlier attempt" and offered
137
+ `--replan` or `--force`. So a server that was down for ten seconds escalated
138
+ an undo that would have worked into one that writes over whatever anybody
139
+ else had changed since.
140
+
141
+ The same write cost a second thing. A row that had already halted on real
142
+ drift carried the diff of what somebody had changed, which is the evidence
143
+ the person deciding is shown; retrying while the server was unreachable
144
+ replaced it with `server fs is not connected`. The halt then offered its
145
+ three-way choice with a transport error where the conflict used to be. The
146
+ read failure is now reported and not recorded, for the reason an unknown
147
+ outcome has never been recorded either: a rollback may not write down as
148
+ fact a thing it did not observe.
149
+
150
+ - **The way past a drift halt no longer sends you round in a circle.** It
151
+ printed `put it back as the run left it: synartesis undo <id> --replan`,
152
+ which reads as though the flag does the putting back. It does not: it
153
+ rebuilds each inverse from the current manifest, and the drift check then
154
+ runs again on a resource nobody has touched. Somebody who typed what that
155
+ line told them to got the identical halt, offering the identical three
156
+ options, including the one they had just chosen, for as long as they cared
157
+ to keep typing it. The restoring is the reader's half, as `--help` has
158
+ always said, and the line now says so: `put the resource back, then:`.
159
+
160
+ While there: the three options were spaced by hand and the middle one sat a
161
+ character left of the other two, which is enough to stop a menu reading as
162
+ a menu. They are padded to a column now, and a test fails if they drift
163
+ apart again.
164
+
165
+ - **`--to` past the end of a run answers in one line.** The message was
166
+ already exact -- `--to 99 is past the end of this run, which goes up to 1`
167
+ -- and was followed by five lines of unrelated commands. The bound in that
168
+ sentence was read off the journal, which is precisely the case the
169
+ command list was already meant to stay out of.
170
+
171
+ - **Counts agree with their nouns.** `1 runs and 1 actions removed`,
172
+ `1 actions: 1 gated`. Three places counted the same way and each had its own
173
+ copy of the arithmetic; there is one now, in `style.ts`, beside the other
174
+ decisions about how text is set.
175
+
176
+ - **The repository's social preview card is the whole card.** Every other
177
+ source in `brand/` states its size at 1x and gets its sharpness from the
178
+ shoot's scale factor; this one was written at 2560x1280 and shot at
179
+ 1280x640, so what GitHub served when the repository was pasted into Slack
180
+ or a message was the top-left quarter of itself: three quarters empty
181
+ maroon with the tagline cropped off the bottom edge. It was also the only
182
+ asset here that nothing said how to rebuild, which is how it drifted.
183
+ `brand/README.md` now carries its command with the rest.
184
+
185
+ - **`og:image` states its dimensions.** LinkedIn and Slack lay a link card out
186
+ before the image arrives and held a grey box of the wrong shape until it
187
+ did.
188
+
189
+ ## 0.8.3 — 2026-09-20
190
+
191
+ Documentation, brand and repository furniture. No code changed, and the
192
+ published package behaves exactly as 0.8.2 did.
193
+
194
+ 0.8.2 was tagged and never published to npm; this goes out in its place rather
195
+ than publishing a tarball whose README differs from what its own tag points
196
+ at.
197
+
198
+ ### Added
199
+
200
+ - **Two drawings that move.** The README could say the value is kept before
201
+ the write and could show the result afterwards, and had no way to show the
202
+ sequence in between — which is the whole design. `synartesis-path-loop.svg`
203
+ draws where the proxy sits, which the README had described in prose and
204
+ never drawn at all; `synartesis-undo-loop.svg` is the three phases of an
205
+ undo. Hand-written SVG rather than GIF: two hundred times less weight and no
206
+ fuzziness on a retina screen, at the cost of the brand faces, since a
207
+ browser gives an SVG inside an `<img>` no network and blocks the webfont
208
+ import. Both carry their own ground so they read in either of GitHub's
209
+ themes, and both answer `prefers-reduced-motion` by settling on the end
210
+ state rather than disappearing.
211
+
212
+ - **A wiki, and a sponsor button.** Four pages that deliberately do not repeat
213
+ the documentation — a policy cookbook whose every example was run through
214
+ the real manifest loader first, troubleshooting organised by the symptom in
215
+ front of you, an FAQ, and a home page saying what lives where. Discussions
216
+ opened with a thread saying what is actually proven. Ko-fi produces the
217
+ button today; `github:` is in `FUNDING.yml` and starts working the moment
218
+ that account is enrolled.
219
+
220
+ ### Changed
221
+
222
+ - **The README's navigation is buttons rather than blue text.** The top was a
223
+ hand-written contents bar of eleven in-page anchors, most of which
224
+ duplicated GitHub's own table-of-contents button. One row of six now points
225
+ at the places somebody actually leaves for, with the status badges at half
226
+ the size underneath. Two details that had made the row look accidental: the
227
+ Actions badge is a different height from a flat-square row, and shields
228
+ picks text colour by background brightness, which had put white on the one
229
+ button meant to stand out.
230
+
231
+ ## 0.8.2 — 2026-09-20
232
+
233
+ ### Added
234
+
235
+ - **The git policy's undo is proven, not just plausible.** `check` starts the
236
+ server and confirms every tool the policy names exists, which proves the
237
+ policy loads and proves nothing about recovery: a compensation can resolve to
238
+ a real tool, take the arguments that tool really wants, put nothing back, and
239
+ report `rolled_back`. So `tests/adapter-git.test.ts` stages real changes in a
240
+ real repository, undoes them, and reads the index back with git itself rather
241
+ than through the server being tested.
242
+
243
+ It is a narrower claim than the filesystem and memory files make, and that is
244
+ the policy's shape rather than the test's. This server exposes nothing that
245
+ restores content — nearly every read it offers answers in prose written for a
246
+ person, so almost nothing can be inverted from a captured state — which
247
+ leaves exactly one compensation to prove. Proven: staging comes back off the
248
+ index and the working tree is untouched; a commit is held rather than
249
+ approximated; a branch switch goes through and undo reports it as permanent
250
+ instead of claiming to have reversed it.
251
+
252
+ **The overreach is pinned too.** This server's `git_reset` unstages
253
+ everything, not only what the call staged, so undoing the agent's `git_add`
254
+ also unstages work a person staged by hand. Nothing is lost — both edits are
255
+ still in the working tree — but it is a compensation and not an undo, and the
256
+ test now fails if the policy is ever changed to imply otherwise. Every guard
257
+ in the file was broken in turn to confirm it bites.
258
+
259
+ CI installs `uv`, because mcp-server-git is a Python package. Without it that
260
+ file fails rather than skipping, deliberately: a proof that quietly does not
261
+ run leaves the suite green and the claim unsupported.
262
+
263
+ ### Changed
264
+
265
+ - The README, the user guide and the site said git was "checked only for tool
266
+ existence". That was true when written and is not now, so all three say what
267
+ is actually proven and what still is not. **github** remains untested.
268
+
5
269
  ## 0.8.1 — 2026-09-19
6
270
 
7
271
  Documentation only. No code changed, and the published package behaves exactly
package/README.md CHANGED
@@ -9,23 +9,9 @@ Synartesis sits between your MCP client and the servers it talks to, records
9
9
  every tool call with the state that call replaced, and can put that state back.
10
10
  What cannot be put back, it refuses to let an agent do unsupervised.
11
11
 
12
- ### [Install it, then read the five minutes ahead of you →](docs/synartesis-user-guide.md#the-five-minutes-ahead-of-you)
13
-
14
- [![check](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml/badge.svg)](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml)
15
- [![npm](https://img.shields.io/npm/v/synartesis?color=5e1420&label=npm)](https://www.npmjs.com/package/synartesis)
16
- [![downloads](https://img.shields.io/npm/dm/synartesis?color=5e1420&label=downloads)](https://www.npmjs.com/package/synartesis)
17
- [![node](https://img.shields.io/node/v/synartesis?color=5e1420)](https://nodejs.org)
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)
12
+ [![Get started](https://img.shields.io/badge/Get%20started-5e1420?style=for-the-badge&labelColor=5e1420&color=5e1420&logoColor=f6e9e5)](#install) [![Desktop app](https://img.shields.io/badge/Desktop%20app-5e1420?style=for-the-badge&labelColor=5e1420&color=5e1420&logoColor=f6e9e5)](#the-desktop-window) [![User guide](https://img.shields.io/badge/User%20guide-5e1420?style=for-the-badge&labelColor=5e1420&color=5e1420&logoColor=f6e9e5)](docs/synartesis-user-guide.md) [![Wiki](https://img.shields.io/badge/Wiki-5e1420?style=for-the-badge&labelColor=5e1420&color=5e1420&logoColor=f6e9e5)](https://github.com/ArhaanDev24/Synartesis/wiki) [![Discussions](https://img.shields.io/badge/Discussions-5e1420?style=for-the-badge&labelColor=5e1420&color=5e1420&logoColor=f6e9e5)](https://github.com/ArhaanDev24/Synartesis/discussions) [![★ Star](https://img.shields.io/badge/%E2%98%85%20Star-f1b696?style=for-the-badge&labelColor=f1b696&color=f1b696)](https://github.com/ArhaanDev24/Synartesis)
13
+
14
+ [![check](https://img.shields.io/github/actions/workflow/status/ArhaanDev24/Synartesis/check.yml?branch=main&label=check&style=flat-square)](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml) [![npm](https://img.shields.io/npm/v/synartesis?color=5e1420&label=npm&style=flat-square)](https://www.npmjs.com/package/synartesis) [![downloads](https://img.shields.io/npm/dm/synartesis?color=5e1420&label=downloads&style=flat-square)](https://www.npmjs.com/package/synartesis) [![node](https://img.shields.io/node/v/synartesis?color=5e1420&style=flat-square)](https://nodejs.org) [![licence MIT](https://img.shields.io/badge/licence-MIT-5e1420?style=flat-square)](LICENSE) [![synartesis.online](https://img.shields.io/badge/synartesis.online-5e1420?style=flat-square)](https://synartesis.online)
29
15
 
30
16
  </div>
31
17
 
@@ -38,6 +24,8 @@ It is not a sandbox: the container your agent runs in is disposable, but the
38
24
  CRM row it updated over the network is not. It is not a tracing tool: a trace
39
25
  tells you `update_customer` ran forty times, not what the values were before.
40
26
 
27
+ ![Where Synartesis sits: a tool call goes from your agent through the proxy, which writes the state it is about to replace into a local journal, and on to your tools](https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-path-loop.svg)
28
+
41
29
  **If this is a problem you have, [a star](https://github.com/ArhaanDev24/Synartesis) helps other people find it.**
42
30
  It is a young project, and that is most of how anybody learns it exists.
43
31
 
@@ -75,7 +63,13 @@ machine, and the proxy talks only to the servers your policy names.
75
63
 
76
64
  ## See it in action
77
65
 
78
- Both shots are real output from [`./demo/filesystem-demo.sh`](demo/filesystem-demo.sh),
66
+ **The order is the whole design.** The value is kept *before* the write, which
67
+ is what makes putting it back possible at all — and it is the one thing a
68
+ screenshot cannot show:
69
+
70
+ ![Capture, change, put back: the value 412,800 is copied into the journal before the agent writes North to 0, and the captured copy is what comes back](https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-undo-loop.svg)
71
+
72
+ Both shots below are real output from [`./demo/filesystem-demo.sh`](demo/filesystem-demo.sh),
79
73
  pasted rather than typeset. An agent overwrote a file and tried to move another.
80
74
  One command puts the first back and reports that the second never happened:
81
75
 
@@ -93,7 +87,7 @@ It stops at the record that moved and exits non-zero. Anything already put back
93
87
  stays put back, and it prints the three ways on: leave it, restore the resource
94
88
  and `--replan`, or `--force` to overwrite deliberately.
95
89
 
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)
90
+ **Next:** [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) · [the full guide](docs/synartesis-user-guide.md)
97
91
 
98
92
  ---
99
93
  ## Install
@@ -122,7 +116,7 @@ installed.**
122
116
  Needs Node 22 or newer. npm ships a prebuilt SQLite binding, so no toolchain is
123
117
  required unless you build from a clone.
124
118
 
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)
119
+ **Setting it up by hand:** [the walkthrough](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
120
 
127
121
  ---
128
122
 
@@ -273,7 +267,7 @@ It loads the manifest, connects to every server it names, and says which tools
273
267
  your policy covers, which it does not, and where the two disagree. A policy
274
268
  that names a tool the server no longer has is an error, not a warning.
275
269
 
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)
270
+ **Going further:** [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) · [the cookbook](https://github.com/ArhaanDev24/Synartesis/wiki/Policy-cookbook)
277
271
 
278
272
 
279
273
  ---
@@ -421,6 +415,7 @@ get it if it is not installed.
421
415
  | `show <id> --live` | The same, plus what has changed in the world since |
422
416
  | `show <id> --full` | Every argument, snapshot and inverse, nothing elided |
423
417
  | `gates` / `approve <id>` / `deny <id>` | What is waiting, and answering it |
418
+ | `resolve <id> --applied\|--failed` | Settle a call whose outcome nothing established |
424
419
  | `undo <id>` | Reverse a session, newest action first |
425
420
  | `undo <id> --dry-run` | Plan it and change nothing |
426
421
  | `undo <id> --replan` | Rebuild each undo from the current manifest |
@@ -458,17 +453,32 @@ cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
458
453
  - **An error is not proof that nothing happened.** A timeout or a tool-level
459
454
  error after a write leaves the outcome *unknown*, not failed, and undo will
460
455
  not step past it. Where a pre-read exists it is consulted to settle the
461
- question instead of guessing.
456
+ question instead of guessing, and where none can settle it, `resolve` is how
457
+ a person who looked says which way it went -- an action resolved as applied
458
+ still has no inverse, so undo reports it as something it cannot put back
459
+ rather than pretending otherwise.
462
460
  - **An undo is only as good as the policy that recorded it.** Inverses are
463
461
  resolved when the call happens, so a mistake in a manifest is baked into every
464
462
  run made under it. `undo --replan` rebuilds them from a corrected one.
465
463
 
466
- Two bundled policies are tested against the real server, by making the change
464
+ Three bundled policies are tested against the real server, by making the change
467
465
  and undoing it. **filesystem**: exact byte-for-byte restoration, drift refusal,
468
466
  and absence told apart from a read that failed. **memory**: the graph is put
469
467
  back as it was, entities the agent only tried to create are left alone, and a
470
- delete of an entity is held rather than approximated. **git and github** are
471
- checked only for tool existence — their recovery guarantees are not yet proven.
468
+ delete of an entity is held rather than approximated.
469
+
470
+ **git** is the narrowest of the three, because the policy is: this server
471
+ exposes nothing that can restore content, so there is no restoration to prove.
472
+ What is proven is what it does claim — staging is taken back off the index and
473
+ the working tree is left alone; a commit is held rather than approximated; a
474
+ branch switch goes through and is reported as something undo cannot take back.
475
+ And the overreach is pinned too: this server's reset unstages *everything*, so
476
+ undoing the agent's `git_add` also unstages work a person staged by hand. That
477
+ is why it is recorded as a compensation and not an undo, and there is now a
478
+ test that fails if it ever gets described as one.
479
+
480
+ **github** is checked only for tool existence — its recovery guarantees are not
481
+ proven.
472
482
 
473
483
  All three of filesystem, memory and git declare `provenance: live`, and that
474
484
  word is narrower than it looks: it says the policy has met its server and the
@@ -561,11 +571,19 @@ same gate a pull request has to pass:
561
571
  pnpm check
562
572
  ```
563
573
 
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.
574
+ **Where each thing goes.** A bug is an [issue](https://github.com/ArhaanDev24/Synartesis/issues). A question, an
575
+ idea, or something you built with it is a
576
+ [discussion](https://github.com/ArhaanDev24/Synartesis/discussions). A policy you wrote for a server that does not
577
+ ship with one, a symptom you worked out, or an answer worth keeping goes in the
578
+ [wiki](https://github.com/ArhaanDev24/Synartesis/wiki), which anybody can edit — it holds the things that are
579
+ better off community-maintained, and deliberately does not repeat the
580
+ documentation. A vulnerability goes in none of those:
581
+ [`SECURITY.md`](SECURITY.md) says where.
582
+
583
+ And if it saved you an afternoon, **[star the repo](https://github.com/ArhaanDev24/Synartesis)**; it costs you a
584
+ click and is most of how a project this size gets found. If it saved you more
585
+ than that, there is [a tip jar](https://ko-fi.com/synartesis) — entirely
586
+ optional, and nothing here is behind it.
569
587
 
570
588
  ---
571
589
 
@@ -150,6 +150,9 @@ function usable(stream) {
150
150
  function spaced(text) {
151
151
  return Array.from(text).join(" ");
152
152
  }
153
+ function counted(n, noun) {
154
+ return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
155
+ }
153
156
  function palette(on) {
154
157
  const paint = (codes, text) => on ? `${codes}${text}${RESET}` : text;
155
158
  return {
@@ -200,6 +203,7 @@ var NOTHING_RECORDED_YET = [
200
203
 
201
204
  // src/journal/journal.ts
202
205
  import { chmodSync, existsSync as existsSync2, mkdirSync } from "fs";
206
+ import { hostname } from "os";
203
207
  import { dirname as dirname2 } from "path";
204
208
  import Database from "better-sqlite3";
205
209
  import { z } from "zod";
@@ -254,6 +258,37 @@ CREATE TABLE IF NOT EXISTS actions (
254
258
  UNIQUE(run_id, seq)
255
259
  );
256
260
 
261
+ -- Who is currently sending an inverse for an action, so that a dead owner can
262
+ -- be told from a live one.
263
+ --
264
+ -- Without this, a row left in rolling_back by an undo that was killed could
265
+ -- never be reclaimed by anything. The claim exists to stop two undos each
266
+ -- sending the same inverse -- harmless for a restore, a second real change to
267
+ -- the world for a compensation -- and refusing was the only safe answer to
268
+ -- "is somebody still working on this?" when there was no way to ask. So an
269
+ -- action could be stranded permanently by one Ctrl-C, on the command whose
270
+ -- entire job is getting back. rollback.ts said as much in a comment: there is
271
+ -- no lease to consult, and inventing a schema for one is a separate piece of
272
+ -- work. This is that table.
273
+ --
274
+ -- Not a schema version bump, on the same argument the indexes above make: a
275
+ -- table nothing older reads changes no row and no meaning, IF NOT EXISTS makes
276
+ -- it idempotent, and an older build opening the same file afterwards neither
277
+ -- notices nor cares -- it simply goes on refusing to reclaim, which is what it
278
+ -- did before. A bump would refuse to open every journal already out there.
279
+ --
280
+ -- host and pid together, because a pid is only meaningful on the machine that
281
+ -- issued it, and journals are shared. claimed_at is for the message rather
282
+ -- than the decision: liveness is asked of the operating system, not inferred
283
+ -- from a clock, so there is no timeout to tune and no window in which a slow
284
+ -- undo is mistaken for a dead one.
285
+ CREATE TABLE IF NOT EXISTS leases (
286
+ action_id TEXT PRIMARY KEY REFERENCES actions(id),
287
+ host TEXT NOT NULL,
288
+ pid INTEGER NOT NULL,
289
+ claimed_at TEXT NOT NULL
290
+ );
291
+
257
292
  CREATE INDEX IF NOT EXISTS actions_by_run ON actions(run_id, seq);
258
293
 
259
294
  -- Deliberately not a schema version bump. Adding an index changes no row and
@@ -339,6 +374,43 @@ CREATE INDEX IF NOT EXISTS actions_writes ON actions(run_id, seq)
339
374
  -- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
340
375
  -- build opening the same file afterwards neither notices nor cares.
341
376
  CREATE INDEX IF NOT EXISTS actions_seen ON actions(server, ts);
377
+
378
+ -- The twin of actions_undoable, and partial for the same reason. The console
379
+ -- asks, of every session, two questions: is there anything here an undo would
380
+ -- reverse, and is there anything here a person still has to decide. The first
381
+ -- is actions_undoable above; this is the second. It was being answered by
382
+ -- materialising every action in every run -- snapshots, results and inverses,
383
+ -- each through a zod parse -- eight times a second, to arrive at two integers
384
+ -- per row. Measured on forty runs of five hundred actions with two-kilobyte
385
+ -- snapshots, a hundred-megabyte journal: 62ms a frame, which on a 120ms tick
386
+ -- is half the event loop spent deciding what to draw, so keypresses queued
387
+ -- behind renders and the screen felt stuck.
388
+ --
389
+ -- The inverse_json test is in the predicate rather than in the query, so the
390
+ -- count never reaches into a row: an unrecoverable action with no inverse is
391
+ -- not a decision anybody can act on, and telling the two apart is exactly
392
+ -- what cost the frame.
393
+ --
394
+ -- Added the same way as the ones above and for the same reason: no row
395
+ -- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
396
+ -- build opening the same file afterwards neither notices nor cares.
397
+ CREATE INDEX IF NOT EXISTS actions_conflicted ON actions(run_id)
398
+ WHERE status = 'unrecoverable' AND inverse_json IS NOT NULL;
399
+
400
+ -- The one query the seven above left behind, and the one running most often.
401
+ -- recentActions asks for the newest twelve rows by time, and the watch screen
402
+ -- asks it every 120ms. There was no index on ts alone -- actions_gated leads
403
+ -- with status and actions_seen leads with server, so neither can serve a bare
404
+ -- ORDER BY ts -- which left a full scan plus a temporary b-tree over a table
405
+ -- whose rows carry the snapshots, to return twelve of them. The columns are
406
+ -- in the same order the query sorts by, so sqlite walks this backwards and
407
+ -- stops at the limit. Measured on forty runs of five hundred actions with
408
+ -- two-kilobyte snapshots, a hundred-megabyte journal: 7.4ms to 0.03ms.
409
+ --
410
+ -- Added the same way as the ones above and for the same reason: no row
411
+ -- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
412
+ -- build opening the same file afterwards neither notices nor cares.
413
+ CREATE INDEX IF NOT EXISTS actions_recent ON actions(ts, seq);
342
414
  `;
343
415
 
344
416
  // src/journal/journal.ts
@@ -351,6 +423,22 @@ var runSchema = z.object({
351
423
  status: z.enum(["active", "complete", "rolled_back", "partial"])
352
424
  });
353
425
  var seenSchema = z.object({ server: z.string(), ts: z.string() });
426
+ var countedSchema = z.object({ run_id: z.string(), n: z.number() });
427
+ var leaseSchema = z.object({
428
+ action_id: z.string(),
429
+ host: z.string(),
430
+ pid: z.number(),
431
+ claimed_at: z.string()
432
+ });
433
+ function running(pid) {
434
+ try {
435
+ process.kill(pid, 0);
436
+ return true;
437
+ } catch (error) {
438
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
439
+ return code !== "ESRCH";
440
+ }
441
+ }
354
442
  var tallySchema = z.object({
355
443
  run_id: z.string(),
356
444
  actions: z.number(),
@@ -394,6 +482,13 @@ function decode(value) {
394
482
  function orUndefined(value) {
395
483
  return value === null ? void 0 : value;
396
484
  }
485
+ function sameCall(rows, args) {
486
+ if (rows.length === 0) {
487
+ return void 0;
488
+ }
489
+ const wanted = canonical(args ?? {});
490
+ return rows.find((row) => canonical(row.args) === wanted);
491
+ }
397
492
  function toRun(raw) {
398
493
  const row = runSchema.parse(raw);
399
494
  return {
@@ -491,9 +586,14 @@ var SqliteJournal = class {
491
586
  });
492
587
  return id;
493
588
  }
494
- endRun(runId, status) {
589
+ endRun(runId, status, from) {
495
590
  this.#run("endRun", () => {
496
- this.#db.prepare("UPDATE runs SET ended_at = ?, status = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), status, runId);
591
+ if (from === void 0) {
592
+ this.#db.prepare("UPDATE runs SET ended_at = ?, status = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), status, runId);
593
+ return;
594
+ }
595
+ const allowed = from.map(() => "?").join(", ");
596
+ this.#db.prepare(`UPDATE runs SET ended_at = ?, status = ? WHERE id = ? AND status IN (${allowed})`).run((/* @__PURE__ */ new Date()).toISOString(), status, runId, ...from);
497
597
  });
498
598
  }
499
599
  closeAbandonedRun(runId) {
@@ -603,6 +703,9 @@ var SqliteJournal = class {
603
703
  markFailed(actionId, error) {
604
704
  this.#settle("markFailed", actionId, "failed", ["pending", "gated", "approved"], error);
605
705
  }
706
+ settleByHand(actionId, outcome, note) {
707
+ return this.#claim("settleByHand", actionId, outcome, ["pending"], note);
708
+ }
606
709
  /**
607
710
  * The call was interrupted, so whether the upstream applied it is genuinely
608
711
  * unknown. The row deliberately stays `pending`: recording it as failed
@@ -632,10 +735,38 @@ var SqliteJournal = class {
632
735
  const result = this.#db.prepare(
633
736
  `UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
634
737
  ).run(actionId, ...from);
635
- return result.changes === 1;
738
+ if (result.changes !== 1) {
739
+ return false;
740
+ }
741
+ this.#db.prepare(
742
+ `INSERT INTO leases (action_id, host, pid, claimed_at) VALUES (?, ?, ?, ?)
743
+ ON CONFLICT(action_id) DO UPDATE SET host = excluded.host,
744
+ pid = excluded.pid, claimed_at = excluded.claimed_at`
745
+ ).run(actionId, hostname(), process.pid, (/* @__PURE__ */ new Date()).toISOString());
746
+ return true;
747
+ });
748
+ }
749
+ leaseFor(actionId) {
750
+ return this.#run("leaseFor", () => {
751
+ const raw = this.#db.prepare("SELECT * FROM leases WHERE action_id = ?").get(actionId);
752
+ if (raw === void 0) {
753
+ return void 0;
754
+ }
755
+ const row = leaseSchema.parse(raw);
756
+ return {
757
+ host: row.host,
758
+ pid: row.pid,
759
+ claimedAt: row.claimed_at,
760
+ alive: row.host === hostname() ? running(row.pid) : void 0
761
+ };
636
762
  });
637
763
  }
764
+ /** Releases a claim. Safe to call when there is none. */
765
+ #release(actionId) {
766
+ this.#db.prepare("DELETE FROM leases WHERE action_id = ?").run(actionId);
767
+ }
638
768
  markRolledBack(actionId) {
769
+ this.#release(actionId);
639
770
  this.#settle("markRolledBack", actionId, "rolled_back", [
640
771
  "rolling_back",
641
772
  "applied",
@@ -649,6 +780,7 @@ var SqliteJournal = class {
649
780
  * was briefly unwell, and rollback is expected to be retried (D7).
650
781
  */
651
782
  markInverseRejected(actionId, error) {
783
+ this.#release(actionId);
652
784
  this.#settle("markInverseRejected", actionId, "applied", ["rolling_back"], error);
653
785
  }
654
786
  /**
@@ -721,6 +853,12 @@ var SqliteJournal = class {
721
853
  return move.immediate();
722
854
  });
723
855
  }
856
+ listPending() {
857
+ return this.#run(
858
+ "listPending",
859
+ () => this.#db.prepare("SELECT * FROM actions WHERE status = 'pending' ORDER BY ts").all().map(toAction)
860
+ );
861
+ }
724
862
  listGated() {
725
863
  return this.#run(
726
864
  "listGated",
@@ -736,8 +874,7 @@ var SqliteJournal = class {
736
874
  AND approved_at >= ?
737
875
  ORDER BY approved_at DESC`
738
876
  ).all(query.server, query.tool, query.notBefore).map(toAction);
739
- const wanted = canonical(query.args ?? {});
740
- return rows.find((row) => canonical(row.args) === wanted);
877
+ return sameCall(rows, query.args);
741
878
  });
742
879
  }
743
880
  findGated(query) {
@@ -747,8 +884,7 @@ var SqliteJournal = class {
747
884
  WHERE run_id = ? AND server = ? AND tool = ? AND status = 'gated'
748
885
  ORDER BY seq`
749
886
  ).all(query.runId, query.server, query.tool).map(toAction);
750
- const wanted = canonical(query.args ?? {});
751
- return rows.find((row) => canonical(row.args) === wanted);
887
+ return sameCall(rows, query.args);
752
888
  });
753
889
  }
754
890
  findPending(query) {
@@ -758,8 +894,7 @@ var SqliteJournal = class {
758
894
  WHERE run_id = ? AND server = ? AND tool = ? AND status = 'pending'
759
895
  ORDER BY seq`
760
896
  ).all(query.runId, query.server, query.tool).map(toAction);
761
- const wanted = canonical(query.args ?? {});
762
- return rows.find((row) => canonical(row.args) === wanted);
897
+ return sameCall(rows, query.args);
763
898
  });
764
899
  }
765
900
  getAction(actionId) {
@@ -824,6 +959,30 @@ var SqliteJournal = class {
824
959
  return tally;
825
960
  });
826
961
  }
962
+ standingPerRun() {
963
+ return this.#run("standingPerRun", () => {
964
+ const standing2 = /* @__PURE__ */ new Map();
965
+ const count = (sql, field) => {
966
+ for (const raw of this.#db.prepare(sql).all()) {
967
+ const row = countedSchema.parse(raw);
968
+ const at = standing2.get(row.run_id) ?? { undoable: 0, conflicted: 0 };
969
+ at[field] = row.n;
970
+ standing2.set(row.run_id, at);
971
+ }
972
+ };
973
+ count(
974
+ `SELECT run_id, COUNT(*) AS n FROM actions INDEXED BY actions_undoable
975
+ WHERE status = 'applied' AND inverse_json IS NOT NULL GROUP BY run_id`,
976
+ "undoable"
977
+ );
978
+ count(
979
+ `SELECT run_id, COUNT(*) AS n FROM actions INDEXED BY actions_conflicted
980
+ WHERE status = 'unrecoverable' AND inverse_json IS NOT NULL GROUP BY run_id`,
981
+ "conflicted"
982
+ );
983
+ return standing2;
984
+ });
985
+ }
827
986
  /**
828
987
  * The newest run that still holds something an undo would put back.
829
988
  *
@@ -1973,6 +2132,7 @@ export {
1973
2132
  pathBinaryMatches,
1974
2133
  findManifest,
1975
2134
  findJournal,
2135
+ counted,
1976
2136
  style,
1977
2137
  errorStyle,
1978
2138
  WORDMARK,
@@ -2011,4 +2171,4 @@ export {
2011
2171
  observeState,
2012
2172
  connectStdioUpstream
2013
2173
  };
2014
- //# sourceMappingURL=chunk-O3QDPYEL.js.map
2174
+ //# sourceMappingURL=chunk-JE7MOCZO.js.map
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  canonical,
10
10
  cliCommand,
11
11
  connectStdioUpstream,
12
+ counted,
12
13
  createPolicyResolver,
13
14
  createRouter,
14
15
  describeStanding,
@@ -38,7 +39,7 @@ import {
38
39
  verifyAgainstServers,
39
40
  warnUntested,
40
41
  wasRefused
41
- } from "./chunk-O3QDPYEL.js";
42
+ } from "./chunk-JE7MOCZO.js";
42
43
  import {
43
44
  DriftConflict,
44
45
  ManifestError,
@@ -365,6 +366,15 @@ var toolResult = z2.looseObject({ isError: z2.boolean().default(false) });
365
366
  function sameState(a, b) {
366
367
  return canonical(a) === canonical(b);
367
368
  }
369
+ function held(lease) {
370
+ if (lease === void 0) {
371
+ return "No lease is recorded against it, so it was claimed by a build from before leases existed and there is no way from here to tell a live owner from a dead one. ";
372
+ }
373
+ if (lease.alive === void 0) {
374
+ return `It was claimed by process ${String(lease.pid)} on ${lease.host} at ${lease.claimedAt}, and this is not that machine, so whether it is still running cannot be asked from here. `;
375
+ }
376
+ return `It is held by process ${String(lease.pid)} on ${lease.host}, claimed at ${lease.claimedAt}, and that process is still running. `;
377
+ }
368
378
  function classify(action, replanning, goAhead) {
369
379
  switch (action.status) {
370
380
  case "rolled_back":
@@ -435,6 +445,14 @@ async function rollback(options) {
435
445
  let leftInPlace = false;
436
446
  const projected = /* @__PURE__ */ new Map();
437
447
  for (const action of inScope) {
448
+ if (options.interrupt?.aborted === true) {
449
+ halted = {
450
+ seq: action.seq,
451
+ reason: "interrupted, so this and everything before it were left as they are",
452
+ detail: "Nothing was half applied: the stop was taken between actions. Run undo again to carry on."
453
+ };
454
+ break;
455
+ }
438
456
  let forcedOver;
439
457
  if (action.class === "readonly") {
440
458
  steps.push({ ...describeStep(action), kind: "skip", reason: "readonly", verified: true });
@@ -483,9 +501,6 @@ ${seen}` : seen;
483
501
  const reason = `could not read current state to check for drift: ${describe(error)}`;
484
502
  halted = { seq: action.seq, reason, detail: "" };
485
503
  steps.push({ ...describeStep(action), kind: "halt", reason, verified: false });
486
- if (!dryRun) {
487
- journal.markUnrecoverable(action.id, reason);
488
- }
489
504
  break;
490
505
  }
491
506
  if (sameState(current, recordedPost.data)) {
@@ -576,14 +591,20 @@ ${seen}` : seen;
576
591
  if (dryRun) {
577
592
  continue;
578
593
  }
579
- const claimable = force || policies !== void 0 ? ["applied", "unrecoverable"] : ["applied"];
594
+ const lease = action.status === "rolling_back" ? journal.leaseFor(action.id) : void 0;
595
+ const abandoned = verified && lease?.alive === false;
596
+ const claimable = [
597
+ "applied",
598
+ ...force || policies !== void 0 ? ["unrecoverable"] : [],
599
+ ...abandoned ? ["rolling_back"] : []
600
+ ];
580
601
  const claimed = journal.markRollingBack(action.id, claimable);
581
602
  if (!claimed) {
582
- const reason = action.status === "rolling_back" ? "an inverse for this action was already sent and never finished; whether another undo still holds it cannot be told from here" : "another undo is already working on this action";
603
+ const reason = action.status === "rolling_back" ? "an inverse for this action was already sent and never finished, and the undo that sent it is still running" : "another undo is already working on this action";
583
604
  halted = {
584
605
  seq: action.seq,
585
606
  reason,
586
- detail: action.status === "rolling_back" ? "Nothing here can tell a live owner from a dead one, and this build has no lease to consult. Read the action with `show --live`: if the resource still shows the agent's write, the inverse never landed." : ""
607
+ detail: action.status === "rolling_back" ? held(lease) + "Read the action with `show --live`: if the resource still shows the agent's write, the inverse never landed." : ""
587
608
  };
588
609
  steps[steps.length - 1] = {
589
610
  ...describeStep(action),
@@ -2101,36 +2122,28 @@ function runsView(journal, screen, options) {
2101
2122
  ];
2102
2123
  }
2103
2124
  const at = Math.min(screen.cursor, runs.length - 1);
2125
+ const tally2 = journal.tallyRuns();
2104
2126
  return runs.map((run, index) => {
2105
- const actions = journal.getActions(run.id);
2106
- const held = actions.filter((action) => action.status === "gated").length;
2127
+ const counts = tally2.get(run.id);
2128
+ const total = counts?.actions ?? 0;
2129
+ const held2 = counts?.waiting ?? 0;
2107
2130
  const here = index === at && canPress(options);
2108
2131
  const name = (run.label ?? "an agent").padEnd(24);
2109
- const note = held === 0 ? "" : ` ${style.accent(`${String(held)} awaiting approval`)}`;
2110
- return ` ${here ? style.accent(CURSOR) : " "} ${here ? style.accent(name) : style.strong(name)} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(`${String(actions.length)} actions`)}${note}`;
2132
+ const note = held2 === 0 ? "" : ` ${style.accent(`${String(held2)} awaiting approval`)}`;
2133
+ return ` ${here ? style.accent(CURSOR) : " "} ${here ? style.accent(name) : style.strong(name)} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(counted(total, "action"))}${note}`;
2111
2134
  });
2112
2135
  }
2113
- function standing2(actions) {
2114
- let undoable = 0;
2115
- let conflicted = 0;
2116
- for (const action of actions) {
2117
- if (action.inverse === void 0) {
2118
- continue;
2119
- }
2120
- if (action.status === "applied") {
2121
- undoable += 1;
2122
- } else if (action.status === "unrecoverable") {
2123
- conflicted += 1;
2124
- }
2125
- }
2126
- return { undoable, conflicted };
2136
+ var NOTHING = { undoable: 0, conflicted: 0 };
2137
+ function standing2(journal, runId) {
2138
+ return journal.standingPerRun().get(runId) ?? NOTHING;
2127
2139
  }
2128
2140
  function elsewhere(journal, exceptId) {
2141
+ const perRun = journal.standingPerRun();
2129
2142
  for (const run of [...journal.listRuns()].reverse()) {
2130
2143
  if (run.id === exceptId) {
2131
2144
  continue;
2132
2145
  }
2133
- const found = standing2(journal.getActions(run.id));
2146
+ const found = perRun.get(run.id) ?? NOTHING;
2134
2147
  if (found.undoable > 0 || found.conflicted > 0) {
2135
2148
  return run;
2136
2149
  }
@@ -2371,7 +2384,7 @@ async function openConsole(options) {
2371
2384
  if (run === void 0) {
2372
2385
  return [];
2373
2386
  }
2374
- const here = standing2(ready.getActions(run.id));
2387
+ const here = standing2(ready, run.id);
2375
2388
  const id = run.id.slice(0, 8);
2376
2389
  if (here.undoable > 0) {
2377
2390
  return [
@@ -2592,7 +2605,7 @@ async function openConsole(options) {
2592
2605
  if (ready === void 0 || run === void 0) {
2593
2606
  return;
2594
2607
  }
2595
- const here = standing2(ready.getActions(run.id));
2608
+ const here = standing2(ready, run.id);
2596
2609
  if (here.undoable === 0 && here.conflicted === 0) {
2597
2610
  say(nothingToUndo(ready, run));
2598
2611
  return;
@@ -2614,7 +2627,7 @@ async function openConsole(options) {
2614
2627
  if (ready === void 0 || run === void 0) {
2615
2628
  return;
2616
2629
  }
2617
- const here = standing2(ready.getActions(run.id));
2630
+ const here = standing2(ready, run.id);
2618
2631
  if (here.undoable === 0 && here.conflicted === 0) {
2619
2632
  say(nothingToUndo(ready, run));
2620
2633
  return;
@@ -2788,6 +2801,7 @@ var COMMANDS = `
2788
2801
  synartesis watch [--by <name>] [--journal <path>]
2789
2802
  synartesis approve [actionId|--all] [--by <name>] [--journal <path>]
2790
2803
  synartesis deny [actionId|--all] [--by <name>] [--reason <text>] [--journal <path>]
2804
+ synartesis resolve [actionId] --applied|--failed [--by <name>] [--reason <text>]
2791
2805
  synartesis undo [runId] [--to <seq>] [--dry-run] [--replan] [--force [--yes]]
2792
2806
  [--manifest <path>] [--journal <path>]
2793
2807
 
@@ -2803,6 +2817,14 @@ this command. Both share one journal, so either can undo what the other did.
2803
2817
  close ends a run left active by a proxy that was killed; nothing guesses at
2804
2818
  that, since several proxies can share one journal.
2805
2819
 
2820
+ resolve settles a call whose outcome was never established -- a proxy killed
2821
+ mid-write, or a server that answered too late. undo stops at one of those
2822
+ rather than guess, and stopping there used to be permanent: it blocked
2823
+ everything older in the same run. Only a person can say which way it went, so
2824
+ this records that they said it, and who. An action resolved as applied still
2825
+ has no inverse, so undo will report it as something it cannot put back and
2826
+ carry on with the rest.
2827
+
2806
2828
  prune reclaims space. Putting a file back means keeping what was in it, so a
2807
2829
  journal grows at several times what an agent writes and never shrinks by
2808
2830
  itself. Nothing still active or still waiting on a person is ever pruned.
@@ -2831,6 +2853,8 @@ resource back as the run left it and --replan, or --force to overwrite.
2831
2853
  --once watch prints the current state and exits
2832
2854
  --json machine-readable output for list, show and gates
2833
2855
  --dry-run read current state and print the plan without changing anything
2856
+ --applied resolve: the call did land, though nothing recorded it
2857
+ --failed resolve: the call never landed
2834
2858
  --replan rebuild each undo from the current manifest, for a run recorded
2835
2859
  under a policy that turned out to be wrong
2836
2860
  --force undo even where the resource changed after the run. On its own it
@@ -3012,7 +3036,7 @@ async function runCheck(argv) {
3012
3036
  out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
3013
3037
  const pinned = Object.entries(manifest.pins ?? {});
3014
3038
  const servers = Object.keys(manifest.servers);
3015
- const unpinned = servers.filter((name) => !pinned.some(([held]) => held === name));
3039
+ const unpinned = servers.filter((name) => !pinned.some(([held2]) => held2 === name));
3016
3040
  out(
3017
3041
  ` ${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(", ")}`))}`
3018
3042
  );
@@ -3332,6 +3356,7 @@ ${listed(matches)}`,
3332
3356
  }
3333
3357
  var RUN = { one: "run", many: "runs" };
3334
3358
  var WAITING = { one: "action awaiting approval", many: "actions awaiting approval" };
3359
+ var UNSETTLED = { one: "action whose outcome is unknown", many: "actions whose outcome is unknown" };
3335
3360
  process.stdout.on("error", (error) => {
3336
3361
  if (error.code === "EPIPE") {
3337
3362
  process.exit(0);
@@ -3408,12 +3433,12 @@ function laid(text, width) {
3408
3433
  function runList(journal, asJson, journalPath) {
3409
3434
  const runs = [...journal.listRuns()].reverse();
3410
3435
  const tally2 = journal.tallyRuns();
3411
- const counted = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
3436
+ const counted2 = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
3412
3437
  if (asJson) {
3413
3438
  out(
3414
3439
  JSON.stringify(
3415
3440
  runs.map((run) => {
3416
- const actions = counted(run.id).actions;
3441
+ const actions = counted2(run.id).actions;
3417
3442
  return { ...run, actions, actionCount: actions };
3418
3443
  })
3419
3444
  )
@@ -3435,7 +3460,7 @@ function runList(journal, asJson, journalPath) {
3435
3460
  )
3436
3461
  );
3437
3462
  for (const run of runs) {
3438
- const actions = counted(run.id);
3463
+ const actions = counted2(run.id);
3439
3464
  const { unknown, waiting } = actions;
3440
3465
  const notes = [
3441
3466
  unknown === 0 ? "" : `${String(unknown)} of unknown outcome`,
@@ -3658,7 +3683,7 @@ function summarise2(actions) {
3658
3683
  }
3659
3684
  const parts = [...counts].sort(([a], [b]) => a < b ? -1 : 1).map(([k, v]) => `${String(v)} ${k}`);
3660
3685
  const undoable = actions.filter((a) => a.inverse !== void 0).length;
3661
- return `${String(actions.length)} actions: ${parts.join(", ")} | ${String(undoable)} with a recorded undo`;
3686
+ return `${counted(actions.length, "action")}: ${parts.join(", ")} | ${String(undoable)} with a recorded undo`;
3662
3687
  }
3663
3688
  var journalArg = "";
3664
3689
  var manifestArg = "";
@@ -3718,13 +3743,13 @@ function runPrune(argv, journal, journalPath) {
3718
3743
  for (const run of stale) {
3719
3744
  actions += run.actions;
3720
3745
  out(
3721
- ` ${style.strong((run.label ?? "an agent").padEnd(24))} ${style.quiet(run.at.slice(0, 19).replace("T", " "))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(`${String(run.actions)} actions`)}`
3746
+ ` ${style.strong((run.label ?? "an agent").padEnd(24))} ${style.quiet(run.at.slice(0, 19).replace("T", " "))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(counted(run.actions, "action"))}`
3722
3747
  );
3723
3748
  }
3724
3749
  out("");
3725
3750
  if (planned) {
3726
3751
  out(
3727
- ` ${style.accent(`${String(stale.length)} runs`)} ${style.quiet(`and ${String(actions)} actions would go. Nothing was changed.`)}`
3752
+ ` ${style.accent(counted(stale.length, "run"))} ${style.quiet(`and ${counted(actions, "action")} would go. Nothing was changed.`)}`
3728
3753
  );
3729
3754
  out("");
3730
3755
  return 0;
@@ -3732,12 +3757,58 @@ function runPrune(argv, journal, journalPath) {
3732
3757
  const removed = journal.deleteRuns(stale.map((run) => run.id));
3733
3758
  journal.vacuum();
3734
3759
  out(
3735
- ` ${style.accent(`${String(removed.runs)} runs`)} ${style.quiet(`and ${String(removed.actions)} actions removed.`)}`
3760
+ ` ${style.accent(counted(removed.runs, "run"))} ${style.quiet(`and ${counted(removed.actions, "action")} removed.`)}`
3736
3761
  );
3737
3762
  out(` ${style.quiet(`Journal ${sizeBefore} \u2192 ${sizeOf(journalPath)}.`)}`);
3738
3763
  out("");
3739
3764
  return 0;
3740
3765
  }
3766
+ function runResolve(argv, journal) {
3767
+ const applied = argv.includes("--applied");
3768
+ const failed = argv.includes("--failed");
3769
+ if (applied === failed) {
3770
+ throw new UsageError(
3771
+ "resolve needs exactly one of --applied (the call landed) or --failed (it never did)"
3772
+ );
3773
+ }
3774
+ const by = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3775
+ const why = flag(argv, "--reason");
3776
+ const given = positional(argv)[1];
3777
+ if (given !== void 0) {
3778
+ const already = journal.getAction(given);
3779
+ if (already !== void 0 && already.status !== "pending") {
3780
+ process.stderr.write(
3781
+ `synartesis: ${given} is not waiting to be resolved (it is ${labelFor(already)})
3782
+ `
3783
+ );
3784
+ return 1;
3785
+ }
3786
+ }
3787
+ const action = pick(journal.listPending(), given, UNSETTLED);
3788
+ const outcome = applied ? "applied" : "failed";
3789
+ const note = `resolved as ${outcome} by ${by}` + (why === void 0 ? "" : `: ${why}`);
3790
+ if (!journal.settleByHand(action.id, outcome, note)) {
3791
+ const now = journal.getAction(action.id);
3792
+ process.stderr.write(
3793
+ `synartesis: ${action.id} was settled by the proxy first (it is ${now?.status ?? "gone"})
3794
+ `
3795
+ );
3796
+ return 1;
3797
+ }
3798
+ out("");
3799
+ out(
3800
+ ` ${style.accent(outcome)} ${style.strong(`${action.server}.${action.tool}`)} ${style.quiet(action.id.slice(0, 8))}`
3801
+ );
3802
+ out("");
3803
+ out(
3804
+ ` ${style.quiet(
3805
+ applied ? "Undo will now pass it, and report it as something it cannot put back:" : "Undo will now pass it, as a call that never applied:"
3806
+ )}`
3807
+ );
3808
+ out(` ${style.strong(`${cliCommand()} undo ${action.runId.slice(0, 8)}`)}`);
3809
+ out("");
3810
+ return 0;
3811
+ }
3741
3812
  function runClose(argv, journal) {
3742
3813
  const all = journal.listRuns();
3743
3814
  const given = positional(argv)[1];
@@ -3903,13 +3974,15 @@ function report(result, alreadyForcing = false, as = "") {
3903
3974
  const self = cliCommand();
3904
3975
  const id = result.runId.slice(0, 8);
3905
3976
  out("");
3906
- out(` ${style.quiet("keep the change, drop the undo:")} ${style.quiet("nothing to do")}`);
3907
- out(
3908
- ` ${style.quiet("put it back as the run left it:")} ${style.strong(`${self} undo ${id} --replan`)}`
3909
- );
3910
- out(
3911
- ` ${style.quiet("undo anyway, losing the change:")} ${style.strong(`${self} undo ${id} --force`)}`
3912
- );
3977
+ const ways = [
3978
+ ["keep the change, drop the undo:", style.quiet("nothing to do")],
3979
+ ["put the resource back, then:", style.strong(`${self} undo ${id} --replan`)],
3980
+ ["undo anyway, losing the change:", style.strong(`${self} undo ${id} --force`)]
3981
+ ];
3982
+ const column = Math.max(...ways.map(([label]) => label.length));
3983
+ for (const [label, command] of ways) {
3984
+ out(` ${style.quiet(label.padEnd(column))} ${command}`);
3985
+ }
3913
3986
  }
3914
3987
  }
3915
3988
  const permanent = result.steps.filter((step) => step.kind === "permanent");
@@ -3970,6 +4043,22 @@ async function withUpstreams(manifestPath, use, only) {
3970
4043
  }
3971
4044
  }
3972
4045
  }
4046
+ function stopOnInterrupt() {
4047
+ const stopping = new AbortController();
4048
+ let asked = false;
4049
+ for (const sign of ["SIGINT", "SIGTERM"]) {
4050
+ process.once(sign, () => {
4051
+ if (asked) {
4052
+ process.exit(130);
4053
+ }
4054
+ asked = true;
4055
+ stopping.abort();
4056
+ out("");
4057
+ out(` ${style.quiet("stopping after this action; press again to stop now")}`);
4058
+ });
4059
+ }
4060
+ return stopping.signal;
4061
+ }
3973
4062
  function serversUsedBy(journal, runId) {
3974
4063
  return new Set(journal.getActions(runId).map((action) => action.server));
3975
4064
  }
@@ -3983,7 +4072,8 @@ async function performUndo(manifestPath, journal, runId, options) {
3983
4072
  ...options.toSeq === void 0 ? {} : { toSeq: options.toSeq },
3984
4073
  ...options.replan === true ? { replanWith: manifest } : {},
3985
4074
  dryRun: options.dryRun,
3986
- ...options.force === true ? { force: true } : {}
4075
+ ...options.force === true ? { force: true } : {},
4076
+ interrupt: stopOnInterrupt()
3987
4077
  }),
3988
4078
  // A replan re-resolves inverses from the current policy, which may name a
3989
4079
  // server this run never used; everything else needs only what it touched.
@@ -3994,11 +4084,20 @@ async function runUndo(argv, journal) {
3994
4084
  const rawTo = flag(argv, "--to");
3995
4085
  const toSeq = rawTo === void 0 ? void 0 : Number(rawTo);
3996
4086
  if (toSeq !== void 0 && (!Number.isInteger(toSeq) || toSeq < 1)) {
3997
- throw new UsageError("--to needs a positive whole number");
4087
+ throw new UsageError("--to needs a positive whole number", false);
3998
4088
  }
3999
4089
  const given = positional(argv)[1];
4000
4090
  const chosen = pick([...journal.listRuns()].reverse(), given, RUN, true);
4001
4091
  const runId = chosen.id;
4092
+ if (journal.getRun(runId)?.status === "active" && !argv.includes("--yes") && !argv.includes("--dry-run")) {
4093
+ throw new UsageError(
4094
+ `${runId.slice(0, 8)} has not ended, so an agent may still be writing to it.
4095
+ See what an undo would do: ${cliCommand()} undo ${runId.slice(0, 8)} --dry-run
4096
+ If its proxy is gone: ${cliCommand()} close ${runId.slice(0, 8)}
4097
+ Undo it anyway: ${cliCommand()} undo ${runId.slice(0, 8)} --yes`,
4098
+ false
4099
+ );
4100
+ }
4002
4101
  if (given === void 0) {
4003
4102
  const actions = journal.getActions(runId);
4004
4103
  const left = actions.filter(
@@ -4039,7 +4138,8 @@ async function runUndo(argv, journal) {
4039
4138
  const highest = journal.getActions(runId).reduce((top, action) => Math.max(top, action.seq), 0);
4040
4139
  if (toSeq > highest) {
4041
4140
  throw new UsageError(
4042
- `--to ${String(toSeq)} is past the end of this run, which goes up to ${String(highest)}`
4141
+ `--to ${String(toSeq)} is past the end of this run, which goes up to ${String(highest)}`,
4142
+ false
4043
4143
  );
4044
4144
  }
4045
4145
  }
@@ -4117,6 +4217,7 @@ var KNOWN_COMMANDS = [
4117
4217
  "watch",
4118
4218
  "approve",
4119
4219
  "deny",
4220
+ "resolve",
4120
4221
  "undo",
4121
4222
  "help",
4122
4223
  "version"
@@ -4140,6 +4241,9 @@ var FLAGS = /* @__PURE__ */ new Set([
4140
4241
  "--dry-run",
4141
4242
  "--replan",
4142
4243
  "--reason",
4244
+ // resolve: which way the unknown outcome actually went.
4245
+ "--applied",
4246
+ "--failed",
4143
4247
  "--force",
4144
4248
  "--yes",
4145
4249
  "--older-than",
@@ -4313,6 +4417,8 @@ ${COMMANDS}`);
4313
4417
  return runPrune(argv, journal, journalPath);
4314
4418
  case "gates":
4315
4419
  return runGates(journal, asJson);
4420
+ case "resolve":
4421
+ return runResolve(argv, journal);
4316
4422
  case "approve":
4317
4423
  return runDecision(argv, journal, true);
4318
4424
  case "deny":
package/dist/proxy.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  verifyAgainstServers,
26
26
  warnUntested,
27
27
  withIdempotencyKey
28
- } from "./chunk-O3QDPYEL.js";
28
+ } from "./chunk-JE7MOCZO.js";
29
29
  import {
30
30
  SnapshotError,
31
31
  UpstreamError,
@@ -256,12 +256,19 @@ import { z } from "zod";
256
256
 
257
257
  // src/gate/heuristic.ts
258
258
  var READ_ONLY = /^(select|with|show|explain|describe|desc|values|table)\b/;
259
+ var WRITES = /\b(insert|update|delete|merge|upsert|replace|truncate|drop|alter|create|grant|revoke|vacuum|attach|detach|reindex|call|do|copy|lock|pragma|into)\b/;
260
+ function code(text) {
261
+ return text.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/'(?:[^']|'')*'/g, " ").replace(/"(?:[^"]|"")*"/g, " ").trim();
262
+ }
259
263
  function isReadOnlyStatement(text) {
260
- const stripped = text.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
264
+ const stripped = code(text);
261
265
  if (!READ_ONLY.test(stripped.toLowerCase())) {
262
266
  return false;
263
267
  }
264
- return stripped.replace(/;\s*$/, "").indexOf(";") === -1;
268
+ if (stripped.replace(/;\s*$/, "").indexOf(";") !== -1) {
269
+ return false;
270
+ }
271
+ return !WRITES.test(stripped.toLowerCase());
265
272
  }
266
273
  function shouldGateOnWrite(args) {
267
274
  if (typeof args !== "object" || args === null) {
@@ -344,8 +351,8 @@ function neverDispatched(error) {
344
351
  if (message.includes("Not connected")) {
345
352
  return true;
346
353
  }
347
- const code = isRecord(error) ? error["code"] : void 0;
348
- return code === ErrorCode.MethodNotFound || code === ErrorCode.InvalidParams || code === ErrorCode.InvalidRequest || code === ErrorCode.ParseError;
354
+ const code2 = isRecord(error) ? error["code"] : void 0;
355
+ return code2 === ErrorCode.MethodNotFound || code2 === ErrorCode.InvalidParams || code2 === ErrorCode.InvalidRequest || code2 === ErrorCode.ParseError;
349
356
  }
350
357
  var SYNARTESIS_INSTRUCTIONS = [
351
358
  "These tools are guarded by Synartesis, which records every change so it can be undone later.",
@@ -1078,7 +1085,7 @@ function createProxyServer(options) {
1078
1085
  server.onclose = () => {
1079
1086
  connected = false;
1080
1087
  if (runId !== void 0) {
1081
- journal.endRun(runId, "complete");
1088
+ journal.endRun(runId, "complete", ["active"]);
1082
1089
  runId = void 0;
1083
1090
  }
1084
1091
  previousOnClose?.();
@@ -1259,7 +1266,7 @@ async function main() {
1259
1266
  }
1260
1267
  const proxy = build();
1261
1268
  let shuttingDown = false;
1262
- const shutdown = (code) => {
1269
+ const shutdown = (code2) => {
1263
1270
  if (shuttingDown) {
1264
1271
  return;
1265
1272
  }
@@ -1274,7 +1281,7 @@ async function main() {
1274
1281
  await upstream.close();
1275
1282
  }
1276
1283
  journal.close();
1277
- process.exit(code);
1284
+ process.exit(code2);
1278
1285
  })();
1279
1286
  };
1280
1287
  process.on("SIGINT", () => {
@@ -1,6 +1,9 @@
1
1
  # Policy for mcp-server-git, the official git server, run through uvx.
2
2
  #
3
- # Verified against the real server on a real repository.
3
+ # Verified against the real server on a real repository, and round-tripped by
4
+ # tests/adapter-git.test.ts: the one compensation below is run and its effect
5
+ # read back with git itself, and both gates are checked from both ends. Change
6
+ # a class or a gate here and that file fails.
4
7
  #
5
8
  # The finding worth carrying away from writing this one: nearly every read this
6
9
  # server offers answers in prose meant for a person -- git_status says "On
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.8.1",
3
+ "version": "0.8.5",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,