@synapsor/runner 0.1.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +391 -25
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/runner.mjs +2945 -193
  6. package/docs/README.md +40 -0
  7. package/docs/app-owned-executors.md +38 -0
  8. package/docs/capability-authoring.md +265 -0
  9. package/docs/cloud-mode.md +24 -0
  10. package/docs/current-scope.md +29 -0
  11. package/docs/dependency-license-inventory.md +35 -0
  12. package/docs/doctor.md +98 -0
  13. package/docs/getting-started-own-database.md +131 -46
  14. package/docs/handler-helper.md +228 -0
  15. package/docs/http-mcp.md +85 -17
  16. package/docs/licensing.md +36 -0
  17. package/docs/local-mode.md +44 -25
  18. package/docs/mcp-audit.md +8 -8
  19. package/docs/mcp-client-setup.md +59 -21
  20. package/docs/openai-agents-sdk.md +57 -0
  21. package/docs/recipes.md +6 -6
  22. package/docs/release-notes.md +327 -0
  23. package/docs/release-policy.md +125 -0
  24. package/docs/result-envelope-v2.md +151 -0
  25. package/docs/rfcs/001-result-envelope-v2.md +143 -0
  26. package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
  27. package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
  28. package/docs/store-lifecycle.md +83 -0
  29. package/docs/troubleshooting-first-run.md +6 -6
  30. package/docs/use-your-own-database.md +18 -0
  31. package/docs/writeback-executors.md +92 -1
  32. package/examples/app-owned-writeback/README.md +128 -0
  33. package/examples/app-owned-writeback/business-actions.md +221 -0
  34. package/examples/app-owned-writeback/command-handler.mjs +55 -0
  35. package/examples/app-owned-writeback/node-fastify-handler.mjs +64 -0
  36. package/examples/app-owned-writeback/python-fastapi-handler.py +66 -0
  37. package/examples/mcp-postgres-billing-app-handler/README.md +94 -0
  38. package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +123 -0
  39. package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
  40. package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
  41. package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
  42. package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
  43. package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
  44. package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
  45. package/examples/openai-agents-http/README.md +19 -12
  46. package/examples/openai-agents-http/agent.py +29 -65
  47. package/examples/openai-agents-stdio/README.md +10 -6
  48. package/examples/openai-agents-stdio/agent.py +4 -2
  49. package/examples/reference-support-billing-app/README.md +16 -16
  50. package/examples/reference-support-billing-app/mcp-client.generic.json +1 -1
  51. package/fixtures/benchmark/mcp-efficiency.json +53 -0
  52. package/fixtures/benchmark/mcp-efficiency.txt +25 -0
  53. package/fixtures/protocol/MANIFEST.json +54 -0
  54. package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
  55. package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
  56. package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
  57. package/fixtures/protocol/runner-registration.v1.json +22 -0
  58. package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
  59. package/package.json +6 -1
  60. package/schemas/change-set.v1.schema.json +140 -0
  61. package/schemas/execution-receipt.v1.schema.json +34 -0
  62. package/schemas/onboarding-selection.v1.schema.json +132 -0
  63. package/schemas/runner-registration.v1.schema.json +48 -0
  64. package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
  65. package/schemas/synapsor.app-handler-request.v1.json +119 -0
  66. package/schemas/synapsor.runner.schema.json +415 -0
  67. package/schemas/writeback-job.v1.schema.json +121 -0
package/dist/runner.mjs CHANGED
@@ -444,23 +444,26 @@ function sleep(ms) {
444
444
  }
445
445
 
446
446
  // packages/config/src/index.ts
447
- var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "cloud", "strict"]);
447
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "cloud", "strict", "result_format"]);
448
448
  var STORAGE_KEYS = /* @__PURE__ */ new Set(["sqlite_path"]);
449
449
  var CLOUD_KEYS = /* @__PURE__ */ new Set(["base_url_env", "runner_token_env", "runner_id", "runner_version", "project_id", "adapter_id", "source_id", "engines", "capabilities", "session"]);
450
450
  var SOURCE_KEYS = /* @__PURE__ */ new Set([
451
451
  "engine",
452
452
  "read_url_env",
453
453
  "write_url_env",
454
+ "read_only",
454
455
  "statement_timeout_ms",
455
456
  "ssl"
456
457
  ]);
457
458
  var TRUSTED_CONTEXT_KEYS = /* @__PURE__ */ new Set(["provider", "values"]);
458
459
  var CONTEXT_KEYS = TRUSTED_CONTEXT_KEYS;
459
- var EXECUTOR_KEYS = /* @__PURE__ */ new Set(["type", "url_env", "method", "auth", "timeout_ms", "command_env"]);
460
+ var EXECUTOR_KEYS = /* @__PURE__ */ new Set(["type", "url_env", "method", "auth", "signing_secret_env", "timeout_ms", "command_env"]);
460
461
  var EXECUTOR_AUTH_KEYS = /* @__PURE__ */ new Set(["type", "token_env"]);
461
462
  var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
462
463
  "name",
463
464
  "kind",
465
+ "description",
466
+ "returns_hint",
464
467
  "source",
465
468
  "context",
466
469
  "executor",
@@ -480,7 +483,7 @@ var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
480
483
  ]);
481
484
  var TARGET_KEYS = /* @__PURE__ */ new Set(["schema", "table", "primary_key", "tenant_key", "single_tenant_dev"]);
482
485
  var LOOKUP_KEYS = /* @__PURE__ */ new Set(["id_from_arg"]);
483
- var ARG_KEYS = /* @__PURE__ */ new Set(["type", "required", "max_length", "minimum", "maximum", "enum"]);
486
+ var ARG_KEYS = /* @__PURE__ */ new Set(["type", "description", "required", "max_length", "minimum", "maximum", "enum"]);
484
487
  var PATCH_BINDING_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
485
488
  var NUMERIC_BOUND_KEYS = /* @__PURE__ */ new Set(["minimum", "maximum"]);
486
489
  var TRANSITION_GUARD_KEYS = /* @__PURE__ */ new Set(["from_column", "allowed"]);
@@ -549,6 +552,9 @@ function validateRunnerCapabilityConfig(input) {
549
552
  if (input.version !== 1) {
550
553
  errors.push({ path: "$.version", code: "UNSUPPORTED_CONFIG_VERSION", message: "Runner config version must be 1." });
551
554
  }
555
+ if (input.result_format !== void 0 && input.result_format !== 1 && input.result_format !== 2) {
556
+ errors.push({ path: "$.result_format", code: "INVALID_RESULT_FORMAT", message: "result_format must be 1 or 2." });
557
+ }
552
558
  if (!isRunnerMode(input.mode)) {
553
559
  errors.push({ path: "$.mode", code: "INVALID_MODE", message: "mode must be read_only, shadow, review, or cloud." });
554
560
  }
@@ -559,6 +565,7 @@ function validateRunnerCapabilityConfig(input) {
559
565
  validateTrustedContext(input.trusted_context, input.contexts, input.capabilities, input.mode, strict, errors, warnings);
560
566
  validateExecutors(input.executors, input.mode, strict, errors);
561
567
  validateCapabilities(input.capabilities, input.sources, input.contexts, input.executors, input.mode, strict, errors, warnings);
568
+ validateWritebackReadiness(input.sources, input.capabilities, input.mode, errors, warnings);
562
569
  scanForForbiddenFields(input, "$", errors);
563
570
  return { ok: errors.length === 0, errors, warnings };
564
571
  }
@@ -617,14 +624,37 @@ function validateSources(value, mode, strict, errors, warnings) {
617
624
  if (source.statement_timeout_ms !== void 0 && !isPositiveInteger(source.statement_timeout_ms)) {
618
625
  errors.push({ path: `${path4}.statement_timeout_ms`, code: "INVALID_TIMEOUT", message: "statement_timeout_ms must be a positive integer." });
619
626
  }
627
+ if (source.read_only !== void 0 && typeof source.read_only !== "boolean") {
628
+ errors.push({ path: `${path4}.read_only`, code: "INVALID_SOURCE_READ_ONLY", message: "read_only must be true or false when provided." });
629
+ }
630
+ }
631
+ }
632
+ function validateWritebackReadiness(sources, capabilities, mode, errors, warnings) {
633
+ if (mode !== "review" || !isRecord(sources) || !Array.isArray(capabilities)) return;
634
+ capabilities.forEach((capability, index) => {
635
+ if (!isRecord(capability) || capability.kind !== "proposal") return;
636
+ const sourceName = isNonEmptyString(capability.source) ? capability.source : void 0;
637
+ const source = sourceName ? sources[sourceName] : void 0;
638
+ if (!sourceName || !isRecord(source)) return;
639
+ const executor = isNonEmptyString(capability.executor) ? capability.executor : "sql_update";
640
+ const directSql = executor === "sql_update";
641
+ if (!directSql) return;
642
+ if (source.read_only === true) {
643
+ errors.push({
644
+ path: `$.capabilities[${index}].executor`,
645
+ code: "READ_ONLY_SOURCE_DIRECT_WRITEBACK",
646
+ message: `Proposal capability ${String(capability.name ?? index)} uses direct SQL writeback, but source ${sourceName} is marked read_only.`
647
+ });
648
+ return;
649
+ }
620
650
  if (source.write_url_env === void 0) {
621
651
  warnings.push({
622
- path: `${path4}.write_url_env`,
652
+ path: `$.sources.${sourceName}.write_url_env`,
623
653
  code: "WRITEBACK_DISABLED",
624
- message: "No write_url_env is configured; review-mode proposal execution cannot apply external DB changes."
654
+ message: "No write_url_env is configured; direct SQL review-mode proposal execution cannot apply external DB changes."
625
655
  });
626
656
  }
627
- }
657
+ });
628
658
  }
