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 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 typecheck && pnpm lint
396
+ pnpm check
389
397
  ```
390
398
 
391
- Every push runs those on Linux and macOS across Node 22 and 24, plus the demo
392
- and the installer.
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
@@ -20,8 +20,8 @@ patches to older ones.
20
20
 
21
21
  | Version | Supported |
22
22
  |---|---|
23
- | 0.3.x | yes |
24
- | < 0.3 | no |
23
+ | 0.6.x | yes |
24
+ | < 0.6 | no |
25
25
 
26
26
  ## What the journal holds, and why it matters
27
27
 
@@ -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. Of the policies that ship, filesystem and memory have been; git and github have not.";
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-JLLAK2QC.js.map
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-JLLAK2QC.js";
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
- const seen = /* @__PURE__ */ new Map();
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 complete, 1 halted or partial, 2 bad usage or configuration.
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 = flag(argv, "--client");
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 = flag(argv, "--client");
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(JSON.stringify(runs.map((run) => ({ ...run, actions: counted(run.id).actions }))));
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.status === "rolled_back" ? 0 : 1;
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
- out(` ${style.quiet("nothing has been written. To go ahead and lose that:")}`);
4052
- out(` ${style.strong(`${cliCommand()} undo ${runId.slice(0, 8)} --force --yes`)}`);
4053
- out("");
4054
- return 1;
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: argv.includes("--dry-run"),
4088
+ dryRun,
4061
4089
  ...toSeq === void 0 ? {} : { toSeq },
4062
4090
  replan,
4063
- ...forcing && said ? { force: true } : {}
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
- `${replan ? " --replan" : ""}${forcing && said ? " --force --yes" : ""}`
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-JLLAK2QC.js";
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 missingPriorState;
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
- journal.attachSnapshot(pending.actionId, snapshot);
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
- missingPriorState = reason;
711
- verify = void 0;
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 || missingPriorState !== void 0 ? { present: false } : { present: true, value: snapshot };
726
- if (missingPriorState !== void 0 && !askedAlready) {
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 (missingPriorState !== void 0) {
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 && missingPriorState === 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
- let recovered;
846
- if (policy.inverse !== void 0 && missingPriorState === void 0) {
847
- try {
848
- recovered = planInverse(policy.inverse, { args, snapshot, result: void 0 });
849
- } catch {
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: `the call applied but its answer never arrived: ${describe(error)}`
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 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
- # Only reversible when the destination did not exist. Moving onto a file
95
- # that did overwrites it, and moving back afterwards restores the source and
96
- # leaves nothing where the destination's contents were: undo reports success
97
- # while the file it destroyed stays destroyed. There is no pre-read that
98
- # would tell the two cases apart -- a snapshot that finds nothing is how this
99
- # policy says "cannot be undone", which is backwards here, since finding
100
- # nothing is the safe case. So it asks.
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: irreversible
103
- gate: always
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"
@@ -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: this server answers with both a text block
46
- # holding a bare array and a structuredContent object wrapping it, and
47
- # structured data wins over re-parsed text. Written against the text block,
48
- # the first version of this policy resolved to nothing and undo halted. It is
49
- # the same mistake the filesystem policy made, and the reason neither of them
50
- # can be trusted until it has met a real server.
51
- #
52
- # $result is the structured block, not the text one, and here the two
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.6.22",
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
- "prepublishOnly": "tsup"
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",