synartesis 0.6.11 → 0.6.13

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/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ IDEMPOTENCY_META_KEY,
3
4
  NOTHING_RECORDED_YET,
4
5
  WORDMARK,
5
6
  banner,
@@ -8,6 +9,7 @@ import {
8
9
  connectStdioUpstream,
9
10
  createPolicyResolver,
10
11
  createRouter,
12
+ describeStanding,
11
13
  findJournal,
12
14
  findManifest,
13
15
  labelFor,
@@ -16,18 +18,23 @@ import {
16
18
  openJournal,
17
19
  parseManifest,
18
20
  pathBinaryMatches,
21
+ pinBlock,
19
22
  planInverse,
20
23
  planRead,
21
24
  proxyCommand,
22
25
  qualify,
23
26
  resolvedRead,
24
27
  rule,
28
+ standing,
25
29
  style,
26
30
  toPayload,
27
31
  toResolvedRead,
32
+ toolShapes,
33
+ untested,
28
34
  verifyAgainstServers,
35
+ warnUntested,
29
36
  wasRefused
30
- } from "./chunk-DRDKIFD3.js";
37
+ } from "./chunk-6X7GPUGJ.js";
31
38
  import {
32
39
  DriftConflict,
33
40
  ManifestError,
@@ -95,7 +102,14 @@ ${source}
95
102
  `,
96
103
  path
97
104
  ).tools;
98
- return { key, rules, name: hit.manifest, source };
105
+ const claimed = /^\s*provenance:\s*(live|documented)\s*$/m.exec(text)?.[1];
106
+ return {
107
+ key,
108
+ rules,
109
+ name: hit.manifest,
110
+ source,
111
+ ...claimed === "live" || claimed === "documented" ? { provenance: claimed } : {}
112
+ };
99
113
  } catch {
100
114
  return void 0;
101
115
  }
@@ -254,12 +268,20 @@ async function draftManifest(options) {
254
268
  `${options.name} is already declared in the manifest; remove it first or choose another name`
255
269
  );
256
270
  }
271
+ const known = knownPolicyFor(options.command, options.args);
272
+ const claim = known?.provenance === void 0 ? [] : [
273
+ ...known.provenance === "documented" ? [
274
+ ` # This policy has never been run against the real server. Check it`,
275
+ ` # against your own setup before trusting undo on it.`
276
+ ] : [],
277
+ ` provenance: ${known.provenance}`
278
+ ];
257
279
  const server = [
258
280
  ` ${options.name}:`,
259
281
  ` command: ${quote(options.command)}`,
260
- ` args: [${options.args.map(quote).join(", ")}]`
282
+ ` args: [${options.args.map(quote).join(", ")}]`,
283
+ ...claim
261
284
  ].join("\n");
262
- const known = knownPolicyFor(options.command, options.args);
263
285
  const adopted = known === void 0 ? void 0 : adopt(known, options.name, tools);
264
286
  const policies = adopted?.source ?? tools.map((tool) => draftTool(options.name, tool)).join("\n\n");
265
287
  if (existing === void 0) {
@@ -284,10 +306,24 @@ async function draftManifest(options) {
284
306
  policies,
285
307
  ``
286
308
  ].join("\n");
287
- return adopted === void 0 || known === void 0 ? { yaml } : { yaml, adopted: { server: known.name, tools: adopted.covered } };
309
+ return adopted === void 0 || known === void 0 ? { yaml } : {
310
+ yaml,
311
+ adopted: {
312
+ server: known.name,
313
+ tools: adopted.covered,
314
+ ...known.provenance === void 0 ? {} : { provenance: known.provenance }
315
+ }
316
+ };
288
317
  }
289
318
  const merged = mergeInto(existing, server, policies, options.name);
290
- return adopted === void 0 || known === void 0 ? { yaml: merged } : { yaml: merged, adopted: { server: known.name, tools: adopted.covered } };
319
+ return adopted === void 0 || known === void 0 ? { yaml: merged } : {
320
+ yaml: merged,
321
+ adopted: {
322
+ server: known.name,
323
+ tools: adopted.covered,
324
+ ...known.provenance === void 0 ? {} : { provenance: known.provenance }
325
+ }
326
+ };
291
327
  }