629
659
  function validateCloud(value, mode, strict, errors) {
630
660
  if (mode !== "cloud") {
@@ -797,6 +827,9 @@ function validateExecutors(value, mode, strict, errors) {
797
827
  errors.push({ path: `${path4}.method`, code: "INVALID_HANDLER_METHOD", message: "http_handler.method must be POST, PUT, or PATCH." });
798
828
  }
799
829
  validateExecutorAuth(executor.auth, `${path4}.auth`, strict, errors);
830
+ if (executor.signing_secret_env !== void 0 && !isEnvName(executor.signing_secret_env)) {
831
+ errors.push({ path: `${path4}.signing_secret_env`, code: "HANDLER_SIGNING_SECRET_ENV_INVALID", message: "http_handler.signing_secret_env must name an environment variable containing the HMAC signing secret." });
832
+ }
800
833
  if (executor.timeout_ms !== void 0 && !isPositiveInteger(executor.timeout_ms)) {
801
834
  errors.push({ path: `${path4}.timeout_ms`, code: "INVALID_HANDLER_TIMEOUT", message: "http_handler.timeout_ms must be a positive integer." });
802
835
  }
@@ -852,6 +885,12 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
852
885
  if (!isCapabilityKind(value.kind)) {
853
886
  errors.push({ path: `${path4}.kind`, code: "INVALID_CAPABILITY_KIND", message: "kind must be read or proposal." });
854
887
  }
888
+ if (value.description !== void 0 && !isNonEmptyString(value.description)) {
889
+ errors.push({ path: `${path4}.description`, code: "INVALID_CAPABILITY_DESCRIPTION", message: "description must be a non-empty string when provided." });
890
+ }
891
+ if (value.returns_hint !== void 0 && !isNonEmptyString(value.returns_hint)) {
892
+ errors.push({ path: `${path4}.returns_hint`, code: "INVALID_RETURNS_HINT", message: "returns_hint must be a non-empty string when provided." });
893
+ }
855
894
  if (!isNonEmptyString(value.source) || !sourceNames.has(value.source)) {
856
895
  errors.push({ path: `${path4}.source`, code: "UNKNOWN_SOURCE", message: "Capability source must reference a configured source." });
857
896
  }
@@ -926,6 +965,9 @@ function validateArgs(value, path4, strict, errors) {
926
965
  if (!["string", "number", "boolean"].includes(String(arg.type))) {
927
966
  errors.push({ path: `${argPath}.type`, code: "INVALID_ARG_TYPE", message: "Argument type must be string, number, or boolean." });
928
967
  }
968
+ if (arg.description !== void 0 && !isNonEmptyString(arg.description)) {
969
+ errors.push({ path: `${argPath}.description`, code: "INVALID_ARG_DESCRIPTION", message: "Argument description must be a non-empty string when provided." });
970
+ }
929
971
  if (arg.max_length !== void 0 && !isPositiveInteger(arg.max_length)) {
930
972
  errors.push({ path: `${argPath}.max_length`, code: "INVALID_MAX_LENGTH", message: "max_length must be a positive integer." });
931
973
  }
@@ -1184,6 +1226,8 @@ import { createServer } from "node:http";
1184
1226
  import path from "node:path";
1185
1227
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
1186
1228
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1229
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1230
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
1187
1231
 
1188
1232
  // packages/proposal-store/src/index.ts
1189
1233
  import { DatabaseSync } from "node:sqlite";
@@ -2071,6 +2115,11 @@ var ProposalStore = class {
2071
2115
  const rows = this.db.prepare("SELECT * FROM proposal_events WHERE proposal_id = ? ORDER BY event_id ASC").all(proposalId);
2072
2116
  return rows.map(rowToEvent).filter((event) => event !== void 0);
2073
2117
  }
2118
+ listEvents(filters = {}) {
2119
+ const query = buildEventQuery(filters);
2120
+ const rows = this.db.prepare(query.sql).all(...query.params);
2121
+ return rows.map(rowToEvent).filter((event) => event !== void 0);
2122
+ }
2074
2123
  receipts(proposalId) {
2075
2124
  const rows = this.db.prepare("SELECT * FROM writeback_receipts WHERE proposal_id = ? ORDER BY receipt_id ASC").all(proposalId);
2076
2125
  return rows.map(rowToReceipt).filter((receipt) => receipt !== void 0);
@@ -2385,6 +2434,15 @@ function buildReceiptQuery(filters) {
2385
2434
  addTimeRange(clauses, params, "created_at", filters.from, filters.to);
2386
2435
  return finishQuery("SELECT * FROM writeback_receipts", clauses, params, filters.limit);
2387
2436
  }
2437
+ function buildEventQuery(filters) {
2438
+ const clauses = [];
2439
+ const params = [];
2440
+ addEqual(clauses, params, "proposal_id", filters.proposal);
2441
+ addEqual(clauses, params, "kind", filters.kind);
2442
+ addEqual(clauses, params, "actor", filters.actor);
2443
+ addTimeRange(clauses, params, "created_at", filters.from, filters.to);
2444
+ return finishQuery("SELECT * FROM proposal_events", clauses, params, filters.limit);
2445
+ }
2388
2446
  function addEqual(clauses, params, column, value) {
2389
2447
  if (!value) return;
2390
2448
  clauses.push(`${column} = ?`);
@@ -2668,74 +2726,118 @@ function loadRuntimeConfigFromFile(configPath = process.env.SYNAPSOR_MCP_CONFIG
2668
2726
  function createMcpRuntime(config, options = {}) {
2669
2727
  assertValidRunnerCapabilityConfig(config);
2670
2728
  const env = options.env ?? process.env;
2671
- const store = options.store ?? new ProposalStore(options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db");
2729
+ const storePath = options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db";
2730
+ const ownsStore = !options.store;
2731
+ const store = options.store ?? new ProposalStore(storePath);
2672
2732
  const readRow = options.readRow ?? readCurrentRow;
2673
2733
  const cloudClient = options.controlPlaneClient ?? (config.mode === "cloud" ? createCloudClient(config, env) : void 0);
2674
2734
  const cloudTools = options.cloudTools ?? [];
2735
+ const resultFormat = options.resultFormat ?? config.result_format ?? 1;
2736
+ const assertStoreAvailable = () => {
2737
+ if (ownsStore) assertPersistentStoreAvailable(storePath);
2738
+ };
2675
2739
  return {
2676
2740
  config,
2677
2741
  store,
2678
2742
  listTools: () => config.mode === "cloud" ? cloudTools : listedLocalCapabilities(config).map((capability) => toolMetadata(capability)),
2679
- callTool: async (name, args) => callConfiguredTool({ config, env, store, readRow, cloudClient, name, args }),
2680
- readResource: (uri) => readLocalResource(store, uri),
2743
+ callTool: async (name, args) => {
2744
+ if (resultFormat === 2) {
2745
+ try {
2746
+ assertStoreAvailable();
2747
+ return await callConfiguredToolV2({ config, env, store, readRow, cloudClient, name, args });
2748
+ } catch (error) {
2749
+ const capability = config.mode === "cloud" ? void 0 : localCapabilities(config).find((item) => item.name === name);
2750
+ return errorEnvelopeFromError(error, capability, name);
2751
+ }
2752
+ }
2753
+ assertStoreAvailable();
2754
+ return callConfiguredTool({ config, env, store, readRow, cloudClient, name, args });
2755
+ },
2756
+ readResource: (uri) => {
2757
+ assertStoreAvailable();
2758
+ return readLocalResource(store, uri);
2759
+ },
2681
2760
  close: () => {
2682
2761
  if (!options.store) store.close();
2683
2762
  }
2684
2763
  };
2685
2764
  }
2686
- function createSynapsorMcpServer(runtime) {
2765
+ function assertPersistentStoreAvailable(storePath) {
2766
+ if (storePath === ":memory:") return;
2767
+ if (fs.existsSync(storePath)) return;
2768
+ throw new McpRuntimeError(
2769
+ "LOCAL_STORE_UNAVAILABLE",
2770
+ "The local Synapsor store is temporarily unavailable. Restart the runner or recreate the store before retrying."
2771
+ );
2772
+ }
2773
+ function createSynapsorMcpServer(runtime, options = {}) {
2687
2774
  const server = new McpServer(
2688
- { name: "synapsor-runner", version: "0.1.0-alpha.8" },
2775
+ { name: "synapsor-runner", version: "0.1.0" },
2689
2776
  { capabilities: { tools: {}, resources: {} } }
2690
2777
  );
2778
+ const toolNameStyle = options.toolNameStyle ?? "canonical";
2691
2779
  if (runtime.config.mode === "cloud") {
2692
- for (const tool of runtime.listTools()) {
2693
- server.registerTool(
2694
- tool.name,
2695
- {
2696
- title: tool.title,
2697
- description: tool.description,
2698
- inputSchema: zodInputShapeFromJsonSchema(tool.input_schema),
2699
- annotations: {
2700
- readOnlyHint: Boolean(tool.annotations.readOnlyHint),
2701
- destructiveHint: false,
2702
- idempotentHint: Boolean(tool.annotations.idempotentHint),
2703
- openWorldHint: false
2780
+ const tools2 = runtime.listTools();
2781
+ const exposedNames = toolNameExposureMap(tools2.map((tool) => tool.name), toolNameStyle);
2782
+ for (const tool of tools2) {
2783
+ for (const exposedName of exposedNames.get(tool.name) ?? [tool.name]) {
2784
+ server.registerTool(
2785
+ exposedName,
2786
+ {
2787
+ title: tool.title,
2788
+ description: toolDescriptionWithCanonical(tool.description, tool.name, exposedName),
2789
+ inputSchema: zodInputShapeFromJsonSchema(tool.input_schema),
2790
+ annotations: {
2791
+ readOnlyHint: Boolean(tool.annotations.readOnlyHint),
2792
+ destructiveHint: false,
2793
+ idempotentHint: Boolean(tool.annotations.idempotentHint),
2794
+ openWorldHint: false
2795
+ },
2796
+ _meta: {
2797
+ ...tool.annotations,
2798
+ "synapsor.cloud_delegated": true,
2799
+ "synapsor.canonical_tool_name": tool.name,
2800
+ "synapsor.exposed_tool_name": exposedName,
2801
+ "synapsor.tool_name_style": toolNameStyle,
2802
+ "synapsor.raw_sql_exposed": false,
2803
+ "synapsor.approval_tool": false
2804
+ }
2704
2805
  },
2705
- _meta: {
2706
- ...tool.annotations,
2707
- "synapsor.cloud_delegated": true,
2708
- "synapsor.raw_sql_exposed": false,
2709
- "synapsor.approval_tool": false
2710
- }
2711
- },
2712
- async (args) => toolCallResult(runtime, tool.name, args)
2713
- );
2806
+ async (args) => toolCallResult(runtime, tool.name, args)
2807
+ );
2808
+ }
2714
2809
  }
2715
2810
  } else {
2716
- for (const capability of listedLocalCapabilities(runtime.config)) {
2717
- server.registerTool(
2718
- capability.name,
2719
- {
2720
- title: capability.name,
2721
- description: capabilityDescription(capability),
2722
- inputSchema: zodInputShape(capability),
2723
- annotations: {
2724
- readOnlyHint: capability.kind === "read",
2725
- destructiveHint: false,
2726
- idempotentHint: capability.kind === "read",
2727
- openWorldHint: false
2811
+ const capabilities = listedLocalCapabilities(runtime.config);
2812
+ const exposedNames = toolNameExposureMap(capabilities.map((capability) => capability.name), toolNameStyle);
2813
+ for (const capability of capabilities) {
2814
+ for (const exposedName of exposedNames.get(capability.name) ?? [capability.name]) {
2815
+ server.registerTool(
2816
+ exposedName,
2817
+ {
2818
+ title: capability.name,
2819
+ description: capabilityDescription(capability, exposedName),
2820
+ inputSchema: zodInputShape(capability),
2821
+ annotations: {
2822
+ readOnlyHint: capability.kind === "read",
2823
+ destructiveHint: false,
2824
+ idempotentHint: capability.kind === "read",
2825
+ openWorldHint: false
2826
+ },
2827
+ _meta: {
2828
+ "synapsor.kind": capability.kind,
2829
+ "synapsor.source": capability.source,
2830
+ "synapsor.target": `${capability.target.schema}.${capability.target.table}`,
2831
+ "synapsor.canonical_tool_name": capability.name,
2832
+ "synapsor.exposed_tool_name": exposedName,
2833
+ "synapsor.tool_name_style": toolNameStyle,
2834
+ "synapsor.raw_sql_exposed": false,
2835
+ "synapsor.approval_tool": false
2836
+ }
2728
2837
  },
2729
- _meta: {
2730
- "synapsor.kind": capability.kind,
2731
- "synapsor.source": capability.source,
2732
- "synapsor.target": `${capability.target.schema}.${capability.target.table}`,
2733
- "synapsor.raw_sql_exposed": false,
2734
- "synapsor.approval_tool": false
2735
- }
2736
- },
2737
- async (args) => toolCallResult(runtime, capability.name, args)
2738
- );
2838
+ async (args) => toolCallResult(runtime, capability.name, args)
2839
+ );
2840
+ }
2739
2841
  }
2740
2842
  }
2741
2843
  server.registerResource(
@@ -2761,8 +2863,8 @@ function createSynapsorMcpServer(runtime) {
2761
2863
  async function serveStdio(options = {}) {
2762
2864
  const config = options.config ?? loadRuntimeConfigFromFile(options.configPath);
2763
2865
  const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, process.env) : void 0;
2764
- const runtime = createMcpRuntime(config, { storePath: options.storePath, cloudTools });
2765
- const server = createSynapsorMcpServer(runtime);
2866
+ const runtime = createMcpRuntime(config, { storePath: options.storePath, resultFormat: options.resultFormat, cloudTools });
2867
+ const server = createSynapsorMcpServer(runtime, { toolNameStyle: options.toolNameStyle });
2766
2868
  const transport = new StdioServerTransport();
2767
2869
  await server.connect(transport);
2768
2870
  await new Promise((resolve) => {
@@ -2797,6 +2899,7 @@ async function startHttpMcpServer(options = {}) {
2797
2899
  const runtime = createMcpRuntime(config, {
2798
2900
  env,
2799
2901
  storePath: options.storePath,
2902
+ resultFormat: options.resultFormat,
2800
2903
  readRow: options.readRow,
2801
2904
  cloudTools
2802
2905
  });
@@ -2844,6 +2947,152 @@ async function startHttpMcpServer(options = {}) {
2844
2947
  close: () => closeHttpServer(server, runtime)
2845
2948
  };
2846
2949
  }
2950
+ async function startStreamableHttpMcpServer(options = {}) {
2951
+ const host = options.host ?? "127.0.0.1";
2952
+ const port = options.port ?? 8766;
2953
+ const authTokenEnv = options.authTokenEnv ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
2954
+ const env = options.env ?? process.env;
2955
+ const devNoAuth = options.devNoAuth === true;
2956
+ if (devNoAuth && !isLoopbackHost(host)) {
2957
+ throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
2958
+ }
2959
+ const authToken = devNoAuth ? void 0 : env[authTokenEnv];
2960
+ if (!devNoAuth && !authToken) {
2961
+ throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
2962
+ }
2963
+ const config = options.config ?? loadRuntimeConfigFromFile(options.configPath);
2964
+ const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, env) : void 0;
2965
+ const sessions = /* @__PURE__ */ new Map();
2966
+ const openSessions = /* @__PURE__ */ new Set();
2967
+ const server = createServer((request, response) => {
2968
+ void handleStreamableHttpMcpRequest({
2969
+ request,
2970
+ response,
2971
+ config,
2972
+ storePath: options.storePath,
2973
+ readRow: options.readRow,
2974
+ cloudTools,
2975
+ env,
2976
+ toolNameStyle: options.toolNameStyle,
2977
+ resultFormat: options.resultFormat,
2978
+ authToken,
2979
+ devNoAuth,
2980
+ corsOrigin: options.corsOrigin,
2981
+ sessions,
2982
+ openSessions
2983
+ });
2984
+ });
2985
+ try {
2986
+ await new Promise((resolve, reject) => {
2987
+ server.once("error", reject);
2988
+ server.listen(port, host, () => {
2989
+ server.off("error", reject);
2990
+ resolve();
2991
+ });
2992
+ });
2993
+ } catch (error) {
2994
+ await closeStreamableSessions(openSessions);
2995
+ throw error;
2996
+ }
2997
+ const address = server.address();
2998
+ const actualHost = address.address === "::" ? host : address.address;
2999
+ const actualPort = address.port;
3000
+ const url = `http://${actualHost}:${actualPort}/mcp`;
3001
+ if (options.log !== false) {
3002
+ const log = options.log ?? process.stderr;
3003
+ log.write(`Synapsor Runner Streamable HTTP MCP listening on ${url}
3004
+ `);
3005
+ log.write(devNoAuth ? "Auth: disabled for localhost development only\n" : `Auth: bearer token from ${authTokenEnv}
3006
+ `);
3007
+ log.write(`Config: ${options.configPath ?? "synapsor.runner.json"}
3008
+ `);
3009
+ log.write(`Store: ${options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db"}
3010
+ `);
3011
+ }
3012
+ return {
3013
+ host: actualHost,
3014
+ port: actualPort,
3015
+ url,
3016
+ close: () => closeStreamableHttpServer(server, openSessions)
3017
+ };
3018
+ }
3019
+ async function handleStreamableHttpMcpRequest(input) {
3020
+ const { request, response, config, storePath, readRow, cloudTools, env, toolNameStyle, resultFormat, authToken, devNoAuth, corsOrigin, sessions, openSessions } = input;
3021
+ try {
3022
+ setCorsHeaders(response, corsOrigin);
3023
+ if (request.method === "OPTIONS" && corsOrigin) {
3024
+ response.statusCode = 204;
3025
+ response.end();
3026
+ return;
3027
+ }
3028
+ const url = new URL(request.url ?? "/", "http://localhost");
3029
+ if (request.method === "GET" && url.pathname === "/healthz") {
3030
+ writeJson(response, 200, {
3031
+ ok: true,
3032
+ transport: "streamable-http",
3033
+ sessions: sessions.size,
3034
+ tools: config.mode === "cloud" ? (cloudTools ?? []).length : listedLocalCapabilities(config).length,
3035
+ mode: config.mode
3036
+ });
3037
+ return;
3038
+ }
3039
+ if (url.pathname !== "/mcp") {
3040
+ writeJson(response, 404, { ok: false, error: "not_found" });
3041
+ return;
3042
+ }
3043
+ if (!devNoAuth && !validBearerToken(request.headers.authorization, authToken ?? "")) {
3044
+ writeJson(response, 401, { ok: false, error: "unauthorized" });
3045
+ return;
3046
+ }
3047
+ const sessionId = headerValue(request.headers["mcp-session-id"]);
3048
+ if (sessionId) {
3049
+ const existing = sessions.get(sessionId);
3050
+ if (!existing) {
3051
+ writeJson(response, 404, jsonRpcError(null, -32e3, "MCP session not found."));
3052
+ return;
3053
+ }
3054
+ await existing.transport.handleRequest(request, response);
3055
+ return;
3056
+ }
3057
+ if (request.method !== "POST") {
3058
+ writeJson(response, 400, jsonRpcError(null, -32e3, "MCP initialize request is required before using this Streamable HTTP session."));
3059
+ return;
3060
+ }
3061
+ const parsedBody = JSON.parse(await readRequestBody(request));
3062
+ if (!containsInitializeRequest(parsedBody)) {
3063
+ writeJson(response, 400, jsonRpcError(requestIdFromPayload(parsedBody), -32e3, "First Streamable HTTP MCP request must be initialize."));
3064
+ return;
3065
+ }
3066
+ let session;
3067
+ const transport = new StreamableHTTPServerTransport({
3068
+ sessionIdGenerator: () => crypto.randomUUID(),
3069
+ onsessioninitialized: (newSessionId) => {
3070
+ if (session) {
3071
+ session.sessionId = newSessionId;
3072
+ sessions.set(newSessionId, session);
3073
+ }
3074
+ },
3075
+ onsessionclosed: (closedSessionId) => {
3076
+ const closed = sessions.get(closedSessionId);
3077
+ if (closed) {
3078
+ disposeStreamableSession(closed, sessions, openSessions);
3079
+ }
3080
+ }
3081
+ });
3082
+ const runtime = createMcpRuntime(config, { env, storePath, resultFormat, readRow, cloudTools });
3083
+ session = { transport, runtime };
3084
+ openSessions.add(session);
3085
+ transport.onclose = () => {
3086
+ if (session) disposeStreamableSession(session, sessions, openSessions);
3087
+ };
3088
+ await createSynapsorMcpServer(runtime, { toolNameStyle }).connect(transport);
3089
+ await transport.handleRequest(request, response, parsedBody);
3090
+ } catch (error) {
3091
+ const message = sanitizeHttpError(error, authToken);
3092
+ if (!response.headersSent) writeJson(response, 200, jsonRpcError(null, -32e3, message));
3093
+ else response.end();
3094
+ }
3095
+ }
2847
3096
  async function handleHttpMcpRequest(input) {
2848
3097
  const { request, response, runtime, authToken, devNoAuth, corsOrigin } = input;
2849
3098
  try {
@@ -2943,6 +3192,71 @@ function validBearerToken(header, expected) {
2943
3192
  const expectedBuffer = Buffer.from(expected);
2944
3193
  return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
2945
3194
  }
3195
+ function headerValue(value) {
3196
+ return Array.isArray(value) ? value[0] : value;
3197
+ }
3198
+ function containsInitializeRequest(payload) {
3199
+ if (Array.isArray(payload)) return payload.some((message) => isInitializeRequest(message));
3200
+ return isInitializeRequest(payload);
3201
+ }
3202
+ function requestIdFromPayload(payload) {
3203
+ if (Array.isArray(payload)) {
3204
+ const request = payload.find((message) => isRecord3(message) && "id" in message);
3205
+ return isRecord3(request) ? request.id ?? null : null;
3206
+ }
3207
+ return isRecord3(payload) ? payload.id ?? null : null;
3208
+ }
3209
+ function openaiToolNameAlias(canonicalName) {
3210
+ const sanitized = canonicalName.replace(/[^A-Za-z0-9_-]+/g, "__").replace(/_{3,}/g, "__").replace(/^_+|_+$/g, "");
3211
+ const base = sanitized.length > 0 ? sanitized : `tool_${shortToolHash(canonicalName)}`;
3212
+ if (base.length <= 64) return base;
3213
+ const suffix = shortToolHash(canonicalName);
3214
+ return `${base.slice(0, Math.max(1, 63 - suffix.length)).replace(/_+$/g, "")}_${suffix}`;
3215
+ }
3216
+ function toolNameExposures(canonicalNames, style) {
3217
+ const exposedNames = toolNameExposureMap(canonicalNames, style);
3218
+ return canonicalNames.flatMap((canonicalName) => {
3219
+ return (exposedNames.get(canonicalName) ?? [canonicalName]).map((exposedName) => ({
3220
+ canonicalName,
3221
+ exposedName,
3222
+ isAlias: exposedName !== canonicalName,
3223
+ style
3224
+ }));
3225
+ });
3226
+ }
3227
+ function toolNameExposureMap(canonicalNames, style) {
3228
+ const exposedByCanonical = /* @__PURE__ */ new Map();
3229
+ const canonicalByExposed = /* @__PURE__ */ new Map();
3230
+ if (style === "both") {
3231
+ for (const canonical of canonicalNames) canonicalByExposed.set(canonical, canonical);
3232
+ }
3233
+ for (const canonical of canonicalNames) {
3234
+ const names = /* @__PURE__ */ new Set();
3235
+ if (style === "canonical" || style === "both") names.add(canonical);
3236
+ if (style === "openai" || style === "both") {
3237
+ let alias = openaiToolNameAlias(canonical);
3238
+ const existing = canonicalByExposed.get(alias);
3239
+ if (existing && existing !== canonical) {
3240
+ const suffix = shortToolHash(canonical);
3241
+ alias = `${alias.slice(0, Math.max(1, 63 - suffix.length)).replace(/_+$/g, "")}_${suffix}`;
3242
+ }
3243
+ canonicalByExposed.set(alias, canonical);
3244
+ names.add(alias);
3245
+ }
3246
+ exposedByCanonical.set(canonical, [...names]);
3247
+ }
3248
+ return exposedByCanonical;
3249
+ }
3250
+ function shortToolHash(value) {
3251
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, 8);
3252
+ }
3253
+ function setCorsHeaders(response, corsOrigin) {
3254
+ if (corsOrigin) {
3255
+ response.setHeader("access-control-allow-origin", corsOrigin);
3256
+ response.setHeader("access-control-allow-methods", "POST, GET, DELETE, OPTIONS");
3257
+ response.setHeader("access-control-allow-headers", "authorization, content-type, mcp-session-id, mcp-protocol-version, last-event-id");
3258
+ }
3259
+ }
2946
3260
  function setCommonHttpHeaders(response, corsOrigin) {
2947
3261
  response.setHeader("content-type", "application/json; charset=utf-8");
2948
3262
  if (corsOrigin) {
@@ -3006,6 +3320,28 @@ async function closeHttpServer(server, runtime) {
3006
3320
  runtime.close();
3007
3321
  });
3008
3322
  }
3323
+ async function closeStreamableHttpServer(server, sessions) {
3324
+ await new Promise((resolve, reject) => {
3325
+ server.close((error) => {
3326
+ if (error) reject(error);
3327
+ else resolve();
3328
+ });
3329
+ }).finally(() => closeStreamableSessions(sessions));
3330
+ }
3331
+ async function closeStreamableSessions(sessions) {
3332
+ for (const session of [...sessions]) {
3333
+ sessions.delete(session);
3334
+ await session.transport.close().catch(() => void 0);
3335
+ disposeStreamableSession(session);
3336
+ }
3337
+ }
3338
+ function disposeStreamableSession(session, sessionMap, openSessions) {
3339
+ if (session.closed) return;
3340
+ session.closed = true;
3341
+ if (session.sessionId) sessionMap?.delete(session.sessionId);
3342
+ openSessions?.delete(session);
3343
+ session.runtime.close();
3344
+ }
3009
3345
  async function toolCallResult(runtime, toolName, args) {
3010
3346
  try {
3011
3347
  const structuredContent = await runtime.callTool(toolName, args);
@@ -3081,6 +3417,11 @@ function cloudToolMetadata(tool) {
3081
3417
  }
3082
3418
  };
3083
3419
  }
3420
+ function toolDescriptionWithCanonical(description, canonicalName, exposedName) {
3421
+ if (!exposedName || exposedName === canonicalName) return description;
3422
+ return `Canonical Synapsor capability: ${canonicalName}.
3423
+ ${description}`;
3424
+ }
3084
3425
  function zodInputShapeFromJsonSchema(schema) {
3085
3426
  const properties = isRecord3(schema.properties) ? schema.properties : {};
3086
3427
  const required = Array.isArray(schema.required) ? new Set(schema.required.map(String)) : /* @__PURE__ */ new Set();
@@ -3258,6 +3599,135 @@ async function callConfiguredTool(input) {
3258
3599
  source_database_mutated: false
3259
3600
  };
3260
3601
  }
3602
+ async function callConfiguredToolV2(input) {
3603
+ const capability = input.config.mode === "cloud" ? void 0 : localCapabilities(input.config).find((item) => item.name === input.name);
3604
+ try {
3605
+ const legacy = await callConfiguredTool(input);
3606
+ return resultEnvelopeFromLegacy(legacy, capability, input.name);
3607
+ } catch (error) {
3608
+ return errorEnvelopeFromError(error, capability, input.name);
3609
+ }
3610
+ }
3611
+ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
3612
+ const action = typeof legacy.action === "string" ? legacy.action : canonicalName;
3613
+ const kind = capability?.kind ?? (typeof legacy.proposal_id === "string" ? "proposal" : "read");
3614
+ const evidenceBundleId = typeof legacy.evidence_bundle_id === "string" ? legacy.evidence_bundle_id : void 0;
3615
+ const sourceChanged = Boolean(legacy.source_database_changed ?? legacy.source_database_mutated ?? false);
3616
+ const context = isRecord3(legacy.trusted_context) ? legacy.trusted_context : void 0;
3617
+ const target2 = isRecord3(legacy.target) ? legacy.target : void 0;
3618
+ if (kind === "proposal") {
3619
+ const proposalId = typeof legacy.proposal_id === "string" ? legacy.proposal_id : "wrp_unknown";
3620
+ const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
3621
+ const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
3622
+ const executor = writebackExecutorName(legacy.writeback);
3623
+ const writebackMode = executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
3624
+ return {
3625
+ ok: true,
3626
+ summary: `Created proposal ${proposalId} for ${targetType} ${targetId}. Source database changed: no.`,
3627
+ action,
3628
+ kind,
3629
+ data: null,
3630
+ proposal: {
3631
+ id: proposalId,
3632
+ state: typeof legacy.status === "string" ? legacy.status : "review_required",
3633
+ target: `${targetType}:${targetId}`,
3634
+ diff: isRecord3(legacy.diff) ? legacy.diff : {},
3635
+ approval_required: legacy.approval_required !== false,
3636
+ writeback: {
3637
+ mode: writebackMode,
3638
+ applied: false
3639
+ },
3640
+ next: "A human must approve outside this model-facing tool surface; nothing is committed yet."
3641
+ },
3642
+ error: null,
3643
+ evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
3644
+ source_database_changed: sourceChanged,
3645
+ _meta: {
3646
+ tenant_id: typeof target2?.tenant_id === "string" ? target2.tenant_id : void 0,
3647
+ principal: typeof context?.principal === "string" ? context.principal : void 0,
3648
+ provenance: typeof context?.provenance === "string" ? context.provenance : void 0,
3649
+ canonical_capability: action
3650
+ }
3651
+ };
3652
+ }
3653
+ const businessObject = isRecord3(legacy.business_object) ? legacy.business_object : void 0;
3654
+ const objectType = typeof businessObject?.type === "string" ? businessObject.type : capability?.target.table ?? "record";
3655
+ const objectId = businessObject?.id !== void 0 ? String(businessObject.id) : String(legacy.action ?? action);
3656
+ return {
3657
+ ok: true,
3658
+ summary: `Read ${objectType} ${objectId} through ${action}. Source database changed: no.`,
3659
+ action,
3660
+ kind: "read",
3661
+ data: isRecord3(legacy.data) ? legacy.data : {},
3662
+ proposal: null,
3663
+ error: null,
3664
+ evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
3665
+ source_database_changed: sourceChanged,
3666
+ _meta: {
3667
+ tenant_id: typeof context?.tenant_id === "string" ? context.tenant_id : void 0,
3668
+ principal: typeof context?.principal === "string" ? context.principal : void 0,
3669
+ provenance: typeof context?.provenance === "string" ? context.provenance : void 0,
3670
+ canonical_capability: action
3671
+ }
3672
+ };
3673
+ }
3674
+ function writebackExecutorName(value) {
3675
+ if (!isRecord3(value)) return void 0;
3676
+ return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
3677
+ }
3678
+ function evidenceHandle(bundleId) {
3679
+ return {
3680
+ bundle_id: bundleId,
3681
+ note: "audit/replay handle; you do not need to act on it during this turn"
3682
+ };
3683
+ }
3684
+ function errorEnvelopeFromError(error, capability, canonicalName) {
3685
+ const safe = safeToolError(error);
3686
+ const action = capability?.name ?? canonicalName;
3687
+ return {
3688
+ ok: false,
3689
+ summary: safe.message,
3690
+ action,
3691
+ kind: capability?.kind ?? "read",
3692
+ data: null,
3693
+ proposal: null,
3694
+ error: safe,
3695
+ evidence: null,
3696
+ source_database_changed: false,
3697
+ _meta: {
3698
+ canonical_capability: action
3699
+ }
3700
+ };
3701
+ }
3702
+ function safeToolError(error) {
3703
+ const runtimeCode = error instanceof McpRuntimeError ? error.code : void 0;
3704
+ if (runtimeCode === "ROW_NOT_FOUND") {
3705
+ return { code: "NOT_FOUND_IN_TENANT", message: "No authorized row was found in the trusted tenant scope.", retryable: false };
3706
+ }
3707
+ if (runtimeCode === "MCP_TOOL_NOT_FOUND") {
3708
+ return { code: "CAPABILITY_NOT_FOUND", message: "The requested Synapsor capability is not available.", retryable: false };
3709
+ }
3710
+ if (runtimeCode === "PROPOSALS_DISABLED") {
3711
+ return { code: "APPROVAL_REQUIRED", message: "Proposal tools are disabled for this runner mode.", retryable: false };
3712
+ }
3713
+ if (runtimeCode && (runtimeCode.startsWith("ARGUMENT_") || runtimeCode === "LOOKUP_ARG_MISSING" || runtimeCode === "MODEL_CANNOT_OVERRIDE_BINDING" || runtimeCode === "TRUSTED_BINDING_MISSING" || runtimeCode === "TRUSTED_CONTEXT_MISSING")) {
3714
+ return { code: "INVALID_ARGUMENT", message: "The tool input or trusted context binding is invalid.", retryable: false };
3715
+ }
3716
+ if (runtimeCode && (runtimeCode.startsWith("PATCH_") || runtimeCode === "CONFLICT_GUARD_MISSING")) {
3717
+ return { code: "POLICY_VIOLATION", message: "The requested change is outside the reviewed capability policy.", retryable: false };
3718
+ }
3719
+ if (runtimeCode === "LOCAL_STORE_UNAVAILABLE") {
3720
+ return { code: "TEMPORARILY_UNAVAILABLE", message: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying.", retryable: true };
3721
+ }
3722
+ if (runtimeCode === "SOURCE_CREDENTIAL_MISSING" || looksLikeInfraError(error)) {
3723
+ return { code: "TEMPORARILY_UNAVAILABLE", message: "The database is temporarily unavailable. Retry later.", retryable: true };
3724
+ }
3725
+ return { code: "INTERNAL", message: "The capability failed safely. Check the local runner logs for details.", retryable: false };
3726
+ }
3727
+ function looksLikeInfraError(error) {
3728
+ const message = error instanceof Error ? error.message : String(error ?? "");
3729
+ return /\b(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|timeout|connect|connection|database|authentication|certificate)\b/i.test(message);
3730
+ }
3261
3731
  function buildChangeSet(input) {
3262
3732
  const patch = buildPatch(input.capability, input.args);
3263
3733
  const before = scalarRecord(input.currentRow);
@@ -3459,7 +3929,7 @@ function zodInputShape(capability) {
3459
3929
  if (spec.type === "number" && spec.maximum !== void 0) schema = schema.max(spec.maximum);
3460
3930
  if (spec.enum && spec.enum.length > 0) schema = schema.refine((value) => spec.enum?.includes(value), "value is not allowlisted");
3461
3931
  if (spec.required === false) schema = schema.optional();
3462
- shape[name] = schema.describe(`${name} business argument`);
3932
+ shape[name] = schema.describe(spec.description ?? `${name} business argument`);
3463
3933
  }
3464
3934
  return shape;
3465
3935
  }
@@ -3472,6 +3942,7 @@ function toolMetadata(capability) {
3472
3942
  input_schema: Object.fromEntries(Object.entries(capability.args).map(([name, spec]) => [name, {
3473
3943
  type: spec.type,
3474
3944
  required: spec.required !== false,
3945
+ ...spec.description !== void 0 ? { description: spec.description } : {},
3475
3946
  ...spec.max_length !== void 0 ? { max_length: spec.max_length } : {},
3476
3947
  ...spec.minimum !== void 0 ? { minimum: spec.minimum } : {},
3477
3948
  ...spec.maximum !== void 0 ? { maximum: spec.maximum } : {},
@@ -3487,11 +3958,23 @@ function toolMetadata(capability) {
3487
3958
  }
3488
3959
  };
3489
3960
  }
3490
- function capabilityDescription(capability) {
3491
- if (capability.kind === "read") {
3492
- return `Read ${capability.target.schema}.${capability.target.table} through a reviewed Synapsor capability with trusted tenant context and evidence.`;
3961
+ function capabilityDescription(capability, exposedName) {
3962
+ const lines = [];
3963
+ if (exposedName && exposedName !== capability.name) {
3964
+ lines.push(`Canonical Synapsor capability: ${capability.name}.`);
3965
+ }
3966
+ if (capability.description) {
3967
+ lines.push(capability.description);
3968
+ } else if (capability.kind === "read") {
3969
+ lines.push(`Read ${capability.target.schema}.${capability.target.table} through a reviewed Synapsor capability with trusted tenant context and evidence.`);
3970
+ } else {
3971
+ lines.push(`Create an evidence-backed Synapsor proposal for ${capability.target.schema}.${capability.target.table}; the source database is not mutated by this tool.`);
3972
+ }
3973
+ if (capability.returns_hint) {
3974
+ lines.push(capability.returns_hint);
3493
3975
  }
3494
- return `Create an evidence-backed Synapsor proposal for ${capability.target.schema}.${capability.target.table}; the source database is not mutated by this tool.`;
3976
+ lines.push("Evidence handles are audit/replay handles; the model does not need to call them during this turn.");
3977
+ return lines.join("\n");
3495
3978
  }
3496
3979
  function buildPatch(capability, args) {
3497
3980
  if (!capability.patch) throw new McpRuntimeError("PATCH_REQUIRED", "Proposal capability has no patch mapping.");
@@ -3608,6 +4091,9 @@ function isRecord3(value) {
3608
4091
  }
3609
4092
  function toolErrorPayload(error) {
3610
4093
  if (error instanceof McpRuntimeError) {
4094
+ if (error.code === "LOCAL_STORE_UNAVAILABLE") {
4095
+ return { ok: false, code: "TEMPORARILY_UNAVAILABLE", error: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying." };
4096
+ }
3611
4097
  return { ok: false, code: error.code, error: error.message };
3612
4098
  }
3613
4099
  return { ok: false, code: "MCP_TOOL_FAILED", error: error instanceof Error ? error.message : String(error) };
@@ -4005,6 +4491,7 @@ import { Pool as Pool3 } from "pg";
4005
4491
  var TENANT_COLUMNS = /* @__PURE__ */ new Set(["tenant_id", "account_id", "organization_id", "org_id", "workspace_id", "customer_id"]);
4006
4492
  var CONFLICT_COLUMNS = /* @__PURE__ */ new Set(["updated_at", "modified_at", "row_version", "version", "lock_version", "etag"]);
4007
4493
  var IMMUTABLE_COLUMNS = /* @__PURE__ */ new Set(["id", "uuid", "created_at", "created_by"]);
4494
+ var DEFAULT_RESULT_FORMAT = 2;
4008
4495
  var SENSITIVE_PATTERNS = [
4009
4496
  /password/i,
4010
4497
  /password_hash/i,
@@ -4056,22 +4543,26 @@ function generateRunnerConfigFromSpec(spec) {
4056
4543
  const mode = spec.mode ?? "shadow";
4057
4544
  const sourceName = spec.source_name ?? (spec.engine === "postgres" ? "local_postgres" : "local_mysql");
4058
4545
  const readUrlEnv = spec.read_url_env ?? spec.database_url_env ?? "SYNAPSOR_DATABASE_READ_URL";
4059
- const writeUrlEnv = spec.write_url_env ?? "SYNAPSOR_DATABASE_WRITE_URL";
4546
+ const writeback2 = normalizedWriteback(spec);
4547
+ const writeUrlEnv = writeback2.executor === "sql_update" ? spec.write_url_env ?? "SYNAPSOR_DATABASE_WRITE_URL" : void 0;
4060
4548
  const tenantEnv = spec.trusted_context?.tenant_id_env ?? "SYNAPSOR_TENANT_ID";
4061
4549
  const principalEnv = spec.trusted_context?.principal_env ?? "SYNAPSOR_PRINCIPAL";
4062
4550
  const objectName = spec.object_name ?? singularize(safeName(spec.table));
4063
4551
  const lookupArg = spec.lookup_arg ?? `${objectName}_id`;
4064
4552
  const inspectToolName = spec.inspect_tool_name ?? `${spec.namespace}.inspect_${objectName}`;
4065
4553
  const proposalToolName = spec.proposal_tool_name ?? `${spec.namespace}.propose_${objectName}_update`;
4554
+ const objectLabel = objectName.replace(/_/g, " ");
4066
4555
  const visibleColumns = unique([spec.primary_key, spec.tenant_key, spec.conflict_column, ...spec.visible_columns].filter((value) => Boolean(value)));
4067
4556
  const readCapability = {
4068
4557
  name: inspectToolName,
4069
4558
  kind: "read",
4559
+ description: spec.inspect_description ?? `Inspect one ${objectLabel} in trusted tenant scope before answering or proposing a change.`,
4560
+ returns_hint: spec.inspect_returns_hint ?? `Returns reviewed ${objectLabel} fields, evidence handle, query audit, and source_database_changed:false.`,
4070
4561
  source: sourceName,
4071
4562
  context: "local_operator",
4072
4563
  target: target(spec),
4073
4564
  args: {
4074
- [lookupArg]: { type: "string", required: true, max_length: 128 }
4565
+ [lookupArg]: { type: "string", required: true, max_length: 128, description: `${capitalize(objectLabel)} id from the user request or trusted app context.` }
4075
4566
  },
4076
4567
  lookup: { id_from_arg: lookupArg },
4077
4568
  visible_columns: visibleColumns,
@@ -4084,8 +4575,11 @@ function generateRunnerConfigFromSpec(spec) {
4084
4575
  capabilities.push({
4085
4576
  name: proposalToolName,
4086
4577
  kind: "proposal",
4578
+ description: spec.proposal_description ?? `Create a review-required proposal to update one ${objectLabel}. The source database remains unchanged until approval and writeback.`,
4579
+ returns_hint: spec.proposal_returns_hint ?? "Returns a proposal id, exact before/after diff, evidence handle, approval status, and source_database_changed:false.",
4087
4580
  source: sourceName,
4088
4581
  context: "local_operator",
4582
+ ...writeback2.executor !== "sql_update" ? { executor: writeback2.executorName } : {},
4089
4583
  target: target(spec),
4090
4584
  args: {
4091
4585
  [lookupArg]: { type: "string", required: true, max_length: 128 },
@@ -4106,12 +4600,14 @@ function generateRunnerConfigFromSpec(spec) {
4106
4600
  const config = {
4107
4601
  version: 1,
4108
4602
  mode,
4603
+ result_format: spec.result_format ?? DEFAULT_RESULT_FORMAT,
4109
4604
  storage: { sqlite_path: "./.synapsor/local.db" },
4110
4605
  sources: {
4111
4606
  [sourceName]: {
4112
4607
  engine: spec.engine,
4113
4608
  read_url_env: readUrlEnv,
4114
- ...mode === "review" ? { write_url_env: writeUrlEnv } : {},
4609
+ ...mode === "review" && writeUrlEnv ? { write_url_env: writeUrlEnv } : {},
4610
+ ...mode === "review" && writeback2.executor !== "sql_update" && !writeUrlEnv ? { read_only: true } : {},
4115
4611
  statement_timeout_ms: spec.statement_timeout_ms ?? 3e3
4116
4612
  }
4117
4613
  },
@@ -4131,11 +4627,20 @@ function generateRunnerConfigFromSpec(spec) {
4131
4627
  }
4132
4628
  }
4133
4629
  },
4630
+ ...mode === "review" && writeback2.executor !== "sql_update" ? { executors: writeback2.executors } : {},
4134
4631
  capabilities
4135
4632
  };
4136
4633
  return {
4137
4634
  config,
4138
- envExample: envExample({ readUrlEnv, writeUrlEnv, tenantEnv, principalEnv, mode, engine: spec.engine }),
4635
+ envExample: envExample({
4636
+ readUrlEnv,
4637
+ writeUrlEnv,
4638
+ tenantEnv,
4639
+ principalEnv,
4640
+ mode,
4641
+ engine: spec.engine,
4642
+ extraEnv: writeback2.extraEnv
4643
+ }),
4139
4644
  mcpSnippets: mcpSnippets()
4140
4645
  };
4141
4646
  }
@@ -4450,6 +4955,52 @@ function target(spec) {
4450
4955
  ...spec.tenant_key ? { tenant_key: spec.tenant_key } : { single_tenant_dev: Boolean(spec.single_tenant_dev) }
4451
4956
  };
4452
4957
  }
4958
+ function normalizedWriteback(spec) {
4959
+ const executor = spec.writeback?.executor ?? "sql_update";
4960
+ if (executor === "sql_update") {
4961
+ return { executor, extraEnv: [] };
4962
+ }
4963
+ const executorName = spec.writeback?.executor_name ?? `${safeName(spec.namespace)}_${executor === "http_handler" ? "http_handler" : "command_handler"}`;
4964
+ if (executor === "http_handler") {
4965
+ const urlEnv = spec.writeback?.handler_url_env ?? "SYNAPSOR_APP_WRITEBACK_URL";
4966
+ const tokenEnv = spec.writeback?.handler_token_env;
4967
+ const signingSecretEnv = spec.writeback?.handler_signing_secret_env;
4968
+ return {
4969
+ executor,
4970
+ executorName,
4971
+ executors: {
4972
+ [executorName]: {
4973
+ type: "http_handler",
4974
+ url_env: urlEnv,
4975
+ method: "POST",
4976
+ ...tokenEnv ? { auth: { type: "bearer_env", token_env: tokenEnv } } : {},
4977
+ ...signingSecretEnv ? { signing_secret_env: signingSecretEnv } : {},
4978
+ timeout_ms: spec.writeback?.timeout_ms ?? 5e3
4979
+ }
4980
+ },
4981
+ extraEnv: [
4982
+ { name: urlEnv, value: "http://127.0.0.1:8787/synapsor/writeback", comment: "App-owned writeback handler endpoint." },
4983
+ ...tokenEnv ? [{ name: tokenEnv, value: "<handler-bearer-token>", comment: "Optional handler bearer token." }] : [],
4984
+ ...signingSecretEnv ? [{ name: signingSecretEnv, value: "<handler-hmac-signing-secret>", comment: "Optional HMAC signing secret for Runner-to-handler requests." }] : []
4985
+ ]
4986
+ };
4987
+ }
4988
+ const commandEnv = spec.writeback?.handler_command_env ?? "SYNAPSOR_APP_WRITEBACK_COMMAND";
4989
+ return {
4990
+ executor,
4991
+ executorName,
4992
+ executors: {
4993
+ [executorName]: {
4994
+ type: "command_handler",
4995
+ command_env: commandEnv,
4996
+ timeout_ms: spec.writeback?.timeout_ms ?? 5e3
4997
+ }
4998
+ },
4999
+ extraEnv: [
5000
+ { name: commandEnv, value: "node ./examples/app-owned-writeback/command-handler.mjs", comment: "Command receives the structured handler proposal JSON on stdin." }
5001
+ ]
5002
+ };
5003
+ }
4453
5004
  function inferPatchArgs(patch, explicit, numericBounds, transitionGuards) {
4454
5005
  const args = {};
4455
5006
  for (const [column, binding] of Object.entries(patch)) {
@@ -4475,14 +5026,18 @@ function envExample(input) {
4475
5026
  "# Synapsor Runner local environment.",
4476
5027
  "# Replace examples locally. Do not commit real credentials.",
4477
5028
  `${input.readUrlEnv}="${readExample}"`,
4478
- ...input.mode === "review" ? [`${input.writeUrlEnv}="${writeExample}"`] : [],
5029
+ ...input.mode === "review" && input.writeUrlEnv ? [`${input.writeUrlEnv}="${writeExample}"`] : [],
5030
+ ...(input.extraEnv ?? []).flatMap((item) => [
5031
+ ...item.comment ? [`# ${item.comment}`] : [],
5032
+ `${item.name}="${item.value}"`
5033
+ ]),
4479
5034
  `${input.tenantEnv}="acme"`,
4480
5035
  `${input.principalEnv}="local_operator"`,
4481
5036
  ""
4482
5037
  ].join("\n");
4483
5038
  }
4484
5039
  function mcpSnippets() {
4485
- const command = "synapsor";
5040
+ const command = "synapsor-runner";
4486
5041
  const args = ["mcp", "serve", "--config", "./synapsor.runner.json", "--store", "./.synapsor/local.db"];
4487
5042
  return {
4488
5043
  "generic-stdio.json": { command, args },
@@ -4495,6 +5050,16 @@ function validateSelectionSpec(spec) {
4495
5050
  if (spec.version !== void 0 && spec.version !== 1) throw new Error("onboarding selection version must be 1.");
4496
5051
  if (spec.engine !== "postgres" && spec.engine !== "mysql") throw new Error("selection engine must be postgres or mysql.");
4497
5052
  if (!["read_only", "shadow", "review", void 0].includes(spec.mode)) throw new Error("selection mode must be read_only, shadow, or review.");
5053
+ if (spec.result_format !== void 0 && spec.result_format !== 1 && spec.result_format !== 2) throw new Error("selection result_format must be 1 or 2.");
5054
+ if (spec.writeback?.executor && !["sql_update", "http_handler", "command_handler"].includes(spec.writeback.executor)) {
5055
+ throw new Error("selection writeback.executor must be sql_update, http_handler, or command_handler.");
5056
+ }
5057
+ if (spec.mode !== "review" && spec.writeback?.executor && spec.writeback.executor !== "sql_update") {
5058
+ throw new Error("app-owned writeback executors are only valid in review mode.");
5059
+ }
5060
+ if (spec.writeback?.timeout_ms !== void 0 && (!Number.isInteger(spec.writeback.timeout_ms) || spec.writeback.timeout_ms <= 0)) {
5061
+ throw new Error("selection writeback.timeout_ms must be a positive integer.");
5062
+ }
4498
5063
  for (const [label, value] of Object.entries({
4499
5064
  schema: spec.schema,
4500
5065
  table: spec.table,
@@ -4540,6 +5105,7 @@ function validateSelectionSpec(spec) {
4540
5105
  spec.tenant_key,
4541
5106
  spec.conflict_column,
4542
5107
  spec.lookup_arg,
5108
+ spec.writeback?.executor_name,
4543
5109
  ...spec.visible_columns ?? [],
4544
5110
  ...spec.allowed_columns ?? [],
4545
5111
  ...Object.keys(spec.numeric_bounds ?? {}),
@@ -4552,6 +5118,10 @@ function validateSelectionSpec(spec) {
4552
5118
  spec.read_url_env,
4553
5119
  spec.database_url_env,
4554
5120
  spec.write_url_env,
5121
+ spec.writeback?.handler_url_env,
5122
+ spec.writeback?.handler_token_env,
5123
+ spec.writeback?.handler_signing_secret_env,
5124
+ spec.writeback?.handler_command_env,
4555
5125
  spec.trusted_context?.tenant_id_env,
4556
5126
  spec.trusted_context?.principal_env
4557
5127
  ].filter(Boolean)) {
@@ -4585,6 +5155,10 @@ function singularize(value) {
4585
5155
  function safeName(value) {
4586
5156
  return value.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "record";
4587
5157
  }
5158
+ function capitalize(value) {
5159
+ if (!value) return value;
5160
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
5161
+ }
4588
5162
  function assertSafeIdentifier(identifier) {
4589
5163
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
4590
5164
  throw new Error(`unsafe identifier in selection: ${identifier}`);
@@ -6036,43 +6610,248 @@ var dangerousDatabaseMcpAuditExample = {
6036
6610
  var defaultConfigPath = "synapsor.runner.json";
6037
6611
  var defaultStorePath = "./.synapsor/local.db";
6038
6612
  var quickDemoStorePath = "./.synapsor/quick-demo.db";
6039
- var referenceDemoDir = "examples/reference-support-billing-app";
6040
- var referenceDemoConfigPath = `${referenceDemoDir}/synapsor.runner.json`;
6041
- var referenceDemoContainer = "synapsor_runner_reference_support_billing";
6042
- var referenceDemoDatabase = "synapsor_reference_support_billing";
6043
- var referenceDemoEnv = {
6044
- REFERENCE_POSTGRES_READ_URL: "postgresql://synapsor_reader:synapsor_reader_password@localhost:55435/synapsor_reference_support_billing",
6045
- REFERENCE_POSTGRES_WRITE_URL: "postgresql://synapsor_writer:synapsor_writer_password@localhost:55435/synapsor_reference_support_billing",
6046
- SYNAPSOR_TENANT_ID: "acme",
6047
- SYNAPSOR_PRINCIPAL: "local_reviewer",
6048
- SYNAPSOR_ENGINE: "postgres",
6049
- SYNAPSOR_DATABASE_URL: "postgresql://synapsor_writer:synapsor_writer_password@localhost:55435/synapsor_reference_support_billing",
6050
- SYNAPSOR_RUNNER_ID: "synapsor_demo_runner",
6051
- SYNAPSOR_SOURCE_ID: "app_postgres",
6052
- SYNAPSOR_CONTROL_PLANE_URL: "http://127.0.0.1:0",
6053
- SYNAPSOR_RUNNER_TOKEN: "syn_wbr_demo_local"
6054
- };
6055
- async function main(argv) {
6056
- const [command, ...rest] = argv;
6057
- if (!command || command === "--help" || command === "-h") {
6058
- usage([]);
6059
- return 0;
6613
+ var generatedSmokeInputPath = "./.synapsor/smoke-input.json";
6614
+ var handlerSecurityWarning = [
6615
+ "IMPORTANT: your app handler owns the final business write.",
6616
+ "Runner creates the proposal and calls your handler only after approval, but your handler must still enforce:",
6617
+ "- tenant/scope check;",
6618
+ "- expected-version or conflict guard;",
6619
+ "- idempotency key;",
6620
+ "- allowed business action;",
6621
+ "- transaction/rollback;",
6622
+ "- safe error receipt.",
6623
+ "",
6624
+ "If you skip those checks, you can reintroduce cross-tenant writes, lost updates, or duplicate writes.",
6625
+ "Use the generated template/helper pattern and keep handler credentials out of MCP."
6626
+ ].join("\n");
6627
+ var handlerTemplateDefinitions = {
6628
+ "node-fastify": {
6629
+ aliases: ["node", "fastify"],
6630
+ fileName: "synapsor-writeback-handler.mjs",
6631
+ description: "HTTP handler template for a Node/Fastify application service.",
6632
+ content: `import Fastify from "fastify";
6633
+
6634
+ const port = Number(process.env.PORT || 8787);
6635
+ const expectedToken = process.env.SYNAPSOR_APP_WRITEBACK_TOKEN || "dev-handler-token";
6636
+
6637
+ const app = Fastify({ logger: true });
6638
+
6639
+ app.post("/synapsor/writeback", async (request, reply) => {
6640
+ const auth = request.headers.authorization || "";
6641
+ if (auth !== \`Bearer \${expectedToken}\`) {
6642
+ return reply.code(401).send({ status: "failed", safe_error_code: "UNAUTHORIZED" });
6060
6643
  }
6061
- if (!isKnownTopLevelCommand(command)) {
6062
- process2.stderr.write(`Unknown command: ${cliCommandName()} ${command}
6063
6644
 
6064
- Try:
6065
- ${cliCommandName()} --help
6066
- `);
6067
- return 2;
6645
+ const body = request.body || {};
6646
+ const changeSet = body.change_set || {};
6647
+
6648
+ if (!body.proposal_id || !body.idempotency_key || !changeSet.scope?.tenant_id) {
6649
+ return reply.code(400).send({ status: "failed", safe_error_code: "BAD_WRITEBACK_REQUEST" });
6068
6650
  }
6069
- if (isHelpRequest(rest)) {
6070
- usage([command, ...rest.filter((arg) => arg !== "--help" && arg !== "-h")]);
6071
- return 0;
6651
+
6652
+ if (body.dry_run) {
6653
+ return {
6654
+ status: "applied",
6655
+ rows_affected: 0,
6656
+ source_database_mutated: false,
6657
+ details: { dry_run: true },
6658
+ };
6072
6659
  }
6073
- if (command === "help") {
6074
- usage(rest);
6075
- return 0;
6660
+
6661
+ /*
6662
+ * IMPORTANT: your app handler owns the final business write.
6663
+ * Runner creates the proposal and calls your handler only after approval,
6664
+ * but your handler must still enforce tenant/scope, expected-version or
6665
+ * conflict guard, idempotency key, allowed business action,
6666
+ * transaction/rollback, and safe error receipt.
6667
+ *
6668
+ * If you skip those checks, you can reintroduce cross-tenant writes,
6669
+ * lost updates, or duplicate writes. Keep handler credentials out of MCP.
6670
+ *
6671
+ * Put your app-owned transaction here.
6672
+ *
6673
+ * Examples:
6674
+ * - insert a refund_review row;
6675
+ * - insert an account_credit row;
6676
+ * - open a support_ticket row;
6677
+ * - update multiple related rows in one app transaction.
6678
+ *
6679
+ * Re-check tenant/principal authorization, idempotency, row/version guards,
6680
+ * and business policy before mutating application state.
6681
+ */
6682
+
6683
+ return {
6684
+ status: "applied",
6685
+ rows_affected: 1,
6686
+ previous_version: String(changeSet.guards?.expected_version?.value || ""),
6687
+ new_version: new Date().toISOString(),
6688
+ source_database_mutated: true,
6689
+ };
6690
+ });
6691
+
6692
+ app.listen({ host: "127.0.0.1", port });
6693
+ `
6694
+ },
6695
+ "python-fastapi": {
6696
+ aliases: ["python", "fastapi"],
6697
+ fileName: "synapsor_writeback_handler.py",
6698
+ description: "HTTP handler template for a Python/FastAPI application service.",
6699
+ content: `import os
6700
+ from datetime import datetime, timezone
6701
+
6702
+ from fastapi import FastAPI, Header, HTTPException
6703
+
6704
+ app = FastAPI()
6705
+ expected_token = os.getenv("SYNAPSOR_APP_WRITEBACK_TOKEN", "dev-handler-token")
6706
+
6707
+
6708
+ @app.post("/synapsor/writeback")
6709
+ async def synapsor_writeback(body: dict, authorization: str | None = Header(default=None)):
6710
+ if authorization != f"Bearer {expected_token}":
6711
+ raise HTTPException(status_code=401, detail={"status": "failed", "safe_error_code": "UNAUTHORIZED"})
6712
+
6713
+ change_set = body.get("change_set") or {}
6714
+ scope = change_set.get("scope") or {}
6715
+ if not body.get("proposal_id") or not body.get("idempotency_key") or not scope.get("tenant_id"):
6716
+ raise HTTPException(status_code=400, detail={"status": "failed", "safe_error_code": "BAD_WRITEBACK_REQUEST"})
6717
+
6718
+ if body.get("dry_run"):
6719
+ return {
6720
+ "status": "applied",
6721
+ "rows_affected": 0,
6722
+ "source_database_mutated": False,
6723
+ "details": {"dry_run": True},
6724
+ }
6725
+
6726
+ # Put your app-owned transaction here.
6727
+ #
6728
+ # IMPORTANT: your app handler owns the final business write.
6729
+ # Runner creates the proposal and calls your handler only after approval,
6730
+ # but your handler must still enforce tenant/scope, expected-version or
6731
+ # conflict guard, idempotency key, allowed business action,
6732
+ # transaction/rollback, and safe error receipt.
6733
+ #
6734
+ # If you skip those checks, you can reintroduce cross-tenant writes,
6735
+ # lost updates, or duplicate writes. Keep handler credentials out of MCP.
6736
+ #
6737
+ # Examples:
6738
+ # - insert a refund_review row;
6739
+ # - insert an account_credit row;
6740
+ # - open a support_ticket row;
6741
+ # - update multiple related rows in one app transaction.
6742
+ #
6743
+ # Re-check tenant/principal authorization, idempotency, row/version guards,
6744
+ # and business policy before mutating application state.
6745
+
6746
+ expected_version = ((change_set.get("guards") or {}).get("expected_version") or {}).get("value", "")
6747
+ return {
6748
+ "status": "applied",
6749
+ "rows_affected": 1,
6750
+ "previous_version": str(expected_version),
6751
+ "new_version": datetime.now(timezone.utc).isoformat(),
6752
+ "source_database_mutated": True,
6753
+ }
6754
+ `
6755
+ },
6756
+ command: {
6757
+ aliases: ["script", "local-command"],
6758
+ fileName: "synapsor-command-handler.mjs",
6759
+ description: "Local command handler template for scripts or job runners.",
6760
+ content: `#!/usr/bin/env node
6761
+
6762
+ const chunks = [];
6763
+ for await (const chunk of process.stdin) chunks.push(chunk);
6764
+
6765
+ const request = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
6766
+ const changeSet = request.change_set || {};
6767
+
6768
+ if (!request.proposal_id || !request.idempotency_key || !changeSet.scope?.tenant_id) {
6769
+ process.stdout.write(JSON.stringify({
6770
+ status: "failed",
6771
+ safe_error_code: "BAD_WRITEBACK_REQUEST",
6772
+ source_database_mutated: false,
6773
+ }));
6774
+ process.exit(0);
6775
+ }
6776
+
6777
+ if (request.dry_run) {
6778
+ process.stdout.write(JSON.stringify({
6779
+ status: "applied",
6780
+ rows_affected: 0,
6781
+ source_database_mutated: false,
6782
+ details: { dry_run: true },
6783
+ }));
6784
+ process.exit(0);
6785
+ }
6786
+
6787
+ /*
6788
+ * IMPORTANT: your app handler owns the final business write.
6789
+ * Runner creates the proposal and calls your handler only after approval,
6790
+ * but your handler must still enforce tenant/scope, expected-version or
6791
+ * conflict guard, idempotency key, allowed business action,
6792
+ * transaction/rollback, and safe error receipt.
6793
+ *
6794
+ * If you skip those checks, you can reintroduce cross-tenant writes,
6795
+ * lost updates, or duplicate writes. Keep handler credentials out of MCP.
6796
+ *
6797
+ * Put your app-owned command transaction here.
6798
+ *
6799
+ * Examples:
6800
+ * - call an internal service;
6801
+ * - enqueue a review job;
6802
+ * - run an app script that uses your normal ORM.
6803
+ *
6804
+ * Re-check tenant/principal authorization, idempotency, row/version guards,
6805
+ * and business policy before mutating application state.
6806
+ */
6807
+
6808
+ process.stdout.write(JSON.stringify({
6809
+ status: "applied",
6810
+ rows_affected: 1,
6811
+ previous_version: String(changeSet.guards?.expected_version?.value || ""),
6812
+ new_version: new Date().toISOString(),
6813
+ source_database_mutated: true,
6814
+ }));
6815
+ `
6816
+ }
6817
+ };
6818
+ var referenceDemoDir = "examples/reference-support-billing-app";
6819
+ var referenceDemoConfigPath = `${referenceDemoDir}/synapsor.runner.json`;
6820
+ var referenceDemoContainer = "synapsor_runner_reference_support_billing";
6821
+ var referenceDemoDatabase = "synapsor_reference_support_billing";
6822
+ var referenceDemoEnv = {
6823
+ REFERENCE_POSTGRES_READ_URL: "postgresql://synapsor_reader:synapsor_reader_password@localhost:55435/synapsor_reference_support_billing",
6824
+ REFERENCE_POSTGRES_WRITE_URL: "postgresql://synapsor_writer:synapsor_writer_password@localhost:55435/synapsor_reference_support_billing",
6825
+ SYNAPSOR_TENANT_ID: "acme",
6826
+ SYNAPSOR_PRINCIPAL: "local_reviewer",
6827
+ SYNAPSOR_ENGINE: "postgres",
6828
+ SYNAPSOR_DATABASE_URL: "postgresql://synapsor_writer:synapsor_writer_password@localhost:55435/synapsor_reference_support_billing",
6829
+ SYNAPSOR_RUNNER_ID: "synapsor_demo_runner",
6830
+ SYNAPSOR_SOURCE_ID: "app_postgres",
6831
+ SYNAPSOR_CONTROL_PLANE_URL: "http://127.0.0.1:0",
6832
+ SYNAPSOR_RUNNER_TOKEN: "syn_wbr_demo_local"
6833
+ };
6834
+ async function main(argv) {
6835
+ const [command, ...rest] = argv;
6836
+ if (!command || command === "--help" || command === "-h") {
6837
+ usage([]);
6838
+ return 0;
6839
+ }
6840
+ if (!isKnownTopLevelCommand(command)) {
6841
+ process2.stderr.write(`Unknown command: ${cliCommandName()} ${command}
6842
+
6843
+ Try:
6844
+ ${cliCommandName()} --help
6845
+ `);
6846
+ return 2;
6847
+ }
6848
+ if (isHelpRequest(rest)) {
6849
+ usage([command, ...rest.filter((arg) => arg !== "--help" && arg !== "-h")]);
6850
+ return 0;
6851
+ }
6852
+ if (command === "help") {
6853
+ usage(rest);
6854
+ return 0;
6076
6855
  }
6077
6856
  if (command === "init") return init(rest);
6078
6857
  if (command === "inspect") return inspect(rest);
@@ -6082,11 +6861,15 @@ ${cliCommandName()} --help
6082
6861
  if (command === "apply") return apply(rest);
6083
6862
  if (command === "propose") return propose(rest);
6084
6863
  if (command === "audit") return audit(rest);
6085
- if (command === "start") return start();
6864
+ if (command === "start") return start(rest);
6865
+ if (command === "up") return up(rest);
6086
6866
  if (command === "runner") return runnerCommand(rest);
6087
6867
  if (command === "cloud") return cloud(rest);
6088
6868
  if (command === "mcp") return mcp(rest);
6869
+ if (command === "smoke") return smoke(rest);
6089
6870
  if (command === "tools") return tools(rest);
6871
+ if (command === "writeback") return writeback(rest);
6872
+ if (command === "handler") return handler(rest);
6090
6873
  if (command === "onboard") return onboard(rest);
6091
6874
  if (command === "demo") return demo(rest);
6092
6875
  if (command === "recipes") return recipes(rest);
@@ -6097,6 +6880,7 @@ ${cliCommandName()} --help
6097
6880
  if (command === "query-audit") return queryAudit(rest);
6098
6881
  if (command === "receipts") return receipts(rest);
6099
6882
  if (command === "activity") return activity(rest);
6883
+ if (command === "events") return events(rest);
6100
6884
  if (command === "store") return storeCommand(rest);
6101
6885
  if (command === "shadow") return shadow(rest);
6102
6886
  if (command === "ui") return ui(rest);
@@ -6108,11 +6892,16 @@ ${cliCommandName()} --help
6108
6892
  return 2;
6109
6893
  }
6110
6894
  async function init(args) {
6895
+ const answersPath = optionalArg(args, "--answers");
6896
+ if (answersPath) {
6897
+ return initFromAnswers(args, answersPath);
6898
+ }
6111
6899
  const specPath = optionalArg(args, "--spec");
6112
6900
  if (specPath) {
6113
6901
  return initFromSpec(args, specPath);
6114
6902
  }
6115
- if (args.includes("--wizard") || process2.stdin.isTTY && process2.stdout.isTTY && !args.includes("--starter")) {
6903
+ const scripted = isScriptedOnboardingArgs(args);
6904
+ if (args.includes("--wizard") || process2.stdin.isTTY && process2.stdout.isTTY && !args.includes("--starter") && !scripted) {
6116
6905
  return runInitWizard(args);
6117
6906
  }
6118
6907
  const inspectionJson = optionalArg(args, "--inspection-json");
@@ -6278,12 +7067,94 @@ async function runInitWizard(args, options = {}) {
6278
7067
  if (transitionFromColumns.length > 0) ensureColumnsExist(transitionFromColumns, columns, "transition from");
6279
7068
  }
6280
7069
  }
6281
- const namespace = await askDefault(ask, "Capability namespace", optionalArg(args, "--namespace") ?? recipeSpec?.namespace ?? "source");
6282
- const objectName = await askDefault(ask, "Business object name", optionalArg(args, "--object-name") ?? recipeSpec?.object_name ?? safeObjectName(table.name));
7070
+ const inferredObjectName = recipeSpec?.object_name ?? safeObjectName(table.name);
7071
+ const namespace = await askDefault(ask, "Capability namespace", optionalArg(args, "--namespace") ?? recipeSpec?.namespace ?? inferCapabilityNamespace(table.name));
7072
+ const objectName = await askDefault(ask, "Business object name", optionalArg(args, "--object-name") ?? inferredObjectName);
6283
7073
  const lookupArg = await askDefault(ask, "Model-visible object id argument", optionalArg(args, "--lookup-arg") ?? recipeSpec?.lookup_arg ?? `${objectName}_id`);
6284
- const writeUrlEnv = mode === "review" ? await askEnvName(ask, "Write URL env var for trusted apply path", optionalArg(args, "--write-url-env") ?? "SYNAPSOR_DATABASE_WRITE_URL") : optionalArg(args, "--write-url-env") ?? "SYNAPSOR_DATABASE_WRITE_URL";
7074
+ const defaultInspectToolName = recipeSpec?.inspect_tool_name ?? `${namespace}.inspect_${objectName}`;
7075
+ const inspectToolName = await askDefault(
7076
+ ask,
7077
+ "Read capability name",
7078
+ optionalArg(args, "--read-tool") ?? optionalArg(args, "--inspect-tool-name") ?? defaultInspectToolName
7079
+ );
7080
+ const defaultProposalToolName = recipeSpec?.proposal_tool_name ?? `${namespace}.propose_${objectName}_update`;
7081
+ const proposalToolName = mode === "read_only" ? void 0 : await askDefault(
7082
+ ask,
7083
+ "Proposal capability name",
7084
+ optionalArg(args, "--proposal-tool") ?? optionalArg(args, "--proposal-tool-name") ?? defaultProposalToolName
7085
+ );
7086
+ const smokeObjectId = await askDefault(ask, "Optional real object id for a first smoke call", optionalArg(args, "--smoke-id") ?? "");
7087
+ const objectLabel = objectName.replace(/_/g, " ");
7088
+ const inspectDescription = await askDefault(
7089
+ ask,
7090
+ "Read capability description",
7091
+ optionalArg(args, "--inspect-description") ?? `Inspect one ${objectLabel} in trusted tenant scope before answering or proposing a change.`
7092
+ );
7093
+ const inspectReturnsHint = await askDefault(
7094
+ ask,
7095
+ "Read capability returns hint",
7096
+ optionalArg(args, "--inspect-returns-hint") ?? `Returns reviewed ${objectLabel} fields, evidence handle, query audit, and source_database_changed:false.`
7097
+ );
7098
+ const proposalDescription = mode === "read_only" ? void 0 : await askDefault(
7099
+ ask,
7100
+ "Proposal capability description",
7101
+ optionalArg(args, "--proposal-description") ?? `Create a review-required proposal to update one ${objectLabel}. The source database remains unchanged until approval and writeback.`
7102
+ );
7103
+ const proposalReturnsHint = mode === "read_only" ? void 0 : await askDefault(
7104
+ ask,
7105
+ "Proposal capability returns hint",
7106
+ optionalArg(args, "--proposal-returns-hint") ?? "Returns a proposal id, exact before/after diff, evidence handle, approval status, and source_database_changed:false."
7107
+ );
7108
+ const resultFormatAnswer = await askChoice(ask, "MCP result envelope", optionalArg(args, "--result-format") ? normalizeResultFormatAnswer(optionalArg(args, "--result-format")) : "v2", ["v2", "v1", "default"]);
7109
+ const resultFormat = resultFormatAnswer === "v1" ? 1 : resultFormatAnswer === "v2" ? 2 : void 0;
7110
+ let writeUrlEnv = optionalArg(args, "--write-url-env");
7111
+ let writeback2;
7112
+ let generatedHandlerTemplate;
7113
+ if (mode === "review") {
7114
+ const writebackPath = await askChoice(
7115
+ ask,
7116
+ "Writeback path",
7117
+ optionalArg(args, "--writeback") ?? "sql_update",
7118
+ ["sql_update", "http_handler", "command_handler"]
7119
+ );
7120
+ if (writebackPath === "sql_update") {
7121
+ writeUrlEnv = await askEnvName(ask, "Write URL env var for trusted direct SQL apply", writeUrlEnv ?? "SYNAPSOR_DATABASE_WRITE_URL");
7122
+ writeback2 = { executor: "sql_update" };
7123
+ } else if (writebackPath === "http_handler") {
7124
+ const urlEnv = await askEnvName(ask, "App-owned HTTP handler URL env var", optionalArg(args, "--handler-url-env") ?? "SYNAPSOR_APP_WRITEBACK_URL");
7125
+ const tokenEnv = await askOptionalEnvName(ask, "Optional HTTP handler bearer-token env var", optionalArg(args, "--handler-token-env") ?? "");
7126
+ const signingSecretEnv = await askOptionalEnvName(ask, "Optional HTTP handler HMAC signing-secret env var", optionalArg(args, "--handler-signing-secret-env") ?? "");
7127
+ writeback2 = {
7128
+ executor: "http_handler",
7129
+ executor_name: optionalArg(args, "--executor-name"),
7130
+ handler_url_env: urlEnv,
7131
+ ...tokenEnv ? { handler_token_env: tokenEnv } : {},
7132
+ ...signingSecretEnv ? { handler_signing_secret_env: signingSecretEnv } : {},
7133
+ timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
7134
+ };
7135
+ const writeTemplate = await askChoice(ask, "Write starter app-owned handler template", args.includes("--skip-handler-template") ? "no" : "yes", ["yes", "no"]);
7136
+ if (writeTemplate === "yes") {
7137
+ const template = await askChoice(ask, "Handler template", optionalArg(args, "--handler-template") ?? "node-fastify", ["node-fastify", "python-fastapi"]);
7138
+ const output = await askDefault(ask, "Handler template output", optionalArg(args, "--handler-output") ?? optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions[template].fileName);
7139
+ generatedHandlerTemplate = { name: template, output };
7140
+ }
7141
+ } else {
7142
+ const commandEnv = await askEnvName(ask, "App-owned command handler env var", optionalArg(args, "--handler-command-env") ?? "SYNAPSOR_APP_WRITEBACK_COMMAND");
7143
+ writeback2 = {
7144
+ executor: "command_handler",
7145
+ executor_name: optionalArg(args, "--executor-name"),
7146
+ handler_command_env: commandEnv,
7147
+ timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
7148
+ };
7149
+ const writeTemplate = await askChoice(ask, "Write starter app-owned handler template", args.includes("--skip-handler-template") ? "no" : "yes", ["yes", "no"]);
7150
+ if (writeTemplate === "yes") {
7151
+ const output = await askDefault(ask, "Handler template output", optionalArg(args, "--handler-output") ?? optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions.command.fileName);
7152
+ generatedHandlerTemplate = { name: "command", output };
7153
+ }
7154
+ }
7155
+ }
6285
7156
  const approvalRole = mode === "read_only" ? "local_reviewer" : await askDefault(ask, "Required approval role", optionalArg(args, "--approval-role") ?? recipeSpec?.approval?.required_role ?? "local_reviewer");
6286
- const spec = {
7157
+ let spec = {
6287
7158
  version: 1,
6288
7159
  engine: inspection.engine,
6289
7160
  mode,
@@ -6298,9 +7169,14 @@ async function runInitWizard(args, options = {}) {
6298
7169
  conflict_column: conflictAnswer || void 0,
6299
7170
  namespace,
6300
7171
  object_name: objectName,
6301
- inspect_tool_name: recipeSpec?.inspect_tool_name,
6302
- proposal_tool_name: recipeSpec?.proposal_tool_name,
7172
+ inspect_tool_name: inspectToolName,
7173
+ proposal_tool_name: proposalToolName,
7174
+ inspect_description: inspectDescription,
7175
+ inspect_returns_hint: inspectReturnsHint,
7176
+ proposal_description: proposalDescription,
7177
+ proposal_returns_hint: proposalReturnsHint,
6303
7178
  lookup_arg: lookupArg,
7179
+ result_format: resultFormat,
6304
7180
  visible_columns: visibleColumns,
6305
7181
  allowed_columns: allowedColumns,
6306
7182
  patch,
@@ -6313,27 +7189,115 @@ async function runInitWizard(args, options = {}) {
6313
7189
  },
6314
7190
  approval: {
6315
7191
  required_role: approvalRole
6316
- }
7192
+ },
7193
+ writeback: writeback2
6317
7194
  };
6318
- const generated = generateRunnerConfigFromSpec(spec);
6319
- const tools2 = generated.config.capabilities.map((capability) => `${capability.name} (${capability.kind})`);
7195
+ let generated = generateRunnerConfigFromSpec(spec);
6320
7196
  stdout.write("\nPreview:\n");
6321
- stdout.write(` trusted context: tenant from ${tenantEnv}${singleTenantDev ? " (single-tenant dev source)" : tenantAnswer ? ` via ${tenantAnswer}` : ""}; principal from ${principalEnv}
7197
+ printWizardContractPreview(stdout, { spec, generated, engine: inspection.engine, table });
7198
+ if (generatedHandlerTemplate) {
7199
+ stdout.write(` handler template: ${generatedHandlerTemplate.output}
6322
7200
  `);
6323
- stdout.write(` source: ${inspection.engine} ${table.schema}.${table.name}
7201
+ stdout.write(`${handlerSecurityWarning}
6324
7202
  `);
6325
- stdout.write(` mode: ${mode}
6326
- `);
6327
- stdout.write(` exposed tools: ${tools2.join(", ")}
6328
- `);
6329
- stdout.write(" not exposed: execute_sql, approval tools, commit tools, database URLs, write credentials, model-controlled tenant authority\n");
7203
+ }
7204
+ const editPreview = await askDefault(ask, "Edit visible fields or capability names before writing? Type yes to edit", "no");
7205
+ if (editPreview.toLowerCase() === "yes") {
7206
+ const updatedVisible = parseColumnList(await askDefault(
7207
+ ask,
7208
+ "Final visible columns",
7209
+ spec.visible_columns?.join(",") ?? visibleColumns.join(",")
7210
+ ));
7211
+ ensureColumnsExist(updatedVisible, columns, "visible");
7212
+ const currentReadTool = spec.inspect_tool_name ?? generated.config.capabilities.find((capability) => capability.kind === "read")?.name ?? inspectToolName;
7213
+ const updatedReadTool = await askDefault(ask, "Final read capability name", currentReadTool);
7214
+ const currentProposalTool = spec.proposal_tool_name ?? generated.config.capabilities.find((capability) => capability.kind === "proposal")?.name ?? proposalToolName ?? "";
7215
+ const updatedProposalTool = spec.mode === "read_only" ? void 0 : await askDefault(ask, "Final proposal capability name", currentProposalTool);
7216
+ spec = {
7217
+ ...spec,
7218
+ visible_columns: updatedVisible,
7219
+ inspect_tool_name: updatedReadTool,
7220
+ proposal_tool_name: updatedProposalTool
7221
+ };
7222
+ generated = generateRunnerConfigFromSpec(spec);
7223
+ stdout.write("\nUpdated preview:\n");
7224
+ printWizardContractPreview(stdout, { spec, generated, engine: inspection.engine, table });
7225
+ }
7226
+ const generatedCapabilities = generated.config.capabilities;
7227
+ const smokeToolName = generatedCapabilities[0]?.name ?? "<inspect_tool>";
6330
7228
  const confirmed = await askDefault(ask, "Write generated config and MCP snippets? Type yes to continue", "no");
6331
7229
  if (confirmed.toLowerCase() !== "yes") throw new Error("guided init canceled before writing files");
6332
- await writeGeneratedOnboardingFiles(outputArg(args) ?? "synapsor.runner.json", generated, args.includes("--force"));
6333
- stdout.write(`Next: run \`${cliCommandName()} doctor --config synapsor.runner.json\`, then \`${cliCommandName()} mcp serve --config synapsor.runner.json --store ./.synapsor/local.db\`.
7230
+ const outputPath = outputArg(args) ?? "synapsor.runner.json";
7231
+ await writeGeneratedOnboardingFiles(outputPath, generated, args.includes("--force"), { printNext: false });
7232
+ if (generatedHandlerTemplate) {
7233
+ await writeHandlerTemplateFile(generatedHandlerTemplate.name, generatedHandlerTemplate.output, args.includes("--force"));
7234
+ stdout.write(`created ${generatedHandlerTemplate.output}
7235
+ `);
7236
+ }
7237
+ if (smokeObjectId) {
7238
+ await writeGeneratedSmokeInputFile(lookupArg, smokeObjectId, args.includes("--force"));
7239
+ stdout.write(`created ${generatedSmokeInputPath}
7240
+ `);
7241
+ const smoke2 = await maybeRunGeneratedSmokeCall({
7242
+ config: generated.config,
7243
+ env: options.env ?? process2.env,
7244
+ input: { [lookupArg]: smokeObjectId },
7245
+ readUrlEnv: configDatabaseUrlEnv,
7246
+ tenantEnv,
7247
+ principalEnv,
7248
+ readRow: options.readRow,
7249
+ storePath: defaultStorePath,
7250
+ toolName: smokeToolName
7251
+ });
7252
+ stdout.write(smoke2);
7253
+ }
7254
+ stdout.write("Next:\n");
7255
+ stdout.write(` 1. Set trusted env vars from .env.example, then run: ${cliCommandName()} doctor --config ${outputPath}
7256
+ `);
7257
+ if (smokeObjectId) {
7258
+ stdout.write(` 2. Smoke-call the read capability: ${cliCommandName()} smoke call ${smokeToolName} --input ${generatedSmokeInputPath} --config ${outputPath} --store ${defaultStorePath}
7259
+ `);
7260
+ } else {
7261
+ stdout.write(` 2. Smoke-call a real row: ${cliCommandName()} smoke call ${smokeToolName} --json '{"${lookupArg}":"<real_id>"}' --config ${outputPath} --store ${defaultStorePath}
7262
+ `);
7263
+ }
7264
+ stdout.write(` 3. Serve MCP tools: ${cliCommandName()} mcp serve --config ${outputPath} --store ${defaultStorePath}
7265
+ `);
7266
+ stdout.write(` OpenAI Agents SDK: use ${cliCommandName()} mcp serve-streamable-http --config ${outputPath} --store ${defaultStorePath} --alias-mode openai
6334
7267
  `);
6335
7268
  return 0;
6336
7269
  }
7270
+ function printWizardContractPreview(stdout, input) {
7271
+ const capabilities = input.generated.config.capabilities;
7272
+ const tools2 = capabilities.map((capability) => `${capability.name} (${capability.kind})`);
7273
+ const readCapability = capabilities.find((capability) => capability.kind === "read")?.name ?? input.spec.inspect_tool_name ?? "<read_tool>";
7274
+ const proposalCapability = capabilities.find((capability) => capability.kind === "proposal")?.name ?? input.spec.proposal_tool_name;
7275
+ const visibleColumns = input.spec.visible_columns ?? [];
7276
+ const tenantEnv = input.spec.trusted_context?.tenant_id_env ?? "SYNAPSOR_TENANT_ID";
7277
+ const principalEnv = input.spec.trusted_context?.principal_env ?? "SYNAPSOR_PRINCIPAL";
7278
+ const visiblePreview = visibleColumns.length <= 12 ? visibleColumns.join(", ") : `${visibleColumns.slice(0, 12).join(", ")} (+${visibleColumns.length - 12} more)`;
7279
+ stdout.write(` trusted context: tenant from ${tenantEnv}${input.spec.single_tenant_dev ? " (single-tenant dev source)" : input.spec.tenant_key ? ` via ${input.spec.tenant_key}` : ""}; principal from ${principalEnv}
7280
+ `);
7281
+ stdout.write(` source: ${input.engine} ${input.table.schema}.${input.table.name}
7282
+ `);
7283
+ stdout.write(` primary key: ${input.spec.primary_key}${input.spec.conflict_column ? `; conflict guard: ${input.spec.conflict_column}` : ""}
7284
+ `);
7285
+ stdout.write(` visible fields: ${visiblePreview || "none"}
7286
+ `);
7287
+ stdout.write(` mode: ${input.spec.mode}
7288
+ `);
7289
+ stdout.write(` result envelope: ${input.spec.result_format ? `v${input.spec.result_format}` : "default"}
7290
+ `);
7291
+ stdout.write(` writeback path: ${input.spec.writeback?.executor ?? (input.spec.mode === "review" ? "sql_update" : "none")}
7292
+ `);
7293
+ stdout.write(` read capability: ${readCapability}
7294
+ `);
7295
+ if (proposalCapability) stdout.write(` proposal capability: ${proposalCapability}
7296
+ `);
7297
+ stdout.write(` exposed tools: ${tools2.join(", ")}
7298
+ `);
7299
+ stdout.write(" not exposed: execute_sql, approval tools, commit tools, database URLs, write credentials, model-controlled tenant authority\n");
7300
+ }
6337
7301
  async function initFromSpec(args, specPath) {
6338
7302
  if (!args.includes("--non-interactive")) {
6339
7303
  throw new Error("init --spec requires --non-interactive so reviewed selections are explicit.");
@@ -6342,9 +7306,123 @@ async function initFromSpec(args, specPath) {
6342
7306
  const force = args.includes("--force");
6343
7307
  const spec = JSON.parse(await fs3.readFile(specPath, "utf8"));
6344
7308
  const generated = generateRunnerConfigFromSpec(spec);
7309
+ if (args.includes("--dry-run")) {
7310
+ process2.stdout.write(`${JSON.stringify(generated.config, null, 2)}
7311
+ `);
7312
+ return 0;
7313
+ }
6345
7314
  await writeGeneratedOnboardingFiles(output, generated, force);
6346
7315
  return 0;
6347
7316
  }
7317
+ async function initFromAnswers(args, answersPath) {
7318
+ const output = outputArg(args) ?? "synapsor.runner.json";
7319
+ const force = args.includes("--force");
7320
+ const raw = JSON.parse(await fs3.readFile(answersPath, "utf8"));
7321
+ const spec = answersToSelectionSpec(raw);
7322
+ const generated = generateRunnerConfigFromSpec(spec);
7323
+ if (args.includes("--dry-run")) {
7324
+ process2.stdout.write(`${JSON.stringify(generated.config, null, 2)}
7325
+ `);
7326
+ return 0;
7327
+ }
7328
+ await writeGeneratedOnboardingFiles(output, generated, force);
7329
+ await maybeWriteHandlerTemplateForArgs(args, spec.writeback);
7330
+ return 0;
7331
+ }
7332
+ function isScriptedOnboardingArgs(args) {
7333
+ return args.includes("--yes") || args.includes("--non-interactive") || args.includes("--dry-run") || Boolean(optionalArg(args, "--answers")) || Boolean(optionalArg(args, "--inspection-json")) || Boolean(optionalArg(args, "--table"));
7334
+ }
7335
+ function answersToSelectionSpec(raw) {
7336
+ if (!isRecord6(raw)) throw new Error("--answers file must contain a JSON object");
7337
+ const mode = stringValue(raw.mode) ?? "review";
7338
+ if (!["read_only", "shadow", "review"].includes(mode)) throw new Error("answers.mode must be read_only, shadow, or review");
7339
+ const engine = stringValue(raw.engine) ?? "postgres";
7340
+ if (engine !== "postgres" && engine !== "mysql") throw new Error("answers.engine must be postgres or mysql");
7341
+ const table = requiredAnswerString(raw.table, "table");
7342
+ const objectName = stringValue(raw.object_name) ?? safeObjectName(table);
7343
+ const namespace = stringValue(raw.namespace) ?? inferCapabilityNamespace(table);
7344
+ const writebackRaw = stringValue(raw.writeback) ?? "sql_update";
7345
+ if (!["sql_update", "http_handler", "command_handler"].includes(writebackRaw)) throw new Error("answers.writeback must be sql_update, http_handler, or command_handler");
7346
+ const writeback2 = writebackRaw === "sql_update" ? { executor: "sql_update" } : writebackRaw === "http_handler" ? {
7347
+ executor: "http_handler",
7348
+ handler_url_env: stringValue(raw.handler_url_env) ?? "SYNAPSOR_APP_WRITEBACK_URL",
7349
+ ...stringValue(raw.handler_token_env) ? { handler_token_env: stringValue(raw.handler_token_env) } : {},
7350
+ ...stringValue(raw.handler_signing_secret_env) ? { handler_signing_secret_env: stringValue(raw.handler_signing_secret_env) } : {}
7351
+ } : {
7352
+ executor: "command_handler",
7353
+ handler_command_env: stringValue(raw.handler_command_env) ?? "SYNAPSOR_APP_WRITEBACK_COMMAND"
7354
+ };
7355
+ const patch = parsePatchBindings(arrayOrStringList(raw.patch), "--answers.patch");
7356
+ const allowedColumns = arrayOrStringList(raw.allowed_columns);
7357
+ return {
7358
+ version: 1,
7359
+ engine,
7360
+ mode,
7361
+ source_name: stringValue(raw.source_name),
7362
+ read_url_env: stringValue(raw.read_url_env) ?? stringValue(raw.database_url_env) ?? "DATABASE_URL",
7363
+ write_url_env: writeback2.executor === "sql_update" ? stringValue(raw.write_url_env) ?? "SYNAPSOR_DATABASE_WRITE_URL" : stringValue(raw.write_url_env),
7364
+ schema: requiredAnswerString(raw.schema, "schema"),
7365
+ table,
7366
+ primary_key: requiredAnswerString(raw.primary_key, "primary_key"),
7367
+ tenant_key: stringValue(raw.tenant_column) ?? stringValue(raw.tenant_key),
7368
+ single_tenant_dev: raw.single_tenant_dev === true,
7369
+ conflict_column: stringValue(raw.conflict_column),
7370
+ namespace,
7371
+ object_name: objectName,
7372
+ inspect_tool_name: stringValue(raw.read_tool) ?? stringValue(raw.inspect_tool_name),
7373
+ proposal_tool_name: stringValue(raw.proposal_tool) ?? stringValue(raw.proposal_tool_name),
7374
+ lookup_arg: stringValue(raw.id_arg) ?? stringValue(raw.lookup_arg),
7375
+ inspect_description: stringValue(raw.read_description) ?? stringValue(raw.inspect_description),
7376
+ inspect_returns_hint: stringValue(raw.read_returns_hint) ?? stringValue(raw.inspect_returns_hint),
7377
+ proposal_description: stringValue(raw.proposal_description),
7378
+ proposal_returns_hint: stringValue(raw.proposal_returns_hint),
7379
+ result_format: resultFormatFromAnswerValue(raw.result_format),
7380
+ visible_columns: arrayOrStringList(raw.visible_columns),
7381
+ allowed_columns: allowedColumns.length > 0 ? allowedColumns : void 0,
7382
+ patch,
7383
+ numeric_bounds: parseNumericBoundsInput(arrayOrStringList(raw.patch_bounds ?? raw.numeric_bounds).join(",")),
7384
+ transition_guards: parseTransitionGuardsInput(arrayOrStringList(raw.status_guards ?? raw.transition_guards).join(",")),
7385
+ trusted_context: {
7386
+ tenant_id_env: stringValue(raw.tenant_env) ?? "SYNAPSOR_TENANT_ID",
7387
+ principal_env: stringValue(raw.principal_env) ?? "SYNAPSOR_PRINCIPAL"
7388
+ },
7389
+ approval: {
7390
+ required_role: stringValue(raw.approval_role) ?? "local_reviewer"
7391
+ },
7392
+ writeback: writeback2
7393
+ };
7394
+ }
7395
+ async function maybeWriteHandlerTemplateForArgs(args, writeback2) {
7396
+ if (!writeback2 || writeback2.executor === "sql_update" || args.includes("--no-emit-handler") || args.includes("--skip-handler-template")) return;
7397
+ if (!args.includes("--emit-handler") && !optionalArg(args, "--handler-template") && !optionalArg(args, "--handler-output") && !optionalArg(args, "--handler-template-output")) return;
7398
+ const defaultTemplate = writeback2.executor === "command_handler" ? "command" : "node-fastify";
7399
+ const template = resolveHandlerTemplateName(optionalArg(args, "--handler-template") ?? defaultTemplate);
7400
+ const output = optionalArg(args, "--handler-output") ?? optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions[template].fileName;
7401
+ await writeHandlerTemplateFile(template, output, args.includes("--force"));
7402
+ process2.stdout.write(`created ${output}
7403
+ `);
7404
+ process2.stdout.write(`${handlerSecurityWarning}
7405
+ `);
7406
+ }
7407
+ function stringValue(value) {
7408
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
7409
+ }
7410
+ function requiredAnswerString(value, field) {
7411
+ const result = stringValue(value);
7412
+ if (!result) throw new Error(`--answers missing ${field}`);
7413
+ return result;
7414
+ }
7415
+ function arrayOrStringList(value) {
7416
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
7417
+ if (typeof value === "string") return value.split(",").map((item) => item.trim()).filter(Boolean);
7418
+ return [];
7419
+ }
7420
+ function resultFormatFromAnswerValue(value) {
7421
+ if (value === void 0 || value === null || value === "" || value === "default") return void 0;
7422
+ if (value === 1 || value === "1" || value === "v1") return 1;
7423
+ if (value === 2 || value === "2" || value === "v2") return 2;
7424
+ throw new Error("result_format must be default, v1, v2, 1, or 2");
7425
+ }
6348
7426
  async function initFromInspection(args, inspection, databaseUrlEnv) {
6349
7427
  const tableName = optionalArg(args, "--table");
6350
7428
  if (!tableName) {
@@ -6368,7 +7446,7 @@ async function initFromInspection(args, inspection, databaseUrlEnv) {
6368
7446
  process2.stderr.write(`warning: no database primary-key constraint detected for ${table.schema}.${table.name}; using candidate column ${primaryKey}. Verify uniqueness before enabling writeback.
6369
7447
  `);
6370
7448
  }
6371
- const tenantKey = optionalArg(args, "--tenant-key") ?? table.suggestions.tenant_columns[0];
7449
+ const tenantKey = optionalArg(args, "--tenant-column") ?? optionalArg(args, "--tenant-key") ?? table.suggestions.tenant_columns[0];
6372
7450
  const singleTenantDev = args.includes("--single-tenant-dev");
6373
7451
  if (!tenantKey && !singleTenantDev) {
6374
7452
  throw new Error(`--tenant-key is required for ${table.schema}.${table.name}, or pass --single-tenant-dev for a reviewed single-tenant dev source.`);
@@ -6389,22 +7467,33 @@ async function initFromInspection(args, inspection, databaseUrlEnv) {
6389
7467
  const numericBounds = parseNumericBoundsFlags(args);
6390
7468
  const transitionGuards = parseTransitionGuardFlags(args);
6391
7469
  const allowedColumns = listArg(args, "--allowed-columns") ?? Object.keys(patch);
7470
+ const writeback2 = writebackSpecFromArgs(args);
7471
+ const sqlWriteback = (writeback2?.executor ?? "sql_update") === "sql_update";
7472
+ const objectName = optionalArg(args, "--object-name") ?? safeObjectName(table.name);
7473
+ const namespace = optionalArg(args, "--namespace") ?? inferCapabilityNamespace(table.name);
6392
7474
  const spec = {
6393
7475
  version: 1,
6394
7476
  engine: inspection.engine,
6395
7477
  mode,
6396
7478
  source_name: optionalArg(args, "--source-name"),
6397
7479
  read_url_env: databaseUrlEnv,
6398
- write_url_env: optionalArg(args, "--write-url-env") ?? "SYNAPSOR_DATABASE_WRITE_URL",
7480
+ write_url_env: sqlWriteback ? optionalArg(args, "--write-url-env") ?? "SYNAPSOR_DATABASE_WRITE_URL" : optionalArg(args, "--write-url-env"),
6399
7481
  schema: table.schema,
6400
7482
  table: table.name,
6401
7483
  primary_key: primaryKey,
6402
7484
  tenant_key: tenantKey,
6403
7485
  single_tenant_dev: singleTenantDev,
6404
7486
  conflict_column: conflictColumn,
6405
- namespace: optionalArg(args, "--namespace") ?? "source",
6406
- object_name: optionalArg(args, "--object-name"),
6407
- lookup_arg: optionalArg(args, "--lookup-arg"),
7487
+ namespace,
7488
+ object_name: objectName,
7489
+ inspect_tool_name: optionalArg(args, "--read-tool") ?? optionalArg(args, "--inspect-tool-name"),
7490
+ proposal_tool_name: optionalArg(args, "--proposal-tool") ?? optionalArg(args, "--proposal-tool-name"),
7491
+ lookup_arg: optionalArg(args, "--id-arg") ?? optionalArg(args, "--lookup-arg"),
7492
+ inspect_description: optionalArg(args, "--read-description") ?? optionalArg(args, "--inspect-description"),
7493
+ inspect_returns_hint: optionalArg(args, "--read-returns-hint") ?? optionalArg(args, "--inspect-returns-hint"),
7494
+ proposal_description: optionalArg(args, "--proposal-description"),
7495
+ proposal_returns_hint: optionalArg(args, "--proposal-returns-hint"),
7496
+ result_format: resultFormatOption(args),
6408
7497
  visible_columns: visibleColumns,
6409
7498
  allowed_columns: allowedColumns,
6410
7499
  patch,
@@ -6416,17 +7505,24 @@ async function initFromInspection(args, inspection, databaseUrlEnv) {
6416
7505
  },
6417
7506
  approval: {
6418
7507
  required_role: optionalArg(args, "--approval-role") ?? "local_reviewer"
6419
- }
7508
+ },
7509
+ writeback: writeback2
6420
7510
  };
6421
7511
  const generated = generateRunnerConfigFromSpec(spec);
7512
+ if (args.includes("--dry-run")) {
7513
+ process2.stdout.write(`${JSON.stringify(generated.config, null, 2)}
7514
+ `);
7515
+ return 0;
7516
+ }
6422
7517
  await writeGeneratedOnboardingFiles(outputArg(args) ?? "synapsor.runner.json", generated, args.includes("--force"));
7518
+ await maybeWriteHandlerTemplateForArgs(args, writeback2);
6423
7519
  process2.stdout.write(`selected ${table.schema}.${table.name} from ${inspection.engine} inspection
6424
7520
  `);
6425
7521
  process2.stdout.write(`exposed tools: ${generated.config.capabilities.map((capability) => capability.name).join(", ")}
6426
7522
  `);
6427
7523
  return 0;
6428
7524
  }
6429
- async function writeGeneratedOnboardingFiles(output, generated, force) {
7525
+ async function writeGeneratedOnboardingFiles(output, generated, force, options = {}) {
6430
7526
  await writeFileGuarded(output, `${JSON.stringify(generated.config, null, 2)}
6431
7527
  `, force);
6432
7528
  await writeFileGuarded(".env.example", generated.envExample, force);
@@ -6440,8 +7536,46 @@ async function writeGeneratedOnboardingFiles(output, generated, force) {
6440
7536
  `);
6441
7537
  process2.stdout.write("created .env.example\n");
6442
7538
  process2.stdout.write("created MCP client snippets under .synapsor/mcp\n");
6443
- process2.stdout.write(`Next: set the referenced environment variables, run \`${cliCommandName()} config validate\`, then run \`${cliCommandName()} mcp serve\`.
7539
+ if (options.printNext !== false) {
7540
+ process2.stdout.write(`Next: set the referenced environment variables, run \`${cliCommandName()} config validate\`, then run \`${cliCommandName()} mcp serve\`.
6444
7541
  `);
7542
+ }
7543
+ }
7544
+ async function writeGeneratedSmokeInputFile(lookupArg, objectId, force) {
7545
+ await fs3.mkdir(path3.dirname(path3.resolve(generatedSmokeInputPath)), { recursive: true });
7546
+ await writeFileGuarded(generatedSmokeInputPath, `${JSON.stringify({ [lookupArg]: objectId }, null, 2)}
7547
+ `, force);
7548
+ }
7549
+ async function maybeRunGeneratedSmokeCall(input) {
7550
+ const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !input.env[envName]);
7551
+ if (required.length > 0) {
7552
+ return [
7553
+ "Smoke call not run yet.",
7554
+ `Missing trusted/runtime env vars: ${required.join(", ")}`,
7555
+ "Set them from .env.example, then run the printed smoke command.",
7556
+ ""
7557
+ ].join("\n");
7558
+ }
7559
+ const store = new ProposalStore(input.storePath);
7560
+ const runtime = createMcpRuntime(input.config, { store, env: input.env, readRow: input.readRow });
7561
+ try {
7562
+ const result = await runtime.callTool(input.toolName, input.input);
7563
+ return [
7564
+ "Smoke call ran successfully.",
7565
+ "",
7566
+ formatSmokeCallResult(input.toolName, input.input, result, input.storePath)
7567
+ ].join("\n");
7568
+ } catch (error) {
7569
+ const message = error instanceof Error ? error.message : String(error);
7570
+ return [
7571
+ "Smoke call attempted but did not pass.",
7572
+ `Reason: ${message}`,
7573
+ "The generated config was written. Fix the trusted env values or object id, then rerun the printed smoke command.",
7574
+ ""
7575
+ ].join("\n");
7576
+ } finally {
7577
+ runtime.close();
7578
+ }
6445
7579
  }
6446
7580
  async function askTtyQuestion(question, defaultValue) {
6447
7581
  const rl = readline.createInterface({ input: process2.stdin, output: process2.stderr });
@@ -6466,12 +7600,49 @@ async function askEnvName(ask, question, defaultValue) {
6466
7600
  if (!/^[A-Z_][A-Z0-9_]*$/.test(answer)) throw new Error(`${question} must be an environment-variable name`);
6467
7601
  return answer;
6468
7602
  }
7603
+ async function askOptionalEnvName(ask, question, defaultValue) {
7604
+ const answer = await askDefault(ask, question, defaultValue);
7605
+ if (!answer) return void 0;
7606
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(answer)) throw new Error(`${question} must be an environment-variable name`);
7607
+ return answer;
7608
+ }
6469
7609
  async function askColumn(ask, question, defaultValue, columns) {
6470
7610
  const answer = await askDefault(ask, question, defaultValue);
6471
7611
  if (!answer) throw new Error(`${question} is required`);
6472
7612
  if (!columns.includes(answer)) throw new Error(`${question} ${answer} does not exist in selected table/view`);
6473
7613
  return answer;
6474
7614
  }
7615
+ function positiveIntegerOption(args, name) {
7616
+ const raw = optionalArg(args, name);
7617
+ if (!raw) return void 0;
7618
+ const value = Number(raw);
7619
+ if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
7620
+ return value;
7621
+ }
7622
+ function writebackSpecFromArgs(args) {
7623
+ const raw = optionalArg(args, "--writeback");
7624
+ if (!raw) return void 0;
7625
+ if (!["sql_update", "http_handler", "command_handler"].includes(raw)) {
7626
+ throw new Error("--writeback must be sql_update, http_handler, or command_handler");
7627
+ }
7628
+ if (raw === "sql_update") return { executor: "sql_update" };
7629
+ if (raw === "http_handler") {
7630
+ return {
7631
+ executor: "http_handler",
7632
+ executor_name: optionalArg(args, "--executor-name"),
7633
+ handler_url_env: optionalArg(args, "--handler-url-env") ?? "SYNAPSOR_APP_WRITEBACK_URL",
7634
+ ...optionalArg(args, "--handler-token-env") ? { handler_token_env: optionalArg(args, "--handler-token-env") } : {},
7635
+ ...optionalArg(args, "--handler-signing-secret-env") ? { handler_signing_secret_env: optionalArg(args, "--handler-signing-secret-env") } : {},
7636
+ timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
7637
+ };
7638
+ }
7639
+ return {
7640
+ executor: "command_handler",
7641
+ executor_name: optionalArg(args, "--executor-name"),
7642
+ handler_command_env: optionalArg(args, "--handler-command-env") ?? "SYNAPSOR_APP_WRITEBACK_COMMAND",
7643
+ timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
7644
+ };
7645
+ }
6475
7646
  function parseColumnList(value) {
6476
7647
  return uniqueStrings(value.split(",").map((item) => item.trim()).filter(Boolean));
6477
7648
  }
@@ -6560,6 +7731,82 @@ function safeObjectName(tableName) {
6560
7731
  const base = tableName.replace(/[^A-Za-z0-9_]/g, "_").replace(/s$/, "");
6561
7732
  return /^[A-Za-z_]/.test(base) ? base : `record_${base}`;
6562
7733
  }
7734
+ function inferCapabilityNamespace(tableName) {
7735
+ const objectName = safeObjectName(tableName);
7736
+ const [firstPart] = objectName.split("_").filter(Boolean);
7737
+ return firstPart ?? objectName;
7738
+ }
7739
+ function requiredWritebackEngine(args) {
7740
+ const value = optionalArg(args, "--engine") ?? firstPositional(args);
7741
+ if (value === "postgres" || value === "mysql") return value;
7742
+ throw new Error("writeback command requires --engine postgres or --engine mysql");
7743
+ }
7744
+ function formatPostgresReceiptMigration(schema) {
7745
+ if (!schema) {
7746
+ return [
7747
+ "-- Synapsor Runner direct SQL writeback receipt table.",
7748
+ "-- Run this as a database owner, or grant CREATE on the target schema to the trusted writer.",
7749
+ `${postgresReceiptMigration};`,
7750
+ ""
7751
+ ].join("\n");
7752
+ }
7753
+ const quotedSchema = quoteSqlIdentifier(schema, "postgres");
7754
+ const qualified = `${quotedSchema}.synapsor_writeback_receipts`;
7755
+ return [
7756
+ "-- Synapsor Runner direct SQL writeback receipt table.",
7757
+ "-- If you use a dedicated schema, ensure the writer connection search_path includes it.",
7758
+ `CREATE SCHEMA IF NOT EXISTS ${quotedSchema};`,
7759
+ `${postgresReceiptMigration.replace("synapsor_writeback_receipts", qualified)};`,
7760
+ "",
7761
+ "-- Example writer URL option for this schema:",
7762
+ `-- postgresql://writer:...@host/db?options=-csearch_path%3D${encodeURIComponent(`${schema},public`)}`,
7763
+ ""
7764
+ ].join("\n");
7765
+ }
7766
+ function formatMysqlReceiptMigration(database) {
7767
+ return [
7768
+ "-- Synapsor Runner direct SQL writeback receipt table.",
7769
+ "-- Run this in the database/schema used by the trusted writer connection.",
7770
+ ...database ? [`USE ${quoteSqlIdentifier(database, "mysql")};`] : [],
7771
+ `${mysqlReceiptMigration};`,
7772
+ ""
7773
+ ].join("\n");
7774
+ }
7775
+ function formatPostgresReceiptGrants(schema, writerRole) {
7776
+ const quotedSchema = quoteSqlIdentifier(schema, "postgres");
7777
+ const quotedRole = writerRole === "<writer_role>" ? writerRole : quoteSqlIdentifier(writerRole, "postgres");
7778
+ const table = `${quotedSchema}.synapsor_writeback_receipts`;
7779
+ return [
7780
+ "-- Least-privilege grants for a pre-created Synapsor Runner receipt table.",
7781
+ `GRANT USAGE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
7782
+ `GRANT SELECT, INSERT, UPDATE ON TABLE ${table} TO ${quotedRole};`,
7783
+ "",
7784
+ "-- If you want Runner to create the receipt table itself, also grant CREATE on the schema:",
7785
+ `-- GRANT CREATE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
7786
+ "",
7787
+ "-- If the schema is not public, make sure the writer connection search_path includes it.",
7788
+ `-- ALTER ROLE ${quotedRole} SET search_path = ${schema}, public;`,
7789
+ ""
7790
+ ].join("\n");
7791
+ }
7792
+ function formatMysqlReceiptGrants(database, writerRole) {
7793
+ const quotedDatabase = database === "<database_name>" ? "`<database_name>`" : quoteSqlIdentifier(database, "mysql");
7794
+ const account = writerRole === "<writer_role>" ? "'<writer_user>'@'%'" : writerRole;
7795
+ return [
7796
+ "-- Least-privilege grants for a pre-created Synapsor Runner receipt table.",
7797
+ `GRANT SELECT, INSERT, UPDATE ON ${quotedDatabase}.synapsor_writeback_receipts TO ${account};`,
7798
+ "",
7799
+ "-- If you want Runner to create the receipt table itself, also grant CREATE on the database:",
7800
+ `-- GRANT CREATE ON ${quotedDatabase}.* TO ${account};`,
7801
+ ""
7802
+ ].join("\n");
7803
+ }
7804
+ function quoteSqlIdentifier(identifier, engine) {
7805
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
7806
+ throw new Error(`unsafe ${engine} identifier: ${identifier}`);
7807
+ }
7808
+ return engine === "postgres" ? `"${identifier}"` : `\`${identifier}\``;
7809
+ }
6563
7810
  function findInspectionTable(inspection, tableName, schemaName) {
6564
7811
  const candidates = inspection.tables.filter((table) => {
6565
7812
  if (schemaName && table.schema !== schemaName) return false;
@@ -6593,6 +7840,7 @@ function repeatedArgs(args, flag) {
6593
7840
  }
6594
7841
  function parsePatchFlags(args) {
6595
7842
  const patch = {};
7843
+ Object.assign(patch, parsePatchBindings(repeatedArgs(args, "--patch"), "--patch"));
6596
7844
  for (const binding of repeatedArgs(args, "--patch-fixed")) {
6597
7845
  const [column, ...rest] = binding.split("=");
6598
7846
  const value = rest.join("=");
@@ -6607,8 +7855,27 @@ function parsePatchFlags(args) {
6607
7855
  }
6608
7856
  return patch;
6609
7857
  }
7858
+ function parsePatchBindings(bindings, label) {
7859
+ const patch = {};
7860
+ for (const rawBinding of bindings.flatMap((binding) => binding.split(",")).map((item) => item.trim()).filter(Boolean)) {
7861
+ const [column, ...rest] = rawBinding.split("=");
7862
+ const expression = rest.join("=");
7863
+ if (!column || !expression) throw new Error(`${label} must use column=fixed:value or column=arg:name`);
7864
+ const [kind, ...valueParts] = expression.split(":");
7865
+ const value = valueParts.join(":");
7866
+ if (!valueParts.length || !value) throw new Error(`${label} must use column=fixed:value or column=arg:name`);
7867
+ if (kind === "fixed") {
7868
+ patch[column] = { fixed: parseFixedPatchValue(value) };
7869
+ } else if (kind === "arg") {
7870
+ patch[column] = { from_arg: value };
7871
+ } else {
7872
+ throw new Error(`${label} patch kind for ${column} must be fixed or arg`);
7873
+ }
7874
+ }
7875
+ return patch;
7876
+ }
6610
7877
  function parseNumericBoundsFlags(args) {
6611
- return parseNumericBoundsInput(repeatedArgs(args, "--numeric-bound").join(","));
7878
+ return parseNumericBoundsInput([...repeatedArgs(args, "--numeric-bound"), ...repeatedArgs(args, "--patch-bounds")].join(","));
6612
7879
  }
6613
7880
  function parseNumericBoundsInput(input) {
6614
7881
  const bounds = {};
@@ -6640,7 +7907,7 @@ function formatNumericBounds(bounds) {
6640
7907
  return Object.entries(bounds).map(([column, bound]) => `${column}=${bound.minimum ?? ""}:${bound.maximum ?? ""}`).join(",");
6641
7908
  }
6642
7909
  function parseTransitionGuardFlags(args) {
6643
- return parseTransitionGuardsInput(repeatedArgs(args, "--transition-guard").join(","));
7910
+ return parseTransitionGuardsInput([...repeatedArgs(args, "--transition-guard"), ...repeatedArgs(args, "--status-guards")].join(","));
6644
7911
  }
6645
7912
  function parseTransitionGuardsInput(input) {
6646
7913
  const guards = {};
@@ -6722,7 +7989,7 @@ function formatSchemaInspectionForCli(inspection, databaseUrlEnv) {
6722
7989
  }
6723
7990
  lines.push("");
6724
7991
  lines.push("Next:");
6725
- lines.push(` ${cliCommandName()} init --wizard --from-env ${databaseUrlEnv}`);
7992
+ lines.push(` ${cliCommandName()} onboard db --from-env ${databaseUrlEnv}`);
6726
7993
  lines.push(` ${cliCommandName()} tools preview --config ./synapsor.runner.json --store ./.synapsor/local.db`);
6727
7994
  return `${lines.join("\n")}
6728
7995
  `;
@@ -6829,6 +8096,54 @@ function envPresenceCheck(envName, message) {
6829
8096
  message: process2.env[envName] ? `${envName} is set.` : message
6830
8097
  };
6831
8098
  }
8099
+ async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
8100
+ try {
8101
+ const url = new URL(rawUrl);
8102
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
8103
+ return {
8104
+ name: `executor:${executorName}:handler-reachability`,
8105
+ ok: false,
8106
+ level: "fail",
8107
+ message: "HTTP handler URL must use http or https."
8108
+ };
8109
+ }
8110
+ } catch {
8111
+ return {
8112
+ name: `executor:${executorName}:handler-reachability`,
8113
+ ok: false,
8114
+ level: "fail",
8115
+ message: "HTTP handler URL env value is not a valid URL."
8116
+ };
8117
+ }
8118
+ const controller = new AbortController();
8119
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs || 3e3, 1e4)));
8120
+ try {
8121
+ const response = await fetch(rawUrl, {
8122
+ method: "OPTIONS",
8123
+ headers: { accept: "application/json" },
8124
+ signal: controller.signal
8125
+ });
8126
+ return {
8127
+ name: `executor:${executorName}:handler-reachability`,
8128
+ ok: true,
8129
+ level: "pass",
8130
+ message: `HTTP handler endpoint responded with HTTP ${response.status}; network path is reachable. This is not an apply/writeback probe.`
8131
+ };
8132
+ } catch (error) {
8133
+ return {
8134
+ name: `executor:${executorName}:handler-reachability`,
8135
+ ok: false,
8136
+ level: "fail",
8137
+ message: `HTTP handler endpoint did not respond to the reachability probe (${safeReachabilityError(error)}).`
8138
+ };
8139
+ } finally {
8140
+ clearTimeout(timeout);
8141
+ }
8142
+ }
8143
+ function safeReachabilityError(error) {
8144
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return "timeout";
8145
+ return "connection failed";
8146
+ }
6832
8147
  async function inspectConfiguredSource(input) {
6833
8148
  if (!process2.env[input.source.read_url_env]) return;
6834
8149
  const capabilities = (input.config.capabilities ?? []).filter((capability) => capability.source === input.sourceName);
@@ -7101,7 +8416,7 @@ async function firstRunDoctor(args) {
7101
8416
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
7102
8417
  const storePath = optionalArg(args, "--store") ?? "./.synapsor/local.db";
7103
8418
  const configExists = await fileExists(configPath);
7104
- checks.push(configExists ? pass("config", `Runner config exists at ${configPath}.`, "MCP serve/smoke need a reviewed config.", "No action needed.") : warn("config", `Runner config not found at ${configPath}.`, "Own-database MCP setup needs a generated config.", `Run ${cliCommandName()} demo first, or run ${cliCommandName()} init --wizard --from-env DATABASE_URL.`));
8419
+ checks.push(configExists ? pass("config", `Runner config exists at ${configPath}.`, "MCP serve/smoke need a reviewed config.", "No action needed.") : warn("config", `Runner config not found at ${configPath}.`, "Own-database MCP setup needs a generated config.", `Run ${cliCommandName()} demo first, or run ${cliCommandName()} onboard db --from-env DATABASE_URL.`));
7105
8420
  if (configExists) {
7106
8421
  const parsedConfig = JSON.parse(await fs3.readFile(configPath, "utf8"));
7107
8422
  const validation = validateRunnerCapabilityConfig(parsedConfig);
@@ -7258,6 +8573,8 @@ function formatFirstRunDoctor(report) {
7258
8573
  async function localDoctor(args) {
7259
8574
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
7260
8575
  const allowSharedCredential = args.includes("--allow-shared-credential");
8576
+ const checkHandlers = args.includes("--check-handlers");
8577
+ const checkWriteback = args.includes("--check-writeback") || args.includes("--check-db");
7261
8578
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
7262
8579
  const checks = [];
7263
8580
  const validation = validateRunnerCapabilityConfig(parsed);
@@ -7300,6 +8617,24 @@ async function localDoctor(args) {
7300
8617
  } else {
7301
8618
  checks.push({ name: `source:${sourceName}:write-url-env`, ok: false, level: "fail", message: "SQL writeback proposal capabilities require write_url_env for trusted writeback." });
7302
8619
  }
8620
+ const writeUrl = source.write_url_env ? process2.env[source.write_url_env] : void 0;
8621
+ if (checkWriteback && writeUrl) {
8622
+ checks.push(...await directSqlWritebackDoctorChecks(parsed, sourceName, source, writeUrl));
8623
+ } else if (checkWriteback) {
8624
+ checks.push({
8625
+ name: `source:${sourceName}:writeback-probe`,
8626
+ ok: false,
8627
+ level: "fail",
8628
+ message: "Direct SQL writeback probe skipped because the writer env var is missing."
8629
+ });
8630
+ } else {
8631
+ checks.push({
8632
+ name: `source:${sourceName}:writeback-probe`,
8633
+ ok: true,
8634
+ level: "warn",
8635
+ message: `Direct SQL writeback was not probed. Rerun doctor with --check-writeback to verify writer connectivity, receipt-table permissions, and rollback-only target-table access.`
8636
+ });
8637
+ }
7303
8638
  }
7304
8639
  }
7305
8640
  await inspectConfiguredSource({ config: parsed, sourceName, source, checks });
@@ -7308,10 +8643,34 @@ async function localDoctor(args) {
7308
8643
  if (!isRecord6(executor)) continue;
7309
8644
  if (executor.type === "http_handler") {
7310
8645
  const urlEnv = String(executor.url_env ?? "");
7311
- if (urlEnv) checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
8646
+ if (urlEnv) {
8647
+ checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
8648
+ const handlerUrl = process2.env[urlEnv];
8649
+ if (checkHandlers && handlerUrl) {
8650
+ checks.push(await httpHandlerReachabilityCheck(executorName, handlerUrl, Number(executor.timeout_ms ?? 3e3)));
8651
+ } else if (!checkHandlers) {
8652
+ checks.push({
8653
+ name: `executor:${executorName}:handler-reachability`,
8654
+ ok: true,
8655
+ level: "warn",
8656
+ message: `Handler reachability was not probed for ${executorName}. Rerun doctor with --check-handlers to verify the network path without applying a proposal.`
8657
+ });
8658
+ }
8659
+ }
7312
8660
  const auth = isRecord6(executor.auth) ? executor.auth : void 0;
7313
8661
  const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
7314
8662
  if (tokenEnv) checks.push(envPresenceCheck(tokenEnv, `${tokenEnv} is required for http_handler executor ${executorName} bearer auth.`));
8663
+ const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
8664
+ if (signingSecretEnv) {
8665
+ checks.push(envPresenceCheck(signingSecretEnv, `${signingSecretEnv} is required to sign http_handler requests for executor ${executorName}.`));
8666
+ } else {
8667
+ checks.push({
8668
+ name: `executor:${executorName}:handler-signing`,
8669
+ ok: true,
8670
+ level: "warn",
8671
+ message: `No signing_secret_env is configured for http_handler executor ${executorName}. HMAC signing is recommended unless the handler is loopback-only and protected by another trusted boundary.`
8672
+ });
8673
+ }
7315
8674
  }
7316
8675
  if (executor.type === "command_handler") {
7317
8676
  const commandEnv = String(executor.command_env ?? "");
@@ -7348,6 +8707,155 @@ async function localDoctor(args) {
7348
8707
  }
7349
8708
  return report.ok ? 0 : 1;
7350
8709
  }
8710
+ async function directSqlWritebackDoctorChecks(config, sourceName, source, writeUrl) {
8711
+ const checks = [];
8712
+ try {
8713
+ const result = await adapters[source.engine].doctor({
8714
+ controlPlaneUrl: "local",
8715
+ runnerToken: "local",
8716
+ runnerId: "doctor",
8717
+ sourceId: sourceName,
8718
+ databaseUrl: writeUrl,
8719
+ engine: source.engine,
8720
+ pollIntervalMs: 0,
8721
+ logLevel: "error",
8722
+ dryRun: true,
8723
+ stateDir: "./state"
8724
+ });
8725
+ checks.push({
8726
+ name: `source:${sourceName}:receipt-table-probe`,
8727
+ ok: result.ok,
8728
+ level: result.ok ? "pass" : "fail",
8729
+ message: result.ok ? "Writer credential can reach the database and the receipt-table rollback probe succeeded." : `Writer receipt-table probe failed (${safeDatabaseProbeError(result.details)}). ${receiptTableGuidance(source.engine)}`
8730
+ });
8731
+ } catch (error) {
8732
+ checks.push({
8733
+ name: `source:${sourceName}:receipt-table-probe`,
8734
+ ok: false,
8735
+ level: "fail",
8736
+ message: `Writer receipt-table probe failed (${safeDatabaseProbeError(error)}). ${receiptTableGuidance(source.engine)}`
8737
+ });
8738
+ }
8739
+ for (const capability of directSqlProposalCapabilities(config, sourceName)) {
8740
+ try {
8741
+ await rollbackOnlyTargetProbe(source.engine, writeUrl, capability);
8742
+ checks.push({
8743
+ name: `capability:${capability.name}:writeback-target-probe`,
8744
+ ok: true,
8745
+ level: "pass",
8746
+ message: `Rollback-only writer probe reached ${capability.target.schema}.${capability.target.table} and verified configured write columns without mutating business rows.`
8747
+ });
8748
+ } catch (error) {
8749
+ checks.push({
8750
+ name: `capability:${capability.name}:writeback-target-probe`,
8751
+ ok: false,
8752
+ level: "fail",
8753
+ message: `Rollback-only writer probe failed for configured target ${capability.target.schema}.${capability.target.table} (${safeDatabaseProbeError(error)}). Verify writer SELECT/UPDATE on the target table and configured columns.`
8754
+ });
8755
+ }
8756
+ }
8757
+ return checks;
8758
+ }
8759
+ function directSqlProposalCapabilities(config, sourceName) {
8760
+ return (config.capabilities ?? []).filter((capability) => {
8761
+ if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
8762
+ return (capability.executor ?? "sql_update") === "sql_update";
8763
+ });
8764
+ }
8765
+ async function rollbackOnlyTargetProbe(engine, databaseUrl, capability) {
8766
+ if (engine === "postgres") {
8767
+ await rollbackOnlyPostgresTargetProbe(databaseUrl, capability);
8768
+ return;
8769
+ }
8770
+ await rollbackOnlyMysqlTargetProbe(databaseUrl, capability);
8771
+ }
8772
+ async function rollbackOnlyPostgresTargetProbe(databaseUrl, capability) {
8773
+ const pg = await dynamicImportModule("pg");
8774
+ const pool = new pg.Pool({ connectionString: databaseUrl });
8775
+ const client = await pool.connect();
8776
+ try {
8777
+ await client.query("BEGIN");
8778
+ try {
8779
+ const table = `${quotePostgresIdentifier2(capability.target.schema)}.${quotePostgresIdentifier2(capability.target.table)}`;
8780
+ const columns = proposalProbeColumns(capability).map(quotePostgresIdentifier2).join(", ");
8781
+ await client.query(`SELECT ${columns} FROM ${table} WHERE false FOR UPDATE`);
8782
+ for (const column of proposalUpdateProbeColumns(capability)) {
8783
+ const quoted = quotePostgresIdentifier2(column);
8784
+ await client.query(`UPDATE ${table} SET ${quoted} = ${quoted} WHERE false`);
8785
+ }
8786
+ await client.query("ROLLBACK");
8787
+ } catch (error) {
8788
+ await client.query("ROLLBACK").catch(() => void 0);
8789
+ throw error;
8790
+ }
8791
+ } finally {
8792
+ client.release();
8793
+ await pool.end();
8794
+ }
8795
+ }
8796
+ async function rollbackOnlyMysqlTargetProbe(databaseUrl, capability) {
8797
+ const mysql4 = await dynamicImportModule("mysql2/promise");
8798
+ const connection = await mysql4.createConnection({ uri: databaseUrl, dateStrings: true });
8799
+ try {
8800
+ await connection.beginTransaction();
8801
+ try {
8802
+ const table = `${quoteMysqlIdentifier2(capability.target.schema)}.${quoteMysqlIdentifier2(capability.target.table)}`;
8803
+ const columns = proposalProbeColumns(capability).map(quoteMysqlIdentifier2).join(", ");
8804
+ await connection.query(`SELECT ${columns} FROM ${table} WHERE 1 = 0 FOR UPDATE`);
8805
+ for (const column of proposalUpdateProbeColumns(capability)) {
8806
+ const quoted = quoteMysqlIdentifier2(column);
8807
+ await connection.query(`UPDATE ${table} SET ${quoted} = ${quoted} WHERE 1 = 0`);
8808
+ }
8809
+ await connection.rollback();
8810
+ } catch (error) {
8811
+ await connection.rollback().catch(() => void 0);
8812
+ throw error;
8813
+ }
8814
+ } finally {
8815
+ await connection.end();
8816
+ }
8817
+ }
8818
+ async function dynamicImportModule(specifier) {
8819
+ const importer = new Function("specifier", "return import(specifier)");
8820
+ return importer(specifier);
8821
+ }
8822
+ function proposalProbeColumns(capability) {
8823
+ const columns = /* @__PURE__ */ new Set();
8824
+ columns.add(capability.target.primary_key);
8825
+ if (capability.target.tenant_key) columns.add(capability.target.tenant_key);
8826
+ if (capability.conflict_guard?.column) columns.add(capability.conflict_guard.column);
8827
+ for (const column of capability.visible_columns ?? []) columns.add(column);
8828
+ for (const column of proposalUpdateProbeColumns(capability)) columns.add(column);
8829
+ return [...columns];
8830
+ }
8831
+ function proposalUpdateProbeColumns(capability) {
8832
+ const columns = /* @__PURE__ */ new Set();
8833
+ for (const column of capability.allowed_columns ?? []) columns.add(column);
8834
+ for (const column of Object.keys(capability.patch ?? {})) columns.add(column);
8835
+ return [...columns];
8836
+ }
8837
+ function quotePostgresIdentifier2(value) {
8838
+ return `"${value.replace(/"/g, '""')}"`;
8839
+ }
8840
+ function quoteMysqlIdentifier2(value) {
8841
+ return `\`${value.replace(/`/g, "``")}\``;
8842
+ }
8843
+ function safeDatabaseProbeError(error) {
8844
+ const raw = typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error ?? {});
8845
+ const message = raw.toLowerCase();
8846
+ if (/permission|denied|not authorized|insufficient|42501|er_tableaccess_denied|er_dbaccess_denied/.test(message)) return "permission denied";
8847
+ if (/authentication|password|28p01|access denied for user|invalid authorization/.test(message)) return "authentication failed";
8848
+ if (/timeout|timed out|etimedout/.test(message)) return "timeout";
8849
+ if (/econnrefused|enotfound|eai_again|network|connection terminated|connection failed/.test(message)) return "connection failed";
8850
+ if (/does not exist|unknown database|no such table|undefined_table|er_no_such_table|42p01/.test(message)) return "configured object not found";
8851
+ return "database probe failed";
8852
+ }
8853
+ function receiptTableGuidance(engine) {
8854
+ if (engine === "postgres") {
8855
+ return `Pre-create the receipt table with "${cliCommandName()} writeback migration --engine postgres --schema synapsor" and grant it with "${cliCommandName()} writeback grants --engine postgres --schema synapsor --writer-role <writer_role>", or use an app-owned handler executor.`;
8856
+ }
8857
+ return `Pre-create the receipt table with "${cliCommandName()} writeback migration --engine mysql --schema <database_name>" and grant it with "${cliCommandName()} writeback grants --engine mysql --schema <database_name> --writer-role \\"'<writer>'@'%'\\"", or use an app-owned handler executor.`;
8858
+ }
7351
8859
  async function localDoctorStoreStats(storePath) {
7352
8860
  if (!storePath || storePath === ":memory:") return { path: storePath ?? "not configured", exists: storePath === ":memory:" };
7353
8861
  if (!await fileExists(storePath)) return { path: storePath, exists: false };
@@ -7522,8 +9030,8 @@ function findProposalCapability(config, proposal) {
7522
9030
  return capability;
7523
9031
  }
7524
9032
  function proposalExecutorName(proposal, capability) {
7525
- const writeback = proposal.change_set.writeback;
7526
- return capability.executor ?? (typeof writeback.executor === "string" ? writeback.executor : void 0) ?? "sql_update";
9033
+ const writeback2 = proposal.change_set.writeback;
9034
+ return capability.executor ?? (typeof writeback2.executor === "string" ? writeback2.executor : void 0) ?? "sql_update";
7527
9035
  }
7528
9036
  function executorConfig(config, executorName) {
7529
9037
  const raw = config.executors?.[executorName];
@@ -7533,6 +9041,9 @@ function executorConfig(config, executorName) {
7533
9041
  if (raw.type === "sql_update") return { type: "sql_update" };
7534
9042
  throw new Error(`executor ${executorName} has unsupported type`);
7535
9043
  }
9044
+ function signHandlerRequestBody(body, secret) {
9045
+ return `sha256=${crypto5.createHmac("sha256", secret).update(body).digest("hex")}`;
9046
+ }
7536
9047
  async function applyHttpHandlerProposal(input) {
7537
9048
  const duplicate = duplicateHandlerReceipt(input.store, input.proposalId);
7538
9049
  if (duplicate) return alreadyAppliedReceipt(duplicate.receipt, input.runnerId);
@@ -7557,6 +9068,21 @@ async function applyHttpHandlerProposal(input) {
7557
9068
  if (!token) throw new Error(`${input.executor.auth.token_env} is not set`);
7558
9069
  headers.authorization = `Bearer ${token}`;
7559
9070
  }
9071
+ const issuedAt = (/* @__PURE__ */ new Date()).toISOString();
9072
+ const requestBody = JSON.stringify({
9073
+ protocol_version: "1.0",
9074
+ ...prepared.request,
9075
+ issued_at: issuedAt,
9076
+ executor: input.executorName,
9077
+ dry_run: input.dryRun
9078
+ });
9079
+ headers["x-synapsor-issued-at"] = issuedAt;
9080
+ headers["x-synapsor-proposal-id"] = prepared.proposal.proposal_id;
9081
+ if (input.executor.signing_secret_env) {
9082
+ const signingSecret = input.env[input.executor.signing_secret_env];
9083
+ if (!signingSecret) throw new Error(`${input.executor.signing_secret_env} is not set`);
9084
+ headers["x-synapsor-signature"] = signHandlerRequestBody(requestBody, signingSecret);
9085
+ }
7560
9086
  const controller = new AbortController();
7561
9087
  const timeout = setTimeout(() => controller.abort(), Math.max(1, input.executor.timeout_ms ?? 5e3));
7562
9088
  let receipt;
@@ -7564,7 +9090,7 @@ async function applyHttpHandlerProposal(input) {
7564
9090
  const response = await fetch(url, {
7565
9091
  method: input.executor.method ?? "POST",
7566
9092
  headers,
7567
- body: JSON.stringify({ ...prepared.request, executor: input.executorName, dry_run: input.dryRun }),
9093
+ body: requestBody,
7568
9094
  signal: controller.signal
7569
9095
  });
7570
9096
  const text = await response.text();
@@ -7937,7 +9463,13 @@ function capabilityMatchesJob(capability, job) {
7937
9463
  if (reviewedAllowed.size === 0) return false;
7938
9464
  return Object.keys(job.patch).every((column) => reviewedAllowed.has(column));
7939
9465
  }
7940
- async function start() {
9466
+ async function start(args = []) {
9467
+ if (args.length > 0) {
9468
+ if (args.includes("--from-env") || args.includes("--schema") || args.includes("--mode") || args.includes("--engine")) {
9469
+ return onboard(["db", ...args]);
9470
+ }
9471
+ throw new Error(`start accepts own-database onboarding flags such as --from-env DATABASE_URL, or no flags for the legacy polling worker. Unknown start arguments: ${args.join(" ")}`);
9472
+ }
7941
9473
  const config = loadConfig();
7942
9474
  const controller = new AbortController();
7943
9475
  process2.on("SIGINT", () => controller.abort());
@@ -7945,9 +9477,211 @@ async function start() {
7945
9477
  await startPolling(config, adapters, controller.signal);
7946
9478
  return 0;
7947
9479
  }
9480
+ async function up(args = []) {
9481
+ const allowed = /* @__PURE__ */ new Set([
9482
+ "--config",
9483
+ "--store",
9484
+ "--transport",
9485
+ "--serve",
9486
+ "--with-handler",
9487
+ "--host",
9488
+ "--port",
9489
+ "--auth-token-env",
9490
+ "--alias-mode",
9491
+ "--tool-name-style",
9492
+ "--openai-tool-aliases",
9493
+ "--result-format",
9494
+ "--handler-check",
9495
+ "--open-ui",
9496
+ "--print-next",
9497
+ "--dry-run",
9498
+ "--dev-no-auth",
9499
+ "--cors-origin",
9500
+ "--allow-concurrent-store"
9501
+ ]);
9502
+ assertKnownOptions(args, allowed, "up");
9503
+ const configPath = optionalArg(args, "--config") ?? defaultConfigPath;
9504
+ const config = await readRuntimeConfig(configPath);
9505
+ const storePath = optionalArg(args, "--store") ?? config.storage?.sqlite_path ?? defaultStorePath;
9506
+ const serveRequested = args.includes("--serve");
9507
+ const transport = optionalArg(args, "--transport") ?? (serveRequested ? "streamable-http" : "stdio");
9508
+ if (transport !== "stdio" && transport !== "streamable-http") {
9509
+ throw new Error("--transport must be stdio or streamable-http");
9510
+ }
9511
+ if (serveRequested && transport === "stdio") {
9512
+ throw new Error("up --serve starts the Streamable HTTP MCP server. Omit --transport or use --transport streamable-http; for stdio, use mcp client-config so the client launches Runner.");
9513
+ }
9514
+ const port = Number(optionalArg(args, "--port") ?? "8766");
9515
+ if (transport === "streamable-http" && (!Number.isInteger(port) || port <= 0 || port > 65535)) {
9516
+ throw new Error("--port must be an integer from 1 to 65535");
9517
+ }
9518
+ const aliasMode = toolNameStyleOption(args);
9519
+ const resultFormat = resultFormatOption(args);
9520
+ const validation = validateRunnerCapabilityConfig(config);
9521
+ if (!validation.ok) {
9522
+ throw new Error(`cannot bring Runner up with invalid config: ${validation.errors.map((error) => `${error.path} ${error.code}`).join("; ")}`);
9523
+ }
9524
+ if (storePath !== ":memory:") {
9525
+ await fs3.mkdir(path3.dirname(path3.resolve(storePath)), { recursive: true });
9526
+ }
9527
+ await assertNoActiveStoreLease(storePath, args.includes("--allow-concurrent-store"), "review-mode up");
9528
+ const boundary = await inspectMcpToolBoundary([
9529
+ "--config",
9530
+ configPath,
9531
+ "--store",
9532
+ storePath,
9533
+ "--alias-mode",
9534
+ aliasMode
9535
+ ]);
9536
+ process2.stdout.write(formatReviewModeUp({
9537
+ aliasMode,
9538
+ authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9539
+ boundary,
9540
+ config,
9541
+ configPath,
9542
+ dryRun: args.includes("--dry-run"),
9543
+ host: optionalArg(args, "--host") ?? "127.0.0.1",
9544
+ openUi: args.includes("--open-ui"),
9545
+ port,
9546
+ resultFormat,
9547
+ serveRequested,
9548
+ storePath,
9549
+ transport
9550
+ }));
9551
+ if (args.includes("--with-handler") || args.includes("--handler-check")) {
9552
+ process2.stdout.write("\nHandler check:\n");
9553
+ const doctorCode = await doctor(["--config", configPath, "--store", storePath, "--check-handlers"]);
9554
+ if (doctorCode !== 0) return doctorCode;
9555
+ }
9556
+ if (args.includes("--dry-run") || !serveRequested) return boundary.ok ? 0 : 1;
9557
+ if (!boundary.ok) return 1;
9558
+ const serveArgs = [
9559
+ "--config",
9560
+ configPath,
9561
+ "--store",
9562
+ storePath,
9563
+ "--host",
9564
+ optionalArg(args, "--host") ?? "127.0.0.1",
9565
+ "--port",
9566
+ String(port),
9567
+ "--auth-token-env",
9568
+ optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9569
+ "--alias-mode",
9570
+ aliasMode,
9571
+ ...resultFormat ? ["--result-format", String(resultFormat)] : [],
9572
+ ...args.includes("--dev-no-auth") ? ["--dev-no-auth"] : [],
9573
+ ...optionalArg(args, "--cors-origin") ? ["--cors-origin", optionalArg(args, "--cors-origin")] : [],
9574
+ ...args.includes("--allow-concurrent-store") ? ["--allow-concurrent-store"] : []
9575
+ ];
9576
+ return mcpServeStreamableHttp(serveArgs);
9577
+ }
9578
+ function formatReviewModeUp(input) {
9579
+ const lines = [
9580
+ "Synapsor Runner review-mode up",
9581
+ "",
9582
+ `Config: ${input.configPath}`,
9583
+ `Store: ${input.storePath}`,
9584
+ `Mode: ${input.config.mode}`,
9585
+ `Transport: ${input.transport}`,
9586
+ `Serve now: ${input.serveRequested ? "yes" : "no"}`,
9587
+ `Alias mode: ${input.aliasMode}`,
9588
+ `Result format: ${input.resultFormat ? `v${input.resultFormat}` : configResultFormat(input.config)}`,
9589
+ `Dry run: ${input.dryRun ? "yes" : "no"}`,
9590
+ "",
9591
+ "Model-facing tools:",
9592
+ ...formatUpToolLines(input.boundary),
9593
+ "",
9594
+ "Writeback paths:",
9595
+ ...formatUpWritebackLines(input.config)
9596
+ ];
9597
+ const handlerLines = formatUpHandlerLines(input.config);
9598
+ if (handlerLines.length > 0) {
9599
+ lines.push("", "App-owned handler requirements:", ...handlerLines, "", handlerSecurityWarning);
9600
+ }
9601
+ lines.push("", "Server guidance:");
9602
+ if (input.transport === "stdio") {
9603
+ lines.push(
9604
+ " stdio mode is launched by an MCP client. This command does not hold a protocol session open.",
9605
+ ` Print client config: ${cliCommandName()} mcp client-config --client claude-desktop --config ${input.configPath} --store ${input.storePath}`,
9606
+ ` Serve command used by clients: ${cliCommandName()} mcp serve --config ${input.configPath} --store ${input.storePath} --alias-mode ${input.aliasMode}`
9607
+ );
9608
+ } else {
9609
+ lines.push(
9610
+ ` Streamable HTTP endpoint: http://${input.host}:${input.port}/mcp`,
9611
+ ` Auth token env: ${input.authTokenEnv} (${process2.env[input.authTokenEnv] ? "set" : "missing"})`,
9612
+ input.serveRequested ? input.dryRun ? " Status: dry run only; server not started." : " Status: starting after this checklist." : ` Start command: ${cliCommandName()} up --serve --config ${input.configPath} --store ${input.storePath} --port ${input.port} --auth-token-env ${input.authTokenEnv} --alias-mode ${input.aliasMode}`
9613
+ );
9614
+ }
9615
+ if (input.openUi) {
9616
+ lines.push("", "Local review UI:", ` ${cliCommandName()} ui --open --tour --config ${input.configPath} --store ${input.storePath}`);
9617
+ }
9618
+ lines.push("", "Next commands:", ...formatUpNextCommands(input.config, input.configPath, input.storePath), "");
9619
+ return `${lines.join("\n")}
9620
+ `;
9621
+ }
9622
+ function formatUpToolLines(boundary) {
9623
+ if (boundary.exposures.length === 0) return [" - (none)"];
9624
+ return boundary.exposures.map((item) => item.isAlias ? ` - ${item.exposedName} -> ${item.canonicalName}` : ` - ${item.exposedName}`);
9625
+ }
9626
+ function formatUpWritebackLines(config) {
9627
+ const proposals2 = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal");
9628
+ if (proposals2.length === 0) return [" - no proposal capabilities; this config is read-only from Runner's perspective"];
9629
+ return proposals2.map((capability) => {
9630
+ const executorName = capability.executor ?? "sql_update";
9631
+ if (executorName === "sql_update") {
9632
+ const source = config.sources?.[capability.source];
9633
+ const envName = source?.write_url_env ?? "SYNAPSOR_DATABASE_URL";
9634
+ return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${process2.env[envName] ? "set" : "missing"})`;
9635
+ }
9636
+ const executor = config.executors?.[executorName];
9637
+ return ` - ${capability.name}: app-owned ${String(executor?.type ?? "executor")} ${executorName}`;
9638
+ });
9639
+ }
9640
+ function formatUpHandlerLines(config) {
9641
+ const lines = [];
9642
+ for (const [name, executor] of Object.entries(config.executors ?? {})) {
9643
+ if (!isRecord6(executor)) continue;
9644
+ if (executor.type === "http_handler") {
9645
+ const urlEnv = typeof executor.url_env === "string" ? executor.url_env : "";
9646
+ const auth = isRecord6(executor.auth) ? executor.auth : void 0;
9647
+ const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
9648
+ const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
9649
+ lines.push(` - ${name}: http_handler`);
9650
+ if (urlEnv) lines.push(` url env: ${urlEnv} (${process2.env[urlEnv] ? "set" : "missing"})`);
9651
+ if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${process2.env[tokenEnv] ? "set" : "missing"})`);
9652
+ if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${process2.env[signingSecretEnv] ? "set" : "missing"})`);
9653
+ if (!signingSecretEnv) lines.push(" signing secret env: not configured (recommended unless loopback-only)");
9654
+ } else if (executor.type === "command_handler") {
9655
+ const commandEnv = typeof executor.command_env === "string" ? executor.command_env : "";
9656
+ lines.push(` - ${name}: command_handler`);
9657
+ if (commandEnv) lines.push(` command env: ${commandEnv} (${process2.env[commandEnv] ? "set" : "missing"})`);
9658
+ }
9659
+ }
9660
+ return lines;
9661
+ }
9662
+ function configResultFormat(config) {
9663
+ return config.result_format === 2 ? "v2" : config.result_format === 1 ? "v1" : "default";
9664
+ }
9665
+ function formatUpNextCommands(config, configPath, storePath) {
9666
+ const firstTool = (config.capabilities ?? [])[0]?.name ?? "<capability>";
9667
+ const hasHandlers = Object.keys(config.executors ?? {}).length > 0;
9668
+ return [
9669
+ ` - Preview tools: ${cliCommandName()} tools preview --config ${configPath} --store ${storePath}`,
9670
+ ` - Smoke call: ${cliCommandName()} smoke call ${firstTool} --sample --config ${configPath} --store ${storePath}`,
9671
+ ` - List proposals: ${cliCommandName()} proposals list --store ${storePath}`,
9672
+ ` - Show proposal: ${cliCommandName()} proposals show latest --store ${storePath}`,
9673
+ ` - Approve proposal: ${cliCommandName()} proposals approve latest --yes --store ${storePath}`,
9674
+ ` - Apply approved proposal: ${cliCommandName()} apply latest --config ${configPath} --store ${storePath}`,
9675
+ ` - Replay: ${cliCommandName()} replay show latest --store ${storePath}`,
9676
+ ` - Tail events: ${cliCommandName()} events tail --store ${storePath}`,
9677
+ ` - Direct writeback doctor: ${cliCommandName()} doctor --config ${configPath} --check-writeback`,
9678
+ ...hasHandlers ? [` - Handler doctor: ${cliCommandName()} doctor --config ${configPath} --check-handlers`] : []
9679
+ ];
9680
+ }
7948
9681
  async function runnerCommand(args) {
7949
9682
  const [subcommand, ...rest] = args;
7950
- if (subcommand === "start") return start();
9683
+ if (subcommand === "start") return start(rest);
9684
+ if (subcommand === "up") return up(rest);
7951
9685
  if (subcommand === "doctor") return doctor(rest);
7952
9686
  usage();
7953
9687
  return 2;
@@ -7986,7 +9720,7 @@ async function cloudConnect(args) {
7986
9720
  return 1;
7987
9721
  }
7988
9722
  const runnerId = String(parsed.cloud.runner_id || process2.env.SYNAPSOR_RUNNER_ID || "synapsor_runner_local").trim();
7989
- const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0-alpha.8").trim();
9723
+ const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0").trim();
7990
9724
  const engines = normalizeEngines(parsed.cloud.engines);
7991
9725
  const capabilities = normalizeCapabilities(parsed.cloud.capabilities);
7992
9726
  const client = new ControlPlaneClient({
@@ -8037,8 +9771,10 @@ async function mcp(args) {
8037
9771
  const [subcommand, ...rest] = args;
8038
9772
  if (subcommand === "serve") return mcpServe(rest);
8039
9773
  if (subcommand === "serve-http") return mcpServeHttp(rest);
9774
+ if (subcommand === "serve-streamable-http") return mcpServeStreamableHttp(rest);
8040
9775
  if (subcommand === "audit") return mcpAudit(rest);
8041
9776
  if (subcommand === "config") return mcpConfig(rest);
9777
+ if (subcommand === "client-config") return mcpConfigure(rest);
8042
9778
  if (subcommand === "configure") return mcpConfigure(rest);
8043
9779
  if (subcommand === "smoke") return mcpSmoke(rest);
8044
9780
  usage(["mcp"]);
@@ -8047,9 +9783,158 @@ async function mcp(args) {
8047
9783
  async function tools(args) {
8048
9784
  const [subcommand, ...rest] = args;
8049
9785
  if (subcommand === "preview") return toolsPreview(rest);
9786
+ if (subcommand === "list") return toolsPreview(rest);
8050
9787
  usage(["tools"]);
8051
9788
  return 2;
8052
9789
  }
9790
+ async function smoke(args) {
9791
+ const [subcommand, ...rest] = args;
9792
+ if (subcommand === "call") return smokeCall(rest);
9793
+ if (subcommand === "boundary") return mcpSmoke(rest);
9794
+ usage(["smoke"]);
9795
+ return 2;
9796
+ }
9797
+ async function writeback(args) {
9798
+ const [subcommand, ...rest] = args;
9799
+ if (subcommand === "doctor") return writebackDoctor(rest);
9800
+ if (subcommand === "migration") return writebackMigration(rest);
9801
+ if (subcommand === "grants") return writebackGrants(rest);
9802
+ usage(["writeback"]);
9803
+ return 2;
9804
+ }
9805
+ async function handler(args) {
9806
+ const [subcommand, ...rest] = args;
9807
+ if (subcommand === "template") return handlerTemplate(rest);
9808
+ usage(["handler"]);
9809
+ return 2;
9810
+ }
9811
+ async function handlerTemplate(args) {
9812
+ const allowed = /* @__PURE__ */ new Set(["--list", "--output", "--out", "--stdout", "--force"]);
9813
+ assertKnownOptions(args, allowed, "handler template");
9814
+ if (args.includes("--list")) {
9815
+ process2.stdout.write(formatHandlerTemplateList());
9816
+ return 0;
9817
+ }
9818
+ const requested = positional(args, 0);
9819
+ if (!requested) throw new Error("handler template requires <node-fastify|python-fastapi|command>, or use --list");
9820
+ const name = resolveHandlerTemplateName(requested);
9821
+ const definition = handlerTemplateDefinitions[name];
9822
+ const content = definition.content;
9823
+ if (args.includes("--stdout")) {
9824
+ process2.stdout.write(content);
9825
+ return 0;
9826
+ }
9827
+ const output = outputArg(args) ?? definition.fileName;
9828
+ await writeHandlerTemplateFile(name, output, args.includes("--force"));
9829
+ process2.stdout.write(`created ${output}
9830
+ `);
9831
+ process2.stdout.write(`${handlerSecurityWarning}
9832
+ `);
9833
+ return 0;
9834
+ }
9835
+ async function writeHandlerTemplateFile(name, output, force) {
9836
+ const definition = handlerTemplateDefinitions[name];
9837
+ await writeFileGuarded(output, definition.content, force);
9838
+ if (name === "command" || output.endsWith(".mjs") || output.endsWith(".js")) {
9839
+ await fs3.chmod(path3.resolve(output), 493).catch(() => void 0);
9840
+ }
9841
+ }
9842
+ function formatHandlerTemplateList() {
9843
+ return [
9844
+ "Synapsor app-owned writeback handler templates",
9845
+ "",
9846
+ ...Object.entries(handlerTemplateDefinitions).map(([name, definition]) => `- ${name}: ${definition.description}`),
9847
+ "",
9848
+ handlerSecurityWarning,
9849
+ "",
9850
+ "Examples:",
9851
+ ` ${cliCommandName()} handler template node-fastify --output ./synapsor-writeback-handler.mjs`,
9852
+ ` ${cliCommandName()} handler template python-fastapi --output ./synapsor_writeback_handler.py`,
9853
+ ` ${cliCommandName()} handler template command --output ./synapsor-command-handler.mjs`,
9854
+ ""
9855
+ ].join("\n");
9856
+ }
9857
+ function resolveHandlerTemplateName(value) {
9858
+ const normalized = value.trim().toLowerCase();
9859
+ for (const [name, definition] of Object.entries(handlerTemplateDefinitions)) {
9860
+ if (normalized === name || definition.aliases.includes(normalized)) return name;
9861
+ }
9862
+ throw new Error(`unknown handler template: ${value}. Use ${cliCommandName()} handler template --list`);
9863
+ }
9864
+ async function writebackDoctor(args) {
9865
+ const configPath = optionalArg(args, "--config") ?? defaultConfigPath;
9866
+ const config = await readRuntimeConfig(configPath);
9867
+ const checkDb = args.includes("--check-db");
9868
+ const sqlSources = Object.entries(config.sources ?? {}).filter(([sourceName]) => sourceNeedsSqlWriteback(config, sourceName));
9869
+ const lines = [
9870
+ "Synapsor writeback doctor",
9871
+ `Config: ${configPath}`,
9872
+ ""
9873
+ ];
9874
+ if (sqlSources.length === 0) {
9875
+ lines.push("No direct SQL writeback sources found.", "Rich writes can use app-owned http_handler or command_handler executors without Runner creating receipt tables.", "");
9876
+ process2.stdout.write(lines.join("\n"));
9877
+ return 0;
9878
+ }
9879
+ let ok = true;
9880
+ for (const [sourceName, source] of sqlSources) {
9881
+ const writeEnv = source.write_url_env;
9882
+ const writeUrl = writeEnv ? process2.env[writeEnv] : void 0;
9883
+ lines.push(`Source: ${sourceName}`);
9884
+ lines.push(` engine: ${source.engine}`);
9885
+ lines.push(` writer env: ${writeEnv ?? "(missing write_url_env)"}`);
9886
+ lines.push(` env status: ${writeUrl ? "set" : "missing"}`);
9887
+ lines.push(" receipt table: synapsor_writeback_receipts");
9888
+ if (!writeEnv || !writeUrl) ok = false;
9889
+ if (checkDb && writeUrl) {
9890
+ const result = await adapters[source.engine].doctor({
9891
+ controlPlaneUrl: "local",
9892
+ runnerToken: "local",
9893
+ runnerId: "writeback-doctor",
9894
+ sourceId: sourceName,
9895
+ databaseUrl: writeUrl,
9896
+ engine: source.engine,
9897
+ pollIntervalMs: 0,
9898
+ logLevel: "error",
9899
+ dryRun: true,
9900
+ stateDir: "./state"
9901
+ });
9902
+ lines.push(` db check: ${result.ok ? "ok" : "failed"}`);
9903
+ lines.push(` details: ${JSON.stringify(redactConfig(result.details ?? {}))}`);
9904
+ if (!result.ok) ok = false;
9905
+ } else if (checkDb) {
9906
+ lines.push(" db check: skipped because writer env is missing");
9907
+ }
9908
+ lines.push("");
9909
+ }
9910
+ lines.push("Next:");
9911
+ lines.push(` ${cliCommandName()} writeback migration --engine postgres`);
9912
+ lines.push(` ${cliCommandName()} writeback grants --engine postgres --writer-role <writer_role>`);
9913
+ lines.push("");
9914
+ process2.stdout.write(lines.join("\n"));
9915
+ return ok ? 0 : 1;
9916
+ }
9917
+ async function writebackMigration(args) {
9918
+ const engine = requiredWritebackEngine(args);
9919
+ const schema = optionalArg(args, "--schema");
9920
+ if (engine === "postgres") {
9921
+ process2.stdout.write(formatPostgresReceiptMigration(schema));
9922
+ return 0;
9923
+ }
9924
+ process2.stdout.write(formatMysqlReceiptMigration(schema));
9925
+ return 0;
9926
+ }
9927
+ async function writebackGrants(args) {
9928
+ const engine = requiredWritebackEngine(args);
9929
+ const writerRole = optionalArg(args, "--writer-role") ?? "<writer_role>";
9930
+ const schema = optionalArg(args, "--schema") ?? (engine === "postgres" ? "public" : "<database_name>");
9931
+ if (engine === "postgres") {
9932
+ process2.stdout.write(formatPostgresReceiptGrants(schema, writerRole));
9933
+ return 0;
9934
+ }
9935
+ process2.stdout.write(formatMysqlReceiptGrants(schema, writerRole));
9936
+ return 0;
9937
+ }
8053
9938
  async function onboard(args) {
8054
9939
  const [subcommand, ...rest] = args;
8055
9940
  if (subcommand !== "db") {
@@ -8060,8 +9945,10 @@ async function onboard(args) {
8060
9945
  process2.stdout.write("You will inspect metadata, choose one table/view, confirm safety rules, and generate semantic MCP tools without writing JSON by hand.\n\n");
8061
9946
  const outputPath = outputArg(rest) ?? "synapsor.runner.json";
8062
9947
  const storePath = optionalArg(rest, "--store") ?? "./.synapsor/local.db";
8063
- const result = await runInitWizard(["--wizard", ...rest]);
9948
+ const scripted = isScriptedOnboardingArgs(rest);
9949
+ const result = scripted ? await init(["--non-interactive", ...rest]) : await runInitWizard(["--wizard", ...rest]);
8064
9950
  if (result !== 0) return result;
9951
+ if (rest.includes("--dry-run")) return 0;
8065
9952
  process2.stdout.write("\nValidation:\n");
8066
9953
  const configCode = await configValidate(["--config", outputPath]);
8067
9954
  const smokeCode = await mcpSmoke(["--config", outputPath, "--store", storePath]);
@@ -8075,7 +9962,7 @@ async function onboard(args) {
8075
9962
  ${cliCommandName()} mcp serve --config ${outputPath} --store ${storePath}
8076
9963
  `);
8077
9964
  process2.stdout.write(`2. Open local UI:
8078
- ${cliCommandName()} ui --tour --config ${outputPath} --store ${storePath}
9965
+ ${cliCommandName()} ui --open --tour --config ${outputPath} --store ${storePath}
8079
9966
  `);
8080
9967
  process2.stdout.write("3. Approve/apply only after setting a trusted write credential and reviewing the proposal.\n");
8081
9968
  return configCode === 0 && smokeCode === 0 ? 0 : 1;
@@ -8173,7 +10060,7 @@ function formatQuickDemoDetails(seeded) {
8173
10060
  quickDemoStorePath,
8174
10061
  "",
8175
10062
  "If you ran this through one-off npx and did not install the package, prefix",
8176
- "follow-up commands with: npx -y -p @synapsor/runner@alpha synapsor-runner",
10063
+ "follow-up commands with: npx -y -p @synapsor/runner synapsor-runner",
8177
10064
  "",
8178
10065
  "Raw MCP shape:",
8179
10066
  "execute_sql(sql: string)",
@@ -8299,7 +10186,7 @@ function quickDemoGuidedScreens(seeded) {
8299
10186
  body: [
8300
10187
  "Run this next:",
8301
10188
  "",
8302
- "npx -y -p @synapsor/runner@alpha synapsor-runner demo inspect",
10189
+ "npx -y -p @synapsor/runner synapsor-runner demo inspect",
8303
10190
  "",
8304
10191
  "demo inspect shows the proposal, evidence, activity search, and replay commands.",
8305
10192
  "",
@@ -8318,7 +10205,7 @@ function quickDemoGuidedScreens(seeded) {
8318
10205
  "",
8319
10206
  "Use your own staging DB:",
8320
10207
  'export DATABASE_URL="postgres://..."',
8321
- `${cliCommandName()} inspect --from-env DATABASE_URL`,
10208
+ `${cliCommandName()} onboard db --from-env DATABASE_URL`,
8322
10209
  "",
8323
10210
  "Done. You just saw Synapsor's core boundary: business tools for the model, approval/writeback outside the model, and replay for inspection."
8324
10211
  ]
@@ -8351,7 +10238,7 @@ async function waitForEnter(message, options) {
8351
10238
  }
8352
10239
  }
8353
10240
  function quickDemoInspectCommands(useNpx) {
8354
- const cmd = useNpx ? "npx -y -p @synapsor/runner@alpha synapsor-runner" : cliCommandName();
10241
+ const cmd = useNpx ? "npx -y -p @synapsor/runner synapsor-runner" : cliCommandName();
8355
10242
  return [
8356
10243
  {
8357
10244
  label: "Proposal summary",
@@ -8556,44 +10443,122 @@ function quickDemoChangeSet() {
8556
10443
  };
8557
10444
  }
8558
10445
  async function mcpServe(args) {
10446
+ const transport = optionalArg(args, "--transport") ?? "stdio";
10447
+ if (transport === "streamable-http") return mcpServeStreamableHttp(args);
10448
+ if (transport === "http" || transport === "json-rpc-http" || transport === "jsonrpc-http") return mcpServeHttp(args);
10449
+ if (transport !== "stdio") throw new Error("--transport must be stdio, streamable-http, or http");
8559
10450
  const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG;
8560
10451
  const readOnly = args.includes("--read-only");
8561
10452
  const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
8562
- await serveStdio({
8563
- configPath,
8564
- storePath: optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE,
8565
- config
8566
- });
8567
- return 0;
10453
+ const toolNameStyle = toolNameStyleOption(args);
10454
+ const resultFormat = resultFormatOption(args);
10455
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
10456
+ const releaseLease = await writeStoreLease(storePath, "mcp", "stdio", args.includes("--allow-concurrent-store"));
10457
+ try {
10458
+ await serveStdio({
10459
+ configPath,
10460
+ storePath,
10461
+ config,
10462
+ toolNameStyle,
10463
+ resultFormat
10464
+ });
10465
+ return 0;
10466
+ } finally {
10467
+ await releaseLease();
10468
+ }
8568
10469
  }
8569
10470
  async function mcpServeHttp(args) {
10471
+ process2.stderr.write([
10472
+ "Warning: mcp serve-http is a legacy JSON-RPC bridge, not spec MCP Streamable HTTP.",
10473
+ `For OpenAI Agents SDK or standard HTTP MCP clients, use: ${cliCommandName()} mcp serve --transport streamable-http`,
10474
+ ""
10475
+ ].join("\n"));
8570
10476
  const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG;
8571
10477
  const readOnly = args.includes("--read-only");
8572
10478
  const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
8573
10479
  const host = optionalArg(args, "--host") ?? "127.0.0.1";
8574
10480
  const port = Number(optionalArg(args, "--port") ?? "8765");
10481
+ const resultFormat = resultFormatOption(args);
8575
10482
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
8576
10483
  throw new Error("--port must be an integer from 1 to 65535");
8577
10484
  }
8578
10485
  if (host === "0.0.0.0") {
8579
10486
  process2.stderr.write("Warning: binding Synapsor Runner HTTP MCP to 0.0.0.0 exposes model-facing tools on the network. Use TLS, private networking, authentication, and rate limits.\n");
8580
10487
  }
8581
- const server = await startHttpMcpServer({
8582
- configPath,
8583
- config,
8584
- storePath: optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE,
8585
- host,
8586
- port,
8587
- authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
8588
- devNoAuth: args.includes("--dev-no-auth"),
8589
- corsOrigin: optionalArg(args, "--cors-origin")
10488
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
10489
+ const releaseLease = await writeStoreLease(storePath, "mcp", "legacy-jsonrpc", args.includes("--allow-concurrent-store"));
10490
+ let server;
10491
+ try {
10492
+ server = await startHttpMcpServer({
10493
+ configPath,
10494
+ config,
10495
+ storePath,
10496
+ host,
10497
+ port,
10498
+ authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
10499
+ devNoAuth: args.includes("--dev-no-auth"),
10500
+ corsOrigin: optionalArg(args, "--cors-origin"),
10501
+ resultFormat
10502
+ });
10503
+ } catch (error) {
10504
+ await releaseLease();
10505
+ throw error;
10506
+ }
10507
+ process2.stderr.write("Press Ctrl+C to stop.\n");
10508
+ await new Promise((resolve) => {
10509
+ const stop = async () => {
10510
+ process2.off("SIGINT", stop);
10511
+ process2.off("SIGTERM", stop);
10512
+ await server.close();
10513
+ await releaseLease();
10514
+ resolve();
10515
+ };
10516
+ process2.once("SIGINT", stop);
10517
+ process2.once("SIGTERM", stop);
8590
10518
  });
10519
+ return 0;
10520
+ }
10521
+ async function mcpServeStreamableHttp(args) {
10522
+ const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG;
10523
+ const readOnly = args.includes("--read-only");
10524
+ const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
10525
+ const toolNameStyle = toolNameStyleOption(args);
10526
+ const resultFormat = resultFormatOption(args);
10527
+ const host = optionalArg(args, "--host") ?? "127.0.0.1";
10528
+ const port = Number(optionalArg(args, "--port") ?? "8766");
10529
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
10530
+ throw new Error("--port must be an integer from 1 to 65535");
10531
+ }
10532
+ if (host === "0.0.0.0") {
10533
+ process2.stderr.write("Warning: binding Synapsor Runner Streamable HTTP MCP to 0.0.0.0 exposes model-facing tools on the network. Use TLS, private networking, authentication, and rate limits.\n");
10534
+ }
10535
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
10536
+ const releaseLease = await writeStoreLease(storePath, "mcp", "streamable-http", args.includes("--allow-concurrent-store"));
10537
+ let server;
10538
+ try {
10539
+ server = await startStreamableHttpMcpServer({
10540
+ configPath,
10541
+ config,
10542
+ storePath,
10543
+ host,
10544
+ port,
10545
+ toolNameStyle,
10546
+ authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
10547
+ devNoAuth: args.includes("--dev-no-auth"),
10548
+ corsOrigin: optionalArg(args, "--cors-origin"),
10549
+ resultFormat
10550
+ });
10551
+ } catch (error) {
10552
+ await releaseLease();
10553
+ throw error;
10554
+ }
8591
10555
  process2.stderr.write("Press Ctrl+C to stop.\n");
8592
10556
  await new Promise((resolve) => {
8593
10557
  const stop = async () => {
8594
10558
  process2.off("SIGINT", stop);
8595
10559
  process2.off("SIGTERM", stop);
8596
10560
  await server.close();
10561
+ await releaseLease();
8597
10562
  resolve();
8598
10563
  };
8599
10564
  process2.once("SIGINT", stop);
@@ -8601,6 +10566,107 @@ async function mcpServeHttp(args) {
8601
10566
  });
8602
10567
  return 0;
8603
10568
  }
10569
+ async function writeStoreLease(storePath, mode, transport, allowConcurrent) {
10570
+ const resolved = resolveStorePathForLease(storePath);
10571
+ if (!resolved) return async () => void 0;
10572
+ await assertNoActiveStoreLease(resolved, allowConcurrent, "serve");
10573
+ const leasePath = storeLeasePath(resolved);
10574
+ const lease = {
10575
+ pid: process2.pid,
10576
+ mode,
10577
+ transport,
10578
+ store_path: resolved,
10579
+ started_at: (/* @__PURE__ */ new Date()).toISOString()
10580
+ };
10581
+ await fs3.mkdir(path3.dirname(resolved), { recursive: true });
10582
+ await fs3.writeFile(leasePath, `${JSON.stringify(lease, null, 2)}
10583
+ `, "utf8");
10584
+ return async () => {
10585
+ const current = await readStoreLease(resolved);
10586
+ if (current?.pid === process2.pid && current.transport === transport) {
10587
+ await fs3.rm(leasePath, { force: true });
10588
+ }
10589
+ };
10590
+ }
10591
+ async function assertNoActiveStoreLease(storePath, force, operation) {
10592
+ const resolved = resolveStorePathForLease(storePath);
10593
+ if (!resolved) return;
10594
+ const lease = await readStoreLease(resolved);
10595
+ if (!lease) return;
10596
+ if (!pidIsActive(lease.pid)) {
10597
+ await fs3.rm(storeLeasePath(resolved), { force: true });
10598
+ return;
10599
+ }
10600
+ const message = `Local store appears active for ${lease.mode}/${lease.transport} (pid ${lease.pid}, started ${lease.started_at}). Refusing ${operation}. Stop the server or rerun with --allow-concurrent-store/--force if you have verified it is safe.`;
10601
+ if (!force) throw new Error(message);
10602
+ process2.stderr.write(`Warning: ${message}
10603
+ `);
10604
+ }
10605
+ function resolveStorePathForLease(storePath) {
10606
+ const value = storePath ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
10607
+ if (value === ":memory:") return void 0;
10608
+ return path3.resolve(value);
10609
+ }
10610
+ function storeLeasePath(resolvedStorePath) {
10611
+ return `${resolvedStorePath}.lease.json`;
10612
+ }
10613
+ async function readStoreLease(storePath) {
10614
+ const resolved = resolveStorePathForLease(storePath);
10615
+ if (!resolved) return void 0;
10616
+ try {
10617
+ const parsed = JSON.parse(await fs3.readFile(storeLeasePath(resolved), "utf8"));
10618
+ if (typeof parsed.pid !== "number" || typeof parsed.mode !== "string" || typeof parsed.transport !== "string" || typeof parsed.started_at !== "string") {
10619
+ return void 0;
10620
+ }
10621
+ return {
10622
+ pid: parsed.pid,
10623
+ mode: parsed.mode,
10624
+ transport: parsed.transport,
10625
+ store_path: typeof parsed.store_path === "string" ? parsed.store_path : resolved,
10626
+ started_at: parsed.started_at
10627
+ };
10628
+ } catch (error) {
10629
+ if (error.code === "ENOENT") return void 0;
10630
+ return void 0;
10631
+ }
10632
+ }
10633
+ function pidIsActive(pid) {
10634
+ if (!Number.isInteger(pid) || pid <= 0) return false;
10635
+ try {
10636
+ process2.kill(pid, 0);
10637
+ return true;
10638
+ } catch (error) {
10639
+ return error.code === "EPERM";
10640
+ }
10641
+ }
10642
+ function toolNameStyleOption(args) {
10643
+ const requestedStyle = optionalArg(args, "--tool-name-style");
10644
+ const requestedAliasMode = optionalArg(args, "--alias-mode");
10645
+ if (requestedStyle && requestedAliasMode && requestedStyle !== requestedAliasMode) {
10646
+ throw new Error("--tool-name-style and --alias-mode must match when both are provided");
10647
+ }
10648
+ const requested = requestedAliasMode ?? requestedStyle;
10649
+ if (args.includes("--openai-tool-aliases")) {
10650
+ if (requested && requested !== "openai") throw new Error("--openai-tool-aliases cannot be combined with a non-openai alias mode");
10651
+ return "openai";
10652
+ }
10653
+ if (!requested) return "canonical";
10654
+ if (requested === "canonical" || requested === "openai" || requested === "both") return requested;
10655
+ throw new Error("--alias-mode must be canonical, openai, or both");
10656
+ }
10657
+ function resultFormatOption(args) {
10658
+ const requested = optionalArg(args, "--result-format");
10659
+ if (!requested) return void 0;
10660
+ if (requested === "1" || requested === "v1") return 1;
10661
+ if (requested === "2" || requested === "v2") return 2;
10662
+ throw new Error("--result-format must be v1, 1, v2, or 2");
10663
+ }
10664
+ function normalizeResultFormatAnswer(value) {
10665
+ if (value === "1" || value === "v1") return "v1";
10666
+ if (value === "2" || value === "v2") return "v2";
10667
+ if (value === "default") return "default";
10668
+ throw new Error("--result-format must be default, v1, 1, v2, or 2");
10669
+ }
8604
10670
  async function mcpAudit(args) {
8605
10671
  const format = optionalArg(args, "--format") ?? (args.includes("--json") ? "json" : "text");
8606
10672
  if (!["text", "json", "markdown"].includes(format)) {
@@ -8743,21 +10809,33 @@ function formatProposeResult(capabilityName, result, storePath) {
8743
10809
  `;
8744
10810
  }
8745
10811
  async function mcpConfigure(args) {
8746
- const client = optionalArg(args, "--client");
8747
- if (!client) throw new Error("mcp configure requires --client generic-stdio|claude-desktop|cursor|vscode");
10812
+ const client = normalizeMcpClientName(optionalArg(args, "--client"));
10813
+ if (!client) throw new Error("mcp configure requires --client generic-stdio|claude|claude-desktop|cursor|vscode|openai-agents");
8748
10814
  const useAbsolutePaths = args.includes("--absolute-paths");
8749
10815
  const rawConfigPath = optionalArg(args, "--config") ?? "./synapsor.runner.json";
8750
10816
  const rawStorePath = optionalArg(args, "--store") ?? "./.synapsor/local.db";
8751
10817
  const configPath = useAbsolutePaths ? path3.resolve(rawConfigPath) : rawConfigPath;
8752
10818
  const storePath = useAbsolutePaths ? path3.resolve(rawStorePath) : rawStorePath;
10819
+ const transport = mcpClientConfigTransport(args, client);
10820
+ const aliasMode = mcpClientConfigAliasMode(args, client);
10821
+ const includeInstructions = args.includes("--include-instructions");
10822
+ const host = optionalArg(args, "--host") ?? "127.0.0.1";
10823
+ const port = Number(optionalArg(args, "--port") ?? "8766");
10824
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
10825
+ throw new Error("--port must be an integer from 1 to 65535");
10826
+ }
10827
+ const authTokenEnv = optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
8753
10828
  if (!await fileExists(rawConfigPath)) {
8754
10829
  process2.stderr.write(`Warning: config path does not exist yet: ${rawConfigPath}
8755
10830
  `);
8756
10831
  }
8757
- if (!path3.isAbsolute(configPath) || !path3.isAbsolute(storePath)) {
10832
+ if (transport === "stdio" && (!path3.isAbsolute(configPath) || !path3.isAbsolute(storePath))) {
8758
10833
  process2.stderr.write("Warning: relative paths are resolved by the MCP client working directory. Use --absolute-paths if the client runs from another directory.\n");
8759
10834
  }
8760
- const snippet = mcpClientSnippet(client, configPath, storePath);
10835
+ const snippet = mcpClientSnippet(client, configPath, storePath, { transport, aliasMode, host, port, authTokenEnv });
10836
+ if (includeInstructions) {
10837
+ snippet.agent_instructions = mcpAgentInstructions(client, aliasMode);
10838
+ }
8761
10839
  if (args.includes("--write")) {
8762
10840
  const destination = optionalArg(args, "--destination");
8763
10841
  if (!destination) throw new Error("mcp configure --write requires --destination <path>");
@@ -8775,20 +10853,124 @@ async function mcpConfigure(args) {
8775
10853
  async function mcpConfig(args) {
8776
10854
  const [client, ...rest] = args;
8777
10855
  if (!client || client.startsWith("--")) return mcpConfigure(["--client", "claude-desktop", ...args]);
8778
- return mcpConfigure(["--client", client, ...rest]);
10856
+ return mcpConfigure(["--client", normalizeMcpClientName(client) ?? client, ...rest]);
10857
+ }
10858
+ function normalizeMcpClientName(client) {
10859
+ if (client === "claude") return "claude-desktop";
10860
+ return client;
10861
+ }
10862
+ function mcpClientConfigTransport(args, client) {
10863
+ const requested = optionalArg(args, "--transport") ?? (client === "openai-agents" ? "streamable-http" : "stdio");
10864
+ if (requested === "stdio" || requested === "streamable-http") return requested;
10865
+ if (requested === "http" || requested === "json-rpc-http" || requested === "jsonrpc-http") {
10866
+ throw new Error("mcp config uses stdio or streamable-http. The lightweight JSON-RPC HTTP bridge is not a standard MCP client transport.");
10867
+ }
10868
+ throw new Error("--transport must be stdio or streamable-http");
10869
+ }
10870
+ function mcpClientConfigAliasMode(args, client) {
10871
+ const requested = optionalArg(args, "--alias-mode");
10872
+ const aliasMode = requested ?? (args.includes("--openai-tool-aliases") ? "openai" : client === "openai-agents" ? "openai" : "canonical");
10873
+ if (aliasMode === "canonical" || aliasMode === "openai" || aliasMode === "both") return aliasMode;
10874
+ throw new Error("--alias-mode must be canonical, openai, or both");
10875
+ }
10876
+ function serveArgsForClient(configPath, storePath, options) {
10877
+ const args = options.transport === "streamable-http" ? [
10878
+ "mcp",
10879
+ "serve-streamable-http",
10880
+ "--config",
10881
+ configPath,
10882
+ "--store",
10883
+ storePath,
10884
+ "--host",
10885
+ options.host,
10886
+ "--port",
10887
+ String(options.port),
10888
+ "--auth-token-env",
10889
+ options.authTokenEnv
10890
+ ] : ["mcp", "serve", "--config", configPath, "--store", storePath];
10891
+ if (options.aliasMode !== "canonical") args.push("--alias-mode", options.aliasMode);
10892
+ return args;
8779
10893
  }
8780
- function mcpClientSnippet(client, configPath, storePath) {
8781
- const command = "synapsor";
8782
- const args = ["mcp", "serve", "--config", configPath, "--store", storePath];
10894
+ function mcpClientSnippet(client, configPath, storePath, options) {
10895
+ const command = cliCommandName();
10896
+ const args = serveArgsForClient(configPath, storePath, options);
8783
10897
  if (client === "generic" || client === "generic-stdio") return { command, args };
8784
10898
  if (client === "claude-desktop" || client === "cursor") {
10899
+ if (options.transport !== "stdio") throw new Error(`${client} config output currently supports stdio. Use --transport stdio.`);
8785
10900
  return { mcpServers: { synapsor: { command, args } } };
8786
10901
  }
8787
10902
  if (client === "vscode") {
10903
+ if (options.transport !== "stdio") throw new Error("vscode config output currently supports stdio. Use --transport stdio.");
8788
10904
  return { servers: { synapsor: { type: "stdio", command, args } } };
8789
10905
  }
10906
+ if (client === "openai-agents") {
10907
+ if (options.transport !== "streamable-http") throw new Error("openai-agents config output uses Streamable HTTP. Use --transport streamable-http.");
10908
+ const url = `http://${options.host}:${options.port}/mcp`;
10909
+ return {
10910
+ transport: "streamable-http",
10911
+ start_server: {
10912
+ command,
10913
+ args,
10914
+ env: {
10915
+ [options.authTokenEnv]: "<set-a-random-local-token>"
10916
+ }
10917
+ },
10918
+ openai_agents_sdk: {
10919
+ package: "openai-agents",
10920
+ url,
10921
+ headers_from_env: {
10922
+ Authorization: `Bearer $${options.authTokenEnv}`
10923
+ },
10924
+ python: [
10925
+ "import os",
10926
+ "from agents.mcp import MCPServerStreamableHttp",
10927
+ "",
10928
+ "synapsor_mcp = MCPServerStreamableHttp(",
10929
+ ` params={`,
10930
+ ` "url": "${url}",`,
10931
+ ` "headers": {"Authorization": f"Bearer {os.environ['${options.authTokenEnv}']}"},`,
10932
+ " }",
10933
+ ")"
10934
+ ].join("\n")
10935
+ },
10936
+ tool_names: {
10937
+ canonical: "billing.inspect_invoice",
10938
+ model_visible_with_alias_mode_openai: "billing__inspect_invoice",
10939
+ alias_mode: options.aliasMode
10940
+ },
10941
+ notes: [
10942
+ "Start the local Streamable HTTP MCP server before creating the OpenAI Agents SDK server.",
10943
+ "OpenAI-facing configs should use --alias-mode openai because OpenAI function names cannot contain dots.",
10944
+ "Runner maps aliases back to canonical Synapsor capability names and includes the canonical name in MCP tool metadata.",
10945
+ "This config contains no database URLs, write credentials, API keys, or bearer token values."
10946
+ ]
10947
+ };
10948
+ }
8790
10949
  throw new Error(`unsupported MCP client: ${client}`);
8791
10950
  }
10951
+ function mcpAgentInstructions(client, aliasMode) {
10952
+ const toolNameNote = aliasMode === "openai" ? "OpenAI-facing tool names may use aliases such as billing__inspect_invoice. Treat the canonical Synapsor capability name in tool metadata/results as the audit name." : "Use the model-visible Synapsor tool names exactly as listed by the MCP client.";
10953
+ return {
10954
+ target_client: client,
10955
+ alias_mode: aliasMode,
10956
+ recommended_system_prompt: [
10957
+ "Use Synapsor Runner tools in a propose-first pattern.",
10958
+ "Inspect relevant records, policy rows, and other evidence before proposing a change.",
10959
+ "Do not claim a database change was committed unless a result says source_database_changed: true.",
10960
+ "Proposal tools create reviewable proposals only; they do not commit writes.",
10961
+ "You cannot approve, apply, commit, or write back through model-facing MCP tools.",
10962
+ "On VERSION_CONFLICT, re-inspect the record before proposing again.",
10963
+ "Evidence handles are audit/replay handles; you do not need to call them during the turn.",
10964
+ toolNameNote
10965
+ ].join(" "),
10966
+ checklist: [
10967
+ "Inspect evidence before proposing.",
10968
+ "Use trusted session scope; never ask the user/model for tenant or principal values.",
10969
+ "Report proposal ids and source_database_changed exactly from the tool result.",
10970
+ "If ok is false, follow error.code. On TEMPORARILY_UNAVAILABLE, retry later. On NOT_FOUND_IN_TENANT, do not infer cross-tenant existence."
10971
+ ]
10972
+ };
10973
+ }
8792
10974
  async function mcpSmoke(args) {
8793
10975
  const boundary = await inspectMcpToolBoundary(args);
8794
10976
  if (args.includes("--json")) {
@@ -8799,6 +10981,35 @@ async function mcpSmoke(args) {
8799
10981
  }
8800
10982
  return boundary.ok ? 0 : 1;
8801
10983
  }
10984
+ async function smokeCall(args) {
10985
+ const configPath = optionalArg(args, "--config") ?? defaultConfigPath;
10986
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? defaultStorePath;
10987
+ const config = await readRuntimeConfig(configPath);
10988
+ const env = envWithDemoDefaults(config, configPath);
10989
+ const store = new ProposalStore(storePath);
10990
+ const runtime = createMcpRuntime(config, { store, env });
10991
+ try {
10992
+ const tools2 = runtime.listTools();
10993
+ const requestedTool = firstPositional(args);
10994
+ const toolName = requestedTool ?? (tools2.length === 1 ? tools2[0]?.name : void 0);
10995
+ if (!toolName) {
10996
+ throw new Error(`smoke call needs <capability-name> because ${tools2.length} tools are exposed: ${tools2.map((tool) => tool.name).join(", ") || "none"}`);
10997
+ }
10998
+ const capability = (config.capabilities ?? []).find((item) => item.name === toolName);
10999
+ if (!capability && config.mode !== "cloud") throw new Error(`capability not found in ${configPath}: ${toolName}`);
11000
+ const input = capability ? await smokeToolInput(args, capability) : await smokeInputFromArgs(args);
11001
+ const result = await runtime.callTool(toolName, input);
11002
+ if (args.includes("--json")) {
11003
+ process2.stdout.write(`${JSON.stringify({ ok: true, tool: toolName, input, result, store_path: storePath }, null, 2)}
11004
+ `);
11005
+ } else {
11006
+ process2.stdout.write(formatSmokeCallResult(toolName, input, result, storePath));
11007
+ }
11008
+ return 0;
11009
+ } finally {
11010
+ runtime.close();
11011
+ }
11012
+ }
8802
11013
  async function toolsPreview(args) {
8803
11014
  const boundary = await inspectMcpToolBoundary(args);
8804
11015
  if (args.includes("--json")) {
@@ -8806,7 +11017,9 @@ async function toolsPreview(args) {
8806
11017
  ok: boundary.ok,
8807
11018
  config_path: boundary.configPath,
8808
11019
  store_path: boundary.storePath,
11020
+ alias_mode: boundary.aliasMode,
8809
11021
  exposed_to_mcp: boundary.names,
11022
+ alias_mappings: boundary.exposures,
8810
11023
  not_exposed_to_mcp: defaultBlockedToolSurface(),
8811
11024
  checks: boundary.checks
8812
11025
  }, null, 2)}
@@ -8819,6 +11032,7 @@ async function toolsPreview(args) {
8819
11032
  async function inspectMcpToolBoundary(args) {
8820
11033
  const configPath = optionalArg(args, "--config") ?? "./synapsor.runner.json";
8821
11034
  const storePath = optionalArg(args, "--store") ?? "./.synapsor/local.db";
11035
+ const aliasMode = args.includes("--aliases") && !optionalArg(args, "--alias-mode") && !optionalArg(args, "--tool-name-style") ? "both" : toolNameStyleOption(args);
8822
11036
  if (!await fileExists(configPath)) {
8823
11037
  throw new Error(`MCP tool preview could not find ${configPath}.
8824
11038
 
@@ -8826,13 +11040,14 @@ Why it matters:
8826
11040
  The MCP server needs a reviewed config before it can expose semantic tools.
8827
11041
 
8828
11042
  Fix:
8829
- Run ${cliCommandName()} init --wizard --from-env DATABASE_URL, or pass --config <path>.`);
11043
+ Run ${cliCommandName()} onboard db --from-env DATABASE_URL, or pass --config <path>.`);
8830
11044
  }
8831
11045
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
8832
11046
  const runtime = createMcpRuntime(parsed, { storePath });
8833
11047
  try {
8834
11048
  const tools2 = runtime.listTools();
8835
- const names = tools2.map((tool) => tool.name);
11049
+ const exposures = toolNameExposures(tools2.map((tool) => tool.name), aliasMode);
11050
+ const names = exposures.map((item) => item.exposedName);
8836
11051
  const serialized = JSON.stringify(tools2);
8837
11052
  const checks = [
8838
11053
  { name: "semantic tools present", ok: names.length > 0, detail: names.join(", ") || "none" },
@@ -8843,7 +11058,7 @@ Run ${cliCommandName()} init --wizard --from-env DATABASE_URL, or pass --config
8843
11058
  { name: "write credentials absent", ok: !/(password|secret|bearer|private[_-]?key|token)/i.test(serialized), detail: "MCP tools do not include write credentials" }
8844
11059
  ];
8845
11060
  const ok = checks.every((check) => check.ok);
8846
- return { ok, configPath, storePath, names, checks };
11061
+ return { ok, configPath, storePath, aliasMode, names, exposures, checks };
8847
11062
  } finally {
8848
11063
  runtime.close();
8849
11064
  }
@@ -8860,13 +11075,15 @@ function defaultBlockedToolSurface() {
8860
11075
  ];
8861
11076
  }
8862
11077
  function formatToolsPreview(input) {
11078
+ const exposedLines = input.exposures.length > 0 ? input.exposures.map((item) => item.isAlias ? ` - ${item.exposedName} -> ${item.canonicalName}` : ` - ${item.exposedName}`) : [" - (none)"];
8863
11079
  const lines = [
8864
11080
  `Synapsor tools preview: ${input.ok ? "ok" : "failed"}`,
8865
11081
  `Config: ${input.configPath}`,
8866
11082
  `Store: ${input.storePath}`,
11083
+ `Alias mode: ${input.aliasMode}`,
8867
11084
  "",
8868
11085
  "Exposed to MCP:",
8869
- ...input.names.length > 0 ? input.names.map((name) => ` - ${name}`) : [" - (none)"],
11086
+ ...exposedLines,
8870
11087
  "",
8871
11088
  "Not exposed to MCP:",
8872
11089
  ...defaultBlockedToolSurface().map((name) => ` - ${name}`),
@@ -8900,6 +11117,69 @@ function formatMcpSmoke(input) {
8900
11117
  return `${lines.join("\n")}
8901
11118
  `;
8902
11119
  }
11120
+ async function smokeToolInput(args, capability) {
11121
+ if (!args.includes("--sample") && !optionalArg(args, "--input") && !optionalArg(args, "--json")) {
11122
+ return sampleInputForCapability(capability);
11123
+ }
11124
+ return await smokeInputFromArgs(args, capability);
11125
+ }
11126
+ async function smokeInputFromArgs(args, capability) {
11127
+ const jsonInput = optionalArg(args, "--json");
11128
+ const inputPath = optionalArg(args, "--input");
11129
+ const sample = args.includes("--sample");
11130
+ const selected = [Boolean(jsonInput), Boolean(inputPath), sample].filter(Boolean).length;
11131
+ if (selected > 1) throw new Error("smoke call accepts only one of --sample, --input, or --json");
11132
+ if (jsonInput) {
11133
+ const parsed = JSON.parse(jsonInput);
11134
+ if (!isRecord6(parsed)) throw new Error("smoke call --json must be a JSON object");
11135
+ return parsed;
11136
+ }
11137
+ if (inputPath) {
11138
+ const parsed = JSON.parse(await fs3.readFile(inputPath, "utf8"));
11139
+ if (!isRecord6(parsed)) throw new Error("smoke call --input must point to a JSON object");
11140
+ return parsed;
11141
+ }
11142
+ if (sample && capability) return sampleInputForCapability(capability);
11143
+ if (sample) return {};
11144
+ return {};
11145
+ }
11146
+ function formatSmokeCallResult(toolName, input, result, storePath) {
11147
+ const evidenceId = stringField(result, "evidence_bundle_id");
11148
+ const proposalId = stringField(result, "proposal_id");
11149
+ const replayResource = stringField(result, "replay_resource");
11150
+ const sourceChanged = result.source_database_changed === true || result.source_database_mutated === true;
11151
+ const lines = [
11152
+ "Synapsor smoke call: ok",
11153
+ "",
11154
+ "Tool:",
11155
+ toolName,
11156
+ "",
11157
+ "Input:",
11158
+ JSON.stringify(input, null, 2),
11159
+ "",
11160
+ "Source DB changed:",
11161
+ sourceChanged ? "yes" : "no",
11162
+ "",
11163
+ "Evidence:",
11164
+ evidenceId || "(not returned)",
11165
+ ""
11166
+ ];
11167
+ if (proposalId) {
11168
+ lines.push("Proposal:", proposalId, "", "Replay:", replayResource || `synapsor://replay/replay_${proposalId}`, "");
11169
+ }
11170
+ lines.push("Next:");
11171
+ if (evidenceId) lines.push(` ${cliCommandName()} evidence show ${evidenceId} --store ${storePath}`);
11172
+ if (proposalId) {
11173
+ lines.push(` ${cliCommandName()} proposals show ${proposalId} --store ${storePath}`);
11174
+ lines.push(` ${cliCommandName()} proposals approve ${proposalId} --store ${storePath}`);
11175
+ lines.push(` ${cliCommandName()} apply ${proposalId} --store ${storePath}`);
11176
+ lines.push(` ${cliCommandName()} replay show --proposal ${proposalId} --store ${storePath}`);
11177
+ } else if (evidenceId) {
11178
+ lines.push(` ${cliCommandName()} query-audit list --evidence ${evidenceId} --store ${storePath}`);
11179
+ }
11180
+ return `${lines.join("\n")}
11181
+ `;
11182
+ }
8903
11183
  async function writeMcpClientSnippet(destination, client, snippet, yes) {
8904
11184
  const resolved = path3.resolve(destination);
8905
11185
  let existing = {};
@@ -9406,11 +11686,19 @@ async function activity(args) {
9406
11686
  usage(["activity"]);
9407
11687
  return 2;
9408
11688
  }
11689
+ async function events(args) {
11690
+ const [subcommand, ...rest] = args;
11691
+ if (subcommand === "tail") return eventsTail(rest);
11692
+ if (subcommand === "webhook" || subcommand === "push") return eventsWebhook(rest);
11693
+ usage(["events"]);
11694
+ return 2;
11695
+ }
9409
11696
  async function storeCommand(args) {
9410
11697
  const [subcommand, ...rest] = args;
9411
11698
  if (subcommand === "stats") return storeStats(rest);
9412
11699
  if (subcommand === "vacuum") return storeVacuum(rest);
9413
11700
  if (subcommand === "prune") return storePrune(rest);
11701
+ if (subcommand === "reset") return storeReset(rest);
9414
11702
  usage(["store"]);
9415
11703
  return 2;
9416
11704
  }
@@ -9435,6 +11723,10 @@ async function ui(args) {
9435
11723
  });
9436
11724
  process2.stdout.write(`Synapsor Runner local UI: ${server.url}
9437
11725
  `);
11726
+ if (args.includes("--open")) {
11727
+ openBrowser(server.url);
11728
+ process2.stdout.write("Opening the local review UI in your browser when a desktop opener is available.\n");
11729
+ }
9438
11730
  process2.stdout.write("Approval and rejection actions require the per-run local session plus CSRF token. Press Ctrl+C to stop.\n");
9439
11731
  await new Promise((resolve) => {
9440
11732
  const stop = async () => {
@@ -9448,6 +11740,13 @@ async function ui(args) {
9448
11740
  });
9449
11741
  return 0;
9450
11742
  }
11743
+ function openBrowser(url) {
11744
+ const command = process2.platform === "darwin" ? "open" : process2.platform === "win32" ? "cmd" : "xdg-open";
11745
+ const args = process2.platform === "win32" ? ["/c", "start", "", url] : [url];
11746
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
11747
+ child.on("error", () => void 0);
11748
+ child.unref();
11749
+ }
9451
11750
  async function shadowList(args) {
9452
11751
  const store = await openLocalStore(args);
9453
11752
  try {
@@ -9915,6 +12214,125 @@ async function activitySearch(args) {
9915
12214
  store.close();
9916
12215
  }
9917
12216
  }
12217
+ async function eventsTail(args) {
12218
+ assertKnownOptions(args, eventTailAllowedOptions, "events tail");
12219
+ const follow = args.includes("--follow");
12220
+ if (follow && args.includes("--json")) throw new Error("events tail --follow does not support --json yet");
12221
+ const storePath = optionalArg(args, "--store");
12222
+ const intervalMs = Number(optionalArg(args, "--interval-ms") ?? "1000");
12223
+ if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
12224
+ const filters = eventFiltersFromArgs(args);
12225
+ const printOnce = async (seen2) => {
12226
+ const store = await openLocalStore(["--store", storePath ?? "./.synapsor/local.db"]);
12227
+ try {
12228
+ const rows = store.listEvents(filters).sort((left, right) => left.event_id - right.event_id).filter((event) => !seen2?.has(event.event_id));
12229
+ if (seen2) rows.forEach((event) => seen2.add(event.event_id));
12230
+ if (args.includes("--json")) {
12231
+ process2.stdout.write(`${JSON.stringify({ events: rows }, null, 2)}
12232
+ `);
12233
+ } else if (rows.length === 0 && !follow) {
12234
+ process2.stdout.write("No local events found.\n");
12235
+ } else {
12236
+ for (const event of rows) process2.stdout.write(formatEventLine(event, showDetails(args)));
12237
+ }
12238
+ return rows.length;
12239
+ } finally {
12240
+ store.close();
12241
+ }
12242
+ };
12243
+ if (!follow) {
12244
+ await printOnce();
12245
+ return 0;
12246
+ }
12247
+ const seen = /* @__PURE__ */ new Set();
12248
+ await printOnce(seen);
12249
+ await new Promise((resolve) => {
12250
+ const timer = setInterval(() => {
12251
+ void printOnce(seen).catch((error) => {
12252
+ process2.stderr.write(`events tail error: ${safeErrorMessage(error)}
12253
+ `);
12254
+ });
12255
+ }, intervalMs);
12256
+ const stop = () => {
12257
+ clearInterval(timer);
12258
+ process2.off("SIGINT", stop);
12259
+ process2.off("SIGTERM", stop);
12260
+ resolve();
12261
+ };
12262
+ process2.once("SIGINT", stop);
12263
+ process2.once("SIGTERM", stop);
12264
+ });
12265
+ return 0;
12266
+ }
12267
+ async function eventsWebhook(args) {
12268
+ assertKnownOptions(args, eventWebhookAllowedOptions, "events webhook");
12269
+ const url = optionalArg(args, "--url") ?? envValue(optionalArg(args, "--url-env"));
12270
+ if (!url) throw new Error("events webhook requires --url <https://...> or --url-env <ENV>");
12271
+ const endpoint = new URL(url);
12272
+ if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("events webhook URL must use http or https");
12273
+ const follow = args.includes("--follow");
12274
+ const dryRun = args.includes("--dry-run");
12275
+ const jsonOutput = args.includes("--json");
12276
+ if (follow && jsonOutput) throw new Error("events webhook --follow does not support --json");
12277
+ const storePath = optionalArg(args, "--store");
12278
+ const intervalMs = Number(optionalArg(args, "--interval-ms") ?? "1000");
12279
+ const timeoutMs = Number(optionalArg(args, "--timeout-ms") ?? "5000");
12280
+ const sinceEventId = optionalPositiveIntegerArg(args, "--since-event-id");
12281
+ if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
12282
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 250) throw new Error("--timeout-ms must be at least 250");
12283
+ const token = envValue(optionalArg(args, "--auth-token-env"));
12284
+ const filters = eventFiltersFromArgs(args);
12285
+ const seen = /* @__PURE__ */ new Set();
12286
+ const pushOnce = async () => {
12287
+ const store = await openLocalStore(["--store", storePath ?? "./.synapsor/local.db"]);
12288
+ try {
12289
+ const rows = store.listEvents(filters).filter((event) => sinceEventId === void 0 || event.event_id > sinceEventId).sort((left, right) => left.event_id - right.event_id).filter((event) => !seen.has(event.event_id));
12290
+ rows.forEach((event) => seen.add(event.event_id));
12291
+ let delivered = 0;
12292
+ const payloads = [];
12293
+ for (const event of rows) {
12294
+ const payload = localEventWebhookPayload(event, store.path);
12295
+ payloads.push(payload);
12296
+ if (dryRun) {
12297
+ if (!jsonOutput) process2.stdout.write(`${JSON.stringify(payload, null, 2)}
12298
+ `);
12299
+ } else {
12300
+ await postLocalEventWebhook(endpoint, payload, { token, timeoutMs });
12301
+ if (!jsonOutput) process2.stdout.write(`pushed event ${event.event_id} ${event.kind} for ${event.proposal_id} to ${redactWebhookUrl(endpoint)}
12302
+ `);
12303
+ }
12304
+ delivered += 1;
12305
+ }
12306
+ if (rows.length === 0 && !follow && !jsonOutput) process2.stdout.write(dryRun ? "No local events matched for dry-run.\n" : "No local events matched.\n");
12307
+ return { delivered, payloads };
12308
+ } finally {
12309
+ store.close();
12310
+ }
12311
+ };
12312
+ const first = await pushOnce();
12313
+ if (jsonOutput && !follow) {
12314
+ process2.stdout.write(`${JSON.stringify({ ok: true, dry_run: dryRun, delivered: first.delivered, webhook: redactWebhookUrl(endpoint), events: first.payloads }, null, 2)}
12315
+ `);
12316
+ }
12317
+ if (!follow) return 0;
12318
+ await new Promise((resolve) => {
12319
+ const timer = setInterval(() => {
12320
+ void pushOnce().catch((error) => {
12321
+ process2.stderr.write(`events webhook error: ${safeErrorMessage(error)}
12322
+ `);
12323
+ });
12324
+ }, intervalMs);
12325
+ const stop = () => {
12326
+ clearInterval(timer);
12327
+ process2.off("SIGINT", stop);
12328
+ process2.off("SIGTERM", stop);
12329
+ resolve();
12330
+ };
12331
+ process2.once("SIGINT", stop);
12332
+ process2.once("SIGTERM", stop);
12333
+ });
12334
+ return 0;
12335
+ }
9918
12336
  async function storeStats(args) {
9919
12337
  assertKnownOptions(args, storeStatsAllowedOptions, "store stats");
9920
12338
  const store = await openLocalStore(args);
@@ -9952,6 +12370,7 @@ async function storePrune(args) {
9952
12370
  if (args.includes("--yes") && args.includes("--dry-run")) throw new Error("store prune accepts either --dry-run or --yes, not both");
9953
12371
  const cutoff = cutoffFromOlderThan(olderThan);
9954
12372
  const dryRun = !args.includes("--yes");
12373
+ if (!dryRun) await assertNoActiveStoreLease(optionalArg(args, "--store"), args.includes("--force"), "store prune");
9955
12374
  const store = await openLocalStore(args);
9956
12375
  try {
9957
12376
  const result = store.pruneBefore(cutoff, { dryRun });
@@ -9963,6 +12382,36 @@ async function storePrune(args) {
9963
12382
  store.close();
9964
12383
  }
9965
12384
  }
12385
+ async function storeReset(args) {
12386
+ assertKnownOptions(args, storeResetAllowedOptions, "store reset");
12387
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
12388
+ if (storePath === ":memory:") throw new Error("store reset does not apply to :memory: stores");
12389
+ if (!args.includes("--yes")) {
12390
+ throw new Error("store reset is destructive for the local ledger. Rerun with --yes after backing up anything you need.");
12391
+ }
12392
+ await assertNoActiveStoreLease(storePath, args.includes("--force"), "store reset");
12393
+ const resolved = path3.resolve(storePath);
12394
+ const candidates = [resolved, `${resolved}-wal`, `${resolved}-shm`, storeLeasePath(resolved)];
12395
+ const removed = [];
12396
+ for (const candidate of candidates) {
12397
+ try {
12398
+ await fs3.rm(candidate, { force: true });
12399
+ removed.push(candidate);
12400
+ } catch (error) {
12401
+ if (error.code !== "ENOENT") throw error;
12402
+ }
12403
+ }
12404
+ const result = {
12405
+ ok: true,
12406
+ store: resolved,
12407
+ removed,
12408
+ source_database_changed: false
12409
+ };
12410
+ if (args.includes("--json")) process2.stdout.write(`${JSON.stringify(result, null, 2)}
12411
+ `);
12412
+ else process2.stdout.write(formatStoreReset(result));
12413
+ return 0;
12414
+ }
9966
12415
  var commonReadOptions = /* @__PURE__ */ new Set(["--store", "--json", "--details", "--debug"]);
9967
12416
  var showAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
9968
12417
  var exportAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--output", "--out", "--format", "--evidence", "--audit"]);
@@ -10022,6 +12471,26 @@ var receiptListAllowedOptions = /* @__PURE__ */ new Set([
10022
12471
  "--to",
10023
12472
  "--limit"
10024
12473
  ]);
12474
+ var eventTailAllowedOptions = /* @__PURE__ */ new Set([
12475
+ ...commonReadOptions,
12476
+ "--proposal",
12477
+ "--kind",
12478
+ "--actor",
12479
+ "--from",
12480
+ "--to",
12481
+ "--limit",
12482
+ "--follow",
12483
+ "--interval-ms"
12484
+ ]);
12485
+ var eventWebhookAllowedOptions = /* @__PURE__ */ new Set([
12486
+ ...eventTailAllowedOptions,
12487
+ "--url",
12488
+ "--url-env",
12489
+ "--auth-token-env",
12490
+ "--timeout-ms",
12491
+ "--since-event-id",
12492
+ "--dry-run"
12493
+ ]);
10025
12494
  var replayShowAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--proposal", "--replay", "--evidence"]);
10026
12495
  var replayExportAllowedOptions = /* @__PURE__ */ new Set([...replayShowAllowedOptions, "--output", "--out", "--format"]);
10027
12496
  var replayListAllowedOptions = /* @__PURE__ */ new Set([
@@ -10064,7 +12533,8 @@ var activitySearchAllowedOptions = /* @__PURE__ */ new Set([
10064
12533
  ]);
10065
12534
  var storeStatsAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
10066
12535
  var storeVacuumAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
10067
- var storePruneAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--older-than", "--dry-run", "--yes"]);
12536
+ var storePruneAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--older-than", "--dry-run", "--yes", "--force"]);
12537
+ var storeResetAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--yes", "--force"]);
10068
12538
  function assertKnownOptions(args, allowed, commandName) {
10069
12539
  for (const arg of args) {
10070
12540
  if (!arg.startsWith("--")) continue;
@@ -10213,6 +12683,27 @@ function receiptFiltersFromActivityArgs(args, store) {
10213
12683
  limit: limitFromArgs(args)
10214
12684
  };
10215
12685
  }
12686
+ function eventFiltersFromArgs(args) {
12687
+ return {
12688
+ proposal: optionalArg(args, "--proposal"),
12689
+ kind: optionalArg(args, "--kind"),
12690
+ actor: optionalArg(args, "--actor"),
12691
+ from: optionalArg(args, "--from"),
12692
+ to: optionalArg(args, "--to"),
12693
+ limit: limitFromArgs(args)
12694
+ };
12695
+ }
12696
+ function optionalPositiveIntegerArg(args, flag) {
12697
+ const value = optionalArg(args, flag);
12698
+ if (!value) return void 0;
12699
+ const parsed = Number(value);
12700
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
12701
+ return parsed;
12702
+ }
12703
+ function envValue(name) {
12704
+ if (!name) return void 0;
12705
+ return process2.env[name];
12706
+ }
10216
12707
  function linkedProposalFilter(args, store, options = {}) {
10217
12708
  const noLinkedProposal = "__synapsor_no_linked_proposal__";
10218
12709
  const replay2 = optionalArg(args, "--replay");
@@ -10393,8 +12884,8 @@ async function prepareReferenceDemo(args) {
10393
12884
  ].join("\n"));
10394
12885
  const down = spawnSync("docker", ["compose", "-f", composePath, "down", "-v", "--remove-orphans"], { stdio: "inherit", env: process2.env });
10395
12886
  if (down.status !== 0) return down.status ?? 1;
10396
- const up = spawnSync("docker", ["compose", "-f", composePath, "up", "-d"], { stdio: "inherit", env: process2.env });
10397
- if (up.status !== 0) return up.status ?? 1;
12887
+ const up2 = spawnSync("docker", ["compose", "-f", composePath, "up", "-d"], { stdio: "inherit", env: process2.env });
12888
+ if (up2.status !== 0) return up2.status ?? 1;
10398
12889
  await waitForReferenceDemoDatabase();
10399
12890
  await fs3.copyFile(path3.join(demoDir, "synapsor.runner.json"), configPath);
10400
12891
  process2.stdout.write([
@@ -10416,7 +12907,7 @@ async function prepareReferenceDemo(args) {
10416
12907
  `${cliCommandName()} mcp config --absolute-paths --config ./synapsor.runner.json --store ./.synapsor/local.db`,
10417
12908
  "",
10418
12909
  "Open UI:",
10419
- `${cliCommandName()} ui --tour`,
12910
+ `${cliCommandName()} ui --open --tour`,
10420
12911
  ""
10421
12912
  ].join("\n"));
10422
12913
  return 0;
@@ -10470,7 +12961,7 @@ function databaseInputFromArgs(args) {
10470
12961
  if (inlineUrl && !isDatabaseUrl(inlineUrl)) {
10471
12962
  throw new Error("--from must be a postgres://, postgresql://, or mysql:// URL.");
10472
12963
  }
10473
- const fromEnv = optionalArg(args, "--from-env") ?? optionalArg(args, "--database-url-env");
12964
+ const fromEnv = optionalArg(args, "--from-env") ?? optionalArg(args, "--url-env") ?? optionalArg(args, "--database-url-env");
10474
12965
  const configDatabaseUrlEnv = fromEnv ?? "SYNAPSOR_DATABASE_READ_URL";
10475
12966
  if (inlineUrl) {
10476
12967
  return {
@@ -10516,6 +13007,7 @@ function firstPositional(args) {
10516
13007
  "--format",
10517
13008
  "--from",
10518
13009
  "--from-env",
13010
+ "--url-env",
10519
13011
  "--host",
10520
13012
  "--idempotency-key",
10521
13013
  "--input",
@@ -10545,6 +13037,10 @@ function firstPositional(args) {
10545
13037
  "--reason",
10546
13038
  "--recipe",
10547
13039
  "--receipt",
13040
+ "--read-tool",
13041
+ "--inspect-tool-name",
13042
+ "--proposal-tool",
13043
+ "--proposal-tool-name",
10548
13044
  "--replay",
10549
13045
  "--runner",
10550
13046
  "--schema",
@@ -10706,11 +13202,11 @@ function formatProposalDetail(proposal, storedEvidenceItemCount) {
10706
13202
  ...formatChangeLines(proposal)
10707
13203
  ].join("\n") + "\n";
10708
13204
  }
10709
- function formatProposalEventDetail(events) {
10710
- if (events.length === 0) return "Events:\n none\n";
13205
+ function formatProposalEventDetail(events2) {
13206
+ if (events2.length === 0) return "Events:\n none\n";
10711
13207
  return [
10712
13208
  "Events:",
10713
- ...events.map((event) => ` event ${event.event_id}: ${event.kind} by ${event.actor} at ${event.created_at}`)
13209
+ ...events2.map((event) => ` event ${event.event_id}: ${event.kind} by ${event.actor} at ${event.created_at}`)
10714
13210
  ].join("\n") + "\n";
10715
13211
  }
10716
13212
  function formatProposalDebug(proposal, storePath) {
@@ -11172,6 +13668,64 @@ function formatActivityNext(items, storeSuffix) {
11172
13668
  return `${lines.join("\n")}
11173
13669
  `;
11174
13670
  }
13671
+ function formatEventLine(event, details = false) {
13672
+ const lines = [
13673
+ `${event.created_at} ${event.kind}`,
13674
+ ` proposal: ${event.proposal_id}`,
13675
+ ` actor: ${event.actor}`
13676
+ ];
13677
+ if (details && Object.keys(event.payload).length > 0) {
13678
+ lines.push(` payload: ${JSON.stringify(event.payload)}`);
13679
+ }
13680
+ lines.push("");
13681
+ return `${lines.join("\n")}
13682
+ `;
13683
+ }
13684
+ function localEventWebhookPayload(event, storePath) {
13685
+ return {
13686
+ schema_version: "synapsor.local-event-webhook.v1",
13687
+ delivered_at: (/* @__PURE__ */ new Date()).toISOString(),
13688
+ source: {
13689
+ kind: "local_store",
13690
+ store_path: storePath
13691
+ },
13692
+ event
13693
+ };
13694
+ }
13695
+ async function postLocalEventWebhook(endpoint, payload, options) {
13696
+ const controller = new AbortController();
13697
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
13698
+ try {
13699
+ const headers = {
13700
+ "content-type": "application/json",
13701
+ accept: "application/json",
13702
+ "user-agent": "synapsor-runner-events-webhook"
13703
+ };
13704
+ if (options.token) headers.authorization = `Bearer ${options.token}`;
13705
+ const response = await fetch(endpoint, {
13706
+ method: "POST",
13707
+ headers,
13708
+ body: JSON.stringify(payload),
13709
+ signal: controller.signal
13710
+ });
13711
+ if (!response.ok) {
13712
+ const text = await response.text().catch(() => "");
13713
+ throw new Error(`webhook returned HTTP ${response.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
13714
+ }
13715
+ } finally {
13716
+ clearTimeout(timeout);
13717
+ }
13718
+ }
13719
+ function redactWebhookUrl(endpoint) {
13720
+ const copy = new URL(endpoint.toString());
13721
+ copy.username = "";
13722
+ copy.password = "";
13723
+ copy.search = "";
13724
+ return copy.toString();
13725
+ }
13726
+ function safeErrorMessage(error) {
13727
+ return error instanceof Error ? error.message : String(error);
13728
+ }
11175
13729
  function formatStoreStats(stats) {
11176
13730
  return [
11177
13731
  `Local store: ${stats.path}`,
@@ -11203,6 +13757,18 @@ function formatStorePrune(result) {
11203
13757
  return `${lines.join("\n")}
11204
13758
  `;
11205
13759
  }
13760
+ function formatStoreReset(result) {
13761
+ const lines = [
13762
+ "Local store reset complete",
13763
+ `Store: ${result.store}`,
13764
+ `Source database changed: ${result.source_database_changed ? "yes" : "no"}`,
13765
+ "",
13766
+ "Removed:",
13767
+ ...result.removed.length ? result.removed.map((entry) => ` - ${entry}`) : [" - no local store files were present"]
13768
+ ];
13769
+ return `${lines.join("\n")}
13770
+ `;
13771
+ }
11206
13772
  function cutoffFromOlderThan(value) {
11207
13773
  const match = value.match(/^(\d+)([smhd])$/i);
11208
13774
  if (!match) throw new Error("--older-than must use a duration such as 30d, 12h, 90m, or 0d");
@@ -11444,7 +14010,7 @@ function starterCloudConfig() {
11444
14010
  base_url_env: "SYNAPSOR_CLOUD_BASE_URL",
11445
14011
  runner_token_env: "SYNAPSOR_RUNNER_TOKEN",
11446
14012
  runner_id: "synapsor_runner_local",
11447
- runner_version: "0.1.0-alpha.8",
14013
+ runner_version: "0.1.0",
11448
14014
  project_id: "token_scope",
11449
14015
  adapter_id: "mcp.your_adapter",
11450
14016
  source_id: "src_replace_me",
@@ -11479,10 +14045,14 @@ function isKnownTopLevelCommand(command) {
11479
14045
  "propose",
11480
14046
  "audit",
11481
14047
  "start",
14048
+ "up",
11482
14049
  "runner",
11483
14050
  "cloud",
11484
14051
  "mcp",
14052
+ "smoke",
11485
14053
  "tools",
14054
+ "writeback",
14055
+ "handler",
11486
14056
  "onboard",
11487
14057
  "demo",
11488
14058
  "recipes",
@@ -11493,6 +14063,7 @@ function isKnownTopLevelCommand(command) {
11493
14063
  "query-audit",
11494
14064
  "receipts",
11495
14065
  "activity",
14066
+ "events",
11496
14067
  "store",
11497
14068
  "shadow",
11498
14069
  "ui"
@@ -11504,7 +14075,7 @@ function cliCommandName() {
11504
14075
  }
11505
14076
  function usage(args = []) {
11506
14077
  const [command, subcommand] = args;
11507
- const key = command === "mcp" && subcommand ? `mcp ${subcommand}` : command ?? "";
14078
+ const key = (command === "mcp" || command === "handler") && subcommand ? `${command} ${subcommand}` : command ?? "";
11508
14079
  const cmd = cliCommandName();
11509
14080
  const help = {
11510
14081
  "": `Synapsor Runner
@@ -11516,8 +14087,15 @@ Usage:
11516
14087
 
11517
14088
  Commands:
11518
14089
  inspect Inspect a Postgres/MySQL schema
14090
+ start Start guided own-database setup, or no-arg legacy worker polling
14091
+ up Bring up local review mode guidance/server
11519
14092
  init Generate a Synapsor capability contract
11520
14093
  mcp Serve safe semantic tools over MCP
14094
+ onboard One-command own-database setup
14095
+ smoke Test generated tool calls before wiring an MCP client
14096
+ tools List model-facing MCP tools and aliases
14097
+ writeback Print direct SQL writeback receipt DDL, grants, and checks
14098
+ handler Create app-owned writeback handler templates
11521
14099
  propose Create a local evidence-backed proposal
11522
14100
  audit Review MCP/database tool risk
11523
14101
  proposals Review, approve, or reject proposals
@@ -11525,6 +14103,7 @@ Commands:
11525
14103
  query-audit Inspect local query audit records
11526
14104
  receipts Inspect guarded writeback receipts
11527
14105
  activity Search local evidence/replay ledger
14106
+ events Tail or push local proposal/writeback lifecycle events
11528
14107
  store Inspect and maintain the local SQLite ledger
11529
14108
  apply Apply an approved proposal with guarded writeback
11530
14109
  replay Show what happened
@@ -11532,47 +14111,137 @@ Commands:
11532
14111
  ui Open the local review UI
11533
14112
 
11534
14113
  Examples:
14114
+ ${cmd} start --from-env DATABASE_URL
14115
+ ${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db --dry-run
14116
+ ${cmd} onboard db --from-env DATABASE_URL
11535
14117
  ${cmd} inspect --from-env DATABASE_URL
11536
14118
  ${cmd} init --wizard --from-env DATABASE_URL
14119
+ ${cmd} smoke call --config ./synapsor.runner.json --store ./.synapsor/local.db
14120
+ ${cmd} tools list --aliases --config ./synapsor.runner.json --store ./.synapsor/local.db
14121
+ ${cmd} handler template node-fastify --output ./synapsor-writeback-handler.mjs
11537
14122
  ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
11538
14123
  ${cmd} propose billing.propose_late_fee_waiver --sample
11539
14124
  ${cmd} audit ./synapsor.runner.json
14125
+ `,
14126
+ up: `Usage:
14127
+ ${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db [--transport stdio|streamable-http]
14128
+ ${cmd} up --serve --config ./synapsor.runner.json --store ./.synapsor/local.db --port 8766 --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
14129
+ ${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db --handler-check --dry-run
14130
+
14131
+ Validate the local Runner config and store, summarize model-facing tools,
14132
+ explain direct SQL versus app-owned executor writeback, and print the next
14133
+ smoke/approve/apply/replay commands.
14134
+
14135
+ With --transport stdio, \`${cmd} up\` prints MCP client wiring because stdio is
14136
+ launched by the client. \`${cmd} up --serve\` starts the standard Streamable HTTP
14137
+ MCP server after the checklist. Use --with-handler to run the handler doctor
14138
+ before serving app-owned writeback configs.
14139
+
14140
+ Options:
14141
+ --serve
14142
+ --alias-mode canonical|openai|both
14143
+ --result-format v1|v2
14144
+ --handler-check
14145
+ --with-handler
14146
+ --open-ui
14147
+ --dry-run
14148
+ `,
14149
+ start: `Usage:
14150
+ ${cmd} start --from-env DATABASE_URL [--schema public] [--mode read_only|shadow|review]
14151
+ ${cmd} start --from-env DATABASE_URL --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL [--handler-signing-secret-env APP_WRITEBACK_SIGNING_SECRET]
14152
+ ${cmd} start
14153
+
14154
+ With --from-env, run the guided own-database setup: inspect schema, choose one
14155
+ object, create trusted context, generate semantic MCP tools, run/print a smoke
14156
+ call, and print MCP/UI next steps.
14157
+
14158
+ With no flags, start the legacy cloud-linked writeback polling worker from the
14159
+ worker environment config. Prefer \`${cmd} runner start\` for that worker path
14160
+ so it is not confused with first-run onboarding.
11540
14161
  `,
11541
14162
  inspect: `Usage:
11542
14163
  ${cmd} inspect --from-env DATABASE_URL [--engine auto|postgres|mysql] [--schema public] [--json]
14164
+ ${cmd} inspect --engine postgres --url-env DATABASE_URL
11543
14165
  ${cmd} inspect "<postgres-or-mysql-url>" [--engine auto|postgres|mysql] [--schema public] [--json]
11544
14166
 
11545
14167
  Inspect schema metadata without mutating the database or printing credentials.
11546
14168
  `,
11547
14169
  init: `Usage:
11548
14170
  ${cmd} init --wizard --from-env DATABASE_URL [--mode read_only|review|shadow] [--out synapsor.runner.json]
11549
- ${cmd} init --inspection-json schema.json --table invoices --mode review --patch-from-arg waiver_reason=reason
14171
+ ${cmd} init --engine postgres --url-env DATABASE_URL --mode review --table public.invoices
14172
+ ${cmd} init --inspection-json schema.json --table invoices --mode review --patch late_fee_cents=fixed:0,waiver_reason=arg:reason
14173
+ ${cmd} init --answers answers.json --yes
14174
+ ${cmd} init --inspection-json schema.json --table invoices --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL --emit-handler [--handler-signing-secret-env APP_WRITEBACK_SIGNING_SECRET]
11550
14175
 
11551
14176
  Generate a reviewed Synapsor Runner contract. Defaults to read-only in the wizard.
11552
- `,
14177
+ Review mode writeback choices: sql_update, http_handler, command_handler.
14178
+ If --namespace is omitted, init derives one from the table name instead of using source.*.
14179
+ Use --read-tool and --proposal-tool to override the exact model-facing capability names.
14180
+ The guided wizard shows a final preview and lets you revise visible fields or capability names before writing files.
14181
+ Use --yes/--non-interactive plus explicit flags, or --answers, for script/agent-friendly setup without prompts.
14182
+ `,
11553
14183
  mcp: `Usage:
11554
14184
  ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
14185
+ ${cmd} mcp serve --transport streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
14186
+ ${cmd} mcp serve-streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
11555
14187
  ${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
11556
14188
  ${cmd} mcp config --absolute-paths --config ./synapsor.runner.json --store ./.synapsor/local.db
14189
+ ${cmd} mcp client-config --client openai-agents --config ./synapsor.runner.json --store ./.synapsor/local.db
11557
14190
  ${cmd} mcp audit --example dangerous-db-mcp
11558
14191
  ${cmd} mcp audit ./tools-list.json
11559
14192
 
11560
- Use stdio for local MCP clients that launch the runner. Use authenticated HTTP for app/server deployments.
14193
+ Use stdio for local MCP clients that launch the runner. Use Streamable HTTP for standard HTTP MCP clients. Use serve-http only when you explicitly want the lightweight JSON-RPC bridge.
11561
14194
  MCP clients see semantic tools. They do not receive raw SQL, write credentials, approval tools, or commit tools.
14195
+ `,
14196
+ tools: `Usage:
14197
+ ${cmd} tools list --config ./synapsor.runner.json --store ./.synapsor/local.db
14198
+ ${cmd} tools list --aliases --config ./synapsor.runner.json --store ./.synapsor/local.db
14199
+ ${cmd} tools preview --config ./synapsor.runner.json --store ./.synapsor/local.db
14200
+
14201
+ List the model-facing MCP tools generated from a reviewed Runner config.
14202
+ Use --aliases to show canonical Synapsor names and OpenAI-safe aliases.
14203
+ This command never prints database URLs or write credentials.
11562
14204
  `,
11563
14205
  "mcp serve": `Usage:
11564
- ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db [--read-only] [--local]
14206
+ ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db [--transport stdio] [--read-only] [--local] [--alias-mode canonical|openai|both] [--result-format v1|v2]
14207
+ ${cmd} mcp serve --transport streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN [--result-format v2]
11565
14208
 
11566
14209
  Start the stdio MCP server for local MCP clients such as Claude Desktop, Cursor, or local agent tools. Startup logs stay off stdout so the MCP protocol remains clean.
14210
+ Use --alias-mode openai, or --openai-tool-aliases, for clients that reject dotted tool names. Use --alias-mode both to expose canonical and alias names.
14211
+ Use --result-format v2 to return one stable ok/summary/data/proposal/error envelope from every tool call.
14212
+ `,
14213
+ "mcp serve-streamable-http": `Usage:
14214
+ export SYNAPSOR_RUNNER_HTTP_TOKEN=...
14215
+ ${cmd} mcp serve-streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8766] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN] [--alias-mode canonical|openai|both] [--result-format v1|v2]
14216
+
14217
+ Start the spec-compatible MCP Streamable HTTP endpoint for clients and SDKs that support HTTP MCP.
14218
+ Bearer auth is required by default.
14219
+
14220
+ Alpha scope:
14221
+ - Supports MCP initialize/session behavior through the official MCP Streamable HTTP transport.
14222
+ - Use --alias-mode openai, or --openai-tool-aliases, for clients that reject dotted tool names.
14223
+ - Use --alias-mode both to expose canonical names and aliases.
14224
+ - Use --result-format v2 for the stable ok/summary/data/proposal/error envelope.
14225
+ - OpenAI aliases expose names such as billing__inspect_invoice while preserving the canonical Synapsor name in _meta.
14226
+ - Use /mcp for the MCP endpoint and /healthz for service health.
14227
+ - Sessions are in-memory. Restarting the runner clears active HTTP MCP sessions.
14228
+
14229
+ Security:
14230
+ - Defaults to 127.0.0.1:8766.
14231
+ - Refuses to start if the auth token env var is missing.
14232
+ - Use --dev-no-auth only for localhost development.
14233
+ - If binding to 0.0.0.0, use TLS, private networking, authentication, and rate limits.
14234
+ - Optional CORS: --cors-origin http://localhost:3000
11567
14235
  `,
11568
14236
  "mcp serve-http": `Usage:
11569
14237
  export SYNAPSOR_RUNNER_HTTP_TOKEN=...
11570
- ${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8765] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN]
14238
+ ${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8765] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN] [--result-format v1|v2]
11571
14239
 
11572
- Start the HTTP JSON-RPC MCP endpoint for app/server deployments. Bearer auth is required by default.
14240
+ Start the lightweight HTTP JSON-RPC bridge for app/server deployments that want simple POST calls.
14241
+ Bearer auth is required by default.
11573
14242
 
11574
14243
  Alpha scope: supports POST /mcp methods tools/list, tools/call, and resources/read.
11575
- It does not implement full MCP Streamable HTTP initialize/SSE sessions.
14244
+ It does not implement MCP Streamable HTTP initialize/session behavior. Use ${cmd} mcp serve-streamable-http for standard HTTP MCP clients.
11576
14245
 
11577
14246
  Security:
11578
14247
  - Defaults to 127.0.0.1:8765.
@@ -11582,9 +14251,67 @@ Security:
11582
14251
  - Optional CORS: --cors-origin http://localhost:3000
11583
14252
  `,
11584
14253
  "mcp config": `Usage:
11585
- ${cmd} mcp config [claude-desktop|cursor|generic|vscode] [--absolute-paths] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
14254
+ ${cmd} mcp config [claude-desktop|cursor|generic|vscode|openai-agents] [--absolute-paths] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
14255
+ ${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
11586
14256
 
11587
14257
  Print MCP client configuration that references the local runner command, not database URLs. Defaults to claude-desktop.
14258
+ OpenAI Agents SDK output uses Streamable HTTP and OpenAI-safe aliases by default.
14259
+ `,
14260
+ "mcp client-config": `Usage:
14261
+ ${cmd} mcp client-config --client claude-desktop [--absolute-paths] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
14262
+ ${cmd} mcp client-config --client cursor [--absolute-paths] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
14263
+ ${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
14264
+
14265
+ Print MCP client configuration that references the local runner command, not database URLs.
14266
+ OpenAI Agents SDK output uses Streamable HTTP and OpenAI-safe aliases by default.
14267
+ Use --include-instructions to include the recommended propose-first agent prompt.
14268
+ `,
14269
+ smoke: `Usage:
14270
+ ${cmd} smoke call [capability-name] [--sample] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
14271
+ ${cmd} smoke call [capability-name] --json '{"record_id":"..."}'
14272
+ ${cmd} smoke boundary [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
14273
+
14274
+ Call a generated semantic tool locally before wiring Claude, Cursor, or another MCP client. The call uses the same runtime as MCP, records evidence/query audit/proposals in the local store, and does not expose raw SQL or write credentials.
14275
+ `,
14276
+ writeback: `Usage:
14277
+ ${cmd} writeback doctor --config ./synapsor.runner.json [--check-db]
14278
+ ${cmd} writeback migration --engine postgres [--schema synapsor]
14279
+ ${cmd} writeback migration --engine mysql [--schema appdb]
14280
+ ${cmd} writeback grants --engine postgres --writer-role app_writer [--schema synapsor]
14281
+ ${cmd} writeback grants --engine mysql --writer-role "'app_writer'@'%'" [--schema appdb]
14282
+
14283
+ Print and verify the receipt-table setup required by direct SQL writeback. Rich writes should prefer app-owned http_handler or command_handler executors.
14284
+ `,
14285
+ handler: `Usage:
14286
+ ${cmd} handler template --list
14287
+ ${cmd} handler template node-fastify [--output ./synapsor-writeback-handler.mjs] [--force]
14288
+ ${cmd} handler template python-fastapi [--output ./synapsor_writeback_handler.py] [--force]
14289
+ ${cmd} handler template command [--output ./synapsor-command-handler.mjs] [--force]
14290
+
14291
+ Write starter app-owned writeback handlers for approved proposals. Use these when rich writes should run through your application service instead of Runner-managed SQL.
14292
+ `,
14293
+ "handler template": `Usage:
14294
+ ${cmd} handler template --list
14295
+ ${cmd} handler template node-fastify [--output ./synapsor-writeback-handler.mjs] [--force]
14296
+ ${cmd} handler template python-fastapi [--output ./synapsor_writeback_handler.py] [--force]
14297
+ ${cmd} handler template command [--output ./synapsor-command-handler.mjs] [--force]
14298
+ ${cmd} handler template node-fastify --stdout
14299
+
14300
+ Templates:
14301
+ node-fastify HTTP handler for a Node/Fastify application service
14302
+ python-fastapi HTTP handler for a Python/FastAPI application service
14303
+ command Local command handler for scripts or job runners
14304
+
14305
+ The template receives an approved proposal writeback request and must return an applied/conflict/failed receipt. Re-check tenant, principal, idempotency, row/version guards, and business policy before mutating state.
14306
+ `,
14307
+ onboard: `Usage:
14308
+ ${cmd} onboard db --from-env DATABASE_URL [--schema public] [--mode read_only|shadow|review]
14309
+ ${cmd} onboard db --from-env DATABASE_URL --table invoices --mode review --patch late_fee_cents=fixed:0 --write-url-env SYNAPSOR_DATABASE_WRITE_URL --yes
14310
+ ${cmd} onboard db --from-env DATABASE_URL --table invoices --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL --emit-handler --yes
14311
+ ${cmd} onboard db --answers answers.json --yes
14312
+
14313
+ Guided own-database setup: inspect schema, choose one object, create trusted context, choose read-only/shadow/review writeback mode, generate semantic tools, validate config, run a tool-boundary smoke check, and print the MCP/UI next steps.
14314
+ Use --yes/--non-interactive with explicit flags, or --answers, when CI or an LLM agent must run without prompts.
11588
14315
  `,
11589
14316
  propose: `Usage:
11590
14317
  ${cmd} propose <capability-name> --sample [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
@@ -11612,10 +14339,13 @@ Static MCP/database risk review only. This is not a security guarantee.
11612
14339
  doctor: `Usage:
11613
14340
  ${cmd} doctor --config synapsor.runner.json
11614
14341
  ${cmd} doctor --config synapsor.runner.json --json
14342
+ ${cmd} doctor --config synapsor.runner.json --check-handlers
14343
+ ${cmd} doctor --config synapsor.runner.json --check-writeback
11615
14344
  ${cmd} doctor --config synapsor.runner.json --report --redact --output synapsor-doctor.md
11616
14345
  ${cmd} doctor --first-run
11617
14346
 
11618
- Validate local config, environment bindings, semantic tool boundary, source metadata when reachable, and local store stats. Reports are redacted; do not paste secrets into issues.
14347
+ Validate local config, environment bindings, semantic tool boundary, source metadata when reachable, handler signing/reachability, direct SQL writeback readiness, and local store stats. Reports are redacted; do not paste secrets into issues.
14348
+ Use --check-writeback only after reviewing receipt-table DDL/grants; it connects with the trusted writer and can create the receipt table if the writer has permission.
11619
14349
  `,
11620
14350
  proposals: `Usage:
11621
14351
  ${cmd} proposals list [--tenant acme] [--capability billing.propose_late_fee_waiver] [--object invoice:INV-3001] [--status applied]
@@ -11655,6 +14385,14 @@ Inspect local query fingerprints, table names, row counts, and redacted-paramete
11655
14385
  ${cmd} apply --job job.json --config ./synapsor.runner.json --store ./.synapsor/local.db
11656
14386
 
11657
14387
  Apply an approved proposal through guarded writeback. Requires a trusted write credential.
14388
+
14389
+ With --config, the writer connection comes from source.write_url_env, such as
14390
+ SYNAPSOR_DATABASE_WRITE_URL. SYNAPSOR_DATABASE_URL is only the legacy fallback
14391
+ for direct worker/apply flows without a local config.
14392
+
14393
+ Direct SQL writeback creates or writes synapsor_writeback_receipts for
14394
+ idempotency and replay, so the trusted writer needs permission for that table
14395
+ or an administrator must pre-create and grant it.
11658
14396
  `,
11659
14397
  replay: `Usage:
11660
14398
  ${cmd} replay list [--tenant acme] [--object invoice:INV-3001]
@@ -11675,13 +14413,26 @@ Apply an approved proposal through guarded writeback. Requires a trusted write c
11675
14413
 
11676
14414
  Search the local SQLite evidence/replay ledger across proposals, evidence, query audit, receipts, and replay records.
11677
14415
  `,
14416
+ events: `Usage:
14417
+ ${cmd} events tail --store ./.synapsor/local.db
14418
+ ${cmd} events tail --proposal wrp_...
14419
+ ${cmd} events tail --kind writeback_applied
14420
+ ${cmd} events tail --follow --interval-ms 1000
14421
+ ${cmd} events webhook --url http://127.0.0.1:8788/synapsor/events --kind proposal_created
14422
+ ${cmd} events webhook --url-env SYNAPSOR_EVENT_WEBHOOK_URL --auth-token-env SYNAPSOR_EVENT_WEBHOOK_TOKEN --follow
14423
+ ${cmd} events webhook --url http://127.0.0.1:8788/synapsor/events --dry-run
14424
+
14425
+ Show or push local proposal/writeback lifecycle events such as proposal_created, proposal_approved, writeback_applied, writeback_conflict, and writeback_failed. Webhook delivery POSTs one local event envelope per event and never exposes database credentials.
14426
+ `,
11678
14427
  store: `Usage:
11679
14428
  ${cmd} store stats --store ./.synapsor/local.db
11680
14429
  ${cmd} store vacuum --store ./.synapsor/local.db
11681
14430
  ${cmd} store prune --store ./.synapsor/local.db --older-than 30d --dry-run
11682
14431
  ${cmd} store prune --store ./.synapsor/local.db --older-than 30d --yes
14432
+ ${cmd} store prune --store ./.synapsor/local.db --older-than 30d --yes --force
14433
+ ${cmd} store reset --store ./.synapsor/local.db --yes
11683
14434
 
11684
- Local store maintenance only. Prune defaults to dry-run and never touches your source Postgres/MySQL database.
14435
+ Local store maintenance only. Prune defaults to dry-run and reset requires --yes. These commands never touch your source Postgres/MySQL database. Destructive operations refuse while an active server lease exists unless --force is provided.
11685
14436
  `,
11686
14437
  demo: `Usage:
11687
14438
  ${cmd} demo [--force]
@@ -11696,9 +14447,10 @@ Local store maintenance only. Prune defaults to dry-run and never touches your s
11696
14447
  Use --quick for a fixture-only guided walkthrough and local ledger seed with no Docker startup. Use demo inspect to print follow-up commands for the quick-demo fixture.
11697
14448
  `,
11698
14449
  ui: `Usage:
11699
- ${cmd} ui [--tour] [--config synapsor.runner.json] [--store ./.synapsor/local.db]
14450
+ ${cmd} ui [--open] [--tour] [--config synapsor.runner.json] [--store ./.synapsor/local.db]
11700
14451
 
11701
14452
  Open the localhost review UI for proposals, diffs, evidence, receipts, and replay.
14453
+ Use --open to launch the URL in your browser when a desktop opener is available.
11702
14454
  `
11703
14455
  };
11704
14456
  process2.stdout.write(help[key] ?? help[command ?? ""] ?? help[""] ?? "");