synartesis 0.4.0 → 0.4.2

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,99 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.4.2 — 2026-09-09
6
+
7
+ Three bugs, found by auditing the paths the last pass did not touch. Each is
8
+ pinned by a test that fails against 0.4.1.
9
+
10
+ ### Fixed
11
+
12
+ - **One approval could authorise two irreversible calls.** Spending a standing
13
+ approval was an announcement rather than a claim: `markInFlight` and
14
+ `adoptApproval` both wrote unconditionally, so two proxies — which share one
15
+ journal, the reason `close` is never automatic — could read the same approved
16
+ row before either had used it, and both proceed. One person's yes, two emails
17
+ sent, which is the single thing this is here to prevent.
18
+
19
+ Both are now conditional on the row still being `approved` and report whether
20
+ they won it. `adoptApproval` spends first and only carries the approval across
21
+ if it did. The proxy treats losing the race as never having had an approval:
22
+ it asks. The guard already existed for inverses, and its comment describes
23
+ this exact failure; the approval path never got one.
24
+
25
+ - **`prune` deleted sessions still waiting on a person.** `--help` and the
26
+ README both promise that nothing waiting on a person is ever pruned. The query
27
+ enforced it for `pending`, `gated` and `rolling_back`, and not for the two
28
+ other statuses that mean the same thing: `approved`, somebody's yes the agent
29
+ has not spent, and `unrecoverable`, an undo that stopped because somebody had
30
+ changed the resource and is waiting for them to choose. Pruning the first threw
31
+ away a human decision; the second threw away both the conflict and the undo
32
+ they were deciding about.
33
+
34
+ - **Forcing an undo walked around the double-apply guard.** `markRollingBack`
35
+ claims an action by moving it out of `applied`, so two rollbacks cannot both
36
+ send one inverse. `undo --force`, added in 0.4.1, acts on rows an earlier
37
+ refusal left `unrecoverable` — which the claim did not know about, so it never
38
+ claimed them and never reported that it had not. Two concurrent forced undos
39
+ both sent the inverse: harmless for an idempotent write, a second real change
40
+ to the world for a compensable one.
41
+
42
+ ## 0.4.1 — 2026-09-09
43
+
44
+ ### Added
45
+
46
+ - **`synartesis show <session> --live`**, and `l` in the screen. Synartesis
47
+ records what an agent does, not what happens to a file — nothing you do by
48
+ hand goes through the proxy. That is what makes the drift check work, and it
49
+ meant the commonest question about this tool had no answer: has somebody
50
+ edited that file since? You found out by attempting an undo and having it
51
+ refuse.
52
+
53
+ `--live` reads every resource the session touched as it is now and says
54
+ which of them still match what the run left. Nothing is written, no
55
+ reversing call is sent, and no row changes status. Unlike `undo --dry-run`
56
+ it does not stop at the first conflict: a session with five writes reports
57
+ on all five.
58
+
59
+ An earlier write to a resource is reported as superseded rather than
60
+ changed. Undo walks backwards, so each older write is checked against a
61
+ state the one above it restores; measuring them all against the world as it
62
+ is now would call every write but the last one changed.
63
+
64
+ ### Fixed
65
+
66
+ - **Undo acted on a session nobody was looking at.** One cursor serves four
67
+ views, and in the held-calls and connections lists its number counts
68
+ something else entirely — but `u`, `p` and `l` read it against the sessions
69
+ regardless. `g`, `j`, `u`, `y`, four keys pressed while looking at the list
70
+ of held calls, undid a session that was never on screen. That is the failure
71
+ this tool exists to prevent, committed by the tool. Those keys now do
72
+ nothing outside the session views and say why.
73
+
74
+ - **`--force` showed one conflict and overwrote several.** The ask ran a
75
+ dry-run rollback, and a rollback halts at the first drift — so with two
76
+ people's edits underneath it, one diff was printed, `--force --yes` was
77
+ typed, and both were written over. It now reads every conflict and prints
78
+ each one.
79
+
80
+ - **One broken server stopped you undoing anything at all.** `install` covers
81
+ every AI on the machine with one policy, so a manifest routinely names
82
+ servers that have nothing to do with the session in hand — and undo started
83
+ all of them. An entry whose command is not there made every session
84
+ unreadable and unundoable. Undo and `--live` now start only the servers the
85
+ session actually went to, and skip with a reported reason any that still
86
+ will not start. `--replan` still starts everything, since it re-resolves
87
+ inverses from the current policy.
88
+
89
+ - **`--force` ignored `--to`.** A change below the floor — an action the undo
90
+ would not touch — refused the whole command.
91
+
92
+ - The overwrite warning in the screen now expires with the diff that
93
+ justified it, so the second `u` cannot be answered after the evidence has
94
+ scrolled away. `--yes` without `--force` says it is being ignored rather
95
+ than being silently dropped, and `--live` no longer starts every server to
96
+ inspect a session that recorded nothing.
97
+
5
98
  ## 0.4.0 — 2026-09-08
