synartesis 0.6.13 → 0.6.16

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,139 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.6.16 — 2026-09-15
6
+
7
+ ### Fixed
8
+
9
+ - **A run stopped by drift said it had already been undone.** Undo halts on a
10
+ conflict and prints three ways on, one of them `undo <id> --force`. Running
11
+ that answered "Nothing in it is still applied; it has already been undone"
12
+ -- about a change still sitting in the file -- and refused the very command
13
+ it had just recommended. A halted action is `unrecoverable`, keeps its
14
+ inverse, and is exactly what `--force` and `--replan` exist for; the check
15
+ counted only `applied` and read that as nothing left to do. Found by walking
16
+ the drift case end to end rather than by reading the code.
17
+
18
+ ## 0.6.15 — 2026-09-15
19
+
20
+ One gap, named by a stranger on Reddit and real.
21
+
22
+ ### Security
23
+
24
+ - **A retry of a call whose outcome nobody could establish is now held for a
25
+ person.** The tri-state was already there -- a write that times out is not
26
+ called `failed`, the resource is re-read to see which way it went, and what
27
+ cannot be settled is left `pending`, which undo refuses to step over. What
28
+ was missing was the other half: nothing stopped the agent from simply making
29
+ the call again.
30
+
31
+ The idempotency key cannot close this. It is `runId:seq`, so a retry is a new
32
+ row under a different key and no upstream can tell the two attempts were one
33
+ intention -- checked against the real proxy, which records `seq=1 key=…:1`
34
+ and `seq=2 key=…:2`. A comment beside the forward call claimed otherwise and
35
+ has been corrected.
36
+
37
+ So the person who can go and look is asked before a second side effect
38
+ exists. It reuses the gate rather than inventing a second mechanism: the call
39
+ appears in `synartesis gates` with a reason that says what happened, rather
40
+ than the generic policy text.
41
+
42
+ Three things it deliberately does not do. It never holds a **read** -- a read
43
+ that timed out is left `pending` like anything else, but reading twice
44
+ changes nothing, and an agent may always look freely. It holds only the
45
+ **identical** call, matched on server, tool and arguments: the uncertainty is
46
+ about one call, not about the tool. And approving the retry **does not
47
+ resolve the first attempt** -- that row stays `pending` and undo still stops
48
+ on it, because saying yes to going forward is not a claim about what already
49
+ happened.
50
+
51
+ ### Performance
52
+
53
+ - `findPending` runs on the way in to every write, and left to choose sqlite
54
+ walked every action in the run -- rows that carry the snapshots -- to find
55
+ the handful that are pending. A partial index over exactly those rows, named
56
+ explicitly, on a single run of twenty thousand actions with two-kilobyte
57
+ snapshots: **24ms a call to 0.004ms**.
58
+
59
+ ## 0.6.14 — 2026-09-15 (tagged, never published)
60
+
61
+ The terminal now says what to do next, and the argument parser stopped
62
+ disagreeing with itself.
63
+
64
+ ### Added
65
+
66
+ - **Most commands end by naming the one thing worth doing next**, with the
67
+ session id already filled in:
68
+
69
+ ```
70
+ fs.create_directory is held, and the agent is waiting on you: synartesis approve f0c9935d
71
+ ```
72
+
73
+ It is worked out from the journal rather than from what was typed, so it
74
+ changes as the state does. A call held for approval outranks everything else
75
+ -- that is an agent stopped mid-task waiting on a person who may not know it
76
+ is waiting. Below that: the session that changed something, what is not
77
+ pinned, and `watch`. There is at most one, there is none when nothing
78
+ applies, and `--json` never carries it. `SYNARTESIS_NO_HINTS` turns it off.
79
+
80
+ The session it names is not simply the newest one. A client that connects
81
+ and reads opens a session like any other, so "the last thing that happened"
82
+ is regularly a session in which nothing happened; a session that only read,
83
+ or was already undone, or was held and never ran, is never offered for undo.
84
+
85
+ - **A mistyped command gets the word that was meant.** `synartesis lst` used to
86
+ answer with forty lines of help, thirty-nine of them about something the
87
+ person was not doing. It now says `did you mean list?` and lists five
88
+ commands. Flags too: `--jounral`, `--dryrun`, `--forse` all resolve.
89
+
90
+ - **A flag that belongs to `proxy` is named as such.** `--http`, `--token`,
91
+ `--server` and `--log-level` are in the help page, and answering one of them
92
+ with "unknown flag" sent people hunting through that page for a flag already
93
+ in it.
94
+
95
+ ### Fixed
96
+
97
+ - **A wrapped server's own flags were read as ours.** `synartesis init db --
98
+ some-server --manifest audit.yaml` handed `--manifest` to the server
99
+ correctly and *also* took it, writing the policy to a path nobody asked for
100
+ -- on a machine where that path is real, one they already had. `positional`
101
+ and the unknown-flag check had always stopped at the bare `--`; the flag
102
+ reader had not.
103
+
104
+ - **The value of a flag was read as a flag.** `deny --reason "-see ticket 42"`
105
+ answered `unknown flag -see ticket 42`, and `prune --older-than -5` answered
106
+ `unknown flag -5` instead of the message that knows what `--older-than` is
107
+ for. Three separate lists had to agree about which flags take a value and
108
+ did not; there is one now, and a test holds it against the help page.
109
+
110
+ - **`undo --to 0` announced the session before refusing.** It printed "no
111
+ session named, so the most recent: a42bf93a" and only then rejected the
112
+ flag, which reads as though that session had been acted on.
113
+
114
+ - **A hint that could not be pasted.** `list --journal ./bench.db` named a
115
+ session, and the `show` it offered read the *default* journal and answered
116
+ "no run matches". Hints now carry `--journal` and `--manifest` when those
117
+ were not the obvious ones, and `undo --replan --dry-run` offers a command
118
+ that still replans.
119
+
120
+ - **Guessing at a typo was too eager.** A ceiling of half the word answered
121
+ `--server` with `--live` and `--token` with `--to`, neither of which anybody
122
+ meant. Two edits at most now, never for a word under three characters, and a
123
+ swapped pair counts as the one mistake it feels like -- which is what keeps
124
+ `shwo`, `pruen` and `--jounral` reachable at that ceiling.
125
+
126
+ - **Colour was decided for both streams from stdout**, so `synartesis nonsense
127
+ 2> errors.log` in a terminal wrote escape sequences into the file.
128
+
129
+ ### Performance
130
+
131
+ - **`newestUndoable` runs on every `list`** and could only be answered by
132
+ reading the action rows, which carry the snapshots -- so the cost of one
133
+ screen of sessions grew with the size of the data rather than the number of
134
+ sessions. A partial index over exactly the rows it wants fixes that, but only
135
+ when named: left to choose, sqlite takes `actions_gated` and reads the rows
136
+ anyway. Measured on a 246MB journal: **56ms to 0.01ms**.
137
+
5
138
  ## 0.6.13 — 2026-09-14
6
139
 
7
140
  Four things, all of them from one comment by a stranger on Reddit who had
@@ -10,6 +143,26 @@ would have announced itself.
10
143
 
11
144
  ### Security
12
145
 
146
+ - **A server that would not start said nothing useful about why, and could
147
+ hang the proxy outright.** Two faults in the same few lines, found by hitting
148
+ the first one in real use.
149
+
150
+ What it printed was `code: 'MODULE_NOT_FOUND', requireStack: [], },
151
+ Node.js v22.16.0` -- the last four lines of node's output, which are the four
152
+ least informative. `Error: Cannot find module '/path/to/thing'` sits in the
153
+ middle and was thrown away, so the message named neither the file nor
154
+ anything to act on. A line that states a fault now leads, stack frames are
155
+ dropped, and the tail is only the fallback when nothing states one.
156
+
157
+ The second was worse and was found while testing the first: nothing read the
158
+ server's stderr until after the connection failed. A pipe nobody reads fills
159
+ at around 64kB and blocks the writer, so a server that said more than that
160
+ before dying never exited, the connect never rejected, and `check` sat there
161
+ for ever with no output at all. Measured against a server writing 2.4MB to
162
+ stderr: **killed at 30 seconds, against 0.8 seconds and the right error**.
163
+ Stderr is now read as it arrives, capped so a logging loop cannot make the
164
+ proxy the thing that runs out of memory.
165
+
13
166
  - **The one remaining advisory is gone.** `esbuild` reached `pnpm audit`
14
167
  through vite, in the renderer build only -- never in anything published, and
15
168
  `--prod` was already clean. Pinned anyway, the same way as 0.6.12's three:
@@ -6,6 +6,19 @@ import {
6
6
  describe
7
7
  } from "./chunk-YVOO3PTV.js";
8
8
 
9
+ // src/proxy/flags.ts
10
+ var PROXY_FLAGS = [
11
+ "--manifest",
12
+ "--journal",
13
+ "--server",
14
+ "--gate-timeout",
15
+ "--log-level",
16
+ "--http",
17
+ "--http-host",
18
+ "--http-idle",
19
+ "--token"
20
+ ];
21
+
9
22
  // src/invocation.ts
10
23
  import { spawnSync } from "child_process";
11
24
  import { accessSync, constants } from "fs";
@@ -131,23 +144,25 @@ var INK = `${ESC}38;2;214;201;197m`;
131
144
  var DIM = `${ESC}2m`;
132
145
  var BOLD = `${ESC}1m`;
133
146
  var RESET = `${ESC}0m`;
134
- var enabled = process.env["NO_COLOR"] === void 0 && process.env["TERM"] !== "dumb" && process.stdout.isTTY;
135
- function paint(codes, text) {
136
- return enabled ? `${codes}${text}${RESET}` : text;
147
+ function usable(stream) {
148
+ return process.env["NO_COLOR"] === void 0 && process.env["TERM"] !== "dumb" && stream.isTTY;
137
149
  }
138
150
  function spaced(text) {
139
151
  return Array.from(text).join(" ");
140
152
  }
141
- var style = {
142
- /** A section label: small, capital, spaced out. */
143
- label: (text) => paint(ACCENT + DIM, spaced(text.toUpperCase())),
144
- heading: (text) => paint(INK, text.toUpperCase()),
145
- accent: (text) => paint(ACCENT, text),
146
- strong: (text) => paint(INK, text),
147
- quiet: (text) => paint(DIM, text),
148
- /** Off-white on oxblood, the way the wordmark is set. */
149
- plate: (text) => paint(ON_ACCENT + BOLD, ` ${text} `)
150
- };
153
+ function palette(on) {
154
+ const paint = (codes, text) => on ? `${codes}${text}${RESET}` : text;
155
+ return {
156
+ label: (text) => paint(ACCENT + DIM, spaced(text.toUpperCase())),
157
+ heading: (text) => paint(INK, text.toUpperCase()),
158
+ accent: (text) => paint(ACCENT, text),
159
+ strong: (text) => paint(INK, text),
160
+ quiet: (text) => paint(DIM, text),
161
+ plate: (text) => paint(ON_ACCENT + BOLD, ` ${text} `)
162
+ };
163
+ }
164
+ var style = palette(usable(process.stdout));
165
+ var errorStyle = palette(usable(process.stderr));
151
166
  var WORDMARK = spaced("SYNARTESIS");
152
167
  function meander(width) {
153
168
  const unit = "\u2517\u2501\u2513\u250F\u2501\u251B";
@@ -270,6 +285,34 @@ CREATE INDEX IF NOT EXISTS actions_gated ON actions(status, ts);
270
285
  -- no meaning changes, IF NOT EXISTS makes it idempotent, and an older build
271
286
  -- opening the same file afterwards neither notices nor cares.
272
287
  CREATE INDEX IF NOT EXISTS actions_run_status ON actions(run_id, status);
288
+
289
+ -- Partial, which is what makes it small: newestUndoable asks for the newest run
290
+ -- holding something an undo would reverse, and that is a handful of rows out of
291
+ -- a table where every other row is a read, a refusal or something already put
292
+ -- back. Without it the question can only be answered by reaching into the table
293
+ -- to test inverse_json on every applied row -- and those rows carry the
294
+ -- snapshots, so the cost of the hint at the foot of a session list grew with
295
+ -- the size of the data rather than with the number of sessions, which is
296
+ -- exactly what the index above was added to stop. Measured on sixty runs of a
297
+ -- thousand actions with two-kilobyte snapshots, a 246MB journal: 56ms to
298
+ -- 0.01ms.
299
+ --
300
+ -- newestUndoable names this index with INDEXED BY, and has to: left to choose,
301
+ -- sqlite takes actions_gated for the status seek and then reads the rows. See
302
+ -- the query for why that is preferred to running ANALYZE.
303
+ --
304
+ -- Added the same way as the three above and for the same reason: no row
305
+ -- changes, no meaning changes, IF NOT EXISTS makes it idempotent, and an older
306
+ -- build opening the same file afterwards neither notices nor cares.
307
+ CREATE INDEX IF NOT EXISTS actions_undoable ON actions(run_id)
308
+ WHERE status = 'applied' AND inverse_json IS NOT NULL;
309
+
310
+ -- Partial for the same reason: findPending runs on the way in to every write,
311
+ -- looking for an earlier attempt at that exact call whose outcome was never
312
+ -- established. Those are rare, so the index holds almost nothing -- while the
313
+ -- rows it saves reading are the ones carrying the snapshots.
314
+ CREATE INDEX IF NOT EXISTS actions_unresolved ON actions(run_id, server, tool)
315
+ WHERE status = 'pending';
273
316
  `;
