synartesis 0.6.12 → 0.6.15

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,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ IDEMPOTENCY_META_KEY,
3
4
  NOTHING_RECORDED_YET,
5
+ PROXY_FLAGS,
4
6
  WORDMARK,
5
7
  banner,
6
8
  canonical,
@@ -8,6 +10,8 @@ import {
8
10
  connectStdioUpstream,
9
11
  createPolicyResolver,
10
12
  createRouter,
13
+ describeStanding,
14
+ errorStyle,
11
15
  findJournal,
12
16
  findManifest,
13
17
  labelFor,
@@ -16,18 +20,23 @@ import {
16
20
  openJournal,
17
21
  parseManifest,
18
22
  pathBinaryMatches,
23
+ pinBlock,
19
24
  planInverse,
20
25
  planRead,
21
26
  proxyCommand,
22
27
  qualify,
23
28
  resolvedRead,
24
29
  rule,
30
+ standing,
25
31
  style,
26
32
  toPayload,
27
33
  toResolvedRead,
34
+ toolShapes,
35
+ untested,
28
36
  verifyAgainstServers,
37
+ warnUntested,
29
38
  wasRefused
30
- } from "./chunk-FUVEHRJP.js";
39
+ } from "./chunk-OODDXM36.js";
31
40
  import {
32
41
  DriftConflict,
33
42
  ManifestError,
@@ -95,7 +104,14 @@ ${source}
95
104
  `,
96
105
  path
97
106
  ).tools;
98
- return { key, rules, name: hit.manifest, source };
107
+ const claimed = /^\s*provenance:\s*(live|documented)\s*$/m.exec(text)?.[1];
108
+ return {
109
+ key,
110
+ rules,
111
+ name: hit.manifest,
112
+ source,
113
+ ...claimed === "live" || claimed === "documented" ? { provenance: claimed } : {}
114
+ };
99
115
  } catch {
100
116
  return void 0;
101
117
  }
@@ -254,12 +270,20 @@ async function draftManifest(options) {
254
270
  `${options.name} is already declared in the manifest; remove it first or choose another name`
255
271
  );
256
272
  }
273
+ const known = knownPolicyFor(options.command, options.args);
274
+ const claim = known?.provenance === void 0 ? [] : [
275
+ ...known.provenance === "documented" ? [
276
+ ` # This policy has never been run against the real server. Check it`,
277
+ ` # against your own setup before trusting undo on it.`
278
+ ] : [],
279
+ ` provenance: ${known.provenance}`
280
+ ];
257
281
  const server = [
258
282
  ` ${options.name}:`,
259
283
  ` command: ${quote(options.command)}`,
260
- ` args: [${options.args.map(quote).join(", ")}]`
284
+ ` args: [${options.args.map(quote).join(", ")}]`,
285
+ ...claim
261
286
  ].join("\n");
262
- const known = knownPolicyFor(options.command, options.args);
263
287
  const adopted = known === void 0 ? void 0 : adopt(known, options.name, tools);
264
288
  const policies = adopted?.source ?? tools.map((tool) => draftTool(options.name, tool)).join("\n\n");
265
289
  if (existing === void 0) {
@@ -284,10 +308,24 @@ async function draftManifest(options) {
284
308
  policies,
285
309
  ``
286
310
  ].join("\n");
287
- return adopted === void 0 || known === void 0 ? { yaml } : { yaml, adopted: { server: known.name, tools: adopted.covered } };
311
+ return adopted === void 0 || known === void 0 ? { yaml } : {
312
+ yaml,
313
+ adopted: {
314
+ server: known.name,
315
+ tools: adopted.covered,
316
+ ...known.provenance === void 0 ? {} : { provenance: known.provenance }
317
+ }
318
+ };
288
319
  }
289
320
  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 } };
321
+ return adopted === void 0 || known === void 0 ? { yaml: merged } : {
322
+ yaml: merged,
323
+ adopted: {
324
+ server: known.name,
325
+ tools: adopted.covered,
326
+ ...known.provenance === void 0 ? {} : { provenance: known.provenance }
327
+ }
328
+ };
291
329
  }
