synartesis 0.6.22 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/README.md +18 -4
- package/SECURITY.md +2 -2
- package/dist/{chunk-JLLAK2QC.js → chunk-O3QDPYEL.js} +72 -5
- package/dist/cli.js +53 -22
- package/dist/proxy.js +55 -29
- package/manifests/filesystem.yaml +39 -12
- package/manifests/memory.yaml +8 -13
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,143 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.8.0 — 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`expect: absent`, and `move_file` becomes undoable.** A pre-read normally
|
|
10
|
+
captures what a call is about to replace, so a read that finds nothing means
|
|
11
|
+
there is nothing to put back. For a few calls that is exactly backwards:
|
|
12
|
+
moving a file onto a free path is undone by moving it back, and it is finding
|
|
13
|
+
*something* there that puts the call beyond undo, because one inverse cannot
|
|
14
|
+
both move your file back and restore what it landed on.
|
|
15
|
+
|
|
16
|
+
Both halves were measured before anything was written. Declared plainly
|
|
17
|
+
`reversible`, the safe move came back `partial` with the file still moved —
|
|
18
|
+
the trivially reversible case was the one that could not be undone — and the
|
|
19
|
+
dangerous one came back `rolled_back` with the overwritten file gone, which
|
|
20
|
+
is the confident wrong undo this exists to prevent. `expect: absent` on the
|
|
21
|
+
snapshot swaps the two: finding nothing is reversible and the inverse runs;
|
|
22
|
+
finding something is held for a person and recorded with **no inverse**, so
|
|
23
|
+
undo says it cannot be undone rather than putting half of it back.
|
|
24
|
+
|
|
25
|
+
The shipped filesystem policy uses it, so moving a file to a fresh path is
|
|
26
|
+
now an ordinary undoable action instead of an approval prompt. A policy that
|
|
27
|
+
declares it on a non-`reversible` rule, on a `verify` read, or with an
|
|
28
|
+
inverse reading `$snapshot.` — which can never resolve, since nothing is
|
|
29
|
+
captured — is refused at load.
|
|
30
|
+
|
|
31
|
+
Worth recording: this server's own description of `move_file` says "If the
|
|
32
|
+
destination exists, the operation will fail." It does not; it renames over
|
|
33
|
+
the top, because that is what `rename(2)` does. Taking the documentation at
|
|
34
|
+
its word would have made the rule unconditionally reversible and the
|
|
35
|
+
overwrite both unrecoverable and unremarked.
|
|
36
|
+
|
|
37
|
+
## 0.7.0 — 2026-09-16
|
|
38
|
+
|
|
39
|
+
A pass over everything, after an audit of the desktop app, the core and the
|
|
40
|
+
release. Most of what it found was not broken code but things that stated
|
|
41
|
+
something untrue.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **Every desktop binary since 0.6.12 carried the wrong version.**
|
|
46
|
+
electron-builder reads the version from `app/package.json`, which nothing
|
|
47
|
+
else reads, so it stayed put while the root moved. The nine installers
|
|
48
|
+
attached to v0.6.23 were all named `0.6.12`, byte-identical in name to the
|
|
49
|
+
ones on v0.6.12 — two people on different releases could not tell their
|
|
50
|
+
builds apart. `app-packaging.test.ts` already checked seven agreements
|
|
51
|
+
between the packaging files; the absence of the eighth is why this ran for
|
|
52
|
+
eleven releases.
|
|
53
|
+
|
|
54
|
+
- **A spent approval read as "refused" in `watch` and the console.** `labelFor`
|
|
55
|
+
was written for exactly this and `list` and `show` were taught to use it;
|
|
56
|
+
`plainly` was not — and `plainly` is what the two live screens render, so a
|
|
57
|
+
person was told their own yes had been a no, seconds after giving it.
|
|
58
|
+
|
|
59
|
+
- **`undo --dry-run --force` printed no plan.** It showed what forcing would
|
|
60
|
+
write over and returned, so the flag whose whole job is to print the plan
|
|
61
|
+
printed none. A dry run no longer needs `--yes` either: the two-step ask
|
|
62
|
+
protects a write, and a preview writes nothing.
|
|
63
|
+
|
|
64
|
+
- **A `verify:` read was never validated.** `snapshot` and `inverse` are
|
|
65
|
+
checked against the servers at load; `verify` was not, and it is the one
|
|
66
|
+
whose failure is silent — the proxy turns it into a soft warning and carries
|
|
67
|
+
on, so a typo meant that tool had no drift detection at all while both the
|
|
68
|
+
policy and `check` said it was fine.
|
|
69
|
+
|
|
70
|
+
- **A failed inverse on the lost-answer path was swallowed whole.** An inverse
|
|
71
|
+
reading `$result.` cannot be rebuilt when the answer is what went missing,
|
|
72
|
+
and undo reported "cannot be undone" without saying why — on the path where
|
|
73
|
+
undo matters most.
|
|
74
|
+
|
|
75
|
+
- **`--client` with a typo blamed the machine**, reporting "No MCP client
|
|
76
|
+
config was found on this machine". On `uninstall` it said "Nothing was
|
|
77
|
+
covered, so nothing was changed", which reads as reassurance.
|
|
78
|
+
|
|
79
|
+
- **`undo --to` always exited 1.** A floor makes a run `partial` by
|
|
80
|
+
construction, correctly, and the exit code was read off that — so an undo
|
|
81
|
+
that did exactly what it was told could not be told from one that halted on
|
|
82
|
+
somebody's edit.
|
|
83
|
+
|
|
84
|
+
- **`status` read "when was this server last used" off the newest five hundred
|
|
85
|
+
actions**, so a server whose last use had scrolled out of that window came
|
|
86
|
+
back as never used: "covered, nothing through it yet". Asked exactly now,
|
|
87
|
+
from a covering index — 75ms to 3.5ms on sixty thousand actions.
|
|
88
|
+
|
|
89
|
+
### Fixed — the desktop window
|
|
90
|
+
|
|
91
|
+
- **With no policy the window could not be quit.** The branch that drew the
|
|
92
|
+
no-policy screen returned before the IPC handlers were registered and before
|
|
93
|
+
the quit handlers were attached; on Windows and Linux that left a process
|
|
94
|
+
running with no window. Everything after `whenReady` is now one path in
|
|
95
|
+
`boot.ts`, which imports no Electron — which is also how the key-page
|
|
96
|
+
allowlist became testable.
|
|
97
|
+
|
|
98
|
+
- **Two spurious errors before the no-policy screen.** The window asked the
|
|
99
|
+
engine two questions the moment it mounted, which with no policy came back as
|
|
100
|
+
"No handler registered" and were drawn as errors. The push is now a pull.
|
|
101
|
+
|
|
102
|
+
- **`Bridge` was declared twice and neither copy was connected to the
|
|
103
|
+
preload** — the only one that was true. They had already drifted. The shared
|
|
104
|
+
one survives, the exposed object is annotated rather than inferred, and both
|
|
105
|
+
the compiler and a test now catch a future drift.
|
|
106
|
+
|
|
107
|
+
- **A server that would not start vanished**: no event, no log, nothing in the
|
|
108
|
+
window, and a briefing that went on announcing it as connected. It now
|
|
109
|
+
reaches the transcript with the server's own words, and the model is told not
|
|
110
|
+
to plan around tools that are not there.
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
|
|
114
|
+
- `pnpm check` runs build, typecheck, lint and the suite in one command.
|
|
115
|
+
- The release workflow runs that gate before building installers, builds the
|
|
116
|
+
desktop app in CI, runs the memory demo, and takes its release notes from
|
|
117
|
+
`CHANGELOG.md` instead of a list of commit subjects.
|
|
118
|
+
- `npm publish` runs the whole gate rather than only a build.
|
|
119
|
+
- `list --json` and `show --json` both carry `actionCount`, so one name means
|
|
120
|
+
one thing in both. The existing `actions` shape is untouched.
|
|
121
|
+
- The site points at `/releases/latest` rather than a pinned version.
|
|
122
|
+
- The user guide covers `install`, `uninstall`, `status` and `desktop`, and is
|
|
123
|
+
no longer stamped twenty releases back. `CONTRIBUTING.md` no longer shows a
|
|
124
|
+
manifest syntax that throws on load, or asks for a C toolchain the project
|
|
125
|
+
disables.
|
|
126
|
+
|
|
127
|
+
## 0.6.23 — 2026-09-16
|
|
128
|
+
|
|
129
|
+
### Docs
|
|
130
|
+
|
|
131
|
+
- **What to do with a tool that does two things at once.** A call that both
|
|
132
|
+
writes something recoverable and does something that is not -- saves a file
|
|
133
|
+
and posts to an API, updates a record and sends the email about it -- takes
|
|
134
|
+
the class of its least recoverable part, because a rule matches a tool name
|
|
135
|
+
and gives it one class. The guide now says so, says why the alternative is
|
|
136
|
+
worse (undo puts the file back, reports `rolled_back`, and says nothing about
|
|
137
|
+
the request still out there), points at `move_file` as the shipped example of
|
|
138
|
+
the same shape, and shows the one way out: a `compensable` rule whose inverse
|
|
139
|
+
neutralises the unrecoverable half. Asked often enough to be worth writing
|
|
140
|
+
down rather than answering again.
|
|
141
|
+
|
|
5
142
|
## 0.6.22 — 2026-09-16
|
|
6
143
|
|
|
7
144
|
### Added
|
package/README.md
CHANGED
|
@@ -156,7 +156,15 @@ Every tool gets one of four classifications, written down in a manifest:
|
|
|
156
156
|
|
|
157
157
|
A tool your manifest does not mention is treated as `irreversible`. That is
|
|
158
158
|
deliberate: silently forwarding an unknown destructive call is the one failure
|
|
159
|
-
worth avoiding most.
|
|
159
|
+
worth avoiding most. `synartesis check` names them, so you meet that decision
|
|
160
|
+
before your agent does.
|
|
161
|
+
|
|
162
|
+
A few calls are reversible only when nothing is in the way — moving a file onto
|
|
163
|
+
a free path is undone by moving it back, moving it onto an existing file
|
|
164
|
+
destroys what was there. For those, `expect: absent` on the pre-read swaps the
|
|
165
|
+
two: finding nothing is the reversible case, finding something is held for a
|
|
166
|
+
person and recorded with no inverse, so undo says it cannot be undone rather
|
|
167
|
+
than putting half of it back and calling that success.
|
|
160
168
|
|
|
161
169
|
## Has anybody touched it since?
|
|
162
170
|
|
|
@@ -385,11 +393,17 @@ pnpm test
|
|
|
385
393
|
```
|
|
386
394
|
|
|
387
395
|
```bash
|
|
388
|
-
pnpm
|
|
396
|
+
pnpm check
|
|
389
397
|
```
|
|
390
398
|
|
|
391
|
-
Every push runs
|
|
392
|
-
and the
|
|
399
|
+
Every push runs that on Linux and macOS across Node 22 and 24, plus both demos,
|
|
400
|
+
the installer, and a build of the desktop app.
|
|
401
|
+
|
|
402
|
+
**Windows is built and not tested.** The release attaches a Windows installer,
|
|
403
|
+
and no CI job compiles or exercises it — the test matrix is Linux and macOS. It
|
|
404
|
+
is expected to work, the code has no platform-specific paths outside
|
|
405
|
+
`src/locate.ts` and `src/install/clients.ts`, and nobody has proved it. If you
|
|
406
|
+
run Windows and something is wrong there, that is worth an issue.
|
|
393
407
|
|
|
394
408
|
## Licence
|
|
395
409
|
|
package/SECURITY.md
CHANGED
|
@@ -325,6 +325,20 @@ CREATE INDEX IF NOT EXISTS actions_unresolved ON actions(run_id, server, tool)
|
|
|
325
325
|
-- ordering it asks for, so the index both filters and sorts.
|
|
326
326
|
CREATE INDEX IF NOT EXISTS actions_writes ON actions(run_id, seq)
|
|
327
327
|
WHERE class <> 'readonly';
|
|
328
|
+
|
|
329
|
+
-- Covering, like actions_run_status and for the same reason. status asks when
|
|
330
|
+
-- each server was last used; without this the group-by walks the table, and
|
|
331
|
+
-- the table carries the snapshots, so the cost of drawing a connection list
|
|
332
|
+
-- grew with the size of the data those connections had touched rather than
|
|
333
|
+
-- with how many there were. Both columns the query reads are here, so sqlite
|
|
334
|
+
-- never reaches into a row -- the plan says COVERING INDEX, and it is the
|
|
335
|
+
-- covering half that does the work. Measured on sixty thousand actions across
|
|
336
|
+
-- twelve servers with two-kilobyte snapshots: 75ms to 3.5ms.
|
|
337
|
+
--
|
|
338
|
+
-- Added the same way as the ones above and for the same reason: no row
|
|
339
|
+
-- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
|
|
340
|
+
-- build opening the same file afterwards neither notices nor cares.
|
|
341
|
+
CREATE INDEX IF NOT EXISTS actions_seen ON actions(server, ts);
|
|
328
342
|
`;
|
|
329
343
|
|
|
330
344
|
// src/journal/journal.ts
|
|
@@ -336,6 +350,7 @@ var runSchema = z.object({
|
|
|
336
350
|
ended_at: z.string().nullable(),
|
|
337
351
|
status: z.enum(["active", "complete", "rolled_back", "partial"])
|
|
338
352
|
});
|
|
353
|
+
var seenSchema = z.object({ server: z.string(), ts: z.string() });
|
|
339
354
|
var tallySchema = z.object({
|
|
340
355
|
run_id: z.string(),
|
|
341
356
|
actions: z.number(),
|
|
@@ -847,6 +862,33 @@ var SqliteJournal = class {
|
|
|
847
862
|
return raw === void 0 ? void 0 : toAction(raw);
|
|
848
863
|
});
|
|
849
864
|
}
|
|
865
|
+
/**
|
|
866
|
+
* When each server was last used, over everything, not over a window.
|
|
867
|
+
*
|
|
868
|
+
* This was a scan of the newest five hundred actions, which answers a
|
|
869
|
+
* different question: on a busy journal a server whose last use had fallen
|
|
870
|
+
* out of that window came back as never used at all, and `status` reported
|
|
871
|
+
* "covered, nothing through it yet" about a server somebody uses daily.
|
|
872
|
+
* Wrong, and reassuringly so -- it reads as though the connection were
|
|
873
|
+
* merely new.
|
|
874
|
+
*
|
|
875
|
+
* Exact instead, and answered entirely from actions_seen: the query reads
|
|
876
|
+
* only the two columns that index holds, so it never touches the rows
|
|
877
|
+
* carrying snapshots.
|
|
878
|
+
*/
|
|
879
|
+
lastSeenPerServer() {
|
|
880
|
+
return this.#run("lastSeenPerServer", () => {
|
|
881
|
+
const rows = this.#db.prepare("SELECT server, MAX(ts) AS ts FROM actions GROUP BY server").all();
|
|
882
|
+
const seen = /* @__PURE__ */ new Map();
|
|
883
|
+
for (const row of rows) {
|
|
884
|
+
const read2 = seenSchema.safeParse(row);
|
|
885
|
+
if (read2.success) {
|
|
886
|
+
seen.set(read2.data.server, read2.data.ts);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return seen;
|
|
890
|
+
});
|
|
891
|
+
}
|
|
850
892
|
recentActions(limit) {
|
|
851
893
|
return this.#run(
|
|
852
894
|
"recentActions",
|
|
@@ -1113,7 +1155,11 @@ var templateValue = z2.lazy(
|
|
|
1113
1155
|
var callTemplate = z2.strictObject({
|
|
1114
1156
|
tool: z2.string().min(1),
|
|
1115
1157
|
args: z2.record(z2.string(), templateValue).default({}),
|
|
1116
|
-
absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional()
|
|
1158
|
+
absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional(),
|
|
1159
|
+
// Only `absent` for now. `present` would mean "refuse unless something is
|
|
1160
|
+
// already here", which is a different feature nobody has asked for, and a
|
|
1161
|
+
// value with no meaning behind it is worse than one that is missing.
|
|
1162
|
+
expect: z2.literal("absent").optional()
|
|
1117
1163
|
});
|
|
1118
1164
|
var toolPolicy = z2.strictObject({
|
|
1119
1165
|
match: z2.string().min(1),
|
|
@@ -1262,16 +1308,36 @@ function validate(source, manifest) {
|
|
|
1262
1308
|
if (policy.class === "readonly" && policy.snapshot !== void 0) {
|
|
1263
1309
|
source.fail([...path, "snapshot"], "a readonly tool must not declare a snapshot");
|
|
1264
1310
|
}
|
|
1311
|
+
const expectsAbsent = policy.snapshot?.expect === "absent";
|
|
1312
|
+
if (policy.verify?.expect !== void 0) {
|
|
1313
|
+
source.fail(
|
|
1314
|
+
[...path, "verify", "expect"],
|
|
1315
|
+
"expect belongs on a snapshot; a verify read runs after the call, when there is nothing left to expect"
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
if (expectsAbsent && policy.class !== "reversible") {
|
|
1319
|
+
source.fail(
|
|
1320
|
+
[...path, "snapshot", "expect"],
|
|
1321
|
+
`expect: absent says this call is reversible exactly when the read finds nothing, which only means something for a reversible tool, not a ${policy.class} one`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1265
1324
|
if (policy.snapshot !== void 0) {
|
|
1266
1325
|
checkCall(source, [...path, "snapshot"], policy.snapshot, servers, ["$."]);
|
|
1267
1326
|
}
|
|
1268
1327
|
if (policy.inverse !== void 0) {
|
|
1269
1328
|
const allowed = ["$.", "$result."];
|
|
1270
|
-
if (policy.snapshot !== void 0) {
|
|
1329
|
+
if (policy.snapshot !== void 0 && !expectsAbsent) {
|
|
1271
1330
|
allowed.push("$snapshot.");
|
|
1272
1331
|
}
|
|
1273
1332
|
checkCall(source, [...path, "inverse"], policy.inverse, servers, allowed);
|
|
1274
1333
|
}
|
|
1334
|
+
if (policy.verify !== void 0) {
|
|
1335
|
+
const allowed = ["$.", "$result."];
|
|
1336
|
+
if (policy.snapshot !== void 0) {
|
|
1337
|
+
allowed.push("$snapshot.");
|
|
1338
|
+
}
|
|
1339
|
+
checkCall(source, [...path, "verify"], policy.verify, servers, allowed);
|
|
1340
|
+
}
|
|
1275
1341
|
});
|
|
1276
1342
|
}
|
|
1277
1343
|
function withGate(policy) {
|
|
@@ -1280,7 +1346,8 @@ function withGate(policy) {
|
|
|
1280
1346
|
args: call.args,
|
|
1281
1347
|
...call.absent_when === void 0 ? {} : {
|
|
1282
1348
|
absentWhen: typeof call.absent_when === "string" ? [call.absent_when] : [...call.absent_when]
|
|
1283
|
-
}
|
|
1349
|
+
},
|
|
1350
|
+
...call.expect === void 0 ? {} : { expect: call.expect }
|
|
1284
1351
|
});
|
|
1285
1352
|
const gate = policy.gate ?? (policy.class === "irreversible" ? "always" : "never");
|
|
1286
1353
|
return {
|
|
@@ -1561,7 +1628,7 @@ function describeStanding(entry) {
|
|
|
1561
1628
|
return "no claim either way";
|
|
1562
1629
|
}
|
|
1563
1630
|
}
|
|
1564
|
-
var LIVE_IS_NOT_RECOVERY = "`live` means the policy has met its server, not that undo has been round-tripped against it.
|
|
1631
|
+
var LIVE_IS_NOT_RECOVERY = "`live` means the policy has met its server, not that undo has been round-tripped against it. Whether it puts anything back is a separate question, and only a test against the real server answers it.";
|
|
1565
1632
|
function warnUntested(servers) {
|
|
1566
1633
|
const names = servers.join(", ");
|
|
1567
1634
|
const these = servers.length === 1 ? "this policy has" : "these policies have";
|
|
@@ -1944,4 +2011,4 @@ export {
|
|
|
1944
2011
|
observeState,
|
|
1945
2012
|
connectStdioUpstream
|
|
1946
2013
|
};
|
|
1947
|
-
//# sourceMappingURL=chunk-
|
|
2014
|
+
//# sourceMappingURL=chunk-O3QDPYEL.js.map
|
package/dist/cli.js
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
verifyAgainstServers,
|
|
39
39
|
warnUntested,
|
|
40
40
|
wasRefused
|
|
41
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-O3QDPYEL.js";
|
|
42
42
|
import {
|
|
43
43
|
DriftConflict,
|
|
44
44
|
ManifestError,
|
|
@@ -1076,7 +1076,7 @@ function plainly(action) {
|
|
|
1076
1076
|
case "rolling_back":
|
|
1077
1077
|
return { text: "undoing", needs: false };
|
|
1078
1078
|
case "denied":
|
|
1079
|
-
return { text: "refused", needs: false };
|
|
1079
|
+
return labelFor(action) === "used" ? { text: "used", needs: false } : { text: "refused", needs: false };
|
|
1080
1080
|
case "failed":
|
|
1081
1081
|
return { text: "failed", needs: false };
|
|
1082
1082
|
case "approved":
|
|
@@ -1526,6 +1526,12 @@ var LABELS = {
|
|
|
1526
1526
|
cursor: "Cursor",
|
|
1527
1527
|
codex: "Codex"
|
|
1528
1528
|
};
|
|
1529
|
+
var CLIENT_IDS = Object.keys(LABELS).filter(
|
|
1530
|
+
(name) => name in LABELS
|
|
1531
|
+
);
|
|
1532
|
+
function isClientId(value) {
|
|
1533
|
+
return CLIENT_IDS.some((known) => known === value);
|
|
1534
|
+
}
|
|
1529
1535
|
function claudeDesktopPath() {
|
|
1530
1536
|
const home = homedir();
|
|
1531
1537
|
switch (platform()) {
|
|
@@ -1946,14 +1952,7 @@ function applyUninstall(sites, manifestPath) {
|
|
|
1946
1952
|
// src/install/connections.ts
|
|
1947
1953
|
var ACTIVE_WITHIN_MS = 2 * 60 * 1e3;
|
|
1948
1954
|
function lastSeenByServer(journal) {
|
|
1949
|
-
|
|
1950
|
-
for (const action of journal.recentActions(500)) {
|
|
1951
|
-
const known = seen.get(action.server);
|
|
1952
|
-
if (known === void 0 || action.ts > known) {
|
|
1953
|
-
seen.set(action.server, action.ts);
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
return seen;
|
|
1955
|
+
return journal.lastSeenPerServer();
|
|
1957
1956
|
}
|
|
1958
1957
|
function commandMissing(command) {
|
|
1959
1958
|
if (command === void 0) {
|
|
@@ -2848,7 +2847,8 @@ Most commands end by naming the one thing worth doing next, worked out from
|
|
|
2848
2847
|
what is actually in the journal rather than from what was typed. Set
|
|
2849
2848
|
SYNARTESIS_NO_HINTS to turn that off; --json never carries it.
|
|
2850
2849
|
|
|
2851
|
-
Exit codes: 0
|
|
2850
|
+
Exit codes: 0 did what was asked, 1 stopped or left something in place,
|
|
2851
|
+
2 bad usage or configuration.
|
|
2852
2852
|
`;
|
|
2853
2853
|
var UsageError = class extends Error {
|
|
2854
2854
|
/**
|
|
@@ -2940,6 +2940,20 @@ async function runPin(argv) {
|
|
|
2940
2940
|
out("");
|
|
2941
2941
|
return 0;
|
|
2942
2942
|
}
|
|
2943
|
+
function namedClient(argv) {
|
|
2944
|
+
const typed = flag(argv, "--client");
|
|
2945
|
+
if (typed === void 0) {
|
|
2946
|
+
return void 0;
|
|
2947
|
+
}
|
|
2948
|
+
if (!isClientId(typed)) {
|
|
2949
|
+
const near = didYouMean(typed, CLIENT_IDS);
|
|
2950
|
+
throw new UsageError(
|
|
2951
|
+
`--client ${typed} is not a client this knows${near === void 0 ? "" : `; did you mean ${near}?`}
|
|
2952
|
+
It knows: ${CLIENT_IDS.join(", ")}`
|
|
2953
|
+
);
|
|
2954
|
+
}
|
|
2955
|
+
return typed;
|
|
2956
|
+
}
|
|
2943
2957
|
async function runCheck(argv) {
|
|
2944
2958
|
const path = findManifest(flag(argv, "--manifest"));
|
|
2945
2959
|
const manifest = loadManifest(path);
|
|
@@ -3037,7 +3051,7 @@ async function runCheck(argv) {
|
|
|
3037
3051
|
}
|
|
3038
3052
|
async function runInstall(argv) {
|
|
3039
3053
|
const manifestPath = findManifest(flag(argv, "--manifest"));
|
|
3040
|
-
const only =
|
|
3054
|
+
const only = namedClient(argv);
|
|
3041
3055
|
const dryRun = argv.includes("--dry-run");
|
|
3042
3056
|
const printOnly = argv.includes("--print");
|
|
3043
3057
|
const sites = discover(process.cwd()).filter(
|
|
@@ -3122,7 +3136,7 @@ async function runInstall(argv) {
|
|
|
3122
3136
|
}
|
|
3123
3137
|
async function runUninstall(argv) {
|
|
3124
3138
|
const manifestPath = findManifest(flag(argv, "--manifest"));
|
|
3125
|
-
const only =
|
|
3139
|
+
const only = namedClient(argv);
|
|
3126
3140
|
const sites = discover(process.cwd()).filter(
|
|
3127
3141
|
(site) => only === void 0 || site.client === only
|
|
3128
3142
|
);
|
|
@@ -3396,7 +3410,14 @@ function runList(journal, asJson, journalPath) {
|
|
|
3396
3410
|
const tally2 = journal.tallyRuns();
|
|
3397
3411
|
const counted = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
|
|
3398
3412
|
if (asJson) {
|
|
3399
|
-
out(
|
|
3413
|
+
out(
|
|
3414
|
+
JSON.stringify(
|
|
3415
|
+
runs.map((run) => {
|
|
3416
|
+
const actions = counted(run.id).actions;
|
|
3417
|
+
return { ...run, actions, actionCount: actions };
|
|
3418
|
+
})
|
|
3419
|
+
)
|
|
3420
|
+
);
|
|
3400
3421
|
return 0;
|
|
3401
3422
|
}
|
|
3402
3423
|
if (runs.length === 0) {
|
|
@@ -3452,6 +3473,9 @@ async function runShow(argv, journal, asJson) {
|
|
|
3452
3473
|
JSON.stringify({
|
|
3453
3474
|
run,
|
|
3454
3475
|
actions: journal.getActions(runId),
|
|
3476
|
+
// The same name means the same thing in `list --json`, where `actions`
|
|
3477
|
+
// has always been a count and cannot change.
|
|
3478
|
+
actionCount: journal.getActions(runId).length,
|
|
3455
3479
|
...inspection === void 0 ? {} : { live: inspection.resources }
|
|
3456
3480
|
})
|
|
3457
3481
|
);
|
|
@@ -3907,7 +3931,7 @@ function report(result, alreadyForcing = false, as = "") {
|
|
|
3907
3931
|
needs: ["manifest", "journal"]
|
|
3908
3932
|
});
|
|
3909
3933
|
}
|
|
3910
|
-
return result.
|
|
3934
|
+
return result.halted === void 0 && permanent.length === 0 ? 0 : 1;
|
|
3911
3935
|
}
|
|
3912
3936
|
async function withUpstreams(manifestPath, use, only) {
|
|
3913
3937
|
const manifest = loadManifest(manifestPath);
|
|
@@ -4025,6 +4049,8 @@ async function runUndo(argv, journal) {
|
|
|
4025
4049
|
if (said && !forcing) {
|
|
4026
4050
|
process.stderr.write("synartesis: --yes only means anything with --force; ignoring it\n");
|
|
4027
4051
|
}
|
|
4052
|
+
const dryRun = argv.includes("--dry-run");
|
|
4053
|
+
const forcePlan = forcing && (said || dryRun);
|
|
4028
4054
|
if (forcing && !said) {
|
|
4029
4055
|
const over = (await withUpstreams(
|
|
4030
4056
|
manifestPath,
|
|
@@ -4048,25 +4074,30 @@ async function runUndo(argv, journal) {
|
|
|
4048
4074
|
}
|
|
4049
4075
|
}
|
|
4050
4076
|
out("");
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4077
|
+
if (!dryRun) {
|
|
4078
|
+
out(` ${style.quiet("nothing has been written. To go ahead and lose that:")}`);
|
|
4079
|
+
out(` ${style.strong(`${cliCommand()} undo ${runId.slice(0, 8)} --force --yes`)}`);
|
|
4080
|
+
out("");
|
|
4081
|
+
return 1;
|
|
4082
|
+
}
|
|
4055
4083
|
}
|
|
4056
4084
|
}
|
|
4057
4085
|
const replan = argv.includes("--replan");
|
|
4058
4086
|
return report(
|
|
4059
4087
|
await performUndo(manifestPath, journal, runId, {
|
|
4060
|
-
dryRun
|
|
4088
|
+
dryRun,
|
|
4061
4089
|
...toSeq === void 0 ? {} : { toSeq },
|
|
4062
4090
|
replan,
|
|
4063
|
-
...
|
|
4091
|
+
...forcePlan ? { force: true } : {}
|
|
4064
4092
|
}),
|
|
4065
4093
|
forcing,
|
|
4066
4094
|
// --to is deliberately absent, and cannot reach here: a floor leaves
|
|
4067
4095
|
// actions below it alone, which makes the result `partial`, and the hint
|
|
4068
4096
|
// is only offered on `rolled_back`.
|
|
4069
|
-
|
|
4097
|
+
// forcePlan, not `forcing && said`: on a dry run the command that does
|
|
4098
|
+
// this for real is the forced one, and offering it without --force would
|
|
4099
|
+
// hand back something that halts on the drift the preview just showed.
|
|
4100
|
+
`${replan ? " --replan" : ""}${forcePlan ? " --force --yes" : ""}`
|
|
4070
4101
|
);
|
|
4071
4102
|
}
|
|
4072
4103
|
var KNOWN_COMMANDS = [
|
package/dist/proxy.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
verifyAgainstServers,
|
|
26
26
|
warnUntested,
|
|
27
27
|
withIdempotencyKey
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-O3QDPYEL.js";
|
|
29
29
|
import {
|
|
30
30
|
SnapshotError,
|
|
31
31
|
UpstreamError,
|
|
@@ -399,6 +399,18 @@ function compatible(tool, seen) {
|
|
|
399
399
|
}
|
|
400
400
|
return { ...tool, outputSchema: withoutDialect(tool["outputSchema"], seen) };
|
|
401
401
|
}
|
|
402
|
+
function recoverInverse(policy, captured, noPriorState) {
|
|
403
|
+
if (policy.inverse === void 0 || noPriorState) {
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
return { inverse: planInverse(policy.inverse, { ...captured, result: void 0 }) };
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return {
|
|
410
|
+
warning: `and its inverse could not be resolved without that answer: ${describe(error)}`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
402
414
|
function createProxyServer(options) {
|
|
403
415
|
const { upstreams, manifest, journal } = options;
|
|
404
416
|
const router = createRouter(upstreams, manifest);
|
|
@@ -697,18 +709,39 @@ function createProxyServer(options) {
|
|
|
697
709
|
let snapshot;
|
|
698
710
|
let verify;
|
|
699
711
|
let probe;
|
|
700
|
-
let
|
|
712
|
+
let noWayBack;
|
|
713
|
+
let foundNothing = false;
|
|
714
|
+
const expectsAbsent = policy.snapshot?.expect === "absent";
|
|
701
715
|
if (policy.snapshot !== void 0) {
|
|
702
716
|
try {
|
|
703
717
|
verify = planRead(policy.snapshot, { args });
|
|
704
718
|
probe = verify;
|
|
705
719
|
snapshot = await runRead(router, verify, extra.signal);
|
|
706
|
-
|
|
720
|
+
if (expectsAbsent) {
|
|
721
|
+
noWayBack = {
|
|
722
|
+
asked: `something is already there, so this cannot be undone \u2014 putting back what this call moves would leave nothing where the old contents were`,
|
|
723
|
+
recorded: `the pre-read expected nothing and found something, so this call overwrote it and no single inverse can put both back`
|
|
724
|
+
};
|
|
725
|
+
verify = void 0;
|
|
726
|
+
} else {
|
|
727
|
+
journal.attachSnapshot(pending.actionId, snapshot);
|
|
728
|
+
}
|
|
707
729
|
} catch (error) {
|
|
708
730
|
const reason = describe(error);
|
|
709
731
|
if (error instanceof SnapshotError && error.absent) {
|
|
710
|
-
|
|
711
|
-
|
|
732
|
+
foundNothing = true;
|
|
733
|
+
if (expectsAbsent) {
|
|
734
|
+
log?.debug(
|
|
735
|
+
{ seq: pending.seq, tool: route.tool },
|
|
736
|
+
"pre-read found nothing, which is what makes this reversible"
|
|
737
|
+
);
|
|
738
|
+
} else {
|
|
739
|
+
noWayBack = {
|
|
740
|
+
asked: `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${reason}`,
|
|
741
|
+
recorded: `no prior state existed, so there is nothing to restore: ${reason}`
|
|
742
|
+
};
|
|
743
|
+
verify = void 0;
|
|
744
|
+
}
|
|
712
745
|
} else {
|
|
713
746
|
journal.markFailed(pending.actionId, reason);
|
|
714
747
|
log?.error(
|
|
@@ -722,8 +755,8 @@ function createProxyServer(options) {
|
|
|
722
755
|
}
|
|
723
756
|
}
|
|
724
757
|
}
|
|
725
|
-
const priorState = probe === void 0 ||
|
|
726
|
-
if (
|
|
758
|
+
const priorState = probe === void 0 || foundNothing ? { present: false } : { present: true, value: snapshot };
|
|
759
|
+
if (noWayBack !== void 0 && !askedAlready) {
|
|
727
760
|
const standing = journal.findApproval({
|
|
728
761
|
server: route.upstream.name,
|
|
729
762
|
tool: route.tool,
|
|
@@ -731,18 +764,14 @@ function createProxyServer(options) {
|
|
|
731
764
|
notBefore: new Date(Date.now() - APPROVAL_WINDOW_MS).toISOString()
|
|
732
765
|
});
|
|
733
766
|
if (standing === void 0) {
|
|
734
|
-
await decide(
|
|
735
|
-
`nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
|
|
736
|
-
);
|
|
767
|
+
await decide(noWayBack.asked);
|
|
737
768
|
} else if (journal.adoptApproval(pending.actionId, standing)) {
|
|
738
769
|
log?.info(
|
|
739
770
|
{ action: pending.actionId, by: standing.approvedBy, from: standing.runId },
|
|
740
771
|
"proceeding on a standing approval"
|
|
741
772
|
);
|
|
742
773
|
} else {
|
|
743
|
-
await decide(
|
|
744
|
-
`nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
|
|
745
|
-
);
|
|
774
|
+
await decide(noWayBack.asked);
|
|
746
775
|
}
|
|
747
776
|
}
|
|
748
777
|
const forwarded = {
|
|
@@ -788,13 +817,11 @@ function createProxyServer(options) {
|
|
|
788
817
|
if (inferred !== void 0) {
|
|
789
818
|
warnings.push(inferred);
|
|
790
819
|
}
|
|
791
|
-
if (
|
|
792
|
-
warnings.push(
|
|
793
|
-
`no prior state existed, so there is nothing to restore: ${missingPriorState}`
|
|
794
|
-
);
|
|
820
|
+
if (noWayBack !== void 0) {
|
|
821
|
+
warnings.push(noWayBack.recorded);
|
|
795
822
|
}
|
|
796
823
|
let inverse;
|
|
797
|
-
if (policy.inverse !== void 0 &&
|
|
824
|
+
if (policy.inverse !== void 0 && noWayBack === void 0) {
|
|
798
825
|
try {
|
|
799
826
|
inverse = planInverse(policy.inverse, context);
|
|
800
827
|
} catch (error) {
|
|
@@ -842,19 +869,19 @@ function createProxyServer(options) {
|
|
|
842
869
|
} else if (settled === "unknown") {
|
|
843
870
|
journal.markUnknown(pending.actionId, describe(error));
|
|
844
871
|
} else {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
recovered = void 0;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
872
|
+
const recovered = recoverInverse(
|
|
873
|
+
policy,
|
|
874
|
+
{ args, snapshot },
|
|
875
|
+
noWayBack !== void 0
|
|
876
|
+
);
|
|
853
877
|
journal.markApplied(pending.actionId, {
|
|
854
878
|
result: void 0,
|
|
855
|
-
...recovered === void 0 ? {} : { inverse: recovered },
|
|
879
|
+
...recovered.inverse === void 0 ? {} : { inverse: recovered.inverse },
|
|
856
880
|
...verify === void 0 ? {} : { verify },
|
|
857
|
-
warning:
|
|
881
|
+
warning: [
|
|
882
|
+
`the call applied but its answer never arrived: ${describe(error)}`,
|
|
883
|
+
...recovered.warning === void 0 ? [] : [recovered.warning]
|
|
884
|
+
].join("; ")
|
|
858
885
|
});
|
|
859
886
|
}
|
|
860
887
|
}
|
|
@@ -1199,7 +1226,6 @@ async function main() {
|
|
|
1199
1226
|
upstreams,
|
|
1200
1227
|
manifest,
|
|
1201
1228
|
journal,
|
|
1202
|
-
gateTimeoutMs: argv.gateTimeoutMs,
|
|
1203
1229
|
logger: log,
|
|
1204
1230
|
// Absolute, because whoever approves may be in any directory at all.
|
|
1205
1231
|
approveHint: (actionId) => `${cliCommandFrom(import.meta.url)} approve ${actionId.slice(0, 8)} --journal ${resolve(argv.journal)}`
|
|
@@ -88,19 +88,46 @@ tools:
|
|
|
88
88
|
path: "$.path"
|
|
89
89
|
content: "$snapshot.content"
|
|
90
90
|
|
|
91
|
-
# Reversible
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
91
|
+
# Reversible exactly when the destination did not exist, which is what
|
|
92
|
+
# `expect: absent` says.
|
|
93
|
+
#
|
|
94
|
+
# Moving onto a path where nothing is, is undone by moving the file back:
|
|
95
|
+
# the state this replaces is absence, and moving it off restores absence
|
|
96
|
+
# exactly. Moving onto a file that does exist overwrites it, and one inverse
|
|
97
|
+
# cannot both move the file back and restore what it landed on -- undo would
|
|
98
|
+
# report success over a file it had destroyed.
|
|
99
|
+
#
|
|
100
|
+
# Both cases were gated before, because the machinery read "the pre-read
|
|
101
|
+
# found nothing" as "there is nothing to put back" -- true of a write, and
|
|
102
|
+
# precisely backwards here, where finding nothing is the safe case. Measured
|
|
103
|
+
# both ways against the real server: as a plain reversible rule the safe move
|
|
104
|
+
# came back `partial` with the file still moved, and the dangerous one came
|
|
105
|
+
# back `rolled_back` with the overwritten file gone.
|
|
106
|
+
#
|
|
107
|
+
# And the pre-read is load-bearing, not belt and braces. This server's own
|
|
108
|
+
# description of move_file says "If the destination exists, the operation
|
|
109
|
+
# will fail" -- it does not. It renames over the top, silently, because
|
|
110
|
+
# that is what rename(2) does on POSIX. Taking the description at its word
|
|
111
|
+
# would have made this rule unconditionally reversible and the overwrite
|
|
112
|
+
# unrecoverable and unremarked. tests/expect-absent.test.ts pins the real
|
|
113
|
+
# behaviour so a version that starts matching its documentation is a
|
|
114
|
+
# failing test rather than a surprise.
|
|
101
115
|
- match: "fs.move_file"
|
|
102
|
-
class:
|
|
103
|
-
|
|
116
|
+
class: reversible
|
|
117
|
+
snapshot:
|
|
118
|
+
tool: "fs.read_text_file"
|
|
119
|
+
args:
|
|
120
|
+
path: "$.destination"
|
|
121
|
+
# Same reasoning as the two writes above: anything that is not absence
|
|
122
|
+
# is a failed read, not an empty destination, and a destination that
|
|
123
|
+
# exists but cannot be read must not be treated as a free space.
|
|
124
|
+
absent_when: ["ENOENT", "no such file"]
|
|
125
|
+
expect: absent
|
|
126
|
+
inverse:
|
|
127
|
+
tool: "fs.move_file"
|
|
128
|
+
args:
|
|
129
|
+
source: "$.destination"
|
|
130
|
+
destination: "$.source"
|
|
104
131
|
|
|
105
132
|
# No rmdir exists on this server, so a directory once created stays.
|
|
106
133
|
- match: "fs.create_directory"
|
package/manifests/memory.yaml
CHANGED
|
@@ -42,19 +42,14 @@ tools:
|
|
|
42
42
|
# undoing by argument would delete a Grace who was there long before the
|
|
43
43
|
# agent ran. The result carries only what was actually created.
|
|
44
44
|
#
|
|
45
|
-
# $result.entities, not $result
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# the
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
# disagree: the text is a bare list of entities while structuredContent wraps
|
|
54
|
-
# it as {"entities": [...]}. The structured block is the machine-readable
|
|
55
|
-
# contract, so that is the one a path walks. Writing $result[].name here is
|
|
56
|
-
# the natural mistake, and it was the first thing this policy got wrong
|
|
57
|
-
# against the live server.
|
|
45
|
+
# $result.entities, not $result. $result is the structured block, not the
|
|
46
|
+
# text one, and here the two disagree: the text is a bare list of entities
|
|
47
|
+
# while structuredContent wraps it as {"entities": [...]}. The structured
|
|
48
|
+
# block is the machine-readable contract, so that is the one a path walks.
|
|
49
|
+
# Writing $result[].name is the natural mistake, and it was the first thing
|
|
50
|
+
# this policy got wrong against the live server -- the same mistake the
|
|
51
|
+
# filesystem policy made, and the reason neither can be trusted until it has
|
|
52
|
+
# met one.
|
|
58
53
|
- match: "memory.create_entities"
|
|
59
54
|
class: compensable
|
|
60
55
|
inverse:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synartesis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "An undo layer for AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -57,7 +57,8 @@
|
|
|
57
57
|
"app:dist": "pnpm app:build && node app/build/pack.mjs",
|
|
58
58
|
"demo": "tsup --silent && ./demo/filesystem-demo.sh",
|
|
59
59
|
"demo:memory": "tsup --silent && ./demo/memory-demo.sh",
|
|
60
|
-
"
|
|
60
|
+
"check": "pnpm build && pnpm typecheck && pnpm lint && pnpm test",
|
|
61
|
+
"prepublishOnly": "pnpm check"
|
|
61
62
|
},
|
|
62
63
|
"dependencies": {
|
|
63
64
|
"@modelcontextprotocol/sdk": "^1.30.0",
|