292
328
  function mergeInto(existing, server, policies, name) {
293
329
  const serversAt2 = existing.indexOf("\nservers:");
@@ -312,7 +348,6 @@ function mergeInto(existing, server, policies, name) {
312
348
 
313
349
  // src/rollback/rollback.ts
314
350
  import { z as z2 } from "zod";
315
- var IDEMPOTENCY_META_KEY = "synartesis.dev/idempotency-key";
316
351
  var inversePlan = z2.object({
317
352
  server: z2.string(),
318
353
  tool: z2.string(),
@@ -331,8 +366,10 @@ function classify(action, replanning, goAhead) {
331
366
  case "rolled_back":
332
367
  return { kind: "already-reverted", reason: "already rolled back", verified: true };
333
368
  case "failed":
334
- case "denied":
335
- return { kind: "skip", reason: `never applied (${action.status})`, verified: true };
369
+ case "denied": {
370
+ const why = labelFor(action) === "used" ? "never applied: its approval moved to the call that ran" : `never applied (${action.status})`;
371
+ return { kind: "skip", reason: why, verified: true };
372
+ }
336
373
  case "pending":
337
374
  return {
338
375
  kind: "halt",
@@ -572,7 +609,7 @@ ${seen}` : seen;
572
609
  };
573
610
  }
574
611
  function unverifiedBecause(action) {
575
- return action.verify === void 0 ? "no pre-read declared, so drift could not be ruled out" : "the post-state was never captured, so drift could not be ruled out";
612
+ return action.verify === void 0 ? "no read declared for this tool, so drift could not be ruled out -- a `verify` read in its policy would give it one" : "the post-state was never captured, so drift could not be ruled out";
576
613
  }
577
614
  function describeStep(action) {
578
615
  return { seq: action.seq, server: action.server, tool: action.tool };
@@ -652,7 +689,11 @@ async function inspect(options) {
652
689
  continue;
653
690
  }
654
691
  if (SETTLED.has(action.status)) {
655
- resources.push({ ...at, condition: "not-applied", note: action.status });
692
+ resources.push({
693
+ ...at,
694
+ condition: "not-applied",
695
+ note: labelFor(action) === "used" ? "its approval moved to the call that ran" : action.status
696
+ });
656
697
  continue;
657
698
  }
658
699
  if (action.status === "rolled_back") {
@@ -881,6 +922,19 @@ function size(bytes) {
881
922
  }
882
923
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
883
924
  }
925
+ function summariseValue(value) {
926
+ const flat = value.replace(/\s+/g, " ").trim();
927
+ if (flat.length <= 48) {
928
+ return flat;
929
+ }
930
+ const looksLikeALocation = !value.includes("\n") && /[/\\]/.test(value);
931
+ if (looksLikeALocation) {
932
+ const tail = value.slice(-44);
933
+ const from = tail.search(/[/\\]/);
934
+ return `\u2026${from === -1 ? tail : tail.slice(from)}`;
935
+ }
936
+ return size(Buffer.byteLength(value));
937
+ }
884
938
  function summariseArgs(args, limit = 60) {
885
939
  if (!isRecord(args)) {
886
940
  return args === void 0 ? "" : JSON.stringify(args);
@@ -888,7 +942,7 @@ function summariseArgs(args, limit = 60) {
888
942
  const parts = [];
889
943
  for (const [key, value] of Object.entries(args)) {
890
944
  if (typeof value === "string") {
891
- parts.push(value.length > 48 ? `${key} ${size(Buffer.byteLength(value))}` : `${key} ${value}`);
945
+ parts.push(`${key} ${summariseValue(value)}`);
892
946
  continue;
893
947
  }
894
948
  if (Array.isArray(value)) {
@@ -1643,7 +1697,11 @@ async function planInstall(sites, manifestPath, invoker) {
1643
1697
  name,
1644
1698
  original: entry,
1645
1699
  wrapped: proxyEntry(manifestPath, key, entry, invoker),
1646
- ...draft.adopted === void 0 ? {} : { adopted: draft.adopted.server, tools: draft.adopted.tools }
1700
+ ...draft.adopted === void 0 ? {} : {
1701
+ adopted: draft.adopted.server,
1702
+ tools: draft.adopted.tools,
1703
+ ...draft.adopted.provenance === void 0 ? {} : { provenance: draft.adopted.provenance }
1704
+ }
1647
1705
  });
1648
1706
  }
1649
1707
  plans.push({ site, servers: planned, skipped });
@@ -1877,7 +1935,7 @@ function runsView(journal, screen, options) {
1877
1935
  return ` ${here ? style.accent(CURSOR) : " "} ${here ? style.accent(name) : style.strong(name)} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(`${String(actions.length)} actions`)}${note}`;
1878
1936
  });
1879
1937
  }
1880
- function standing(actions) {
1938
+ function standing2(actions) {
1881
1939
  let undoable = 0;
1882
1940
  let conflicted = 0;
1883
1941
  for (const action of actions) {
@@ -1897,7 +1955,7 @@ function elsewhere(journal, exceptId) {
1897
1955
  if (run.id === exceptId) {
1898
1956
  continue;
1899
1957
  }
1900
- const found = standing(journal.getActions(run.id));
1958
+ const found = standing2(journal.getActions(run.id));
1901
1959
  if (found.undoable > 0 || found.conflicted > 0) {
1902
1960
  return run;
1903
1961
  }
@@ -2138,7 +2196,7 @@ async function openConsole(options) {
2138
2196
  if (run === void 0) {
2139
2197
  return [];
2140
2198
  }
2141
- const here = standing(ready.getActions(run.id));
2199
+ const here = standing2(ready.getActions(run.id));
2142
2200
  const id = run.id.slice(0, 8);
2143
2201
  if (here.undoable > 0) {
2144
2202
  return [
@@ -2359,7 +2417,7 @@ async function openConsole(options) {
2359
2417
  if (ready === void 0 || run === void 0) {
2360
2418
  return;
2361
2419
  }
2362
- const here = standing(ready.getActions(run.id));
2420
+ const here = standing2(ready.getActions(run.id));
2363
2421
  if (here.undoable === 0 && here.conflicted === 0) {
2364
2422
  say(nothingToUndo(ready, run));
2365
2423
  return;
@@ -2381,7 +2439,7 @@ async function openConsole(options) {
2381
2439
  if (ready === void 0 || run === void 0) {
2382
2440
  return;
2383
2441
  }
2384
- const here = standing(ready.getActions(run.id));
2442
+ const here = standing2(ready.getActions(run.id));
2385
2443
  if (here.undoable === 0 && here.conflicted === 0) {
2386
2444
  say(nothingToUndo(ready, run));
2387
2445
  return;
@@ -2541,6 +2599,7 @@ var COMMANDS = `
2541
2599
  synartesis status
2542
2600
  synartesis init <server> -- <command> [args...] [--manifest <path>]
2543
2601
  synartesis check [--manifest <path>]
2602
+ synartesis pin [--manifest <path>]
2544
2603
  synartesis list [--journal <path>]
2545
2604
  synartesis show <runId> [--full] [--live] [--journal <path>]
2546
2605
  synartesis gates [--journal <path>]
@@ -2612,6 +2671,19 @@ journal is too. Set SYNARTESIS_HOME to put that somewhere else.
2612
2671
  Exit codes: 0 complete, 1 halted or partial, 2 bad usage or configuration.
2613
2672
  `;
2614
2673
  var UsageError = class extends Error {
2674
+ /**
2675
+ * Whether the command list helps.
2676
+ *
2677
+ * It does for a mistyped flag or command. It does not for "there is nothing
2678
+ * here to act on", which is a fact about the journal rather than about what
2679
+ * was typed -- and answering that with forty lines of unrelated commands
2680
+ * buries the one sentence that matters.
2681
+ */
2682
+ listCommands;
2683
+ constructor(message, listCommands = true) {
2684
+ super(message);
2685
+ this.listCommands = listCommands;
2686
+ }
2615
2687
  };
2616
2688
  function flag(argv, name) {
2617
2689
  const at = argv.indexOf(name);
@@ -2641,9 +2713,51 @@ function positional(argv) {
2641
2713
  }
2642
2714
  return values;
2643
2715
  }
2716
+ async function runPin(argv) {
2717
+ const path = findManifest(flag(argv, "--manifest"));
2718
+ const manifest = loadManifest(path);
2719
+ const shapes = /* @__PURE__ */ new Map();
2720
+ const upstreams = [];
2721
+ try {
2722
+ for (const [name, spec] of Object.entries(manifest.servers)) {
2723
+ const upstream = await connectStdioUpstream({
2724
+ name,
2725
+ command: spec.command,
2726
+ args: spec.args,
2727
+ stderr: "capture",
2728
+ ...spec.env === void 0 ? {} : { env: spec.env }
2729
+ });
2730
+ upstreams.push(upstream);
2731
+ shapes.set(name, await toolShapes(upstream));
2732
+ }
2733
+ } finally {
2734
+ for (const upstream of upstreams) {
2735
+ await upstream.close();
2736
+ }
2737
+ }
2738
+ const block2 = pinBlock(shapes, manifest);
2739
+ out("");
2740
+ out(` ${style.label("pins for")} ${style.strong(path)}`);
2741
+ out("");
2742
+ for (const line2 of block2.split("\n")) {
2743
+ out(` ${line2}`);
2744
+ }
2745
+ out("");
2746
+ out(` ${style.quiet("Paste this into the manifest. From then on a tool whose shape")}`);
2747
+ out(` ${style.quiet("changes stops the proxy instead of quietly keeping its old policy.")}`);
2748
+ out("");
2749
+ return 0;
2750
+ }
2644
2751
  async function runCheck(argv) {
2645
2752
  const path = findManifest(flag(argv, "--manifest"));
2646
2753
  const manifest = loadManifest(path);
2754
+ const unproven = untested(manifest);
2755
+ if (unproven.length > 0) {
2756
+ out("");
2757
+ for (const line2 of wrapped(warnUntested(unproven), 74)) {
2758
+ out(` ${style.accent(line2)}`);
2759
+ }
2760
+ }
2647
2761
  const upstreams = [];
2648
2762
  try {
2649
2763
  for (const [name, spec] of Object.entries(manifest.servers)) {
@@ -2673,8 +2787,20 @@ async function runCheck(argv) {
2673
2787
  out(` ${rule(54)}`);
2674
2788
  out("");
2675
2789
  out(` ${style.quiet("servers ")} ${Object.keys(manifest.servers).join(", ")}`);
2790
+ for (const entry of standing(manifest)) {
2791
+ const said = describeStanding(entry);
2792
+ out(
2793
+ ` ${style.quiet(" ")} ${style.strong(entry.server)} ${entry.provenance === "documented" ? style.accent(said) : style.quiet(said)}`
2794
+ );
2795
+ }
2676
2796
  out(` ${style.quiet("policies")} ${[...counts].map(([k, v]) => `${String(v)} ${k}`).join(", ")}`);
2677
2797
  out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
2798
+ const pinned = Object.entries(manifest.pins ?? {});
2799
+ const servers = Object.keys(manifest.servers);
2800
+ const unpinned = servers.filter((name) => !pinned.some(([held]) => held === name));
2801
+ out(
2802
+ ` ${style.quiet("pinned ")} ${pinned.length === 0 ? style.quiet("nothing -- run `synartesis pin`") : pinned.map(([name, tools]) => `${name} (${String(Object.keys(tools).length)})`).join(", ") + (unpinned.length === 0 ? "" : style.quiet(`; not ${unpinned.join(", ")}`))}`
2803
+ );
2678
2804
  out("");
2679
2805
  out(` ${style.quiet("Anything not mentioned here is treated as irreversible and guarded.")}`);
2680
2806
  out("");
@@ -2711,6 +2837,11 @@ async function runInstall(argv) {
2711
2837
  for (const server of plan.servers) {
2712
2838
  const note = server.adopted === void 0 ? style.accent("drafted, every tool held until you say how to undo it") : style.quiet(`the policy that ships for ${server.adopted} (${String(server.tools ?? 0)} tools)`);
2713
2839
  out(` ${style.strong(server.name.padEnd(18))} ${note}`);
2840
+ if (server.provenance === "documented") {
2841
+ out(
2842
+ ` ${" ".repeat(18)} ${style.accent("never run against the real server -- check it before trusting undo")}`
2843
+ );
2844
+ }
2714
2845
  }
2715
2846
  for (const skip of plan.skipped) {
2716
2847
  out(` ${style.quiet(skip.name.padEnd(18))} ${style.quiet(skip.why)}`);
@@ -2921,18 +3052,19 @@ function pick(candidates2, given, noun, newest = false) {
2921
3052
  if (given === void 0) {
2922
3053
  const [only, ...rest2] = candidates2;
2923
3054
  if (only === void 0) {
2924
- throw new UsageError(`there is no ${noun.one} to act on`);
3055
+ throw new UsageError(`there is no ${noun.one} to act on`, false);
2925
3056
  }
2926
3057
  if (rest2.length > 0 && !newest) {
2927
3058
  throw new UsageError(
2928
3059
  `there are ${String(candidates2.length)} ${noun.many}; name one, or use --all:
2929
- ${listed(candidates2)}`
3060
+ ${listed(candidates2)}`,
3061
+ false
2930
3062
  );
2931
3063
  }
2932
3064
  return only;
2933
3065
  }
2934
3066
  if (given === "") {
2935
- throw new UsageError(`no ${noun.one} was named; an empty id is usually an unset variable`);
3067
+ throw new UsageError(`no ${noun.one} was named; an empty id is usually an unset variable`, false);
2936
3068
  }
2937
3069
  const exact = candidates2.find((item) => item.id === given);
2938
3070
  if (exact !== void 0) {
@@ -2941,12 +3073,13 @@ ${listed(candidates2)}`
2941
3073
  const matches = candidates2.filter((item) => item.id.startsWith(given));
2942
3074
  const [first, ...rest] = matches;
2943
3075
  if (first === void 0) {
2944
- throw new UsageError(`no ${noun.one} matches ${given}`);
3076
+ throw new UsageError(`no ${noun.one} matches ${given}`, false);
2945
3077
  }
2946
3078
  if (rest.length > 0) {
2947
3079
  throw new UsageError(
2948
3080
  `${given} matches ${String(matches.length)} ${noun.many}:
2949
- ${listed(matches)}`
3081
+ ${listed(matches)}`,
3082
+ false
2950
3083
  );
2951
3084
  }
2952
3085
  return first;
@@ -2965,8 +3098,10 @@ function out(line2) {
2965
3098
  }
2966
3099
  function runList(journal, asJson, journalPath) {
2967
3100
  const runs = [...journal.listRuns()].reverse();
3101
+ const tally2 = journal.tallyRuns();
3102
+ const counted = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
2968
3103
  if (asJson) {
2969
- out(JSON.stringify(runs.map((run) => ({ ...run, actions: journal.getActions(run.id).length }))));
3104
+ out(JSON.stringify(runs.map((run) => ({ ...run, actions: counted(run.id).actions }))));
2970
3105
  return 0;
2971
3106
  }
2972
3107
  if (runs.length === 0) {
@@ -2983,16 +3118,15 @@ function runList(journal, asJson, journalPath) {
2983
3118
  )
2984
3119
  );
2985
3120
  for (const run of runs) {
2986
- const actions = journal.getActions(run.id);
2987
- const unknown = actions.filter((action) => action.status === "pending").length;
2988
- const waiting = actions.filter((action) => action.status === "gated").length;
3121
+ const actions = counted(run.id);
3122
+ const { unknown, waiting } = actions;
2989
3123
  const notes = [
2990
3124
  unknown === 0 ? "" : `${String(unknown)} of unknown outcome`,
2991
3125
  waiting === 0 ? "" : `${String(waiting)} awaiting approval`
2992
3126
  ].filter((note2) => note2 !== "");
2993
3127
  const note = notes.length === 0 ? "" : ` ${style.accent(`(${notes.join("; ")})`)}`;
2994
3128
  out(
2995
- ` ${style.strong(run.id)} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${run.status.padEnd(12)} ${String(actions.length).padStart(7)} ${run.label ?? "-"}${note}`
3129
+ ` ${style.strong(run.id)} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${run.status.padEnd(12)} ${String(actions.actions).padStart(7)} ${run.label ?? "-"}${note}`
2996
3130
  );
2997
3131
  }
2998
3132
  out("");
@@ -3062,7 +3196,7 @@ async function runShow(argv, journal, asJson) {
3062
3196
  out(` ${style.quiet(summariseArgs(action.args, 96))}`);
3063
3197
  }
3064
3198
  if (action.approvedAt !== void 0) {
3065
- const verb = action.status === "denied" ? "denied" : "approved";
3199
+ const verb = labelFor(action) === "denied" ? "denied" : "approved";
3066
3200
  out(
3067
3201
  ` ${style.accent(`${verb} by ${action.approvedBy ?? "nobody"}`)} ${style.quiet(`at ${fullTime(action.approvedAt)}`)}`
3068
3202
  );
@@ -3178,7 +3312,8 @@ function truncate3(text, limit) {
3178
3312
  function summarise2(actions) {
3179
3313
  const counts = /* @__PURE__ */ new Map();
3180
3314
  for (const action of actions) {
3181
- counts.set(action.status, (counts.get(action.status) ?? 0) + 1);
3315
+ const label = labelFor(action);
3316
+ counts.set(label, (counts.get(label) ?? 0) + 1);
3182
3317
  }
3183
3318
  const parts = [...counts].sort(([a], [b]) => a < b ? -1 : 1).map(([k, v]) => `${String(v)} ${k}`);
3184
3319
  const undoable = actions.filter((a) => a.inverse !== void 0).length;
@@ -3258,12 +3393,14 @@ function runPrune(argv, journal, journalPath) {
3258
3393
  function runClose(argv, journal) {
3259
3394
  const all = journal.listRuns();
3260
3395
  const given = positional(argv)[1];
3261
- const run = given === void 0 ? pick(
3262
- [...all.filter((candidate) => candidate.status === "active")].reverse(),
3263
- void 0,
3264
- RUN,
3265
- true
3266
- ) : pick([...all].reverse(), given, RUN, true);
3396
+ const active = [...all.filter((candidate) => candidate.status === "active")].reverse();
3397
+ if (given === void 0 && active.length === 0) {
3398
+ out("");
3399
+ out(` ${style.quiet("nothing is open; every run has ended cleanly")}`);
3400
+ out("");
3401
+ return 0;
3402
+ }
3403
+ const run = given === void 0 ? pick(active, void 0, RUN, true) : pick([...all].reverse(), given, RUN, true);
3267
3404
  const closed = journal.closeAbandonedRun(run.id);
3268
3405
  out("");
3269
3406
  out(
@@ -3313,7 +3450,7 @@ function runDecision(argv, journal, approving) {
3313
3450
  const settled = journal.getAction(given);
3314
3451
  if (settled !== void 0 && settled.status !== "gated") {
3315
3452
  process.stderr.write(
3316
- `synartesis: ${given} is no longer awaiting approval (it is ${settled.status})
3453
+ `synartesis: ${given} is no longer awaiting approval (it is ${labelFor(settled)})
3317
3454
  `
3318
3455
  );
3319
3456
  return 1;
@@ -3362,7 +3499,11 @@ function report(result, alreadyForcing = false) {
3362
3499
  if (step.plan !== void 0 && step.kind === "revert") {
3363
3500
  const verb = `${step.replanned === true ? "replanned, " : ""}${result.dryRun ? "would call" : "called"}`;
3364
3501
  out(
3365
- ` ${style.quiet(verb)} ${step.plan.server}.${step.plan.tool} ` + style.quiet(truncate3(JSON.stringify(step.plan.args), 120))
3502
+ ` ${style.quiet(verb)} ${step.plan.server}.${step.plan.tool} ` + // summariseArgs, not truncated json: the raw form spent its whole
3503
+ // budget on a long path's leading directories and cut off before
3504
+ // the filename, so every step read "would call fs.write_file
3505
+ // {path: /Users/.../very/long/pre..." and named nothing.
3506
+ style.quiet(summariseArgs(step.plan.args, 120))
3366
3507
  );
3367
3508
  }
3368
3509
  }
@@ -3589,7 +3730,8 @@ var FLAGS = /* @__PURE__ */ new Set([
3589
3730
  "--help",
3590
3731
  "-h",
3591
3732
  "--version",
3592
- "-V"
3733
+ "-V",
3734
+ "-v"
3593
3735
  ]);
3594
3736
  function version() {
3595
3737
  try {
@@ -3625,12 +3767,13 @@ async function main(argv) {
3625
3767
  await import("./proxy.js");
3626
3768
  return 0;
3627
3769
  }
3628
- if (argv.includes("--help") || argv.includes("-h")) {
3770
+ const first = positional(argv)[0];
3771
+ if (argv.includes("--help") || argv.includes("-h") || first === "help") {
3629
3772
  process.stdout.write(`${banner()}
3630
3773
  ${COMMANDS}`);
3631
3774
  return 0;
3632
3775
  }
3633
- if (argv.includes("--version") || argv.includes("-V")) {
3776
+ if (argv.includes("--version") || argv.includes("-V") || argv.includes("-v") || first === "version") {
3634
3777
  process.stdout.write(`${version()}
3635
3778
  `);
3636
3779
  return 0;
@@ -3677,6 +3820,9 @@ ${COMMANDS}`);
3677
3820
  if (command === "check") {
3678
3821
  return await runCheck(argv);
3679
3822
  }
3823
+ if (command === "pin") {
3824
+ return await runPin(argv);
3825
+ }
3680
3826
  if (command === "install") {
3681
3827
  return await runInstall(argv);
3682
3828
  }
@@ -3764,9 +3910,12 @@ try {
3764
3910
  process.exitCode = await main(process.argv.slice(2));
3765
3911
  } catch (error) {
3766
3912
  if (error instanceof UsageError) {
3767
- process.stderr.write(`synartesis: ${error.message}
3913
+ process.stderr.write(
3914
+ error.listCommands ? `synartesis: ${error.message}
3768
3915
 
3769
- ${COMMANDS}`);
3916
+ ${COMMANDS}` : `synartesis: ${error.message}
3917
+ `
3918
+ );
3770
3919
  process.exitCode = 2;
3771
3920
  } else if (error instanceof ManifestError) {
3772
3921
  process.stderr.write(`synartesis: ${error.message}
package/dist/proxy.js CHANGED
@@ -18,8 +18,11 @@ import {
18
18
  refusal,
19
19
  runRead,
20
20
  toPayload,
21
- verifyAgainstServers
22
- } from "./chunk-DRDKIFD3.js";
21
+ untested,
22
+ verifyAgainstServers,
23
+ warnUntested,
24
+ withIdempotencyKey
25
+ } from "./chunk-6X7GPUGJ.js";
23
26
  import {
24
27
  SnapshotError,
25
28
  UpstreamError,
@@ -730,7 +733,11 @@ function createProxyServer(options) {
730
733
  }
731
734
  const forwarded = {
732
735
  method: "tools/call",
733
- params: { ...request.params, name: route.tool }
736
+ params: {
737
+ ...request.params,
738
+ name: route.tool,
739
+ _meta: withIdempotencyKey(request.params._meta, pending.idempotencyKey)
740
+ }
734
741
  };
735
742
  try {
736
743
  const result = await route.upstream.client.request(forwarded, PassthroughResult, {
@@ -775,6 +782,13 @@ function createProxyServer(options) {
775
782
  warnings.push(`inverse could not be resolved: ${describe(error)}`);
776
783
  }
777
784
  }
785
+ if (verify === void 0 && policy.verify !== void 0) {
786
+ try {
787
+ verify = planRead(policy.verify, context);
788
+ } catch (error) {
789
+ warnings.push(`the drift check could not be planned: ${describe(error)}`);
790
+ }
791
+ }
778
792
  let postSnapshot;
779
793
  if (verify !== void 0) {
780
794
  try {
@@ -1109,6 +1123,10 @@ async function main() {
1109
1123
  process.stderr.write(mark());
1110
1124
  }
1111
1125
  const manifest = loadManifest(argv.manifest);
1126
+ const unproven = untested(manifest);
1127
+ if (unproven.length > 0) {
1128
+ log.warn({ servers: unproven }, warnUntested(unproven));
1129
+ }
1112
1130
  const journal = openJournal(argv.journal);
1113
1131
  const declared = Object.entries(manifest.servers);
1114
1132
  const wanted = argv.server === void 0 ? declared : declared.filter(([name]) => name === argv.server);
@@ -14,6 +14,7 @@ servers:
14
14
  fs:
15
15
  command: node
16
16
  args: ["node_modules/@modelcontextprotocol/server-filesystem/dist/index.js", "."]
17
+ provenance: live
17
18
 
18
19
  tools:
19
20
  # --- reads -------------------------------------------------------------
@@ -105,3 +106,10 @@ tools:
105
106
  - match: "fs.create_directory"
106
107
  class: irreversible
107
108
  gate: always
109
+
110
+ # This policy is written for the server version named at the top. A later
111
+ # version can keep a tool's name and change what it takes, which would leave
112
+ # the policy above describing something else -- and undo would still produce an
113
+ # answer. `synartesis pin` prints a `pins:` block for the server you actually
114
+ # have; with it pasted in, a tool whose shape has moved stops the proxy at
115
+ # startup instead of being quietly trusted.
@@ -15,6 +15,7 @@ servers:
15
15
  git:
16
16
  command: uvx
17
17
  args: ["--from", "mcp-server-git", "mcp-server-git"]
18
+ provenance: live
18
19
 
19
20
  tools:
20
21
  # --- reads -------------------------------------------------------------
@@ -75,3 +76,10 @@ tools:
75
76
  - match: "git.git_checkout"
76
77
  class: irreversible
77
78
  gate: never
79
+
80
+ # This policy is written for the server version named at the top. A later
81
+ # version can keep a tool's name and change what it takes, which would leave
82
+ # the policy above describing something else -- and undo would still produce an
83
+ # answer. `synartesis pin` prints a `pins:` block for the server you actually
84
+ # have; with it pasted in, a tool whose shape has moved stops the proxy at
85
+ # startup instead of being quietly trusted.
@@ -24,6 +24,9 @@ servers:
24
24
  # Read from your shell; never written into this file.
25
25
  env:
26
26
  GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PERSONAL_ACCESS_TOKEN}"
27
+ # Nothing here has met a real github; every shape below comes from
28
+ # documentation. That is a weaker claim than the others make.
29
+ provenance: documented
27
30
 
28
31
  tools:
29
32
  # --- reads -------------------------------------------------------------
@@ -188,3 +191,10 @@ tools:
188
191
  - match: "github.create_release"
189
192
  class: irreversible
190
193
  gate: always
194
+
195
+ # This policy is written for the server version named at the top. A later
196
+ # version can keep a tool's name and change what it takes, which would leave
197
+ # the policy above describing something else -- and undo would still produce an
198
+ # answer. `synartesis pin` prints a `pins:` block for the server you actually
199
+ # have; with it pasted in, a tool whose shape has moved stops the proxy at
200
+ # startup instead of being quietly trusted.
@@ -18,6 +18,7 @@ servers:
18
18
  # or the agent will remember into a different file than it reads from.
19
19
  env:
20
20
  MEMORY_FILE_PATH: "${MEMORY_FILE_PATH}"
21
+ provenance: live
21
22
 
22
23
  tools:
23
24
  # --- reads -------------------------------------------------------------
@@ -54,6 +55,16 @@ tools:
54
55
  tool: "memory.delete_entities"
55
56
  args:
56
57
  entityNames: "$result.entities[].name"
58
+ # Deleting an entity takes its observations and its relations with it. So
59
+ # the compensating delete is only safe while the entity is still the thing
60
+ # the agent made: if anybody has added to it since, undoing destroys work
61
+ # nobody recorded. There is no pre-read to compare against -- the entity
62
+ # did not exist before the call -- so this reads it back afterwards, and
63
+ # undo halts rather than deleting over somebody.
64
+ verify:
65
+ tool: "memory.open_nodes"
66
+ args:
67
+ names: "$result.entities[].name"
57
68
 
58
69
  - match: "memory.create_relations"
59
70
  class: compensable
@@ -94,3 +105,10 @@ tools:
94
105
  - match: "memory.delete_observations"
95
106
  class: irreversible
96
107
  gate: always
108
+
109
+ # This policy is written for the server version named at the top. A later
110
+ # version can keep a tool's name and change what it takes, which would leave
111
+ # the policy above describing something else -- and undo would still produce an
112
+ # answer. `synartesis pin` prints a `pins:` block for the server you actually
113
+ # have; with it pasted in, a tool whose shape has moved stops the proxy at
114
+ # startup instead of being quietly trusted.