synartesis 0.6.20 → 0.7.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 +174 -0
- package/README.md +28 -7
- package/SECURITY.md +2 -2
- package/dist/{chunk-SZHVLCJR.js → chunk-FXSKWMNG.js} +65 -2
- package/dist/cli.js +83 -23
- package/dist/proxy.js +40 -13
- package/manifests/filesystem.yaml +0 -3
- package/manifests/memory.yaml +16 -15
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,180 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.7.0 — 2026-09-16
|
|
6
|
+
|
|
7
|
+
A pass over everything, after an audit of the desktop app, the core and the
|
|
8
|
+
release. Most of what it found was not broken code but things that stated
|
|
9
|
+
something untrue.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Every desktop binary since 0.6.12 carried the wrong version.**
|
|
14
|
+
electron-builder reads the version from `app/package.json`, which nothing
|
|
15
|
+
else reads, so it stayed put while the root moved. The nine installers
|
|
16
|
+
attached to v0.6.23 were all named `0.6.12`, byte-identical in name to the
|
|
17
|
+
ones on v0.6.12 — two people on different releases could not tell their
|
|
18
|
+
builds apart. `app-packaging.test.ts` already checked seven agreements
|
|
19
|
+
between the packaging files; the absence of the eighth is why this ran for
|
|
20
|
+
eleven releases.
|
|
21
|
+
|
|
22
|
+
- **A spent approval read as "refused" in `watch` and the console.** `labelFor`
|
|
23
|
+
was written for exactly this and `list` and `show` were taught to use it;
|
|
24
|
+
`plainly` was not — and `plainly` is what the two live screens render, so a
|
|
25
|
+
person was told their own yes had been a no, seconds after giving it.
|
|
26
|
+
|
|
27
|
+
- **`undo --dry-run --force` printed no plan.** It showed what forcing would
|
|
28
|
+
write over and returned, so the flag whose whole job is to print the plan
|
|
29
|
+
printed none. A dry run no longer needs `--yes` either: the two-step ask
|
|
30
|
+
protects a write, and a preview writes nothing.
|
|
31
|
+
|
|
32
|
+
- **A `verify:` read was never validated.** `snapshot` and `inverse` are
|
|
33
|
+
checked against the servers at load; `verify` was not, and it is the one
|
|
34
|
+
whose failure is silent — the proxy turns it into a soft warning and carries
|
|
35
|
+
on, so a typo meant that tool had no drift detection at all while both the
|
|
36
|
+
policy and `check` said it was fine.
|
|
37
|
+
|
|
38
|
+
- **A failed inverse on the lost-answer path was swallowed whole.** An inverse
|
|
39
|
+
reading `$result.` cannot be rebuilt when the answer is what went missing,
|
|
40
|
+
and undo reported "cannot be undone" without saying why — on the path where
|
|
41
|
+
undo matters most.
|
|
42
|
+
|
|
43
|
+
- **`--client` with a typo blamed the machine**, reporting "No MCP client
|
|
44
|
+
config was found on this machine". On `uninstall` it said "Nothing was
|
|
45
|
+
covered, so nothing was changed", which reads as reassurance.
|
|
46
|
+
|
|
47
|
+
- **`undo --to` always exited 1.** A floor makes a run `partial` by
|
|
48
|
+
construction, correctly, and the exit code was read off that — so an undo
|
|
49
|
+
that did exactly what it was told could not be told from one that halted on
|
|
50
|
+
somebody's edit.
|
|
51
|
+
|
|
52
|
+
- **`status` read "when was this server last used" off the newest five hundred
|
|
53
|
+
actions**, so a server whose last use had scrolled out of that window came
|
|
54
|
+
back as never used: "covered, nothing through it yet". Asked exactly now,
|
|
55
|
+
from a covering index — 75ms to 3.5ms on sixty thousand actions.
|
|
56
|
+
|
|
57
|
+
### Fixed — the desktop window
|
|
58
|
+
|
|
59
|
+
- **With no policy the window could not be quit.** The branch that drew the
|
|
60
|
+
no-policy screen returned before the IPC handlers were registered and before
|
|
61
|
+
the quit handlers were attached; on Windows and Linux that left a process
|
|
62
|
+
running with no window. Everything after `whenReady` is now one path in
|
|
63
|
+
`boot.ts`, which imports no Electron — which is also how the key-page
|
|
64
|
+
allowlist became testable.
|
|
65
|
+
|
|
66
|
+
- **Two spurious errors before the no-policy screen.** The window asked the
|
|
67
|
+
engine two questions the moment it mounted, which with no policy came back as
|
|
68
|
+
"No handler registered" and were drawn as errors. The push is now a pull.
|
|
69
|
+
|
|
70
|
+
- **`Bridge` was declared twice and neither copy was connected to the
|
|
71
|
+
preload** — the only one that was true. They had already drifted. The shared
|
|
72
|
+
one survives, the exposed object is annotated rather than inferred, and both
|
|
73
|
+
the compiler and a test now catch a future drift.
|
|
74
|
+
|
|
75
|
+
- **A server that would not start vanished**: no event, no log, nothing in the
|
|
76
|
+
window, and a briefing that went on announcing it as connected. It now
|
|
77
|
+
reaches the transcript with the server's own words, and the model is told not
|
|
78
|
+
to plan around tools that are not there.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- `pnpm check` runs build, typecheck, lint and the suite in one command.
|
|
83
|
+
- The release workflow runs that gate before building installers, builds the
|
|
84
|
+
desktop app in CI, runs the memory demo, and takes its release notes from
|
|
85
|
+
`CHANGELOG.md` instead of a list of commit subjects.
|
|
86
|
+
- `npm publish` runs the whole gate rather than only a build.
|
|
87
|
+
- `list --json` and `show --json` both carry `actionCount`, so one name means
|
|
88
|
+
one thing in both. The existing `actions` shape is untouched.
|
|
89
|
+
- The site points at `/releases/latest` rather than a pinned version.
|
|
90
|
+
- The user guide covers `install`, `uninstall`, `status` and `desktop`, and is
|
|
91
|
+
no longer stamped twenty releases back. `CONTRIBUTING.md` no longer shows a
|
|
92
|
+
manifest syntax that throws on load, or asks for a C toolchain the project
|
|
93
|
+
disables.
|
|
94
|
+
|
|
95
|
+
## 0.6.23 — 2026-09-16
|
|
96
|
+
|
|
97
|
+
### Docs
|
|
98
|
+
|
|
99
|
+
- **What to do with a tool that does two things at once.** A call that both
|
|
100
|
+
writes something recoverable and does something that is not -- saves a file
|
|
101
|
+
and posts to an API, updates a record and sends the email about it -- takes
|
|
102
|
+
the class of its least recoverable part, because a rule matches a tool name
|
|
103
|
+
and gives it one class. The guide now says so, says why the alternative is
|
|
104
|
+
worse (undo puts the file back, reports `rolled_back`, and says nothing about
|
|
105
|
+
the request still out there), points at `move_file` as the shipped example of
|
|
106
|
+
the same shape, and shows the one way out: a `compensable` rule whose inverse
|
|
107
|
+
neutralises the unrecoverable half. Asked often enough to be worth writing
|
|
108
|
+
down rather than answering again.
|
|
109
|
+
|
|
110
|
+
## 0.6.22 — 2026-09-16
|
|
111
|
+
|
|
112
|
+
### Added
|
|
113
|
+
|
|
114
|
+
- **The memory policy's undo is now proven, not just plausible.** It has always
|
|
115
|
+
declared `provenance: live` -- the tools were checked against the real server
|
|
116
|
+
and the shapes read off its own answers -- while the README said its recovery
|
|
117
|
+
guarantees were unproven. Both were true, and the gap between them is where a
|
|
118
|
+
policy can name every tool correctly, take exactly the arguments the server
|
|
119
|
+
wants, and still resolve an inverse that puts nothing back. That failure
|
|
120
|
+
looks like success: the drift check passes and the report says `rolled_back`.
|
|
121
|
+
|
|
122
|
+
`tests/adapter-memory.test.ts` makes each change against a real knowledge
|
|
123
|
+
graph, undoes it, and compares the file. What the agent added is gone and
|
|
124
|
+
what was already there is untouched; an entity the agent only *tried* to
|
|
125
|
+
create -- this server ignores a duplicate name -- is left alone rather than
|
|
126
|
+
deleted out from under its owner; a relation drawn or removed goes back; and
|
|
127
|
+
a delete of an entity is held for a person rather than approximated, because
|
|
128
|
+
one inverse cannot put back both the entity and the relations that went with
|
|
129
|
+
it. Every one of those was confirmed by breaking the policy and watching the
|
|
130
|
+
matching test fail.
|
|
131
|
+
|
|
132
|
+
Two of the four shipped policies are now round-tripped end to end.
|
|
133
|
+
`synartesis check`, the README and both guides say which, and `git` is still
|
|
134
|
+
named plainly as untested.
|
|
135
|
+
|
|
136
|
+
- `check` also confirms the memory policy covers every tool that server offers,
|
|
137
|
+
so a server that grows one fails here rather than in front of somebody's
|
|
138
|
+
agent.
|
|
139
|
+
|
|
140
|
+
## 0.6.21 — 2026-09-16
|
|
141
|
+
|
|
142
|
+
### Fixed
|
|
143
|
+
|
|
144
|
+
- **`live` claimed more than it meant, and the docs contradicted each other
|
|
145
|
+
about it.** `provenance: live` says a policy has met its server and that the
|
|
146
|
+
tools take the arguments it passes them. `check` printed that as "checked
|
|
147
|
+
against the real server", which reads as a claim that undo works -- and it is
|
|
148
|
+
not one: a policy can be right about every tool name and still record an
|
|
149
|
+
inverse that restores nothing. Meanwhile the README said memory's recovery
|
|
150
|
+
guarantees were unproven while the user guide listed memory among the
|
|
151
|
+
policies that say `live`, so a reader who saw one came away with the opposite
|
|
152
|
+
of what the other meant. `check` now says "shapes read from the real server"
|
|
153
|
+
and adds, once under the server list, that `live` is not a recovery claim and
|
|
154
|
+
that only filesystem has been round-tripped. README and both guides say the
|
|
155
|
+
same thing.
|
|
156
|
+
|
|
157
|
+
### Added
|
|
158
|
+
|
|
159
|
+
- **`check` names the tools no policy covers, instead of describing the rule.**
|
|
160
|
+
An unmatched tool has always been fail-closed -- irreversible, held for a
|
|
161
|
+
person the first time it is called -- and `check` said so as a sentence about
|
|
162
|
+
tools in general while the actual list sat one round trip away. It already
|
|
163
|
+
connects to every server and reads the whole tool list to verify the
|
|
164
|
+
policies, so the answer was in hand and thrown away, and the first anybody
|
|
165
|
+
learned a tool was ungoverned was an agent stopping on it mid-task. Now
|
|
166
|
+
named, per server, with a count. The proxy warns at startup for the same
|
|
167
|
+
reason, and `proxy ready` carries an `ungoverned` count so a policy with no
|
|
168
|
+
gaps and a build that forgot to look do not read alike.
|
|
169
|
+
|
|
170
|
+
For what almost everybody runs, the answer is none: the shipped filesystem
|
|
171
|
+
policy covers every tool that server offers, and there is a test that will
|
|
172
|
+
fail if that stops being true.
|
|
173
|
+
|
|
174
|
+
- The build targeted `node20` while the package requires `>=22`, which is the
|
|
175
|
+
floor better-sqlite3 sets -- on Node 20 it segfaults the moment a database
|
|
176
|
+
opens. Targeting lower was harmless but said the wrong thing about what this
|
|
177
|
+
supports, in the one place a reader could check. Now `node22`.
|
|
178
|
+
|
|
5
179
|
## 0.6.20 — 2026-09-15
|
|
6
180
|
|
|
7
181
|
### Fixed
|
package/README.md
CHANGED
|
@@ -333,10 +333,25 @@ cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
|
|
|
333
333
|
resolved when the call happens, so a mistake in a manifest is baked into every
|
|
334
334
|
run made under it. `undo --replan` rebuilds them from a corrected one.
|
|
335
335
|
|
|
336
|
-
|
|
337
|
-
byte-for-byte restoration, drift refusal,
|
|
338
|
-
|
|
339
|
-
|
|
336
|
+
Two bundled policies are tested against the real server, by making the change
|
|
337
|
+
and undoing it. **filesystem**: exact byte-for-byte restoration, drift refusal,
|
|
338
|
+
and absence told apart from a read that failed. **memory**: the graph is put
|
|
339
|
+
back as it was, entities the agent only tried to create are left alone, and a
|
|
340
|
+
delete of an entity is held rather than approximated. **git and github** are
|
|
341
|
+
checked only for tool existence — their recovery guarantees are not yet proven.
|
|
342
|
+
|
|
343
|
+
All three of filesystem, memory and git declare `provenance: live`, and that
|
|
344
|
+
word is narrower than it looks: it says the policy has met its server and the
|
|
345
|
+
tools take the arguments it passes them, not that undo has been round-tripped.
|
|
346
|
+
`synartesis check` says so under the server list rather than leaving `live` to
|
|
347
|
+
stand for both.
|
|
348
|
+
|
|
349
|
+
**A tool no policy mentions is irreversible and held for a person** the first
|
|
350
|
+
time it is called. That is the safe end of the trade, and it means a server
|
|
351
|
+
that gains a tool in an update does not quietly get a free pass — but it also
|
|
352
|
+
means an agent stopping mid-task on a call nobody expected. `synartesis check`
|
|
353
|
+
names every such tool, and the proxy warns about them at startup, so you can
|
|
354
|
+
write a policy before meeting one rather than after.
|
|
340
355
|
|
|
341
356
|
## Trust
|
|
342
357
|
|
|
@@ -370,11 +385,17 @@ pnpm test
|
|
|
370
385
|
```
|
|
371
386
|
|
|
372
387
|
```bash
|
|
373
|
-
pnpm
|
|
388
|
+
pnpm check
|
|
374
389
|
```
|
|
375
390
|
|
|
376
|
-
Every push runs
|
|
377
|
-
and the
|
|
391
|
+
Every push runs that on Linux and macOS across Node 22 and 24, plus both demos,
|
|
392
|
+
the installer, and a build of the desktop app.
|
|
393
|
+
|
|
394
|
+
**Windows is built and not tested.** The release attaches a Windows installer,
|
|
395
|
+
and no CI job compiles or exercises it — the test matrix is Linux and macOS. It
|
|
396
|
+
is expected to work, the code has no platform-specific paths outside
|
|
397
|
+
`src/locate.ts` and `src/install/clients.ts`, and nobody has proved it. If you
|
|
398
|
+
run Windows and something is wrong there, that is worth an issue.
|
|
378
399
|
|
|
379
400
|
## Licence
|
|
380
401
|
|
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",
|
|
@@ -1272,6 +1314,13 @@ function validate(source, manifest) {
|
|
|
1272
1314
|
}
|
|
1273
1315
|
checkCall(source, [...path, "inverse"], policy.inverse, servers, allowed);
|
|
1274
1316
|
}
|
|
1317
|
+
if (policy.verify !== void 0) {
|
|
1318
|
+
const allowed = ["$.", "$result."];
|
|
1319
|
+
if (policy.snapshot !== void 0) {
|
|
1320
|
+
allowed.push("$snapshot.");
|
|
1321
|
+
}
|
|
1322
|
+
checkCall(source, [...path, "verify"], policy.verify, servers, allowed);
|
|
1323
|
+
}
|
|
1275
1324
|
});
|
|
1276
1325
|
}
|
|
1277
1326
|
function withGate(policy) {
|
|
@@ -1554,18 +1603,30 @@ function untested(manifest) {
|
|
|
1554
1603
|
function describeStanding(entry) {
|
|
1555
1604
|
switch (entry.provenance) {
|
|
1556
1605
|
case "live":
|
|
1557
|
-
return "
|
|
1606
|
+
return "shapes read from the real server";
|
|
1558
1607
|
case "documented":
|
|
1559
1608
|
return "written from documentation, never run against the real server";
|
|
1560
1609
|
case "unstated":
|
|
1561
1610
|
return "no claim either way";
|
|
1562
1611
|
}
|
|
1563
1612
|
}
|
|
1613
|
+
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.";
|
|
1564
1614
|
function warnUntested(servers) {
|
|
1565
1615
|
const names = servers.join(", ");
|
|
1566
1616
|
const these = servers.length === 1 ? "this policy has" : "these policies have";
|
|
1567
1617
|
return `${names}: ${these} never been run against the real server. The classes and inverses here come from documentation, so undo may not work where it says it will. Run \`synartesis check\` against your own credentials, and expect to correct something.`;
|
|
1568
1618
|
}
|
|
1619
|
+
function ungoverned(manifest, advertised) {
|
|
1620
|
+
const resolver = createPolicyResolver(manifest);
|
|
1621
|
+
const found = [];
|
|
1622
|
+
for (const server of [...advertised.keys()].sort()) {
|
|
1623
|
+
const tools = (advertised.get(server) ?? []).filter((tool) => !resolver.resolve(qualify(server, tool)).matched).sort();
|
|
1624
|
+
if (tools.length > 0) {
|
|
1625
|
+
found.push({ server, tools });
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return found;
|
|
1629
|
+
}
|
|
1569
1630
|
|
|
1570
1631
|
// src/proxy/routing.ts
|
|
1571
1632
|
var SEPARATOR = "__";
|
|
@@ -1915,7 +1976,9 @@ export {
|
|
|
1915
1976
|
standing,
|
|
1916
1977
|
untested,
|
|
1917
1978
|
describeStanding,
|
|
1979
|
+
LIVE_IS_NOT_RECOVERY,
|
|
1918
1980
|
warnUntested,
|
|
1981
|
+
ungoverned,
|
|
1919
1982
|
IDEMPOTENCY_META_KEY,
|
|
1920
1983
|
withIdempotencyKey,
|
|
1921
1984
|
createRouter,
|
|
@@ -1930,4 +1993,4 @@ export {
|
|
|
1930
1993
|
observeState,
|
|
1931
1994
|
connectStdioUpstream
|
|
1932
1995
|
};
|
|
1933
|
-
//# sourceMappingURL=chunk-
|
|
1996
|
+
//# sourceMappingURL=chunk-FXSKWMNG.js.map
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
IDEMPOTENCY_META_KEY,
|
|
4
|
+
LIVE_IS_NOT_RECOVERY,
|
|
4
5
|
NOTHING_RECORDED_YET,
|
|
5
6
|
PROXY_FLAGS,
|
|
6
7
|
WORDMARK,
|
|
@@ -32,11 +33,12 @@ import {
|
|
|
32
33
|
toPayload,
|
|
33
34
|
toResolvedRead,
|
|
34
35
|
toolShapes,
|
|
36
|
+
ungoverned,
|
|
35
37
|
untested,
|
|
36
38
|
verifyAgainstServers,
|
|
37
39
|
warnUntested,
|
|
38
40
|
wasRefused
|
|
39
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-FXSKWMNG.js";
|
|
40
42
|
import {
|
|
41
43
|
DriftConflict,
|
|
42
44
|
ManifestError,
|
|
@@ -1074,7 +1076,7 @@ function plainly(action) {
|
|
|
1074
1076
|
case "rolling_back":
|
|
1075
1077
|
return { text: "undoing", needs: false };
|
|
1076
1078
|
case "denied":
|
|
1077
|
-
return { text: "refused", needs: false };
|
|
1079
|
+
return labelFor(action) === "used" ? { text: "used", needs: false } : { text: "refused", needs: false };
|
|
1078
1080
|
case "failed":
|
|
1079
1081
|
return { text: "failed", needs: false };
|
|
1080
1082
|
case "approved":
|
|
@@ -1524,6 +1526,12 @@ var LABELS = {
|
|
|
1524
1526
|
cursor: "Cursor",
|
|
1525
1527
|
codex: "Codex"
|
|
1526
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
|
+
}
|
|
1527
1535
|
function claudeDesktopPath() {
|
|
1528
1536
|
const home = homedir();
|
|
1529
1537
|
switch (platform()) {
|
|
@@ -1944,14 +1952,7 @@ function applyUninstall(sites, manifestPath) {
|
|
|
1944
1952
|
// src/install/connections.ts
|
|
1945
1953
|
var ACTIVE_WITHIN_MS = 2 * 60 * 1e3;
|
|
1946
1954
|
function lastSeenByServer(journal) {
|
|
1947
|
-
|
|
1948
|
-
for (const action of journal.recentActions(500)) {
|
|
1949
|
-
const known = seen.get(action.server);
|
|
1950
|
-
if (known === void 0 || action.ts > known) {
|
|
1951
|
-
seen.set(action.server, action.ts);
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
return seen;
|
|
1955
|
+
return journal.lastSeenPerServer();
|
|
1955
1956
|
}
|
|
1956
1957
|
function commandMissing(command) {
|
|
1957
1958
|
if (command === void 0) {
|
|
@@ -2846,7 +2847,8 @@ Most commands end by naming the one thing worth doing next, worked out from
|
|
|
2846
2847
|
what is actually in the journal rather than from what was typed. Set
|
|
2847
2848
|
SYNARTESIS_NO_HINTS to turn that off; --json never carries it.
|
|
2848
2849
|
|
|
2849
|
-
Exit codes: 0
|
|
2850
|
+
Exit codes: 0 did what was asked, 1 stopped or left something in place,
|
|
2851
|
+
2 bad usage or configuration.
|
|
2850
2852
|
`;
|
|
2851
2853
|
var UsageError = class extends Error {
|
|
2852
2854
|
/**
|
|
@@ -2938,6 +2940,20 @@ async function runPin(argv) {
|
|
|
2938
2940
|
out("");
|
|
2939
2941
|
return 0;
|
|
2940
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
|
+
}
|
|
2941
2957
|
async function runCheck(argv) {
|
|
2942
2958
|
const path = findManifest(flag(argv, "--manifest"));
|
|
2943
2959
|
const manifest = loadManifest(path);
|
|
@@ -2949,6 +2965,7 @@ async function runCheck(argv) {
|
|
|
2949
2965
|
}
|
|
2950
2966
|
}
|
|
2951
2967
|
const upstreams = [];
|
|
2968
|
+
const offered = /* @__PURE__ */ new Map();
|
|
2952
2969
|
try {
|
|
2953
2970
|
for (const [name, spec] of Object.entries(manifest.servers)) {
|
|
2954
2971
|
upstreams.push(
|
|
@@ -2962,6 +2979,9 @@ async function runCheck(argv) {
|
|
|
2962
2979
|
);
|
|
2963
2980
|
}
|
|
2964
2981
|
await verifyAgainstServers(upstreams, manifest);
|
|
2982
|
+
for (const upstream of upstreams) {
|
|
2983
|
+
offered.set(upstream.name, (await toolShapes(upstream)).map((tool) => tool.name));
|
|
2984
|
+
}
|
|
2965
2985
|
} finally {
|
|
2966
2986
|
for (const upstream of upstreams) {
|
|
2967
2987
|
await upstream.close();
|
|
@@ -2983,6 +3003,11 @@ async function runCheck(argv) {
|
|
|
2983
3003
|
` ${style.quiet(" ")} ${style.strong(entry.server)} ${entry.provenance === "documented" ? style.accent(said) : style.quiet(said)}`
|
|
2984
3004
|
);
|
|
2985
3005
|
}
|
|
3006
|
+
if (standing(manifest).some((entry) => entry.provenance === "live")) {
|
|
3007
|
+
for (const line2 of wrapped(LIVE_IS_NOT_RECOVERY, 66)) {
|
|
3008
|
+
out(` ${style.quiet(" ")} ${style.quiet(line2)}`);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
2986
3011
|
out(` ${style.quiet("policies")} ${[...counts].map(([k, v]) => `${String(v)} ${k}`).join(", ")}`);
|
|
2987
3012
|
out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
|
|
2988
3013
|
const pinned = Object.entries(manifest.pins ?? {});
|
|
@@ -2992,7 +3017,25 @@ async function runCheck(argv) {
|
|
|
2992
3017
|
` ${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(", ")}`))}`
|
|
2993
3018
|
);
|
|
2994
3019
|
out("");
|
|
2995
|
-
|
|
3020
|
+
const uncovered = ungoverned(manifest, offered);
|
|
3021
|
+
if (uncovered.length === 0) {
|
|
3022
|
+
out(` ${style.quiet("Every tool these servers offer has a policy.")}`);
|
|
3023
|
+
} else {
|
|
3024
|
+
const total = uncovered.reduce((sum, entry) => sum + entry.tools.length, 0);
|
|
3025
|
+
out(
|
|
3026
|
+
` ${style.accent("guarded by default")} ${style.quiet(
|
|
3027
|
+
`${String(total)} tool${total === 1 ? "" : "s"} here ${total === 1 ? "has" : "have"} no policy, so ${total === 1 ? "it is" : "they are"} treated as`
|
|
3028
|
+
)}`
|
|
3029
|
+
);
|
|
3030
|
+
out(` ${style.quiet("irreversible and held for a person the first time an agent calls")}`);
|
|
3031
|
+
out(` ${style.quiet(`${total === 1 ? "it" : "one"}. Write a policy for any you would rather it got on with.`)}`);
|
|
3032
|
+
out("");
|
|
3033
|
+
for (const entry of uncovered) {
|
|
3034
|
+
for (const line2 of wrapped(entry.tools.join(", "), 60)) {
|
|
3035
|
+
out(` ${style.quiet(entry.server.padEnd(8))} ${style.strong(line2)}`);
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
2996
3039
|
out("");
|
|
2997
3040
|
hint(
|
|
2998
3041
|
firstOf(
|
|
@@ -3008,7 +3051,7 @@ async function runCheck(argv) {
|
|
|
3008
3051
|
}
|
|
3009
3052
|
async function runInstall(argv) {
|
|
3010
3053
|
const manifestPath = findManifest(flag(argv, "--manifest"));
|
|
3011
|
-
const only =
|
|
3054
|
+
const only = namedClient(argv);
|
|
3012
3055
|
const dryRun = argv.includes("--dry-run");
|
|
3013
3056
|
const printOnly = argv.includes("--print");
|
|
3014
3057
|
const sites = discover(process.cwd()).filter(
|
|
@@ -3093,7 +3136,7 @@ async function runInstall(argv) {
|
|
|
3093
3136
|
}
|
|
3094
3137
|
async function runUninstall(argv) {
|
|
3095
3138
|
const manifestPath = findManifest(flag(argv, "--manifest"));
|
|
3096
|
-
const only =
|
|
3139
|
+
const only = namedClient(argv);
|
|
3097
3140
|
const sites = discover(process.cwd()).filter(
|
|
3098
3141
|
(site) => only === void 0 || site.client === only
|
|
3099
3142
|
);
|
|
@@ -3367,7 +3410,14 @@ function runList(journal, asJson, journalPath) {
|
|
|
3367
3410
|
const tally2 = journal.tallyRuns();
|
|
3368
3411
|
const counted = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
|
|
3369
3412
|
if (asJson) {
|
|
3370
|
-
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
|
+
);
|
|
3371
3421
|
return 0;
|
|
3372
3422
|
}
|
|
3373
3423
|
if (runs.length === 0) {
|
|
@@ -3423,6 +3473,9 @@ async function runShow(argv, journal, asJson) {
|
|
|
3423
3473
|
JSON.stringify({
|
|
3424
3474
|
run,
|
|
3425
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,
|
|
3426
3479
|
...inspection === void 0 ? {} : { live: inspection.resources }
|
|
3427
3480
|
})
|
|
3428
3481
|
);
|
|
@@ -3878,7 +3931,7 @@ function report(result, alreadyForcing = false, as = "") {
|
|
|
3878
3931
|
needs: ["manifest", "journal"]
|
|
3879
3932
|
});
|
|
3880
3933
|
}
|
|
3881
|
-
return result.
|
|
3934
|
+
return result.halted === void 0 && permanent.length === 0 ? 0 : 1;
|
|
3882
3935
|
}
|
|
3883
3936
|
async function withUpstreams(manifestPath, use, only) {
|
|
3884
3937
|
const manifest = loadManifest(manifestPath);
|
|
@@ -3996,6 +4049,8 @@ async function runUndo(argv, journal) {
|
|
|
3996
4049
|
if (said && !forcing) {
|
|
3997
4050
|
process.stderr.write("synartesis: --yes only means anything with --force; ignoring it\n");
|
|
3998
4051
|
}
|
|
4052
|
+
const dryRun = argv.includes("--dry-run");
|
|
4053
|
+
const forcePlan = forcing && (said || dryRun);
|
|
3999
4054
|
if (forcing && !said) {
|
|
4000
4055
|
const over = (await withUpstreams(
|
|
4001
4056
|
manifestPath,
|
|
@@ -4019,25 +4074,30 @@ async function runUndo(argv, journal) {
|
|
|
4019
4074
|
}
|
|
4020
4075
|
}
|
|
4021
4076
|
out("");
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
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
|
+
}
|
|
4026
4083
|
}
|
|
4027
4084
|
}
|
|
4028
4085
|
const replan = argv.includes("--replan");
|
|
4029
4086
|
return report(
|
|
4030
4087
|
await performUndo(manifestPath, journal, runId, {
|
|
4031
|
-
dryRun
|
|
4088
|
+
dryRun,
|
|
4032
4089
|
...toSeq === void 0 ? {} : { toSeq },
|
|
4033
4090
|
replan,
|
|
4034
|
-
...
|
|
4091
|
+
...forcePlan ? { force: true } : {}
|
|
4035
4092
|
}),
|
|
4036
4093
|
forcing,
|
|
4037
4094
|
// --to is deliberately absent, and cannot reach here: a floor leaves
|
|
4038
4095
|
// actions below it alone, which makes the result `partial`, and the hint
|
|
4039
4096
|
// is only offered on `rolled_back`.
|
|
4040
|
-
|
|
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" : ""}`
|
|
4041
4101
|
);
|
|
4042
4102
|
}
|
|
4043
4103
|
var KNOWN_COMMANDS = [
|
package/dist/proxy.js
CHANGED
|
@@ -19,11 +19,13 @@ import {
|
|
|
19
19
|
refusal,
|
|
20
20
|
runRead,
|
|
21
21
|
toPayload,
|
|
22
|
+
toolShapes,
|
|
23
|
+
ungoverned,
|
|
22
24
|
untested,
|
|
23
25
|
verifyAgainstServers,
|
|
24
26
|
warnUntested,
|
|
25
27
|
withIdempotencyKey
|
|
26
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-FXSKWMNG.js";
|
|
27
29
|
import {
|
|
28
30
|
SnapshotError,
|
|
29
31
|
UpstreamError,
|
|
@@ -397,6 +399,18 @@ function compatible(tool, seen) {
|
|
|
397
399
|
}
|
|
398
400
|
return { ...tool, outputSchema: withoutDialect(tool["outputSchema"], seen) };
|
|
399
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
|
+
}
|
|
400
414
|
function createProxyServer(options) {
|
|
401
415
|
const { upstreams, manifest, journal } = options;
|
|
402
416
|
const router = createRouter(upstreams, manifest);
|
|
@@ -840,19 +854,19 @@ function createProxyServer(options) {
|
|
|
840
854
|
} else if (settled === "unknown") {
|
|
841
855
|
journal.markUnknown(pending.actionId, describe(error));
|
|
842
856
|
} else {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
recovered = void 0;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
857
|
+
const recovered = recoverInverse(
|
|
858
|
+
policy,
|
|
859
|
+
{ args, snapshot },
|
|
860
|
+
missingPriorState !== void 0
|
|
861
|
+
);
|
|
851
862
|
journal.markApplied(pending.actionId, {
|
|
852
863
|
result: void 0,
|
|
853
|
-
...recovered === void 0 ? {} : { inverse: recovered },
|
|
864
|
+
...recovered.inverse === void 0 ? {} : { inverse: recovered.inverse },
|
|
854
865
|
...verify === void 0 ? {} : { verify },
|
|
855
|
-
warning:
|
|
866
|
+
warning: [
|
|
867
|
+
`the call applied but its answer never arrived: ${describe(error)}`,
|
|
868
|
+
...recovered.warning === void 0 ? [] : [recovered.warning]
|
|
869
|
+
].join("; ")
|
|
856
870
|
});
|
|
857
871
|
}
|
|
858
872
|
}
|
|
@@ -1170,12 +1184,26 @@ async function main() {
|
|
|
1170
1184
|
tools: manifest.tools.filter((rule) => rule.match.startsWith(`${String(argv.server)}.`))
|
|
1171
1185
|
}
|
|
1172
1186
|
);
|
|
1187
|
+
const uncovered = ungoverned(
|
|
1188
|
+
manifest,
|
|
1189
|
+
new Map(await Promise.all(upstreams.map(async (upstream) => [
|
|
1190
|
+
upstream.name,
|
|
1191
|
+
(await toolShapes(upstream)).map((tool) => tool.name)
|
|
1192
|
+
])))
|
|
1193
|
+
);
|
|
1194
|
+
for (const entry of uncovered) {
|
|
1195
|
+
log.warn(
|
|
1196
|
+
{ server: entry.server, tools: entry.tools },
|
|
1197
|
+
"no policy covers these tools; they will be held for approval when called"
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1173
1200
|
log.info(
|
|
1174
1201
|
{
|
|
1175
1202
|
manifest: argv.manifest,
|
|
1176
1203
|
journal: argv.journal,
|
|
1177
1204
|
servers: upstreams.map((upstream) => upstream.name),
|
|
1178
|
-
policies: manifest.tools.length
|
|
1205
|
+
policies: manifest.tools.length,
|
|
1206
|
+
ungoverned: uncovered.reduce((sum, entry) => sum + entry.tools.length, 0)
|
|
1179
1207
|
},
|
|
1180
1208
|
"proxy ready"
|
|
1181
1209
|
);
|
|
@@ -1183,7 +1211,6 @@ async function main() {
|
|
|
1183
1211
|
upstreams,
|
|
1184
1212
|
manifest,
|
|
1185
1213
|
journal,
|
|
1186
|
-
gateTimeoutMs: argv.gateTimeoutMs,
|
|
1187
1214
|
logger: log,
|
|
1188
1215
|
// Absolute, because whoever approves may be in any directory at all.
|
|
1189
1216
|
approveHint: (actionId) => `${cliCommandFrom(import.meta.url)} approve ${actionId.slice(0, 8)} --journal ${resolve(argv.journal)}`
|
|
@@ -88,9 +88,6 @@ tools:
|
|
|
88
88
|
path: "$.path"
|
|
89
89
|
content: "$snapshot.content"
|
|
90
90
|
|
|
91
|
-
# Reversible from its arguments alone: no pre-read could say anything the
|
|
92
|
-
# arguments do not already. Drift cannot be checked for the same reason, so
|
|
93
|
-
# undo reports it as unverified.
|
|
94
91
|
# Only reversible when the destination did not exist. Moving onto a file
|
|
95
92
|
# that did overwrites it, and moving back afterwards restores the source and
|
|
96
93
|
# leaves nothing where the destination's contents were: undo reports success
|
package/manifests/memory.yaml
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# Policy for @modelcontextprotocol/server-memory, the knowledge graph an agent
|
|
2
2
|
# keeps about you between sessions.
|
|
3
3
|
#
|
|
4
|
-
# Verified against the real server: every tool named here was listed
|
|
5
|
-
# the shapes below were read off its own responses rather than from
|
|
4
|
+
# Verified against the real server twice over: every tool named here was listed
|
|
5
|
+
# by it and the shapes below were read off its own responses rather than from
|
|
6
|
+
# docs -- and then the undo itself was run. tests/adapter-memory.test.ts makes
|
|
7
|
+
# each change against a real graph, undoes it, and compares the file: what the
|
|
8
|
+
# agent added is gone, what was already there is untouched, an entity it only
|
|
9
|
+
# tried to create is left alone, and a delete of an entity is held rather than
|
|
10
|
+
# approximated. Every one of those was checked by breaking this policy and
|
|
11
|
+
# watching the test fail.
|
|
6
12
|
#
|
|
7
13
|
# This is the server the taxonomy fits best. Almost everything it does has an
|
|
8
14
|
# exact opposite sitting next to it in the same toolbox, which is what
|
|
@@ -36,19 +42,14 @@ tools:
|
|
|
36
42
|
# undoing by argument would delete a Grace who was there long before the
|
|
37
43
|
# agent ran. The result carries only what was actually created.
|
|
38
44
|
#
|
|
39
|
-
# $result.entities, not $result
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# the
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# disagree: the text is a bare list of entities while structuredContent wraps
|
|
48
|
-
# it as {"entities": [...]}. The structured block is the machine-readable
|
|
49
|
-
# contract, so that is the one a path walks. Writing $result[].name here is
|
|
50
|
-
# the natural mistake, and it was the first thing this policy got wrong
|
|
51
|
-
# 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.
|
|
52
53
|
- match: "memory.create_entities"
|
|
53
54
|
class: compensable
|
|
54
55
|
inverse:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synartesis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -86,6 +87,7 @@
|
|
|
86
87
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
|
87
88
|
"@google/genai": "^2.22.0",
|
|
88
89
|
"@modelcontextprotocol/server-filesystem": "^2026.7.10",
|
|
90
|
+
"@modelcontextprotocol/server-memory": "^2026.8.31",
|
|
89
91
|
"@types/better-sqlite3": "^9.6.0",
|
|
90
92
|
"@types/node": "^26.2.0",
|
|
91
93
|
"@types/react": "^19.3.0",
|