274
317
 
275
318
  // src/journal/journal.ts
@@ -681,6 +724,17 @@ var SqliteJournal = class {
681
724
  return rows.find((row) => canonical(row.args) === wanted);
682
725
  });
683
726
  }
727
+ findPending(query) {
728
+ return this.#run("findPending", () => {
729
+ const rows = this.#db.prepare(
730
+ `SELECT * FROM actions INDEXED BY actions_unresolved
731
+ WHERE run_id = ? AND server = ? AND tool = ? AND status = 'pending'
732
+ ORDER BY seq`
733
+ ).all(query.runId, query.server, query.tool).map(toAction);
734
+ const wanted = canonical(query.args ?? {});
735
+ return rows.find((row) => canonical(row.args) === wanted);
736
+ });
737
+ }
684
738
  getAction(actionId) {
685
739
  return this.#run("getAction", () => {
686
740
  const raw = this.#db.prepare("SELECT * FROM actions WHERE id = ?").get(actionId);
@@ -743,6 +797,33 @@ var SqliteJournal = class {
743
797
  return tally;
744
798
  });
745
799
  }
800
+ /**
801
+ * The newest run that still holds something an undo would put back.
802
+ *
803
+ * Not simply the newest run, and not the newest run with an applied action
804
+ * either. A client that connects and reads opens a session like any other,
805
+ * and reads are recorded and left `applied` -- rollback steps past them --
806
+ * so `applied` alone regularly points at a session in which nothing
807
+ * happened. What makes a run worth offering is an action that is still
808
+ * applied and carries the inverse that would reverse it, which is the same
809
+ * test `undo` itself uses when it goes looking for the session somebody
810
+ * meant. That inverse is also what excludes the reads: the manifest loader
811
+ * refuses an inverse on a readonly tool, so a read can never carry one, and
812
+ * a second condition on the class would be a condition that cannot fire.
813
+ */
814
+ newestUndoable() {
815
+ return this.#run("newestUndoable", () => {
816
+ const raw = this.#db.prepare(
817
+ `SELECT * FROM runs
818
+ WHERE id IN (SELECT run_id FROM actions INDEXED BY actions_undoable
819
+ WHERE status = 'applied'
820
+ AND inverse_json IS NOT NULL)
821
+ ORDER BY started_at DESC, rowid DESC
822
+ LIMIT 1`
823
+ ).get();
824
+ return raw === void 0 ? void 0 : toRun(raw);
825
+ });
826
+ }
746
827
  recentActions(limit) {
747
828
  return this.#run(
748
829
  "recentActions",
@@ -1526,6 +1607,25 @@ function describeError(error) {
1526
1607
  return error instanceof Error ? error.message : String(error);
1527
1608
  }
1528
1609
  var PROXY_CLIENT_INFO = { name: "synartesis-proxy", version: "0.0.0" };
1610
+ var STDERR_KEPT = 256 * 1024;
1611
+ var STDERR_SETTLE_MS = 200;
1612
+ function settled(stream, ms) {
1613
+ if (stream.readableEnded === true) {
1614
+ return Promise.resolve();
1615
+ }
1616
+ return new Promise((resolve2) => {
1617
+ const done = () => {
1618
+ clearTimeout(timer);
1619
+ resolve2();
1620
+ };
1621
+ const timer = setTimeout(done, ms);
1622
+ timer.unref?.();
1623
+ stream.on("end", done);
1624
+ });
1625
+ }
1626
+ function isReadable(stream) {
1627
+ return typeof stream === "object" && stream !== null && "on" in stream && typeof stream.on === "function";
1628
+ }
1529
1629
  function bufferedText(stream) {
1530
1630
  if (typeof stream !== "object" || stream === null || !("read" in stream)) {
1531
1631
  return "";
@@ -1534,14 +1634,26 @@ function bufferedText(stream) {
1534
1634
  if (typeof read2 !== "function") {
1535
1635
  return "";
1536
1636
  }
1537
- const chunk = read2.call(stream);
1538
- if (typeof chunk === "string") {
1539
- return chunk;
1637
+ let text = "";
1638
+ for (; ; ) {
1639
+ const chunk = read2.call(stream);
1640
+ if (typeof chunk === "string") {
1641
+ text += chunk;
1642
+ } else if (Buffer.isBuffer(chunk)) {
1643
+ text += chunk.toString("utf8");
1644
+ } else {
1645
+ break;
1646
+ }
1540
1647
  }
1541
- return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : "";
1648
+ return text;
1542
1649
  }
1650
+ var NAMES_A_FAULT = /^(?:[A-Za-z]*(?:Error|Exception)\b|Cannot find |ENOENT\b|EACCES\b|EADDRINUSE\b|.*: command not found)/;
1543
1651
  function lastWords(text) {
1544
- const lines = text.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
1652
+ const lines = text.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !/^at\s/.test(line));
1653
+ const named = lines.find((line) => NAMES_A_FAULT.test(line));
1654
+ if (named !== void 0) {
1655
+ return named;
1656
+ }
1545
1657
  const kept = lines.slice(-4).join("; ");
1546
1658
  return kept === "" ? void 0 : kept;
1547
1659
  }
@@ -1573,11 +1685,22 @@ async function start(spec) {
1573
1685
  });
1574
1686
  const client = new Client({ ...PROXY_CLIENT_INFO });
1575
1687
  let said = "";
1688
+ const stderr = transport.stderr;
1689
+ const listening = isReadable(stderr);
1690
+ if (listening) {
1691
+ stderr.on("data", (chunk) => {
1692
+ if (chunk !== void 0 && said.length < STDERR_KEPT) {
1693
+ said += typeof chunk === "string" ? chunk : chunk.toString("utf8");
1694
+ }
1695
+ });
1696
+ }
1576
1697
  try {
1577
1698
  await client.connect(transport);
1578
1699
  } catch (error) {
1579
- said = bufferedText(transport.stderr);
1580
- const reason = lastWords(said);
1700
+ if (listening) {
1701
+ await settled(stderr, STDERR_SETTLE_MS);
1702
+ }
1703
+ const reason = lastWords(said === "" ? bufferedText(transport.stderr) : said);
1581
1704
  throw new UpstreamError(
1582
1705
  spec.name,
1583
1706
  "connect",
@@ -1741,6 +1864,7 @@ async function observeState(router, read2, signal) {
1741
1864
  }
1742
1865
 
1743
1866
  export {
1867
+ PROXY_FLAGS,
1744
1868
  cliCommand,
1745
1869
  cliCommandFrom,
1746
1870
  proxyCommand,
@@ -1748,6 +1872,7 @@ export {
1748
1872
  findManifest,
1749
1873
  findJournal,
1750
1874
  style,
1875
+ errorStyle,
1751
1876
  WORDMARK,
1752
1877
  rule,
1753
1878
  mark,
@@ -1782,4 +1907,4 @@ export {
1782
1907
  observeState,
1783
1908
  connectStdioUpstream
1784
1909
  };
1785
- //# sourceMappingURL=chunk-6X7GPUGJ.js.map
1910
+ //# sourceMappingURL=chunk-OODDXM36.js.map
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  IDEMPOTENCY_META_KEY,
4
4
  NOTHING_RECORDED_YET,
5
+ PROXY_FLAGS,
5
6
  WORDMARK,
6
7
  banner,
7
8
  canonical,
@@ -10,6 +11,7 @@ import {
10
11
  createPolicyResolver,
11
12
  createRouter,
12
13
  describeStanding,
14
+ errorStyle,
13
15
  findJournal,
14
16
  findManifest,
15
17
  labelFor,
@@ -34,7 +36,7 @@ import {
34
36
  verifyAgainstServers,
35
37
  warnUntested,
36
38
  wasRefused
37
- } from "./chunk-6X7GPUGJ.js";
39
+ } from "./chunk-OODDXM36.js";
38
40
  import {
39
41
  DriftConflict,
40
42
  ManifestError,
@@ -786,24 +788,6 @@ function verdict(inspection) {
786
788
  return "nothing was recorded in this session";
787
789
  }
788
790
 
789
- // src/watch.ts
790
- import { existsSync as existsSync2 } from "fs";
791
-
792
- // src/keys.ts
793
- var SEQUENCE = /^\u001b(\[[0-9;?]*[ -\/]*[@-~]|O[@-~])/;
794
- function keysIn(chunk) {
795
- const keys = [];
796
- let at = 0;
797
- while (at < chunk.length) {
798
- const rest = chunk.slice(at);
799
- const sequence = rest.startsWith("\x1B") ? SEQUENCE.exec(rest) : null;
800
- const key = sequence?.[0] ?? rest.slice(0, 1);
801
- keys.push(key);
802
- at += key.length;
803
- }
804
- return keys;
805
- }
806
-
807
791
  // src/clock.ts
808
792
  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
809
793
  var pad = (value) => String(value).padStart(2, "0");
@@ -855,6 +839,169 @@ function ago(iso, now = /* @__PURE__ */ new Date()) {
855
839
  return `${String(Math.round(hours / 24))}d ago`;
856
840
  }
857
841
 
842
+ // src/hints.ts
843
+ function hintsWanted() {
844
+ return process.env["SYNARTESIS_NO_HINTS"] === void 0;
845
+ }
846
+ function hintLine(hint2, paths = "") {
847
+ if (hint2.run === void 0) {
848
+ return ` ${style.quiet(hint2.why)}`;
849
+ }
850
+ return ` ${style.quiet(`${hint2.why}:`)} ${style.strong(`${cliCommand()} ${hint2.run}${paths}`)}`;
851
+ }
852
+ function short(id) {
853
+ return id.slice(0, 8);
854
+ }
855
+ function heldCalls(journal) {
856
+ const waiting = journal.listGated();
857
+ const [first, ...rest] = waiting;
858
+ if (first === void 0) {
859
+ return void 0;
860
+ }
861
+ if (rest.length === 0) {
862
+ return {
863
+ why: `${first.server}.${first.tool} is held, and the agent is waiting on you`,
864
+ run: `approve ${short(first.id)}`,
865
+ needs: ["journal"]
866
+ };
867
+ }
868
+ return {
869
+ why: `${String(waiting.length)} calls are held, and the agent is waiting on you`,
870
+ run: "approve --all",
871
+ needs: ["journal"]
872
+ };
873
+ }
874
+ function whatChanged(journal) {
875
+ const run = journal.newestUndoable();
876
+ if (run === void 0) {
877
+ return void 0;
878
+ }
879
+ return {
880
+ why: `${run.label ?? "an agent"} changed something ${ago(run.startedAt)}`,
881
+ run: `show ${short(run.id)}`,
882
+ needs: ["journal"]
883
+ };
884
+ }
885
+ function notPinned(manifest) {
886
+ const pinned = Object.keys(manifest.pins ?? {});
887
+ const loose = Object.keys(manifest.servers).filter((name) => !pinned.includes(name));
888
+ if (loose.length === 0) {
889
+ return void 0;
890
+ }
891
+ return {
892
+ why: pinned.length === 0 ? "no tool here is pinned, so a server that changes shape keeps its old policy" : `${loose.join(", ")} ${loose.length === 1 ? "is" : "are"} not pinned`,
893
+ run: "pin",
894
+ needs: ["manifest"]
895
+ };
896
+ }
897
+ var LEAVE_IT_RUNNING = {
898
+ why: "anything an agent holds will appear here as it happens",
899
+ run: "watch",
900
+ needs: ["journal"]
901
+ };
902
+ function afterStatus(journal) {
903
+ if (journal === void 0) {
904
+ return LEAVE_IT_RUNNING;
905
+ }
906
+ return firstOf(
907
+ () => heldCalls(journal),
908
+ () => whatChanged(journal),
909
+ () => LEAVE_IT_RUNNING
910
+ );
911
+ }
912
+ function firstOf(...candidates2) {
913
+ for (const candidate of candidates2) {
914
+ const hint2 = candidate();
915
+ if (hint2 !== void 0) {
916
+ return hint2;
917
+ }
918
+ }
919
+ return void 0;
920
+ }
921
+ function didYouMean(typed, known) {
922
+ if (typed.length < 3) {
923
+ return void 0;
924
+ }
925
+ const ceiling = typed.length <= 4 ? 1 : 2;
926
+ let best;
927
+ let bestAt = ceiling + 1;
928
+ for (const candidate of known) {
929
+ const gap = distance(typed, candidate);
930
+ if (gap < bestAt) {
931
+ best = candidate;
932
+ bestAt = gap;
933
+ }
934
+ }
935
+ return bestAt <= ceiling ? best : void 0;
936
+ }
937
+ function distance(from, to) {
938
+ const rows = [];
939
+ for (let i = 0; i <= from.length; i += 1) {
940
+ const row = new Array(to.length + 1).fill(0);
941
+ row[0] = i;
942
+ rows.push(row);
943
+ }
944
+ const top = rows[0];
945
+ for (let j = 0; j <= to.length && top !== void 0; j += 1) {
946
+ top[j] = j;
947
+ }
948
+ for (let i = 1; i <= from.length; i += 1) {
949
+ for (let j = 1; j <= to.length; j += 1) {
950
+ const same = from[i - 1] === to[j - 1];
951
+ let best = Math.min(
952
+ (rows[i - 1]?.[j] ?? 0) + 1,
953
+ (rows[i]?.[j - 1] ?? 0) + 1,
954
+ (rows[i - 1]?.[j - 1] ?? 0) + (same ? 0 : 1)
955
+ );
956
+ if (i > 1 && j > 1 && from[i - 1] === to[j - 2] && from[i - 2] === to[j - 1]) {
957
+ best = Math.min(best, (rows[i - 2]?.[j - 2] ?? 0) + 1);
958
+ }
959
+ const row = rows[i];
960
+ if (row !== void 0) {
961
+ row[j] = best;
962
+ }
963
+ }
964
+ }
965
+ return rows[from.length]?.[to.length] ?? to.length;
966
+ }
967
+ var EVERYDAY = [
968
+ ["install", "cover the servers your client already lists"],
969
+ ["watch", "see what an agent does, and answer what it holds"],
970
+ ["list", "the sessions recorded so far"],
971
+ ["show", "what one session did, and whether it still holds"],
972
+ ["undo", "put back what one of them changed"]
973
+ ];
974
+ function shortList() {
975
+ const self = cliCommand();
976
+ return [
977
+ "",
978
+ ...EVERYDAY.map(
979
+ ([name, said]) => ` ${errorStyle.strong(`${self} ${name.padEnd(8)}`)} ${errorStyle.quiet(said)}`
980
+ ),
981
+ "",
982
+ ` ${errorStyle.quiet(`${self} --help for every command.`)}`,
983
+ ""
984
+ ].join("\n");
985
+ }
986
+
987
+ // src/watch.ts
988
+ import { existsSync as existsSync2 } from "fs";
989
+
990
+ // src/keys.ts
991
+ var SEQUENCE = /^\u001b(\[[0-9;?]*[ -\/]*[@-~]|O[@-~])/;
992
+ function keysIn(chunk) {
993
+ const keys = [];
994
+ let at = 0;
995
+ while (at < chunk.length) {
996
+ const rest = chunk.slice(at);
997
+ const sequence = rest.startsWith("\x1B") ? SEQUENCE.exec(rest) : null;
998
+ const key = sequence?.[0] ?? rest.slice(0, 1);
999
+ keys.push(key);
1000
+ at += key.length;
1001
+ }
1002
+ return keys;
1003
+ }
1004
+
858
1005
  // src/describe.ts
859
1006
  import { basename } from "path";
860
1007
  var SUBJECT_KEYS = [
@@ -1513,8 +1660,8 @@ function pruneBackups(path) {
1513
1660
  try {
1514
1661
  const dir = dirname(path);
1515
1662
  const prefix = `${basename2(path)}.synartesis-backup-`;
1516
- const ours = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
1517
- for (const name of ours.slice(0, Math.max(0, ours.length - KEEP_BACKUPS))) {
1663
+ const ours2 = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
1664
+ for (const name of ours2.slice(0, Math.max(0, ours2.length - KEEP_BACKUPS))) {
1518
1665
  rmSync(join(dir, name), { force: true });
1519
1666
  }
1520
1667
  } catch {
@@ -2668,6 +2815,10 @@ it and is found from any directory inside it, the way a version control tool
2668
2815
  finds its root; anything else lives in ~/.synartesis, which is where the
2669
2816
  journal is too. Set SYNARTESIS_HOME to put that somewhere else.
2670
2817
 
2818
+ Most commands end by naming the one thing worth doing next, worked out from
2819
+ what is actually in the journal rather than from what was typed. Set
2820
+ SYNARTESIS_NO_HINTS to turn that off; --json never carries it.
2821
+
2671
2822
  Exit codes: 0 complete, 1 halted or partial, 2 bad usage or configuration.
2672
2823
  `;
2673
2824
  var UsageError = class extends Error {
@@ -2685,25 +2836,37 @@ var UsageError = class extends Error {
2685
2836
  this.listCommands = listCommands;
2686
2837
  }
2687
2838
  };
2839
+ function ours(argv) {
2840
+ const end = argv.indexOf("--");
2841
+ return end === -1 ? argv : argv.slice(0, end);
2842
+ }
2688
2843
  function flag(argv, name) {
2689
- const at = argv.indexOf(name);
2844
+ const mine = ours(argv);
2845
+ const at = mine.indexOf(name);
2690
2846
  if (at === -1) {
2691
2847
  return void 0;
2692
2848
  }
2693
- const value = argv[at + 1];
2849
+ const value = mine[at + 1];
2694
2850
  if (value === void 0 || value.startsWith("--")) {
2695
2851
  throw new UsageError(`${name} needs a value`);
2696
2852
  }
2697
2853
  return value;
2698
2854
  }
2855
+ var TAKES_VALUE = /* @__PURE__ */ new Set([
2856
+ "--manifest",
2857
+ "--journal",
2858
+ "--to",
2859
+ "--by",
2860
+ "--reason",
2861
+ "--older-than",
2862
+ "--client"
2863
+ ]);
2699
2864
  function positional(argv) {
2700
- const skip = /* @__PURE__ */ new Set(["--manifest", "--journal", "--to", "--by", "--reason", "--gate-timeout", "--older-than", "--client"]);
2701
2865
  const values = [];
2702
- const end = argv.indexOf("--");
2703
- const ours = end === -1 ? argv : argv.slice(0, end);
2704
- for (let i = 0; i < ours.length; i += 1) {
2705
- const token = ours[i] ?? "";
2706
- if (skip.has(token)) {
2866
+ const mine = ours(argv);
2867
+ for (let i = 0; i < mine.length; i += 1) {
2868
+ const token = mine[i] ?? "";
2869
+ if (TAKES_VALUE.has(token)) {
2707
2870
  i += 1;
2708
2871
  continue;
2709
2872
  }
@@ -2804,6 +2967,16 @@ async function runCheck(argv) {
2804
2967
  out("");
2805
2968
  out(` ${style.quiet("Anything not mentioned here is treated as irreversible and guarded.")}`);
2806
2969
  out("");
2970
+ hint(
2971
+ firstOf(
2972
+ () => notPinned(manifest),
2973
+ () => ({
2974
+ why: "this is sound; to see which clients it covers",
2975
+ run: "status",
2976
+ needs: ["manifest", "journal"]
2977
+ })
2978
+ )
2979
+ );
2807
2980
  return 0;
2808
2981
  }
2809
2982
  async function runInstall(argv) {
@@ -2997,6 +3170,9 @@ function runStatus(argv) {
2997
3170
  waiting === 0 ? ` ${style.quiet("Everything found is covered.")}` : ` ${style.accent(`${String(waiting)} not covered.`)} ${style.quiet(`${cliCommand()} install covers them.`)}`
2998
3171
  );
2999
3172
  out("");
3173
+ if (waiting === 0) {
3174
+ hint(afterStatus(journal));
3175
+ }
3000
3176
  } finally {
3001
3177
  journal?.close();
3002
3178
  }
@@ -3096,6 +3272,13 @@ function out(line2) {
3096
3272
  process.stdout.write(`${line2}
3097
3273
  `);
3098
3274
  }
3275
+ function hint(candidate) {
3276
+ if (candidate === void 0 || !hintsWanted()) {
3277
+ return;
3278
+ }
3279
+ out(hintLine(candidate, pathArgs(candidate.needs)));
3280
+ out("");
3281
+ }
3099
3282
  function runList(journal, asJson, journalPath) {
3100
3283
  const runs = [...journal.listRuns()].reverse();
3101
3284
  const tally2 = journal.tallyRuns();
@@ -3134,6 +3317,10 @@ function runList(journal, asJson, journalPath) {
3134
3317
  out(` ${style.quiet(`This journal is ${sizeOf(journalPath)}; synartesis prune reclaims what is old enough to lose.`)}`);
3135
3318
  out("");
3136
3319
  }
3320
+ hint(firstOf(
3321
+ () => heldCalls(journal),
3322
+ () => whatChanged(journal)
3323
+ ));
3137
3324
  return 0;
3138
3325
  }
3139
3326
  async function runShow(argv, journal, asJson) {
@@ -3231,6 +3418,16 @@ async function runShow(argv, journal, asJson) {
3231
3418
  out(
3232
3419
  ` ${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")}`
3233
3420
  );
3421
+ } else if (actions.some((action) => action.status === "applied" && action.inverse !== void 0)) {
3422
+ out("");
3423
+ hint({
3424
+ // Not a restatement of the verdict directly above it, which has just
3425
+ // said in its own words that nothing has moved. A hint that repeats
3426
+ // the line above is a line nobody reads twice.
3427
+ why: "to see the plan, before anything is written",
3428
+ run: `undo ${runId.slice(0, 8)} --dry-run`,
3429
+ needs: ["manifest", "journal"]
3430
+ });
3234
3431
  }
3235
3432
  } else {
3236
3433
  out("");
@@ -3320,6 +3517,13 @@ function summarise2(actions) {
3320
3517
  return `${String(actions.length)} actions: ${parts.join(", ")} | ${String(undoable)} with a recorded undo`;
3321
3518
  }
3322
3519
  var journalArg = "";
3520
+ var manifestArg = "";
3521
+ function pathArgs(needs) {
3522
+ if (needs === void 0) {
3523
+ return "";
3524
+ }
3525
+ return needs.map((what) => what === "journal" ? journalArg : manifestArg).join("");
3526
+ }
3323
3527
  var PRUNE_NAG_BYTES = 100 * 1024 * 1024;
3324
3528
  function bytesOf(path) {
3325
3529
  try {
@@ -3398,6 +3602,10 @@ function runClose(argv, journal) {
3398
3602
  out("");
3399
3603
  out(` ${style.quiet("nothing is open; every run has ended cleanly")}`);
3400
3604
  out("");
3605
+ hint(firstOf(
3606
+ () => heldCalls(journal),
3607
+ () => whatChanged(journal)
3608
+ ));
3401
3609
  return 0;
3402
3610
  }
3403
3611
  const run = given === void 0 ? pick(active, void 0, RUN, true) : pick([...all].reverse(), given, RUN, true);
@@ -3419,6 +3627,10 @@ function runGates(journal, asJson) {
3419
3627
  out("");
3420
3628
  out(` ${style.quiet("Nothing is waiting for a decision.")}`);
3421
3629
  out("");
3630
+ hint(firstOf(
3631
+ () => whatChanged(journal),
3632
+ () => LEAVE_IT_RUNNING
3633
+ ));
3422
3634
  return 0;
3423
3635
  }
3424
3636
  out("");
@@ -3447,10 +3659,10 @@ function runDecision(argv, journal, approving) {
3447
3659
  const by = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3448
3660
  const reason = flag(argv, "--reason") ?? "denied by operator";
3449
3661
  if (given !== void 0) {
3450
- const settled = journal.getAction(given);
3451
- if (settled !== void 0 && settled.status !== "gated") {
3662
+ const settled2 = journal.getAction(given);
3663
+ if (settled2 !== void 0 && settled2.status !== "gated") {
3452
3664
  process.stderr.write(
3453
- `synartesis: ${given} is no longer awaiting approval (it is ${labelFor(settled)})
3665
+ `synartesis: ${given} is no longer awaiting approval (it is ${labelFor(settled2)})
3454
3666
  `
3455
3667
  );
3456
3668
  return 1;
@@ -3462,6 +3674,7 @@ function runDecision(argv, journal, approving) {
3462
3674
  return 0;
3463
3675
  }
3464
3676
  let failed = 0;
3677
+ let settled = 0;
3465
3678
  for (const action of targets) {
3466
3679
  const changed = approving ? journal.approve(action.id, by) : journal.deny(action.id, by, reason);
3467
3680
  if (!changed) {
@@ -3473,13 +3686,23 @@ function runDecision(argv, journal, approving) {
3473
3686
  failed += 1;
3474
3687
  continue;
3475
3688
  }
3689
+ settled += 1;
3476
3690
  out(
3477
3691
  ` ${style.accent(approving ? "approved" : "denied")} ${style.strong(`${action.server}.${action.tool}`)} ${style.quiet(action.id)}`
3478
3692
  );
3479
3693
  }
3694
+ if (settled > 0) {
3695
+ out("");
3696
+ hint(
3697
+ firstOf(
3698
+ () => heldCalls(journal),
3699
+ () => approving ? { why: "the agent can make that call again now, and it will go through" } : { why: "the call was refused; the agent is told, and decides what to do next" }
3700
+ )
3701
+ );
3702
+ }
3480
3703
  return failed === 0 ? 0 : 1;
3481
3704
  }
3482
- function report(result, alreadyForcing = false) {
3705
+ function report(result, alreadyForcing = false, as = "") {
3483
3706
  out("");
3484
3707
  out(` ${style.label(result.dryRun ? "dry run" : "undo")} ${style.strong(result.runId)}`);
3485
3708
  out(` ${rule(72)}`);
@@ -3552,6 +3775,13 @@ function report(result, alreadyForcing = false) {
3552
3775
  ` ${style.label("result")} ${result.status === "rolled_back" ? result.status : style.accent(result.status)}`
3553
3776
  );
3554
3777
  out("");
3778
+ if (result.dryRun && result.status === "rolled_back") {
3779
+ hint({
3780
+ why: "nothing was written; to do exactly this for real",
3781
+ run: `undo ${result.runId.slice(0, 8)}${as}`,
3782
+ needs: ["manifest", "journal"]
3783
+ });
3784
+ }
3555
3785
  return result.status === "rolled_back" ? 0 : 1;
3556
3786
  }
3557
3787
  async function withUpstreams(manifestPath, use, only) {
@@ -3612,12 +3842,19 @@ async function performUndo(manifestPath, journal, runId, options) {
3612
3842
  );
3613
3843
  }
3614
3844
  async function runUndo(argv, journal) {
3845
+ const rawTo = flag(argv, "--to");
3846
+ const toSeq = rawTo === void 0 ? void 0 : Number(rawTo);
3847
+ if (toSeq !== void 0 && (!Number.isInteger(toSeq) || toSeq < 1)) {
3848
+ throw new UsageError("--to needs a positive whole number");
3849
+ }
3615
3850
  const given = positional(argv)[1];
3616
3851
  const chosen = pick([...journal.listRuns()].reverse(), given, RUN, true);
3617
3852
  const runId = chosen.id;
3618
3853
  if (given === void 0) {
3619
3854
  const actions = journal.getActions(runId);
3620
- const left = actions.filter((action) => action.status === "applied").length;
3855
+ const left = actions.filter(
3856
+ (action) => action.status === "applied" || action.status === "rolling_back" || action.status === "unrecoverable"
3857
+ ).length;
3621
3858
  out("");
3622
3859
  out(
3623
3860
  ` ${style.quiet("no session named, so the most recent:")} ${style.strong(runId.slice(0, 8))} ` + style.quiet(`${chosen.label ?? "an agent"}, ${shortTime(chosen.startedAt).trim()}`)
@@ -3630,7 +3867,9 @@ async function runUndo(argv, journal) {
3630
3867
  )}`
3631
3868
  );
3632
3869
  const other = [...journal.listRuns()].reverse().find(
3633
- (run) => run.id !== runId && journal.getActions(run.id).some((action) => action.status === "applied" && action.inverse !== void 0)
3870
+ (run) => run.id !== runId && journal.getActions(run.id).some(
3871
+ (action) => action.inverse !== void 0 && (action.status === "applied" || action.status === "unrecoverable")
3872
+ )
3634
3873
  );
3635
3874
  if (other !== void 0) {
3636
3875
  out("");
@@ -3647,11 +3886,6 @@ async function runUndo(argv, journal) {
3647
3886
  return 0;
3648
3887
  }
3649
3888
  }
3650
- const rawTo = flag(argv, "--to");
3651
- const toSeq = rawTo === void 0 ? void 0 : Number(rawTo);
3652
- if (toSeq !== void 0 && (!Number.isInteger(toSeq) || toSeq < 1)) {
3653
- throw new UsageError("--to needs a positive whole number");
3654
- }
3655
3889
  if (toSeq !== void 0) {
3656
3890
  const highest = journal.getActions(runId).reduce((top, action) => Math.max(top, action.seq), 0);
3657
3891
  if (toSeq > highest) {
@@ -3695,16 +3929,42 @@ async function runUndo(argv, journal) {
3695
3929
  return 1;
3696
3930
  }
3697
3931
  }
3932
+ const replan = argv.includes("--replan");
3698
3933
  return report(
3699
3934
  await performUndo(manifestPath, journal, runId, {
3700
3935
  dryRun: argv.includes("--dry-run"),
3701
3936
  ...toSeq === void 0 ? {} : { toSeq },
3702
- replan: argv.includes("--replan"),
3937
+ replan,
3703
3938
  ...forcing && said ? { force: true } : {}
3704
3939
  }),
3705
- forcing
3940
+ forcing,
3941
+ // --to is deliberately absent, and cannot reach here: a floor leaves
3942
+ // actions below it alone, which makes the result `partial`, and the hint
3943
+ // is only offered on `rolled_back`.
3944
+ `${replan ? " --replan" : ""}${forcing && said ? " --force --yes" : ""}`
3706
3945
  );
3707
3946
  }
3947
+ var KNOWN_COMMANDS = [
3948
+ "install",
3949
+ "uninstall",
3950
+ "status",
3951
+ "init",
3952
+ "check",
3953
+ "pin",
3954
+ "list",
3955
+ "show",
3956
+ "gates",
3957
+ "close",
3958
+ "prune",
3959
+ "proxy",
3960
+ "desktop",
3961
+ "watch",
3962
+ "approve",
3963
+ "deny",
3964
+ "undo",
3965
+ "help",
3966
+ "version"
3967
+ ];
3708
3968
  var FLAGS = /* @__PURE__ */ new Set([
3709
3969
  // Two lists have to agree about a flag: this one decides whether it is
3710
3970
  // accepted at all, and the skip set in positional() decides whether its
@@ -3743,13 +4003,26 @@ function version() {
3743
4003
  return "unknown";
3744
4004
  }
3745
4005
  }
4006
+ function unknownCommand(typed) {
4007
+ const meant = didYouMean(typed, KNOWN_COMMANDS);
4008
+ return meant === void 0 ? `unknown command ${typed}` : `unknown command ${typed}; did you mean ${meant}?`;
4009
+ }
3746
4010
  function rejectUnknownFlags(argv) {
3747
- for (const token of argv) {
3748
- if (token === "--") {
3749
- return;
4011
+ const mine = ours(argv);
4012
+ for (let i = 0; i < mine.length; i += 1) {
4013
+ const token = mine[i] ?? "";
4014
+ if (TAKES_VALUE.has(token)) {
4015
+ i += 1;
4016
+ continue;
3750
4017
  }
3751
4018
  if (token.startsWith("-") && token !== "-" && !FLAGS.has(token)) {
3752
- throw new UsageError(`unknown flag ${token}`);
4019
+ if (PROXY_FLAGS.includes(token)) {
4020
+ throw new UsageError(`${token} is a flag for ${cliCommand()} proxy, not for this`);
4021
+ }
4022
+ const meant = didYouMean(token, [...FLAGS]);
4023
+ throw new UsageError(
4024
+ meant === void 0 ? `unknown flag ${token}` : `unknown flag ${token}; did you mean ${meant}?`
4025
+ );
3753
4026
  }
3754
4027
  }
3755
4028
  }
@@ -3779,6 +4052,13 @@ ${COMMANDS}`);
3779
4052
  return 0;
3780
4053
  }
3781
4054
  rejectUnknownFlags(argv);
4055
+ if (command !== void 0 && !KNOWN_COMMANDS.includes(command)) {
4056
+ throw new UsageError(unknownCommand(command));
4057
+ }
4058
+ const givenJournal = flag(argv, "--journal");
4059
+ const givenManifest = flag(argv, "--manifest");
4060
+ journalArg = givenJournal === void 0 ? "" : ` --journal ${resolve3(givenJournal)}`;
4061
+ manifestArg = givenManifest === void 0 ? "" : ` --manifest ${resolve3(givenManifest)}`;
3782
4062
  if (command === void 0) {
3783
4063
  const manifestPath = findManifest(flag(argv, "--manifest"));
3784
4064
  const journalPath2 = findJournal(flag(argv, "--journal"), manifestPath);
@@ -3847,7 +4127,6 @@ ${COMMANDS}`);
3847
4127
  decideAs: flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown"
3848
4128
  });
3849
4129
  }
3850
- journalArg = given === void 0 ? "" : ` --journal ${resolve3(given)}`;
3851
4130
  if (!existsSync8(journalPath) && (command === "list" || command === "show" || command === "gates")) {
3852
4131
  if (asJson) {
3853
4132
  out(JSON.stringify(command === "show" ? { run: null, actions: [] } : []));
@@ -3885,7 +4164,7 @@ ${COMMANDS}`);
3885
4164
  case "undo":
3886
4165
  return await runUndo(argv, journal);
3887
4166
  default:
3888
- throw new UsageError(`unknown command ${command}`);
4167
+ throw new UsageError(unknownCommand(command));
3889
4168
  }
3890
4169
  } finally {
3891
4170
  journal.close();
@@ -3912,8 +4191,7 @@ try {
3912
4191
  if (error instanceof UsageError) {
3913
4192
  process.stderr.write(
3914
4193
  error.listCommands ? `synartesis: ${error.message}
3915
-
3916
- ${COMMANDS}` : `synartesis: ${error.message}
4194
+ ${shortList()}` : `synartesis: ${error.message}
3917
4195
  `
3918
4196
  );
3919
4197
  process.exitCode = 2;
package/dist/proxy.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ PROXY_FLAGS,
3
4
  canonical,
4
5
  cliCommandFrom,
5
6
  connectStdioUpstream,
@@ -22,7 +23,7 @@ import {
22
23
  verifyAgainstServers,
23
24
  warnUntested,
24
25
  withIdempotencyKey
25
- } from "./chunk-6X7GPUGJ.js";
26
+ } from "./chunk-OODDXM36.js";
26
27
  import {
27
28
  SnapshotError,
28
29
  UpstreamError,
@@ -585,14 +586,21 @@ function createProxyServer(options) {
585
586
  enter();
586
587
  try {
587
588
  const wantsGate = policy.gate === "always" || policy.gate === "on_write" && shouldGateOnWrite(args);
588
- const granted = wantsGate ? journal.findApproval({
589
+ const unresolved = policy.class === "readonly" ? void 0 : journal.findPending({
590
+ runId: activeRun,
591
+ server: route.upstream.name,
592
+ tool: route.tool,
593
+ args
594
+ });
595
+ const mustAsk = wantsGate || unresolved !== void 0;
596
+ const granted = mustAsk ? journal.findApproval({
589
597
  server: route.upstream.name,
590
598
  tool: route.tool,
591
599
  args,
592
600
  notBefore: new Date(Date.now() - APPROVAL_WINDOW_MS).toISOString()
593
601
  }) : void 0;
594
602
  const inherited = granted !== void 0 && granted.runId !== activeRun ? granted : void 0;
595
- const waiting = granted === void 0 && wantsGate ? journal.findGated({
603
+ const waiting = granted === void 0 && mustAsk ? journal.findGated({
596
604
  runId: activeRun,
597
605
  server: route.upstream.name,
598
606
  tool: route.tool,
@@ -676,9 +684,13 @@ function createProxyServer(options) {
676
684
  );
677
685
  }
678
686
  };
679
- const askedAlready = wantsGate;
680
- if (wantsGate && (granted === void 0 || !spent)) {
681
- await decide("this action cannot be undone");
687
+ const askedAlready = mustAsk;
688
+ if (mustAsk && (granted === void 0 || !spent)) {
689
+ const because = [
690
+ unresolved === void 0 ? void 0 : `an earlier attempt at this exact call, action ${String(unresolved.seq)}, went out and its outcome could never be established, so sending it again may apply the change a second time`,
691
+ wantsGate ? "this action cannot be undone" : void 0
692
+ ].filter((one) => one !== void 0);
693
+ await decide(because.join(", and "));
682
694
  }
683
695
  let snapshot;
684
696
  let verify;
@@ -1062,7 +1074,7 @@ function parseArgv(argv) {
1062
1074
  const at = argv.indexOf(flag);
1063
1075
  return at === -1 ? void 0 : argv[at + 1];
1064
1076
  };
1065
- const known = ["--manifest", "--journal", "--server", "--gate-timeout", "--log-level", "--http", "--http-host", "--http-idle", "--token"];
1077
+ const known = PROXY_FLAGS;
1066
1078
  const unknown = argv.find((token) => token.startsWith("--") && !known.includes(token));
1067
1079
  if (unknown !== void 0) {
1068
1080
  throw new Error(`unknown flag ${unknown}; expected one of ${known.join(", ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.6.13",
3
+ "version": "0.6.16",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,