292
330
  function mergeInto(existing, server, policies, name) {
293
331
  const serversAt2 = existing.indexOf("\nservers:");
@@ -312,7 +350,6 @@ function mergeInto(existing, server, policies, name) {
312
350
 
313
351
  // src/rollback/rollback.ts
314
352
  import { z as z2 } from "zod";
315
- var IDEMPOTENCY_META_KEY = "synartesis.dev/idempotency-key";
316
353
  var inversePlan = z2.object({
317
354
  server: z2.string(),
318
355
  tool: z2.string(),
@@ -331,8 +368,10 @@ function classify(action, replanning, goAhead) {
331
368
  case "rolled_back":
332
369
  return { kind: "already-reverted", reason: "already rolled back", verified: true };
333
370
  case "failed":
334
- case "denied":
335
- return { kind: "skip", reason: `never applied (${action.status})`, verified: true };
371
+ case "denied": {
372
+ const why = labelFor(action) === "used" ? "never applied: its approval moved to the call that ran" : `never applied (${action.status})`;
373
+ return { kind: "skip", reason: why, verified: true };
374
+ }
336
375
  case "pending":
337
376
  return {
338
377
  kind: "halt",
@@ -572,7 +611,7 @@ ${seen}` : seen;
572
611
  };
573
612
  }
574
613
  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";
614
+ 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
615
  }
577
616
  function describeStep(action) {
578
617
  return { seq: action.seq, server: action.server, tool: action.tool };
@@ -652,7 +691,11 @@ async function inspect(options) {
652
691
  continue;
653
692
  }
654
693
  if (SETTLED.has(action.status)) {
655
- resources.push({ ...at, condition: "not-applied", note: action.status });
694
+ resources.push({
695
+ ...at,
696
+ condition: "not-applied",
697
+ note: labelFor(action) === "used" ? "its approval moved to the call that ran" : action.status
698
+ });
656
699
  continue;
657
700
  }
658
701
  if (action.status === "rolled_back") {
@@ -745,24 +788,6 @@ function verdict(inspection) {
745
788
  return "nothing was recorded in this session";
746
789
  }
747
790
 
748
- // src/watch.ts
749
- import { existsSync as existsSync2 } from "fs";
750
-
751
- // src/keys.ts
752
- var SEQUENCE = /^\u001b(\[[0-9;?]*[ -\/]*[@-~]|O[@-~])/;
753
- function keysIn(chunk) {
754
- const keys = [];
755
- let at = 0;
756
- while (at < chunk.length) {
757
- const rest = chunk.slice(at);
758
- const sequence = rest.startsWith("\x1B") ? SEQUENCE.exec(rest) : null;
759
- const key = sequence?.[0] ?? rest.slice(0, 1);
760
- keys.push(key);
761
- at += key.length;
762
- }
763
- return keys;
764
- }
765
-
766
791
  // src/clock.ts
767
792
  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
768
793
  var pad = (value) => String(value).padStart(2, "0");
@@ -814,6 +839,169 @@ function ago(iso, now = /* @__PURE__ */ new Date()) {
814
839
  return `${String(Math.round(hours / 24))}d ago`;
815
840
  }
816
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
+
817
1005
  // src/describe.ts
818
1006
  import { basename } from "path";
819
1007
  var SUBJECT_KEYS = [
@@ -881,6 +1069,19 @@ function size(bytes) {
881
1069
  }
882
1070
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
883
1071
  }
1072
+ function summariseValue(value) {
1073
+ const flat = value.replace(/\s+/g, " ").trim();
1074
+ if (flat.length <= 48) {
1075
+ return flat;
1076
+ }
1077
+ const looksLikeALocation = !value.includes("\n") && /[/\\]/.test(value);
1078
+ if (looksLikeALocation) {
1079
+ const tail = value.slice(-44);
1080
+ const from = tail.search(/[/\\]/);
1081
+ return `\u2026${from === -1 ? tail : tail.slice(from)}`;
1082
+ }
1083
+ return size(Buffer.byteLength(value));
1084
+ }
884
1085
  function summariseArgs(args, limit = 60) {
885
1086
  if (!isRecord(args)) {
886
1087
  return args === void 0 ? "" : JSON.stringify(args);
@@ -888,7 +1089,7 @@ function summariseArgs(args, limit = 60) {
888
1089
  const parts = [];
889
1090
  for (const [key, value] of Object.entries(args)) {
890
1091
  if (typeof value === "string") {
891
- parts.push(value.length > 48 ? `${key} ${size(Buffer.byteLength(value))}` : `${key} ${value}`);
1092
+ parts.push(`${key} ${summariseValue(value)}`);
892
1093
  continue;
893
1094
  }
894
1095
  if (Array.isArray(value)) {
@@ -1459,8 +1660,8 @@ function pruneBackups(path) {
1459
1660
  try {
1460
1661
  const dir = dirname(path);
1461
1662
  const prefix = `${basename2(path)}.synartesis-backup-`;
1462
- const ours = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
1463
- 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))) {
1464
1665
  rmSync(join(dir, name), { force: true });
1465
1666
  }
1466
1667
  } catch {
@@ -1643,7 +1844,11 @@ async function planInstall(sites, manifestPath, invoker) {
1643
1844
  name,
1644
1845
  original: entry,
1645
1846
  wrapped: proxyEntry(manifestPath, key, entry, invoker),
1646
- ...draft.adopted === void 0 ? {} : { adopted: draft.adopted.server, tools: draft.adopted.tools }
1847
+ ...draft.adopted === void 0 ? {} : {
1848
+ adopted: draft.adopted.server,
1849
+ tools: draft.adopted.tools,
1850
+ ...draft.adopted.provenance === void 0 ? {} : { provenance: draft.adopted.provenance }
1851
+ }
1647
1852
  });
1648
1853
  }
1649
1854
  plans.push({ site, servers: planned, skipped });
@@ -1877,7 +2082,7 @@ function runsView(journal, screen, options) {
1877
2082
  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
2083
  });
1879
2084
  }
1880
- function standing(actions) {
2085
+ function standing2(actions) {
1881
2086
  let undoable = 0;
1882
2087
  let conflicted = 0;
1883
2088
  for (const action of actions) {
@@ -1897,7 +2102,7 @@ function elsewhere(journal, exceptId) {
1897
2102
  if (run.id === exceptId) {
1898
2103
  continue;
1899
2104
  }
1900
- const found = standing(journal.getActions(run.id));
2105
+ const found = standing2(journal.getActions(run.id));
1901
2106
  if (found.undoable > 0 || found.conflicted > 0) {
1902
2107
  return run;
1903
2108
  }
@@ -2138,7 +2343,7 @@ async function openConsole(options) {
2138
2343
  if (run === void 0) {
2139
2344
  return [];
2140
2345
  }
2141
- const here = standing(ready.getActions(run.id));
2346
+ const here = standing2(ready.getActions(run.id));
2142
2347
  const id = run.id.slice(0, 8);
2143
2348
  if (here.undoable > 0) {
2144
2349
  return [
@@ -2359,7 +2564,7 @@ async function openConsole(options) {
2359
2564
  if (ready === void 0 || run === void 0) {
2360
2565
  return;
2361
2566
  }
2362
- const here = standing(ready.getActions(run.id));
2567
+ const here = standing2(ready.getActions(run.id));
2363
2568
  if (here.undoable === 0 && here.conflicted === 0) {
2364
2569
  say(nothingToUndo(ready, run));
2365
2570
  return;
@@ -2381,7 +2586,7 @@ async function openConsole(options) {
2381
2586
  if (ready === void 0 || run === void 0) {
2382
2587
  return;
2383
2588
  }
2384
- const here = standing(ready.getActions(run.id));
2589
+ const here = standing2(ready.getActions(run.id));
2385
2590
  if (here.undoable === 0 && here.conflicted === 0) {
2386
2591
  say(nothingToUndo(ready, run));
2387
2592
  return;
@@ -2541,6 +2746,7 @@ var COMMANDS = `
2541
2746
  synartesis status
2542
2747
  synartesis init <server> -- <command> [args...] [--manifest <path>]
2543
2748
  synartesis check [--manifest <path>]
2749
+ synartesis pin [--manifest <path>]
2544
2750
  synartesis list [--journal <path>]
2545
2751
  synartesis show <runId> [--full] [--live] [--journal <path>]
2546
2752
  synartesis gates [--journal <path>]
@@ -2609,29 +2815,58 @@ it and is found from any directory inside it, the way a version control tool
2609
2815
  finds its root; anything else lives in ~/.synartesis, which is where the
2610
2816
  journal is too. Set SYNARTESIS_HOME to put that somewhere else.
2611
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
+
2612
2822
  Exit codes: 0 complete, 1 halted or partial, 2 bad usage or configuration.
2613
2823
  `;
2614
2824
  var UsageError = class extends Error {
2825
+ /**
2826
+ * Whether the command list helps.
2827
+ *
2828
+ * It does for a mistyped flag or command. It does not for "there is nothing
2829
+ * here to act on", which is a fact about the journal rather than about what
2830
+ * was typed -- and answering that with forty lines of unrelated commands
2831
+ * buries the one sentence that matters.
2832
+ */
2833
+ listCommands;
2834
+ constructor(message, listCommands = true) {
2835
+ super(message);
2836
+ this.listCommands = listCommands;
2837
+ }
2615
2838
  };
2839
+ function ours(argv) {
2840
+ const end = argv.indexOf("--");
2841
+ return end === -1 ? argv : argv.slice(0, end);
2842
+ }
2616
2843
  function flag(argv, name) {
2617
- const at = argv.indexOf(name);
2844
+ const mine = ours(argv);
2845
+ const at = mine.indexOf(name);
2618
2846
  if (at === -1) {
2619
2847
  return void 0;
2620
2848
  }
2621
- const value = argv[at + 1];
2849
+ const value = mine[at + 1];
2622
2850
  if (value === void 0 || value.startsWith("--")) {
2623
2851
  throw new UsageError(`${name} needs a value`);
2624
2852
  }
2625
2853
  return value;
2626
2854
  }
2855
+ var TAKES_VALUE = /* @__PURE__ */ new Set([
2856
+ "--manifest",
2857
+ "--journal",
2858
+ "--to",
2859
+ "--by",
2860
+ "--reason",
2861
+ "--older-than",
2862
+ "--client"
2863
+ ]);
2627
2864
  function positional(argv) {
2628
- const skip = /* @__PURE__ */ new Set(["--manifest", "--journal", "--to", "--by", "--reason", "--gate-timeout", "--older-than", "--client"]);
2629
2865
  const values = [];
2630
- const end = argv.indexOf("--");
2631
- const ours = end === -1 ? argv : argv.slice(0, end);
2632
- for (let i = 0; i < ours.length; i += 1) {
2633
- const token = ours[i] ?? "";
2634
- 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)) {
2635
2870
  i += 1;
2636
2871
  continue;
2637
2872
  }
@@ -2641,9 +2876,51 @@ function positional(argv) {
2641
2876
  }
2642
2877
  return values;
2643
2878
  }
2879
+ async function runPin(argv) {
2880
+ const path = findManifest(flag(argv, "--manifest"));
2881
+ const manifest = loadManifest(path);
2882
+ const shapes = /* @__PURE__ */ new Map();
2883
+ const upstreams = [];
2884
+ try {
2885
+ for (const [name, spec] of Object.entries(manifest.servers)) {
2886
+ const upstream = await connectStdioUpstream({
2887
+ name,
2888
+ command: spec.command,
2889
+ args: spec.args,
2890
+ stderr: "capture",
2891
+ ...spec.env === void 0 ? {} : { env: spec.env }
2892
+ });
2893
+ upstreams.push(upstream);
2894
+ shapes.set(name, await toolShapes(upstream));
2895
+ }
2896
+ } finally {
2897
+ for (const upstream of upstreams) {
2898
+ await upstream.close();
2899
+ }
2900
+ }
2901
+ const block2 = pinBlock(shapes, manifest);
2902
+ out("");
2903
+ out(` ${style.label("pins for")} ${style.strong(path)}`);
2904
+ out("");
2905
+ for (const line2 of block2.split("\n")) {
2906
+ out(` ${line2}`);
2907
+ }
2908
+ out("");
2909
+ out(` ${style.quiet("Paste this into the manifest. From then on a tool whose shape")}`);
2910
+ out(` ${style.quiet("changes stops the proxy instead of quietly keeping its old policy.")}`);
2911
+ out("");
2912
+ return 0;
2913
+ }
2644
2914
  async function runCheck(argv) {
2645
2915
  const path = findManifest(flag(argv, "--manifest"));
2646
2916
  const manifest = loadManifest(path);
2917
+ const unproven = untested(manifest);
2918
+ if (unproven.length > 0) {
2919
+ out("");
2920
+ for (const line2 of wrapped(warnUntested(unproven), 74)) {
2921
+ out(` ${style.accent(line2)}`);
2922
+ }
2923
+ }
2647
2924
  const upstreams = [];
2648
2925
  try {
2649
2926
  for (const [name, spec] of Object.entries(manifest.servers)) {
@@ -2673,11 +2950,33 @@ async function runCheck(argv) {
2673
2950
  out(` ${rule(54)}`);
2674
2951
  out("");
2675
2952
  out(` ${style.quiet("servers ")} ${Object.keys(manifest.servers).join(", ")}`);
2953
+ for (const entry of standing(manifest)) {
2954
+ const said = describeStanding(entry);
2955
+ out(
2956
+ ` ${style.quiet(" ")} ${style.strong(entry.server)} ${entry.provenance === "documented" ? style.accent(said) : style.quiet(said)}`
2957
+ );
2958
+ }
2676
2959
  out(` ${style.quiet("policies")} ${[...counts].map(([k, v]) => `${String(v)} ${k}`).join(", ")}`);
2677
2960
  out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
2961
+ const pinned = Object.entries(manifest.pins ?? {});
2962
+ const servers = Object.keys(manifest.servers);
2963
+ const unpinned = servers.filter((name) => !pinned.some(([held]) => held === name));
2964
+ out(
2965
+ ` ${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(", ")}`))}`
2966
+ );
2678
2967
  out("");
2679
2968
  out(` ${style.quiet("Anything not mentioned here is treated as irreversible and guarded.")}`);
2680
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
+ );
2681
2980
  return 0;
2682
2981
  }
2683
2982
  async function runInstall(argv) {
@@ -2711,6 +3010,11 @@ async function runInstall(argv) {
2711
3010
  for (const server of plan.servers) {
2712
3011
  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
3012
  out(` ${style.strong(server.name.padEnd(18))} ${note}`);
3013
+ if (server.provenance === "documented") {
3014
+ out(
3015
+ ` ${" ".repeat(18)} ${style.accent("never run against the real server -- check it before trusting undo")}`
3016
+ );
3017
+ }
2714
3018
  }
2715
3019
  for (const skip of plan.skipped) {
2716
3020
  out(` ${style.quiet(skip.name.padEnd(18))} ${style.quiet(skip.why)}`);
@@ -2866,6 +3170,9 @@ function runStatus(argv) {
2866
3170
  waiting === 0 ? ` ${style.quiet("Everything found is covered.")}` : ` ${style.accent(`${String(waiting)} not covered.`)} ${style.quiet(`${cliCommand()} install covers them.`)}`
2867
3171
  );
2868
3172
  out("");
3173
+ if (waiting === 0) {
3174
+ hint(afterStatus(journal));
3175
+ }
2869
3176
  } finally {
2870
3177
  journal?.close();
2871
3178
  }
@@ -2921,18 +3228,19 @@ function pick(candidates2, given, noun, newest = false) {
2921
3228
  if (given === void 0) {
2922
3229
  const [only, ...rest2] = candidates2;
2923
3230
  if (only === void 0) {
2924
- throw new UsageError(`there is no ${noun.one} to act on`);
3231
+ throw new UsageError(`there is no ${noun.one} to act on`, false);
2925
3232
  }
2926
3233
  if (rest2.length > 0 && !newest) {
2927
3234
  throw new UsageError(
2928
3235
  `there are ${String(candidates2.length)} ${noun.many}; name one, or use --all:
2929
- ${listed(candidates2)}`
3236
+ ${listed(candidates2)}`,
3237
+ false
2930
3238
  );
2931
3239
  }
2932
3240
  return only;
2933
3241
  }
2934
3242
  if (given === "") {
2935
- throw new UsageError(`no ${noun.one} was named; an empty id is usually an unset variable`);
3243
+ throw new UsageError(`no ${noun.one} was named; an empty id is usually an unset variable`, false);
2936
3244
  }
2937
3245
  const exact = candidates2.find((item) => item.id === given);
2938
3246
  if (exact !== void 0) {
@@ -2941,12 +3249,13 @@ ${listed(candidates2)}`
2941
3249
  const matches = candidates2.filter((item) => item.id.startsWith(given));
2942
3250
  const [first, ...rest] = matches;
2943
3251
  if (first === void 0) {
2944
- throw new UsageError(`no ${noun.one} matches ${given}`);
3252
+ throw new UsageError(`no ${noun.one} matches ${given}`, false);
2945
3253
  }
2946
3254
  if (rest.length > 0) {
2947
3255
  throw new UsageError(
2948
3256
  `${given} matches ${String(matches.length)} ${noun.many}:
2949
- ${listed(matches)}`
3257
+ ${listed(matches)}`,
3258
+ false
2950
3259
  );
2951
3260
  }
2952
3261
  return first;
@@ -2963,10 +3272,19 @@ function out(line2) {
2963
3272
  process.stdout.write(`${line2}
2964
3273
  `);
2965
3274
  }
3275
+ function hint(candidate) {
3276
+ if (candidate === void 0 || !hintsWanted()) {
3277
+ return;
3278
+ }
3279
+ out(hintLine(candidate, pathArgs(candidate.needs)));
3280
+ out("");
3281
+ }
2966
3282
  function runList(journal, asJson, journalPath) {
2967
3283
  const runs = [...journal.listRuns()].reverse();
3284
+ const tally2 = journal.tallyRuns();
3285
+ const counted = (id) => tally2.get(id) ?? { actions: 0, unknown: 0, waiting: 0, applied: 0 };
2968
3286
  if (asJson) {
2969
- out(JSON.stringify(runs.map((run) => ({ ...run, actions: journal.getActions(run.id).length }))));
3287
+ out(JSON.stringify(runs.map((run) => ({ ...run, actions: counted(run.id).actions }))));
2970
3288
  return 0;
2971
3289
  }
2972
3290
  if (runs.length === 0) {
@@ -2983,16 +3301,15 @@ function runList(journal, asJson, journalPath) {
2983
3301
  )
2984
3302
  );
2985
3303
  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;
3304
+ const actions = counted(run.id);
3305
+ const { unknown, waiting } = actions;
2989
3306
  const notes = [
2990
3307
  unknown === 0 ? "" : `${String(unknown)} of unknown outcome`,
2991
3308
  waiting === 0 ? "" : `${String(waiting)} awaiting approval`
2992
3309
  ].filter((note2) => note2 !== "");
2993
3310
  const note = notes.length === 0 ? "" : ` ${style.accent(`(${notes.join("; ")})`)}`;
2994
3311
  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}`
3312
+ ` ${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
3313
  );
2997
3314
  }
2998
3315
  out("");
@@ -3000,6 +3317,10 @@ function runList(journal, asJson, journalPath) {
3000
3317
  out(` ${style.quiet(`This journal is ${sizeOf(journalPath)}; synartesis prune reclaims what is old enough to lose.`)}`);
3001
3318
  out("");
3002
3319
  }
3320
+ hint(firstOf(
3321
+ () => heldCalls(journal),
3322
+ () => whatChanged(journal)
3323
+ ));
3003
3324
  return 0;
3004
3325
  }
3005
3326
  async function runShow(argv, journal, asJson) {
@@ -3062,7 +3383,7 @@ async function runShow(argv, journal, asJson) {
3062
3383
  out(` ${style.quiet(summariseArgs(action.args, 96))}`);
3063
3384
  }
3064
3385
  if (action.approvedAt !== void 0) {
3065
- const verb = action.status === "denied" ? "denied" : "approved";
3386
+ const verb = labelFor(action) === "denied" ? "denied" : "approved";
3066
3387
  out(
3067
3388
  ` ${style.accent(`${verb} by ${action.approvedBy ?? "nobody"}`)} ${style.quiet(`at ${fullTime(action.approvedAt)}`)}`
3068
3389
  );
@@ -3097,6 +3418,16 @@ async function runShow(argv, journal, asJson) {
3097
3418
  out(
3098
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")}`
3099
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
+ });
3100
3431
  }
3101
3432
  } else {
3102
3433
  out("");
@@ -3178,13 +3509,21 @@ function truncate3(text, limit) {
3178
3509
  function summarise2(actions) {
3179
3510
  const counts = /* @__PURE__ */ new Map();
3180
3511
  for (const action of actions) {
3181
- counts.set(action.status, (counts.get(action.status) ?? 0) + 1);
3512
+ const label = labelFor(action);
3513
+ counts.set(label, (counts.get(label) ?? 0) + 1);
3182
3514
  }
3183
3515
  const parts = [...counts].sort(([a], [b]) => a < b ? -1 : 1).map(([k, v]) => `${String(v)} ${k}`);
3184
3516
  const undoable = actions.filter((a) => a.inverse !== void 0).length;
3185
3517
  return `${String(actions.length)} actions: ${parts.join(", ")} | ${String(undoable)} with a recorded undo`;
3186
3518
  }
3187
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
+ }
3188
3527
  var PRUNE_NAG_BYTES = 100 * 1024 * 1024;
3189
3528
  function bytesOf(path) {
3190
3529
  try {
@@ -3258,12 +3597,18 @@ function runPrune(argv, journal, journalPath) {
3258
3597
  function runClose(argv, journal) {
3259
3598
  const all = journal.listRuns();
3260
3599
  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);
3600
+ const active = [...all.filter((candidate) => candidate.status === "active")].reverse();
3601
+ if (given === void 0 && active.length === 0) {
3602
+ out("");
3603
+ out(` ${style.quiet("nothing is open; every run has ended cleanly")}`);
3604
+ out("");
3605
+ hint(firstOf(
3606
+ () => heldCalls(journal),
3607
+ () => whatChanged(journal)
3608
+ ));
3609
+ return 0;
3610
+ }
3611
+ const run = given === void 0 ? pick(active, void 0, RUN, true) : pick([...all].reverse(), given, RUN, true);
3267
3612
  const closed = journal.closeAbandonedRun(run.id);
3268
3613
  out("");
3269
3614
  out(
@@ -3282,6 +3627,10 @@ function runGates(journal, asJson) {
3282
3627
  out("");
3283
3628
  out(` ${style.quiet("Nothing is waiting for a decision.")}`);
3284
3629
  out("");
3630
+ hint(firstOf(
3631
+ () => whatChanged(journal),
3632
+ () => LEAVE_IT_RUNNING
3633
+ ));
3285
3634
  return 0;
3286
3635
  }
3287
3636
  out("");
@@ -3310,10 +3659,10 @@ function runDecision(argv, journal, approving) {
3310
3659
  const by = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3311
3660
  const reason = flag(argv, "--reason") ?? "denied by operator";
3312
3661
  if (given !== void 0) {
3313
- const settled = journal.getAction(given);
3314
- if (settled !== void 0 && settled.status !== "gated") {
3662
+ const settled2 = journal.getAction(given);
3663
+ if (settled2 !== void 0 && settled2.status !== "gated") {
3315
3664
  process.stderr.write(
3316
- `synartesis: ${given} is no longer awaiting approval (it is ${settled.status})
3665
+ `synartesis: ${given} is no longer awaiting approval (it is ${labelFor(settled2)})
3317
3666
  `
3318
3667
  );
3319
3668
  return 1;
@@ -3325,6 +3674,7 @@ function runDecision(argv, journal, approving) {
3325
3674
  return 0;
3326
3675
  }
3327
3676
  let failed = 0;
3677
+ let settled = 0;
3328
3678
  for (const action of targets) {
3329
3679
  const changed = approving ? journal.approve(action.id, by) : journal.deny(action.id, by, reason);
3330
3680
  if (!changed) {
@@ -3336,13 +3686,23 @@ function runDecision(argv, journal, approving) {
3336
3686
  failed += 1;
3337
3687
  continue;
3338
3688
  }
3689
+ settled += 1;
3339
3690
  out(
3340
3691
  ` ${style.accent(approving ? "approved" : "denied")} ${style.strong(`${action.server}.${action.tool}`)} ${style.quiet(action.id)}`
3341
3692
  );
3342
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
+ }
3343
3703
  return failed === 0 ? 0 : 1;
3344
3704
  }
3345
- function report(result, alreadyForcing = false) {
3705
+ function report(result, alreadyForcing = false, as = "") {
3346
3706
  out("");
3347
3707
  out(` ${style.label(result.dryRun ? "dry run" : "undo")} ${style.strong(result.runId)}`);
3348
3708
  out(` ${rule(72)}`);
@@ -3362,7 +3722,11 @@ function report(result, alreadyForcing = false) {
3362
3722
  if (step.plan !== void 0 && step.kind === "revert") {
3363
3723
  const verb = `${step.replanned === true ? "replanned, " : ""}${result.dryRun ? "would call" : "called"}`;
3364
3724
  out(
3365
- ` ${style.quiet(verb)} ${step.plan.server}.${step.plan.tool} ` + style.quiet(truncate3(JSON.stringify(step.plan.args), 120))
3725
+ ` ${style.quiet(verb)} ${step.plan.server}.${step.plan.tool} ` + // summariseArgs, not truncated json: the raw form spent its whole
3726
+ // budget on a long path's leading directories and cut off before
3727
+ // the filename, so every step read "would call fs.write_file
3728
+ // {path: /Users/.../very/long/pre..." and named nothing.
3729
+ style.quiet(summariseArgs(step.plan.args, 120))
3366
3730
  );
3367
3731
  }
3368
3732
  }
@@ -3411,6 +3775,13 @@ function report(result, alreadyForcing = false) {
3411
3775
  ` ${style.label("result")} ${result.status === "rolled_back" ? result.status : style.accent(result.status)}`
3412
3776
  );
3413
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
+ }
3414
3785
  return result.status === "rolled_back" ? 0 : 1;
3415
3786
  }
3416
3787
  async function withUpstreams(manifestPath, use, only) {
@@ -3471,6 +3842,11 @@ async function performUndo(manifestPath, journal, runId, options) {
3471
3842
  );
3472
3843
  }
3473
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
+ }
3474
3850
  const given = positional(argv)[1];
3475
3851
  const chosen = pick([...journal.listRuns()].reverse(), given, RUN, true);
3476
3852
  const runId = chosen.id;
@@ -3506,11 +3882,6 @@ async function runUndo(argv, journal) {
3506
3882
  return 0;
3507
3883
  }
3508
3884
  }
3509
- const rawTo = flag(argv, "--to");
3510
- const toSeq = rawTo === void 0 ? void 0 : Number(rawTo);
3511
- if (toSeq !== void 0 && (!Number.isInteger(toSeq) || toSeq < 1)) {
3512
- throw new UsageError("--to needs a positive whole number");
3513
- }
3514
3885
  if (toSeq !== void 0) {
3515
3886
  const highest = journal.getActions(runId).reduce((top, action) => Math.max(top, action.seq), 0);
3516
3887
  if (toSeq > highest) {
@@ -3554,16 +3925,42 @@ async function runUndo(argv, journal) {
3554
3925
  return 1;
3555
3926
  }
3556
3927
  }
3928
+ const replan = argv.includes("--replan");
3557
3929
  return report(
3558
3930
  await performUndo(manifestPath, journal, runId, {
3559
3931
  dryRun: argv.includes("--dry-run"),
3560
3932
  ...toSeq === void 0 ? {} : { toSeq },
3561
- replan: argv.includes("--replan"),
3933
+ replan,
3562
3934
  ...forcing && said ? { force: true } : {}
3563
3935
  }),
3564
- forcing
3936
+ forcing,
3937
+ // --to is deliberately absent, and cannot reach here: a floor leaves
3938
+ // actions below it alone, which makes the result `partial`, and the hint
3939
+ // is only offered on `rolled_back`.
3940
+ `${replan ? " --replan" : ""}${forcing && said ? " --force --yes" : ""}`
3565
3941
  );
3566
3942
  }
3943
+ var KNOWN_COMMANDS = [
3944
+ "install",
3945
+ "uninstall",
3946
+ "status",
3947
+ "init",
3948
+ "check",
3949
+ "pin",
3950
+ "list",
3951
+ "show",
3952
+ "gates",
3953
+ "close",
3954
+ "prune",
3955
+ "proxy",
3956
+ "desktop",
3957
+ "watch",
3958
+ "approve",
3959
+ "deny",
3960
+ "undo",
3961
+ "help",
3962
+ "version"
3963
+ ];
3567
3964
  var FLAGS = /* @__PURE__ */ new Set([
3568
3965
  // Two lists have to agree about a flag: this one decides whether it is
3569
3966
  // accepted at all, and the skip set in positional() decides whether its
@@ -3589,7 +3986,8 @@ var FLAGS = /* @__PURE__ */ new Set([
3589
3986
  "--help",
3590
3987
  "-h",
3591
3988
  "--version",
3592
- "-V"
3989
+ "-V",
3990
+ "-v"
3593
3991
  ]);
3594
3992
  function version() {
3595
3993
  try {
@@ -3601,13 +3999,26 @@ function version() {
3601
3999
  return "unknown";
3602
4000
  }
3603
4001
  }
4002
+ function unknownCommand(typed) {
4003
+ const meant = didYouMean(typed, KNOWN_COMMANDS);
4004
+ return meant === void 0 ? `unknown command ${typed}` : `unknown command ${typed}; did you mean ${meant}?`;
4005
+ }
3604
4006
  function rejectUnknownFlags(argv) {
3605
- for (const token of argv) {
3606
- if (token === "--") {
3607
- return;
4007
+ const mine = ours(argv);
4008
+ for (let i = 0; i < mine.length; i += 1) {
4009
+ const token = mine[i] ?? "";
4010
+ if (TAKES_VALUE.has(token)) {
4011
+ i += 1;
4012
+ continue;
3608
4013
  }
3609
4014
  if (token.startsWith("-") && token !== "-" && !FLAGS.has(token)) {
3610
- throw new UsageError(`unknown flag ${token}`);
4015
+ if (PROXY_FLAGS.includes(token)) {
4016
+ throw new UsageError(`${token} is a flag for ${cliCommand()} proxy, not for this`);
4017
+ }
4018
+ const meant = didYouMean(token, [...FLAGS]);
4019
+ throw new UsageError(
4020
+ meant === void 0 ? `unknown flag ${token}` : `unknown flag ${token}; did you mean ${meant}?`
4021
+ );
3611
4022
  }
3612
4023
  }
3613
4024
  }
@@ -3625,17 +4036,25 @@ async function main(argv) {
3625
4036
  await import("./proxy.js");
3626
4037
  return 0;
3627
4038
  }
3628
- if (argv.includes("--help") || argv.includes("-h")) {
4039
+ const first = positional(argv)[0];
4040
+ if (argv.includes("--help") || argv.includes("-h") || first === "help") {
3629
4041
  process.stdout.write(`${banner()}
3630
4042
  ${COMMANDS}`);
3631
4043
  return 0;
3632
4044
  }
3633
- if (argv.includes("--version") || argv.includes("-V")) {
4045
+ if (argv.includes("--version") || argv.includes("-V") || argv.includes("-v") || first === "version") {
3634
4046
  process.stdout.write(`${version()}
3635
4047
  `);
3636
4048
  return 0;
3637
4049
  }
3638
4050
  rejectUnknownFlags(argv);
4051
+ if (command !== void 0 && !KNOWN_COMMANDS.includes(command)) {
4052
+ throw new UsageError(unknownCommand(command));
4053
+ }
4054
+ const givenJournal = flag(argv, "--journal");
4055
+ const givenManifest = flag(argv, "--manifest");
4056
+ journalArg = givenJournal === void 0 ? "" : ` --journal ${resolve3(givenJournal)}`;
4057
+ manifestArg = givenManifest === void 0 ? "" : ` --manifest ${resolve3(givenManifest)}`;
3639
4058
  if (command === void 0) {
3640
4059
  const manifestPath = findManifest(flag(argv, "--manifest"));
3641
4060
  const journalPath2 = findJournal(flag(argv, "--journal"), manifestPath);
@@ -3677,6 +4096,9 @@ ${COMMANDS}`);
3677
4096
  if (command === "check") {
3678
4097
  return await runCheck(argv);
3679
4098
  }
4099
+ if (command === "pin") {
4100
+ return await runPin(argv);
4101
+ }
3680
4102
  if (command === "install") {
3681
4103
  return await runInstall(argv);
3682
4104
  }
@@ -3701,7 +4123,6 @@ ${COMMANDS}`);
3701
4123
  decideAs: flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown"
3702
4124
  });
3703
4125
  }
3704
- journalArg = given === void 0 ? "" : ` --journal ${resolve3(given)}`;
3705
4126
  if (!existsSync8(journalPath) && (command === "list" || command === "show" || command === "gates")) {
3706
4127
  if (asJson) {
3707
4128
  out(JSON.stringify(command === "show" ? { run: null, actions: [] } : []));
@@ -3739,7 +4160,7 @@ ${COMMANDS}`);
3739
4160
  case "undo":
3740
4161
  return await runUndo(argv, journal);
3741
4162
  default:
3742
- throw new UsageError(`unknown command ${command}`);
4163
+ throw new UsageError(unknownCommand(command));
3743
4164
  }
3744
4165
  } finally {
3745
4166
  journal.close();
@@ -3764,9 +4185,11 @@ try {
3764
4185
  process.exitCode = await main(process.argv.slice(2));
3765
4186
  } catch (error) {
3766
4187
  if (error instanceof UsageError) {
3767
- process.stderr.write(`synartesis: ${error.message}
3768
-
3769
- ${COMMANDS}`);
4188
+ process.stderr.write(
4189
+ error.listCommands ? `synartesis: ${error.message}
4190
+ ${shortList()}` : `synartesis: ${error.message}
4191
+ `
4192
+ );
3770
4193
  process.exitCode = 2;
3771
4194
  } else if (error instanceof ManifestError) {
3772
4195
  process.stderr.write(`synartesis: ${error.message}