6
99
 
7
100
  ### Added
package/README.md CHANGED
@@ -526,11 +526,13 @@ was refused with the file untouched.
526
526
  | `init <server> -- <cmd>` | Introspect a server and draft a manifest |
527
527
  | `list` | Every recorded run |
528
528
  | `show <runId>` | One run's timeline, with the undo for each step |
529
+ | `show <runId> --live` | The same, plus what has changed in the world since |
529
530
  | `gates` | What is waiting for a decision |
530
531
  | `approve <actionId>` | Allow a suspended call |
531
532
  | `deny <actionId>` | Refuse one |
532
533
  | `undo <runId>` | Reverse a run, newest action first |
533
534
  | `undo <runId> --replan` | Same, but rebuild each undo from the current manifest |
535
+ | `undo <runId> --force` | Print every change it would write over; `--yes` goes ahead |
534
536
  | `check` | Load a manifest and verify it against the servers it names |
535
537
  | `prune` | Delete runs older than 30 days and reclaim the space |
536
538
  | `close [runId]` | End a run a killed proxy left open. Nothing guesses at this: several proxies can share one journal, so a run left active is indistinguishable from one still being worked on |
@@ -598,6 +600,39 @@ protocol traffic.
598
600
  is baked into every run made under it. `undo --replan` rebuilds them from a
599
601
  corrected manifest using the state already captured, which is the way out.
600
602
 
603
+ ## Has anybody touched it since?
604
+
605
+ Synartesis records what an agent does, not what happens to a file. Nothing you
606
+ do by hand goes through the proxy, so an edit of your own is invisible to the
607
+ journal — and that is what makes the drift check work: when undo reads a file
608
+ and finds bytes it never recorded, it knows somebody else has been there.
609
+
610
+ The cost used to be that you found out by walking into it. You ran the undo,
611
+ and it refused. To ask first:
612
+
613
+ ```bash
614
+ synartesis show <session> --live
615
+ ```
616
+
617
+ It reads every resource the session touched, as it is now, and says which of
618
+ them still match what the run left:
619
+
620
+ ```
621
+ 1 ← reversible applied filesystem.write_file changed since
622
+ at line 13:
623
+ + A HUMAN WAS HERE
624
+ 0 removed, 1 added.
625
+
626
+ 1 changed since this ran; undoing would write over it
627
+ ```
628
+
629
+ Nothing is written, no reversing call is sent, and no row changes status.
630
+ Unlike `undo --dry-run`, it does not stop at the first conflict — a session
631
+ with five writes reports on all five. `l` in the screen does the same thing.
632
+
633
+ If you decide the recorded value is the one worth keeping, `undo --force`
634
+ prints every line it would write over and stops; `--force --yes` goes ahead.
635
+
601
636
  ## Watching it work
602
637
 
603
638
  Synartesis is not a daemon and cannot be one. An MCP client spawns a stdio
@@ -470,9 +470,26 @@ var SqliteJournal = class {
470
470
  this.#db.prepare("UPDATE actions SET status = 'pending', error = ? WHERE id = ?").run(error, actionId);
471
471
  });
472
472
  }
473
- markRollingBack(actionId) {
473
+ /**
474
+ * Claim an action to send its inverse, or report that somebody else has.
475
+ *
476
+ * Conditional, so the transition is a claim rather than an announcement. Two
477
+ * rollbacks of one run both read the action as applied and both sent its
478
+ * inverse; for a compensating call rather than a restore, that is a second
479
+ * real change to the world.
480
+ *
481
+ * `from` is which statuses may be claimed. It exists because `undo --force`
482
+ * acts on rows left `unrecoverable` by an earlier refusal, and a claim that
483
+ * only knew `applied` never claimed those at all -- so the guard above was
484
+ * simply absent on the one path where a person had already been told the
485
+ * resource is contested.
486
+ */
487
+ markRollingBack(actionId, from = ["applied"]) {
474
488
  return this.#run("markRollingBack", () => {
475
- const result = this.#db.prepare("UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status = 'applied'").run(actionId);
489
+ const slots = from.map(() => "?").join(",");
490
+ const result = this.#db.prepare(
491
+ `UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
492
+ ).run(actionId, ...from);
476
493
  return result.changes === 1;
477
494
  });
478
495
  }
@@ -507,9 +524,19 @@ var SqliteJournal = class {
507
524
  this.#db.prepare("UPDATE actions SET status = 'gated', error = ? WHERE id = ?").run(why ?? null, actionId);
508
525
  });
509
526
  }
527
+ /**
528
+ * Spend a standing approval, or report that somebody else already has.
529
+ *
530
+ * Conditional for the same reason markRollingBack is. Several proxies share
531
+ * one journal, so two can read the same approved row before either has used
532
+ * it, and an unconditional write let both proceed -- one person's yes
533
+ * authorising two irreversible calls, which is the single thing this is here
534
+ * to prevent.
535
+ */
510
536
  markInFlight(actionId) {
511
- this.#run("markInFlight", () => {
512
- this.#db.prepare("UPDATE actions SET status = 'pending' WHERE id = ?").run(actionId);
537
+ return this.#run("markInFlight", () => {
538
+ const result = this.#db.prepare("UPDATE actions SET status = 'pending' WHERE id = ? AND status = 'approved'").run(actionId);
539
+ return result.changes === 1;
513
540
  });
514
541
  }
515
542
  /**
@@ -542,12 +569,18 @@ var SqliteJournal = class {
542
569
  });
543
570
  }
544
571
  adoptApproval(actionId, granted) {
545
- this.#run("adoptApproval", () => {
572
+ return this.#run("adoptApproval", () => {
546
573
  const move = this.#db.transaction(() => {
574
+ const spent = this.#db.prepare(
575
+ "UPDATE actions SET status = 'denied', error = ? WHERE id = ? AND status = 'approved'"
576
+ ).run(`${SPENT_APPROVAL} ${actionId}`, granted.id);
577
+ if (spent.changes !== 1) {
578
+ return false;
579
+ }
547
580
  this.#db.prepare("UPDATE actions SET approved_by = ?, approved_at = ? WHERE id = ?").run(granted.approvedBy ?? null, granted.approvedAt ?? null, actionId);
548
- this.#db.prepare("UPDATE actions SET status = 'denied', error = ? WHERE id = ?").run(`${SPENT_APPROVAL} ${actionId}`, granted.id);
581
+ return true;
549
582
  });
550
- move.immediate();
583
+ return move.immediate();
551
584
  });
552
585
  }
553
586
  listGated() {
@@ -639,10 +672,18 @@ var SqliteJournal = class {
639
672
  FROM runs r
640
673
  WHERE r.status != 'active'
641
674
  AND COALESCE(r.ended_at, r.started_at) < ?
675
+ -- Everything a person still has business with. approved is
676
+ -- somebody's yes that the agent has not spent yet, and
677
+ -- unrecoverable is an undo that stopped because somebody had
678
+ -- changed the resource, waiting for them to choose. Both were
679
+ -- missing, so the promise made in --help and the README -- that
680
+ -- nothing waiting on a person is ever pruned -- was not one this
681
+ -- query kept.
642
682
  AND NOT EXISTS (
643
683
  SELECT 1 FROM actions a
644
684
  WHERE a.run_id = r.id
645
- AND a.status IN ('pending','gated','rolling_back'))
685
+ AND a.status IN
686
+ ('pending','gated','approved','rolling_back','unrecoverable'))
646
687
  ORDER BY at, r.rowid`
647
688
  ).all(before).map(
648
689
  (row) => z.object({
@@ -1493,4 +1534,4 @@ export {
1493
1534
  observeState,
1494
1535
  connectStdioUpstream
1495
1536
  };
1496
- //# sourceMappingURL=chunk-FOA4UIDE.js.map
1537
+ //# sourceMappingURL=chunk-LAZLEAZW.js.map
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  toPayload,
26
26
  verifyAgainstServers,
27
27
  wasRefused
28
- } from "./chunk-FOA4UIDE.js";
28
+ } from "./chunk-LAZLEAZW.js";
29
29
  import {
30
30
  DriftConflict,
31
31
  ManifestError,
@@ -491,8 +491,11 @@ ${seen}` : seen;
491
491
  if (dryRun) {
492
492
  continue;
493
493
  }
494
- const claimed = journal.markRollingBack(action.id);
495
- if (!claimed && action.status === "applied") {
494
+ const claimed = journal.markRollingBack(
495
+ action.id,
496
+ force ? ["applied", "unrecoverable"] : ["applied"]
497
+ );
498
+ if (!claimed && action.status !== "rolling_back") {
496
499
  const reason = "another undo is already working on this action";
497
500
  halted = { seq: action.seq, reason, detail: "" };
498
501
  steps[steps.length - 1] = {
@@ -553,11 +556,11 @@ function describeStep(action) {
553
556
  return { seq: action.seq, server: action.server, tool: action.tool };
554
557
  }
555
558
  function overwriteText(current, action) {
556
- const intended = intendedAfterInverse(action);
557
- if (intended === void 0) {
559
+ const intended2 = intendedAfterInverse(action);
560
+ if (intended2 === void 0) {
558
561
  return "";
559
562
  }
560
- return changedLines(current, intended);
563
+ return changedLines(current, intended2);
561
564
  }
562
565
  function intendedAfterInverse(action) {
563
566
  return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
@@ -595,6 +598,98 @@ async function executeInverse(router, plan, idempotencyKey, signal) {
595
598
  return { ok: true };
596
599
  }
597
600
 
601
+ // src/rollback/inspect.ts
602
+ import { z as z3 } from "zod";
603
+ var inversePlan2 = z3.object({
604
+ server: z3.string(),
605
+ tool: z3.string(),
606
+ args: z3.record(z3.string(), z3.unknown())
607
+ });
608
+ var observation2 = z3.union([
609
+ z3.object({ present: z3.literal(true), value: z3.unknown() }),
610
+ z3.object({ present: z3.literal(false) })
611
+ ]);
612
+ function sameState2(a, b) {
613
+ return canonical(a) === canonical(b);
614
+ }
615
+ function intended(action) {
616
+ return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
617
+ }
618
+ function resourceKey(plan) {
619
+ return canonical({ server: plan.server, tool: plan.tool, args: plan.args });
620
+ }
621
+ var SETTLED = /* @__PURE__ */ new Set(["failed", "denied", "gated", "approved"]);
622
+ async function inspect(options) {
623
+ const { journal, router, runId } = options;
624
+ const signal = options.signal ?? new AbortController().signal;
625
+ const actions = journal.getActions(runId);
626
+ const resources = [];
627
+ const seen = /* @__PURE__ */ new Set();
628
+ for (const action of [...actions].reverse()) {
629
+ const at = { seq: action.seq, server: action.server, tool: action.tool };
630
+ if (action.class === "readonly") {
631
+ continue;
632
+ }
633
+ if (SETTLED.has(action.status)) {
634
+ resources.push({ ...at, condition: "not-applied", note: action.status });
635
+ continue;
636
+ }
637
+ if (action.status === "rolled_back") {
638
+ resources.push({ ...at, condition: "restored" });
639
+ continue;
640
+ }
641
+ const verify = inversePlan2.safeParse(action.verify);
642
+ const post = observation2.safeParse(action.postSnapshot);
643
+ if (!verify.success || !post.success) {
644
+ resources.push({
645
+ ...at,
646
+ condition: "unknowable",
647
+ note: action.inverse === void 0 ? "nothing was captured to restore" : "no pre-read was declared"
648
+ });
649
+ continue;
650
+ }
651
+ const key = resourceKey(verify.data);
652
+ if (seen.has(key)) {
653
+ resources.push({ ...at, condition: "superseded" });
654
+ continue;
655
+ }
656
+ seen.add(key);
657
+ let current;
658
+ try {
659
+ current = await observeState(router, verify.data, signal);
660
+ } catch (error) {
661
+ resources.push({ ...at, condition: "unknowable", note: `could not read it: ${describe(error)}` });
662
+ continue;
663
+ }
664
+ if (sameState2(current, post.data)) {
665
+ resources.push({ ...at, condition: "unchanged" });
666
+ continue;
667
+ }
668
+ const back = intended(action);
669
+ if (back !== void 0 && sameState2(current, back)) {
670
+ resources.push({ ...at, condition: "restored", note: "put back outside this journal" });
671
+ continue;
672
+ }
673
+ resources.push({
674
+ ...at,
675
+ condition: "changed",
676
+ diff: changedLines(post.data, current)
677
+ });
678
+ }
679
+ return { runId, resources: resources.reverse() };
680
+ }
681
+ function verdict(inspection) {
682
+ const changed = inspection.resources.filter((r) => r.condition === "changed").length;
683
+ const undoable = inspection.resources.filter((r) => r.condition === "unchanged").length;
684
+ if (changed > 0) {
685
+ return `${String(changed)} changed since this ran; undoing would write over ${changed === 1 ? "it" : "them"}`;
686
+ }
687
+ if (undoable > 0) {
688
+ return `nothing has been touched since; all ${String(undoable)} would undo cleanly`;
689
+ }
690
+ return "nothing here is still applied";
691
+ }
692
+
598
693
  // src/watch.ts
599
694
  import { existsSync as existsSync2 } from "fs";
600
695
 
@@ -1868,13 +1963,14 @@ function footer(screen, options) {
1868
1963
  return ["", ` ${style.accent(what)} ${keyHint2("y", "yes")} ${keyHint2("n", "no")}`];
1869
1964
  }
1870
1965
  const keys = screen.mode === "connections" ? [keyHint2("enter", "connect"), keyHint2("a", "connect all"), keyHint2("r", "rescan"), keyHint2("j/k", "move"), keyHint2("h", "back")] : screen.mode === "gates" ? [keyHint2("a", "approve"), keyHint2("d", "deny"), keyHint2("j/k", "move"), keyHint2("r", "runs")] : screen.mode === "run" ? [
1966
+ keyHint2("l", "check now"),
1871
1967
  keyHint2("p", "preview undo"),
1872
1968
  keyHint2("u", "undo"),
1873
- keyHint2("esc", "back"),
1874
- keyHint2("g", "held")
1969
+ keyHint2("f", "expand"),
1970
+ keyHint2("esc", "back")
1875
1971
  ] : [
1876
1972
  keyHint2("enter", "open"),
1877
- keyHint2("p", "preview undo"),
1973
+ keyHint2("l", "check now"),
1878
1974
  keyHint2("u", "undo"),
1879
1975
  keyHint2("j/k", "move"),
1880
1976
  keyHint2("g", "held")
@@ -1962,6 +2058,7 @@ async function openConsole(options) {
1962
2058
  ""
1963
2059
  ].join("\n");
1964
2060
  };
2061
+ const onASession = () => screen.mode === "runs" || screen.mode === "run";
1965
2062
  const selectedRun = (ready) => {
1966
2063
  if (screen.mode === "run" && screen.openRun !== void 0) {
1967
2064
  return ready.getRun(screen.openRun);
@@ -2049,6 +2146,28 @@ async function openConsole(options) {
2049
2146
  screen.busy = void 0;
2050
2147
  }
2051
2148
  };
2149
+ const look = async (runId) => {
2150
+ if (options.check === void 0) {
2151
+ say("no way to read the current state was configured");
2152
+ return;
2153
+ }
2154
+ screen.busy = "reading how things are now...";
2155
+ try {
2156
+ const found = await options.check(runId);
2157
+ const changed = found.resources.filter((one) => one.condition === "changed");
2158
+ say(
2159
+ [
2160
+ verdict(found),
2161
+ ...changed.length === 0 ? [] : ["", ...changed.flatMap((one) => [`${String(one.seq)} ${one.server}.${one.tool}`, ...(one.diff ?? "").split("\n")])]
2162
+ ].join("\n"),
2163
+ changed.length === 0 ? NOTICE_TICKS2 : READING_TICKS
2164
+ );
2165
+ } catch (error) {
2166
+ say(error instanceof Error ? error.message : "could not read the current state");
2167
+ } finally {
2168
+ screen.busy = void 0;
2169
+ }
2170
+ };
2052
2171
  const rescan = () => {
2053
2172
  screen.groups = options.scan?.() ?? [];
2054
2173
  };
@@ -2160,7 +2279,26 @@ async function openConsole(options) {
2160
2279
  decide(false);
2161
2280
  }
2162
2281
  return;
2282
+ case "l": {
2283
+ if (!onASession()) {
2284
+ return;
2285
+ }
2286
+ if (screen.busy !== void 0) {
2287
+ say("still working on the last one");
2288
+ return;
2289
+ }
2290
+ const ready = open();
2291
+ const run = ready === void 0 ? void 0 : selectedRun(ready);
2292
+ if (ready === void 0 || run === void 0) {
2293
+ return;
2294
+ }
2295
+ void look(run.id);
2296
+ return;
2297
+ }
2163
2298
  case "p": {
2299
+ if (!onASession()) {
2300
+ return;
2301
+ }
2164
2302
  const ready = open();
2165
2303
  const run = ready === void 0 ? void 0 : selectedRun(ready);
2166
2304
  if (ready === void 0 || run === void 0) {
@@ -2175,6 +2313,10 @@ async function openConsole(options) {
2175
2313
  return;
2176
2314
  }
2177
2315
  case "u": {
2316
+ if (!onASession()) {
2317
+ say("undo works on a session; r for the list");
2318
+ return;
2319
+ }
2178
2320
  if (screen.busy !== void 0) {
2179
2321
  say("still working on the last one");
2180
2322
  return;
@@ -2241,6 +2383,7 @@ async function openConsole(options) {
2241
2383
  for (; !screen.stop; tick += 1) {
2242
2384
  if (screen.notice !== "" && tick >= screen.noticeUntil) {
2243
2385
  screen.notice = "";
2386
+ screen.warned = void 0;
2244
2387
  }
2245
2388
  options.write(clear + frame());
2246
2389
  if (options.maxTicks !== void 0 && tick + 1 >= options.maxTicks) {
@@ -2289,7 +2432,7 @@ var COMMANDS = `
2289
2432
  synartesis init <server> -- <command> [args...] [--manifest <path>]
2290
2433
  synartesis check [--manifest <path>]
2291
2434
  synartesis list [--journal <path>]
2292
- synartesis show <runId> [--full] [--journal <path>]
2435
+ synartesis show <runId> [--full] [--live] [--journal <path>]
2293
2436
  synartesis gates [--journal <path>]
2294
2437
  synartesis close [runId] [--journal <path>]
2295
2438
  synartesis prune [--older-than <days>] [--dry-run] [--journal <path>]
@@ -2329,6 +2472,7 @@ resource back as the run left it and --replan, or --force to overwrite.
2329
2472
  --client claude-code, claude-desktop, cursor or codex; all by default
2330
2473
  --print show the entries install would write, and write nothing
2331
2474
  --full show every argument, snapshot and inverse in full, nothing elided
2475
+ --live read each resource as it is now and say what has changed since
2332
2476
  --server serve one server from the manifest, keeping its tool names
2333
2477
  --manifest synartesis.yaml, looked for here and upwards, then in the home
2334
2478
  --journal beside the manifest, or the one in the home
@@ -2743,13 +2887,24 @@ function runList(journal, asJson, journalPath) {
2743
2887
  }
2744
2888
  return 0;
2745
2889
  }
2746
- function runShow(argv, journal, asJson) {
2890
+ async function runShow(argv, journal, asJson) {
2747
2891
  const full = argv.includes("--full");
2748
2892
  const runs = [...journal.listRuns()].reverse();
2749
2893
  const run = pick(runs, positional(argv)[1], RUN, true);
2750
2894
  const runId = run.id;
2895
+ const inspection = argv.includes("--live") && journal.getActions(runId).length > 0 ? await withUpstreams(
2896
+ findManifest(flag(argv, "--manifest")),
2897
+ async (router) => await inspect({ journal, router, runId }),
2898
+ serversUsedBy(journal, runId)
2899
+ ) : void 0;
2751
2900
  if (asJson) {
2752
- out(JSON.stringify({ run, actions: journal.getActions(runId) }));
2901
+ out(
2902
+ JSON.stringify({
2903
+ run,
2904
+ actions: journal.getActions(runId),
2905
+ ...inspection === void 0 ? {} : { live: inspection.resources }
2906
+ })
2907
+ );
2753
2908
  return 0;
2754
2909
  }
2755
2910
  out("");
@@ -2772,10 +2927,17 @@ function runShow(argv, journal, asJson) {
2772
2927
  out(` ${style.label("timeline")}`);
2773
2928
  out(` ${rule(72)}`);
2774
2929
  out("");
2930
+ const live = new Map((inspection?.resources ?? []).map((found) => [found.seq, found]));
2775
2931
  for (const action of actions) {
2932
+ const now = live.get(action.seq);
2776
2933
  out(
2777
- ` ${style.quiet(String(action.seq).padStart(3))} ${badgeOf(action)} ${statusOf(action)} ${style.strong(`${action.server}.${action.tool}`)}`
2934
+ ` ${style.quiet(String(action.seq).padStart(3))} ${badgeOf(action)} ${statusOf(action)} ${style.strong(`${action.server}.${action.tool}`)}` + (now === void 0 ? "" : ` ${conditionOf(now)}`)
2778
2935
  );
2936
+ if (now?.diff !== void 0) {
2937
+ for (const line2 of now.diff.split("\n")) {
2938
+ out(` ${style.quiet(line2)}`);
2939
+ }
2940
+ }
2779
2941
  if (full) {
2780
2942
  out(` ${style.quiet("arguments")}`);
2781
2943
  for (const line2 of block(action.args)) {
@@ -2812,9 +2974,40 @@ function runShow(argv, journal, asJson) {
2812
2974
  }
2813
2975
  out("");
2814
2976
  out(` ${summarise2(actions)}`);
2977
+ if (inspection !== void 0) {
2978
+ out("");
2979
+ const spoiled = inspection.resources.some((found) => found.condition === "changed");
2980
+ out(` ${spoiled ? style.accent(verdict(inspection)) : style.quiet(verdict(inspection))}`);
2981
+ if (spoiled) {
2982
+ out(
2983
+ ` ${style.quiet("undo stops at the first of them; ")}${style.strong(`${cliCommand()} undo ${runId.slice(0, 8)} --force`)}${style.quiet(" shows what it would write")}`
2984
+ );
2985
+ }
2986
+ } else {
2987
+ out("");
2988
+ out(
2989
+ ` ${style.quiet("has anything changed since? ")}${style.strong(`${cliCommand()} show ${runId.slice(0, 8)} --live`)}`
2990
+ );
2991
+ }
2815
2992
  out("");
2816
2993
  return 0;
2817
2994
  }
2995
+ function conditionOf(found) {
2996
+ switch (found.condition) {
2997
+ case "changed":
2998
+ return style.accent("changed since");
2999
+ case "unchanged":
3000
+ return style.quiet("unchanged");
3001
+ case "restored":
3002
+ return style.quiet(found.note === void 0 ? "back to before" : `back to before, ${found.note}`);
3003
+ case "superseded":
3004
+ return style.quiet("older write to the same thing");
3005
+ case "not-applied":
3006
+ return style.quiet(`never applied (${found.note ?? "settled"})`);
3007
+ default:
3008
+ return style.quiet(found.note ?? "cannot tell");
3009
+ }
3010
+ }
2818
3011
  var CLASS_MARK = {
2819
3012
  readonly: "\xB7",
2820
3013
  reversible: "\u2190",
@@ -3105,45 +3298,63 @@ function report(result, alreadyForcing = false) {
3105
3298
  out("");
3106
3299
  return result.status === "rolled_back" ? 0 : 1;
3107
3300
  }
3108
- async function performUndo(manifestPath, journal, runId, options) {
3301
+ async function withUpstreams(manifestPath, use, only) {
3109
3302
  const manifest = loadManifest(manifestPath);
3110
3303
  const upstreams = [];
3304
+ const missing = [];
3111
3305
  try {
3112
3306
  for (const [name, spec] of Object.entries(manifest.servers)) {
3113
- upstreams.push(
3114
- await connectStdioUpstream({
3115
- name,
3116
- command: spec.command,
3117
- args: spec.args,
3118
- stderr: "capture",
3119
- ...spec.env === void 0 ? {} : { env: spec.env }
3120
- })
3121
- );
3122
- }
3123
- const base = {
3124
- journal,
3125
- router: createRouter(upstreams, manifest),
3126
- runId,
3127
- ...options.toSeq === void 0 ? {} : { toSeq: options.toSeq },
3128
- ...options.replan === true ? { replanWith: manifest } : {}
3129
- };
3130
- if (options.preflight === true) {
3131
- const seen = await rollback({ ...base, dryRun: true });
3132
- if (seen.halted?.conflict === true) {
3133
- return seen;
3307
+ if (only !== void 0 && !only.has(name)) {
3308
+ continue;
3309
+ }
3310
+ try {
3311
+ upstreams.push(
3312
+ await connectStdioUpstream({
3313
+ name,
3314
+ command: spec.command,
3315
+ args: spec.args,
3316
+ stderr: "capture",
3317
+ ...spec.env === void 0 ? {} : { env: spec.env }
3318
+ })
3319
+ );
3320
+ } catch (error) {
3321
+ missing.push(`${name}: ${describe(error)}`);
3134
3322
  }
3135
3323
  }
3136
- return await rollback({
3137
- ...base,
3138
- dryRun: options.dryRun,
3139
- ...options.force === true ? { force: true } : {}
3140
- });
3324
+ if (upstreams.length === 0 && missing.length > 0) {
3325
+ throw new ManifestError(`no server could be started. ${missing.join("; ")}`);
3326
+ }
3327
+ for (const why of missing) {
3328
+ process.stderr.write(`synartesis: ${why}; anything through it cannot be reached
3329
+ `);
3330
+ }
3331
+ return await use(createRouter(upstreams, manifest), manifest);
3141
3332
  } finally {
3142
3333
  for (const upstream of upstreams) {
3143
3334
  await upstream.close();
3144
3335
  }
3145
3336
  }
3146
3337
  }
3338
+ function serversUsedBy(journal, runId) {
3339
+ return new Set(journal.getActions(runId).map((action) => action.server));
3340
+ }
3341
+ async function performUndo(manifestPath, journal, runId, options) {
3342
+ return await withUpstreams(
3343
+ manifestPath,
3344
+ async (router, manifest) => await rollback({
3345
+ journal,
3346
+ router,
3347
+ runId,
3348
+ ...options.toSeq === void 0 ? {} : { toSeq: options.toSeq },
3349
+ ...options.replan === true ? { replanWith: manifest } : {},
3350
+ dryRun: options.dryRun,
3351
+ ...options.force === true ? { force: true } : {}
3352
+ }),
3353
+ // A replan re-resolves inverses from the current policy, which may name a
3354
+ // server this run never used; everything else needs only what it touched.
3355
+ options.replan === true ? void 0 : serversUsedBy(journal, runId)
3356
+ );
3357
+ }
3147
3358
  async function runUndo(argv, journal) {
3148
3359
  const given = positional(argv)[1];
3149
3360
  const chosen = pick([...journal.listRuns()].reverse(), given, RUN, true);
@@ -3195,21 +3406,48 @@ async function runUndo(argv, journal) {
3195
3406
  }
3196
3407
  const forcing = argv.includes("--force");
3197
3408
  const said = argv.includes("--yes");
3198
- const result = await performUndo(findManifest(flag(argv, "--manifest")), journal, runId, {
3199
- dryRun: argv.includes("--dry-run"),
3200
- ...toSeq === void 0 ? {} : { toSeq },
3201
- replan: argv.includes("--replan"),
3202
- ...forcing ? { force: said, preflight: !said } : {}
3203
- });
3204
- const code = report(result, forcing);
3205
- if (forcing && !said && result.halted?.conflict === true) {
3206
- out(` ${style.quiet("nothing has been written. To go ahead and lose that change:")}`);
3207
- out(
3208
- ` ${style.strong(`${cliCommand()} undo ${runId.slice(0, 8)} --force --yes`)}`
3409
+ const manifestPath = findManifest(flag(argv, "--manifest"));
3410
+ if (said && !forcing) {
3411
+ process.stderr.write("synartesis: --yes only means anything with --force; ignoring it\n");
3412
+ }
3413
+ if (forcing && !said) {
3414
+ const over = (await withUpstreams(
3415
+ manifestPath,
3416
+ async (router) => await inspect({ journal, router, runId }),
3417
+ serversUsedBy(journal, runId)
3418
+ )).resources.filter(
3419
+ // Below --to nothing is undone, so a change down there is not something
3420
+ // this command would write over and must not stand in its way.
3421
+ (one) => one.condition === "changed" && (toSeq === void 0 || one.seq >= toSeq)
3209
3422
  );
3210
- out("");
3423
+ if (over.length > 0) {
3424
+ out("");
3425
+ out(
3426
+ ` ${style.accent(`${String(over.length)} changed since this ran`)} ` + style.quiet(`undoing would write over ${over.length === 1 ? "it" : "them"}`)
3427
+ );
3428
+ for (const one of over) {
3429
+ out("");
3430
+ out(` ${style.quiet(String(one.seq).padStart(3))} ${style.strong(`${one.server}.${one.tool}`)}`);
3431
+ for (const line2 of (one.diff ?? "").split("\n")) {
3432
+ out(` ${style.quiet(line2)}`);
3433
+ }
3434
+ }
3435
+ out("");
3436
+ out(` ${style.quiet("nothing has been written. To go ahead and lose that:")}`);
3437
+ out(` ${style.strong(`${cliCommand()} undo ${runId.slice(0, 8)} --force --yes`)}`);
3438
+ out("");
3439
+ return 1;
3440
+ }
3211
3441
  }
3212
- return code;
3442
+ return report(
3443
+ await performUndo(manifestPath, journal, runId, {
3444
+ dryRun: argv.includes("--dry-run"),
3445
+ ...toSeq === void 0 ? {} : { toSeq },
3446
+ replan: argv.includes("--replan"),
3447
+ ...forcing && said ? { force: true } : {}
3448
+ }),
3449
+ forcing
3450
+ );
3213
3451
  }
3214
3452
  var FLAGS = /* @__PURE__ */ new Set([
3215
3453
  // Two lists have to agree about a flag: this one decides whether it is
@@ -3219,6 +3457,7 @@ var FLAGS = /* @__PURE__ */ new Set([
3219
3457
  "--client",
3220
3458
  "--print",
3221
3459
  "--full",
3460
+ "--live",
3222
3461
  "--manifest",
3223
3462
  "--journal",
3224
3463
  "--to",
@@ -3292,6 +3531,18 @@ ${COMMANDS}`);
3292
3531
  write: (text) => process.stdout.write(text),
3293
3532
  live: process.stdout.isTTY,
3294
3533
  decideAs: flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown",
3534
+ check: async (runId) => {
3535
+ const journal2 = openJournal(journalPath2, { mustExist: true });
3536
+ try {
3537
+ return await withUpstreams(
3538
+ manifestPath,
3539
+ async (router) => await inspect({ journal: journal2, router, runId }),
3540
+ serversUsedBy(journal2, runId)
3541
+ );
3542
+ } finally {
3543
+ journal2.close();
3544
+ }
3545
+ },
3295
3546
  undo: async (runId, dryRun, force) => {
3296
3547
  const journal2 = openJournal(journalPath2, { mustExist: true });
3297
3548
  try {
@@ -3356,7 +3607,7 @@ ${COMMANDS}`);
3356
3607
  case "list":
3357
3608
  return runList(journal, asJson, journalPath);
3358
3609
  case "show":
3359
- return runShow(argv, journal, asJson);
3610
+ return await runShow(argv, journal, asJson);
3360
3611
  case "close":
3361
3612
  return runClose(argv, journal);
3362
3613
  case "prune":
package/dist/proxy.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  runRead,
20
20
  toPayload,
21
21
  verifyAgainstServers
22
- } from "./chunk-FOA4UIDE.js";
22
+ } from "./chunk-LAZLEAZW.js";
23
23
  import {
24
24
  SnapshotError,
25
25
  UpstreamError,
@@ -588,10 +588,11 @@ function createProxyServer(options) {
588
588
  seq: reusable.seq,
589
589
  idempotencyKey: reusable.idempotencyKey
590
590
  };
591
+ let spent = true;
591
592
  if (inherited !== void 0) {
592
- journal.adoptApproval(pending.actionId, inherited);
593
+ spent = journal.adoptApproval(pending.actionId, inherited);
593
594
  } else if (granted !== void 0 && waiting === void 0) {
594
- journal.markInFlight(granted.id);
595
+ spent = journal.markInFlight(granted.id);
595
596
  }
596
597
  if (granted !== void 0) {
597
598
  log?.info(
@@ -654,7 +655,7 @@ function createProxyServer(options) {
654
655
  }
655
656
  };
656
657
  const askedAlready = wantsGate;
657
- if (wantsGate && granted === void 0) {
658
+ if (wantsGate && (granted === void 0 || !spent)) {
658
659
  await decide("this action cannot be undone");
659
660
  }
660
661
  let snapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,