@malloy-publisher/skills 0.1.51 → 0.1.53

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 (71) hide show
  1. package/package.json +1 -1
  2. package/skills/eval-answer/SKILL.md +1 -1
  3. package/skills/eval-diagnose/SKILL.md +1 -1
  4. package/skills/eval-import/SKILL.md +1 -1
  5. package/skills/eval-improve/SKILL.md +9 -7
  6. package/skills/eval-improve/scripts/improve.py +62 -8
  7. package/skills/eval-improve/scripts/improve_test.py +66 -0
  8. package/skills/eval-judge/SKILL.md +1 -1
  9. package/skills/eval-loop/SKILL.md +1 -1
  10. package/skills/eval-loop/scripts/build_run_package.py +27 -2
  11. package/skills/eval-loop/scripts/build_run_package_test.py +19 -0
  12. package/skills/eval-loop/scripts/run_baseline.py +13 -3
  13. package/skills/eval-loop/scripts/serve.py +29 -0
  14. package/skills/eval-loop/scripts/serve_test.py +40 -0
  15. package/skills/eval-loop/templates/eval-run-package/README.md +14 -16
  16. package/skills/eval-report/SKILL.md +1 -1
  17. package/skills/malloy-analysis/SKILL.md +2 -1
  18. package/skills/malloy-analysis-report/SKILL.md +1 -1
  19. package/skills/malloy-charts/SKILL.md +1 -1
  20. package/skills/malloy-classify/SKILL.md +1 -1
  21. package/skills/malloy-compliance-review/SKILL.md +1 -1
  22. package/skills/malloy-dashboards/SKILL.md +1 -1
  23. package/skills/malloy-define/SKILL.md +1 -1
  24. package/skills/malloy-define-agent/SKILL.md +1 -1
  25. package/skills/malloy-discover/SKILL.md +2 -1
  26. package/skills/malloy-document/SKILL.md +1 -1
  27. package/skills/malloy-excel-review/NOTICE +13 -0
  28. package/skills/malloy-excel-review/SKILL.md +168 -0
  29. package/skills/malloy-excel-review/fixtures/README.md +79 -0
  30. package/skills/malloy-excel-review/fixtures/build_fixture.py +882 -0
  31. package/skills/malloy-excel-review/fixtures/fixture.xlsx +0 -0
  32. package/skills/malloy-excel-review/fixtures/fixture_1904.xlsx +0 -0
  33. package/skills/malloy-excel-review/reference/_concepts.md +186 -0
  34. package/skills/malloy-excel-review/reference/cookbook-lookup-aggregate.md +1208 -0
  35. package/skills/malloy-excel-review/reference/cookbook-pivot.md +804 -0
  36. package/skills/malloy-excel-review/reference/cookbook-scenario.md +934 -0
  37. package/skills/malloy-excel-review/reference/discover.md +334 -0
  38. package/skills/malloy-excel-review/reference/limitations.md +175 -0
  39. package/skills/malloy-excel-review/reference/parity.md +304 -0
  40. package/skills/malloy-excel-review/reference/power-pivot.md +129 -0
  41. package/skills/malloy-excel-review/reference/recover-sources.md +332 -0
  42. package/skills/malloy-excel-review/reference/review-coverage.md +145 -0
  43. package/skills/malloy-excel-review/reference/translate-formulas.md +391 -0
  44. package/skills/malloy-excel-review/scripts/classify_workbook.py +7443 -0
  45. package/skills/malloy-excel-review/scripts/classify_workbook_connections_test.py +1695 -0
  46. package/skills/malloy-excel-review/scripts/classify_workbook_test.py +5251 -0
  47. package/skills/malloy-excel-review/scripts/fixture_test.py +502 -0
  48. package/skills/malloy-getting-started/SKILL.md +4 -4
  49. package/skills/malloy-gotchas-modeling/SKILL.md +2 -2
  50. package/skills/malloy-html-data-apps/SKILL.md +3 -3
  51. package/skills/malloy-html-data-apps/reference/runtime.md +13 -13
  52. package/skills/malloy-lookml-review/SKILL.md +1 -1
  53. package/skills/malloy-lookml-review/reference/build-unnest.md +1 -1
  54. package/skills/malloy-materialization/SKILL.md +44 -5
  55. package/skills/malloy-materialization/reference/annotation.md +9 -5
  56. package/skills/malloy-materialization/reference/refresh.md +1 -1
  57. package/skills/malloy-materialization/reference/storage.md +4 -4
  58. package/skills/malloy-materialization/reference/tuning.md +1 -1
  59. package/skills/malloy-model/SKILL.md +1 -1
  60. package/skills/malloy-model-as-you-go/SKILL.md +2 -2
  61. package/skills/malloy-modeling/SKILL.md +3 -1
  62. package/skills/malloy-notebooks/SKILL.md +1 -1
  63. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  64. package/skills/malloy-powerbi-review/SKILL.md +1 -1
  65. package/skills/malloy-publish/SKILL.md +3 -3
  66. package/skills/malloy-queries/SKILL.md +23 -1
  67. package/skills/malloy-reports/SKILL.md +3 -3
  68. package/skills/malloy-review/SKILL.md +1 -1
  69. package/skills/malloy-review/reference/rubric-correctness.md +1 -1
  70. package/skills/malloy-run-agent/SKILL.md +1 -1
  71. package/skills/malloy-source-unreachable/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.51",
4
+ "version": "0.1.53",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-answer
3
3
  description: 'Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-diagnose
3
3
  description: 'Diagnose why a scored answer failed and who owns the fix, then cluster the failures by shared root cause. Walk dataset, agent-call, get_context/model, get_context/retrieval, construction, then model-definition. Append issue events to the file ledger, linked by traceId, one per cluster. Use after eval-answer, when triaging a run, or before changing a model. Does not edit the model (eval-improve).'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  # Diagnose One Answer
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-import
3
3
  description: 'Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs. Classify each item by what came WITH the question (a query, a number, prose criteria, or nothing), write cases.jsonl and set.json per reference/case-format.md, keep the file as it arrived, and seal each question so a later edit is detectable. Never marks a value-bearing golden verified and never invents a question. Use when questions arrive from outside and need to become a set, or before the first run against a set nobody here authored.'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  # Import questions into an eval set
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-improve
3
3
  description: 'Make the smallest safe edit that closes a diagnosed gap the model repo owns: a Malloy model edit, or a package skill under skills/. Every factual claim carries a probe receipt. Use after eval-diagnose, or when asked to fix a model so an agent can discover the right answer. Never accepts its own edit; the acceptance check belongs to eval-loop. Does not decide whether an answer was wrong (eval-answer) or why (eval-diagnose).'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  # Improve the Model
@@ -117,12 +117,14 @@ publishing.
117
117
 
118
118
  Know which copy of the file the server actually reads. Hosts commonly serve a
119
119
  copy of the package rather than your working tree, so editing the model repo
120
- and reloading recompiles the unchanged copy: the reload succeeds, nothing
121
- changes, and a verification probe quietly tests the old model. Confirm the
122
- edit reached what is served before you trust a probe, and keep the model repo
123
- the source of truth that gets committed. On open-source Publisher the served
124
- copy lives under `publisher_data/<env>/<pkg>/` unless the environment is
125
- watch-mounted; other hosts distinguish a draft from a published version.
120
+ and reloading changes nothing: the reload succeeds, and a verification probe
121
+ quietly tests the old model. Confirm the edit reached what is served before
122
+ you trust a probe, and keep the model repo the source of truth that gets
123
+ committed. On open-source Publisher, a package outside a watch-mounted
124
+ environment is a published version, which is immutable: a reload answers it
125
+ `mode: unchanged` and never reads your files. Serve the model in watch mode
126
+ (`eval.py serve model --set <set> --watch-env <env> --reinit`, then
127
+ `improve.py --watch-mode`). Other hosts distinguish a draft from a published version.
126
128
 
127
129
  ## Step 3: One smallest edit
128
130
 
@@ -39,13 +39,18 @@ events describe diffs that never existed alone.
39
39
 
40
40
  MAKING AN EDIT VISIBLE TO THE SERVER
41
41
 
42
- Publisher normally serves a snapshot copy under `publisher_data/<env>/<pkg>/`,
43
- so editing the repo and reloading recompiles the unchanged copy: the reload
44
- succeeds, nothing changes, and the verification probe quietly tests the old
45
- model. `--watch-mode` says Publisher was started with `--watch-env`, which
46
- symlinks local-dir packages in place and makes edits live; then the helper is
47
- just a reload. Without it the helper syncs first. Either way the agent gets one
48
- generated script rather than a general shell.
42
+ Publisher serves a package outside a watched environment as a published
43
+ version under `publisher_data/<env>/<pkg>/<version>/`, which is immutable:
44
+ editing the repo and reloading changes nothing, and the verification probe
45
+ quietly tests the old model. `--watch-mode` says Publisher was started with
46
+ `--watch-env` (`eval.py serve model --set <set> --watch-env <env>`), which symlinks
47
+ local-dir packages in place and makes edits live; then the helper is just a
48
+ reload. Without it the helper syncs the edit onto the served copy first, which
49
+ works only on a server with versioning off. Either way a package the server
50
+ serves as a published version is refused before any agent starts (the server
51
+ is asked, in both modes): nothing synced there is served, the sync would
52
+ delete it, and a reload of it answers 200 without reading the edit. Either way
53
+ the agent gets one generated script rather than a general shell.
49
54
  """
50
55
  from __future__ import annotations
51
56
 
@@ -55,6 +60,7 @@ import pathlib
55
60
  import subprocess
56
61
  import sys
57
62
  import time
63
+ import urllib.request
58
64
  from typing import Any
59
65
 
60
66
  sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent
@@ -116,6 +122,37 @@ echo "reloaded; if sourceContentSha did not change, the edit did not reach the s
116
122
  SYNC_HELPER = "sync_and_reload.sh"
117
123
 
118
124
 
125
+ def served_version(rest_url: str | None, environment: str,
126
+ package: str) -> str | None:
127
+ """The version the model server serves this package as, or None.
128
+
129
+ The server's own answer: a published version's resource carries its
130
+ `versionId`, and a watch-mounted or copied package has none. A version
131
+ is immutable, so neither the sync nor watch mode reaches it. None also
132
+ when the server cannot be asked; the folder check below still stands.
133
+ """
134
+ if not rest_url:
135
+ return None
136
+ url = (f"{rest_url.rstrip('/')}/api/v0/environments/{environment}"
137
+ f"/packages/{package}")
138
+ try:
139
+ with urllib.request.urlopen(url, timeout=30) as r:
140
+ return json.load(r).get("versionId") or None
141
+ except Exception:
142
+ return None
143
+
144
+
145
+ def served_as_versions(served: pathlib.Path) -> bool:
146
+ """Whether Publisher serves this package folder as published versions.
147
+
148
+ A version lives in `<pkg>/<version>/`, so the folder holds no
149
+ publisher.json of its own. A copy (versioning off) does, and a watch
150
+ mount is a symlink to the source.
151
+ """
152
+ return (served.is_dir() and not served.is_symlink()
153
+ and not (served / "publisher.json").exists())
154
+
155
+
119
156
  def git(args: list[str], cwd: pathlib.Path) -> str:
120
157
  return subprocess.run(["git", *args], cwd=cwd, capture_output=True,
121
158
  text=True).stdout
@@ -386,7 +423,9 @@ def main(argv: list[str] | None = None) -> int:
386
423
  ap.add_argument("--server-root", type=pathlib.Path, default=None,
387
424
  help="Publisher SERVER_ROOT; only needed without watch "
388
425
  "mode. Each accepted edit is rsynced onto the copy "
389
- "this root serves and reloaded. Default: the one "
426
+ "this root serves and reloaded, which works only "
427
+ "with versioning off: a root serving published "
428
+ "versions is refused. Default: the one "
390
429
  "`serve.py --role model` uses, when it exists")
391
430
  ap.add_argument("--watch-mode", action="store_true",
392
431
  help="Publisher was started with --watch-env, so edits are "
@@ -483,6 +522,21 @@ def main(argv: list[str] | None = None) -> int:
483
522
  print("warning: no --watch-mode and no --server-root, so the agent "
484
523
  "cannot sync its edit into Publisher's snapshot; every probe it "
485
524
  "runs will test the unedited model")
525
+ # Asked in both modes: --watch-mode is the caller's claim, and a server
526
+ # started with --watch-env over a preserved root still serves a version.
527
+ version = served_version(a.rest_url, a.environment, a.package)
528
+ served = (a.server_root / "publisher_data" / a.environment / a.package
529
+ if a.server_root and not a.watch_mode else None)
530
+ if version or (served is not None and served_as_versions(served)):
531
+ what = (f"the model server serves {a.package} as published version "
532
+ f"{version}" if version else f"{served} holds published versions")
533
+ why = ("so watch mode is not in effect for it" if a.watch_mode else
534
+ "no edit synced there is ever served, and the sync would "
535
+ "delete them")
536
+ raise SystemExit(
537
+ f"{what}, which is immutable: {why}. Fix: restart the model "
538
+ f"server in watch mode, `eval.py serve model --set {a.set_dir} "
539
+ f"--watch-env {a.environment} --reinit`, then pass --watch-mode")
486
540
 
487
541
  events = read_jsonl(a.run / "events.jsonl")
488
542
  cases = {c["qid"]: c for c in read_jsonl(a.set_dir / "cases.jsonl")}
@@ -229,6 +229,72 @@ class TruthEnvironment(unittest.TestCase):
229
229
 
230
230
 
231
231
 
232
+ class ServedAsVersions(unittest.TestCase):
233
+ """The snapshot sync (`rsync --delete` onto the served folder) is safe only
234
+ over a copy. Over published versions it serves nothing and deletes them,
235
+ so it is refused."""
236
+
237
+ def setUp(self):
238
+ self.root = pathlib.Path(tempfile.mkdtemp())
239
+ self.addCleanup(shutil.rmtree, self.root, True)
240
+
241
+ def test_a_folder_of_versions_is_versions(self):
242
+ served = self.root / "pkg"
243
+ (served / "1.0.0").mkdir(parents=True)
244
+ (served / "1.0.0" / "publisher.json").write_text("{}")
245
+ self.assertTrue(improve.served_as_versions(served))
246
+
247
+ def test_a_copy_is_not(self):
248
+ served = self.root / "pkg"
249
+ served.mkdir()
250
+ (served / "publisher.json").write_text("{}")
251
+ self.assertFalse(improve.served_as_versions(served))
252
+
253
+ def test_a_watch_mount_is_not(self):
254
+ source = self.root / "src"
255
+ source.mkdir()
256
+ served = self.root / "pkg"
257
+ served.symlink_to(source, target_is_directory=True)
258
+ self.assertFalse(improve.served_as_versions(served))
259
+
260
+ def test_a_missing_folder_is_not(self):
261
+ self.assertFalse(improve.served_as_versions(self.root / "nothing"))
262
+
263
+
264
+ class ServedVersion(unittest.TestCase):
265
+ """The server says whether it serves the package as a published version."""
266
+
267
+ def serve(self, body: bytes):
268
+ import http.server
269
+ import threading
270
+
271
+ class H(http.server.BaseHTTPRequestHandler):
272
+ def do_GET(self):
273
+ self.send_response(200)
274
+ self.end_headers()
275
+ self.wfile.write(body)
276
+
277
+ def log_message(self, *a):
278
+ pass
279
+
280
+ srv = http.server.HTTPServer(("127.0.0.1", 0), H)
281
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
282
+ self.addCleanup(srv.shutdown)
283
+ return f"http://127.0.0.1:{srv.server_port}"
284
+
285
+ def test_a_version_is_named(self):
286
+ base = self.serve(b'{"name": "ecommerce", "versionId": "1.0.0"}')
287
+ self.assertEqual(improve.served_version(base, "e", "ecommerce"), "1.0.0")
288
+
289
+ def test_a_watch_mounted_package_has_none(self):
290
+ base = self.serve(b'{"name": "ecommerce"}')
291
+ self.assertIsNone(improve.served_version(base, "e", "ecommerce"))
292
+
293
+ def test_a_server_that_cannot_be_asked_is_none(self):
294
+ self.assertIsNone(improve.served_version("http://127.0.0.1:9", "e", "p"))
295
+ self.assertIsNone(improve.served_version(None, "e", "p"))
296
+
297
+
232
298
  class ClusterMembers(unittest.TestCase):
233
299
  """The improver sees each case's key. A rows golden kept in
234
300
  `golden.path` used to reach it as no key at all."""
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-judge
3
3
  description: 'Decide whether ONE answer matches its golden, and say whether you believe the golden. Read this before emitting any verdict. Covers containment, column pairing, near_match, refusals, and the gold_status judgement. Use when scoring an attempt in an evaluation run; never to conduct a run (eval-loop), diagnose a failure (eval-diagnose) or edit a model (eval-improve).'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  # The judge
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-loop
3
3
  description: 'Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint. You are the conductor: import cases into the file ledger, spawn a blind answerer, then run eval-answer, eval-diagnose, and eval-improve. Persistence is plain files: the set in the model package''s evals/ directory, runs in the set''s workdir; checkpoints are git commits of the model repo. `eval.py` runs each step from the set''s eval.toml. Use to score a model, diagnose failures, improve behind an acceptance check, or roll back a bad direction.'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  # The Evaluation Loop
@@ -32,6 +32,7 @@ import pathlib
32
32
  import shlex
33
33
  import shutil
34
34
  import sys
35
+ import time
35
36
  import urllib.error
36
37
  import urllib.request
37
38
  from typing import Any
@@ -555,6 +556,7 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
555
556
  for name in ("publisher.json", "eval_run.malloy"):
556
557
  if (TEMPLATE / name).exists():
557
558
  shutil.copy(TEMPLATE / name, out / name)
559
+ stamp_version(out / "publisher.json")
558
560
  if (TEMPLATE / "notebooks").exists():
559
561
  shutil.copytree(TEMPLATE / "notebooks", out / "notebooks", dirs_exist_ok=True)
560
562
  if (TEMPLATE / "public").exists():
@@ -566,6 +568,27 @@ def build(run_dirs: list[pathlib.Path], set_dir: pathlib.Path,
566
568
  "steps": len(steps_rows)}
567
569
 
568
570
 
571
+ def stamp_version(manifest: pathlib.Path) -> None:
572
+ """Give this build a version of its own, higher than any earlier build's.
573
+
574
+ Publisher publishes a package from its location as the version its
575
+ publisher.json declares, and refuses different content under a version it
576
+ already holds (409). A version per build lets a rebuilt report replace the
577
+ one served: the highest version becomes `latest`. The patch is the time in
578
+ milliseconds, and above any version already in the file, so a second stamp
579
+ in the same millisecond still rises. Builds from different machines rely
580
+ on their clocks: one whose clock runs behind publishes a lower version,
581
+ which does not become `latest`.
582
+ """
583
+ if not manifest.exists():
584
+ return
585
+ body = json.loads(manifest.read_text())
586
+ previous = str(body.get("version", "")).rsplit(".", 1)[-1]
587
+ floor = int(previous) + 1 if previous.isdigit() else 0
588
+ body["version"] = f"1.0.{max(time.time_ns() // 1_000_000, floor)}"
589
+ manifest.write_text(json.dumps(body, indent=4) + "\n")
590
+
591
+
569
592
  def serving_lines(cfg: config.Config, run_dirs: list[pathlib.Path],
570
593
  out: pathlib.Path, on_model_server: bool = False) -> list[str]:
571
594
  """The registration command and both URLs, which are in different path spaces.
@@ -605,8 +628,10 @@ def serving_lines(cfg: config.Config, run_dirs: list[pathlib.Path],
605
628
  def register(base: str, env: str, out: pathlib.Path) -> str | None:
606
629
  """Register the built package on `base`, or return why it could not be.
607
630
 
608
- A POST for a name the server already has re-copies the package, so a
609
- rebuilt report replaces the one it served; no delete is needed first.
631
+ A POST publishes the build as the version stamp_version gave it, so a
632
+ rebuilt report becomes the package's `latest` and replaces the one it
633
+ served; no delete is needed first. (A server with versioning off
634
+ re-copies the package instead, to the same effect.)
610
635
  """
611
636
  body = json.dumps({"name": out.name, "location": str(out.resolve())}).encode()
612
637
  req = urllib.request.Request(
@@ -301,6 +301,25 @@ class RefusalsAndServing(unittest.TestCase):
301
301
  self.assertEqual(json.loads(body)["location"], str(out.resolve()))
302
302
 
303
303
 
304
+ class AVersionPerBuild(unittest.TestCase):
305
+ """A rebuilt report publishes as a new version, which becomes `latest`."""
306
+
307
+ def test_each_build_declares_a_higher_version(self):
308
+ tmp = pathlib.Path(tempfile.mkdtemp())
309
+ self.addCleanup(shutil.rmtree, tmp, True)
310
+ manifest = tmp / "publisher.json"
311
+ manifest.write_text(json.dumps({"name": "eval-run", "version": "1.0.0"}))
312
+ build_run_package.stamp_version(manifest)
313
+ first = json.loads(manifest.read_text())
314
+ build_run_package.stamp_version(manifest)
315
+ second = json.loads(manifest.read_text())
316
+ self.assertEqual(first["name"], "eval-run")
317
+ major_minor = [v["version"].rsplit(".", 1)[0] for v in (first, second)]
318
+ self.assertEqual(major_minor, ["1.0", "1.0"])
319
+ patches = [int(v["version"].rsplit(".", 1)[1]) for v in (first, second)]
320
+ self.assertGreater(patches[1], patches[0])
321
+
322
+
304
323
  class Register(unittest.TestCase):
305
324
  """`package` registers the report on the truth server itself."""
306
325
 
@@ -138,6 +138,12 @@ TOOL_ARM_TOOLS = ("mcp__publisher__get_skill",)
138
138
  # `get_status` and `list_packages` reveal the other packages on the server and
139
139
  # are no use to an answerer given its scope in the prompt.
140
140
  #
141
+ # report_api a second query path, under REST rules rather than
142
+ # execute_query's; its answer is not one about the model.
143
+ # show_report draws a panel for a person; a headless answerer gets
144
+ # list_reports only text, and report titles and descriptions are
145
+ # discovery outside get_context, the thing measured.
146
+ #
141
147
  # Enumerated, because deny beats allow: naming the server (`mcp__publisher`,
142
148
  # or `mcp__publisher__*`) removes ALL of its tools including the ones wanted,
143
149
  # verified 2026-09-08.
@@ -146,7 +152,10 @@ ANSWER_DENIED = ("mcp__publisher__compile_model",
146
152
  "mcp__publisher__search_database_schema",
147
153
  "mcp__publisher__reload_package",
148
154
  "mcp__publisher__get_status",
149
- "mcp__publisher__list_packages")
155
+ "mcp__publisher__list_packages",
156
+ "mcp__publisher__list_reports",
157
+ "mcp__publisher__show_report",
158
+ "mcp__publisher__report_api")
150
159
 
151
160
  # Every tool this server exposes, so the two lists above can be checked to
152
161
  # PARTITION it rather than merely not overlap. That is the answer to the one
@@ -158,8 +167,9 @@ ANSWER_DENIED = ("mcp__publisher__compile_model",
158
167
  # a mismatch here is a decision, not a lint.
159
168
  PUBLISHER_MCP_TOOLS = ("compile_model", "execute_query", "get_agent",
160
169
  "get_context", "get_skill", "get_status",
161
- "list_packages", "reload_package",
162
- "search_database_schema", "search_malloy_docs")
170
+ "list_packages", "list_reports", "reload_package",
171
+ "report_api", "search_database_schema",
172
+ "search_malloy_docs", "show_report")
163
173
  # The platform target: a hosted MCP server exposing the same two operations
164
174
  # under its own names. The CLI addresses a tool as `mcp__<server>__<tool>`, so
165
175
  # both halves are configuration -- `--hosted-mcp-server` names the server (which
@@ -176,6 +176,22 @@ def init_decision(root: pathlib.Path, reinit: bool,
176
176
  f"publisher.config.json")
177
177
 
178
178
 
179
+ def watch_note(watch_env: str | None, seed: bool) -> str | None:
180
+ """The warning for --watch-env on a start that does not seed, or None.
181
+
182
+ An environment is mounted in place only when it is first loaded from the
183
+ config, so on a preserved root a package already published stays a
184
+ version: the flag sets PUBLISHER_WATCH and changes nothing an edit
185
+ reaches.
186
+ """
187
+ if not watch_env or seed:
188
+ return None
189
+ return (f" ! --watch-env {watch_env} on a preserved server root: a package "
190
+ f"this root already published stays an immutable version, so an "
191
+ f"edit never reaches it. Pass --reinit too, unless the root was "
192
+ f"first served with --watch-env")
193
+
194
+
179
195
  # One ranking call, unscoped, is what kicks the embedding sync.
180
196
  #
181
197
  # RANKING, because the marker and the sync both hang off the ranking path: a
@@ -458,6 +474,14 @@ def main(argv: list[str] | None = None) -> int:
458
474
  "With --role, the role's mcp_port in eval.toml, as --port")
459
475
  ap.add_argument("--allow-proxy", action="store_true")
460
476
  ap.add_argument("--trace-retrieval", action="store_true")
477
+ ap.add_argument("--watch-env", metavar="ENV",
478
+ help="serve ENV in watch mode (PUBLISHER_WATCH): its local "
479
+ "packages are mounted in place, so an edit is read on "
480
+ "reload, which eval-improve needs. Without it each "
481
+ "package is a published version, which no edit "
482
+ "reaches. The mount is decided when the environment "
483
+ "is first loaded, so add --reinit to switch a server "
484
+ "root that is already serving")
461
485
  ap.add_argument("--reinit", action="store_true",
462
486
  help="drop the store and re-read publisher.config.json. "
463
487
  "Needed after a config edit -- a new environment or a "
@@ -565,8 +589,13 @@ def main(argv: list[str] | None = None) -> int:
565
589
  env["PUBLISHER_ALLOW_PROXY_CONNECTIONS"] = "true"
566
590
  if a.trace_retrieval:
567
591
  env["PUBLISHER_MCP_TRACE"] = "retrieval"
592
+ if a.watch_env:
593
+ env["PUBLISHER_WATCH"] = a.watch_env
568
594
  seed, why = init_decision(root, a.reinit, changed)
569
595
  print(why)
596
+ stale_watch = watch_note(a.watch_env, seed)
597
+ if stale_watch:
598
+ print(stale_watch)
570
599
  note = retrieval_note(env) if a.role != "truth" else None
571
600
  if note:
572
601
  print(note)
@@ -470,6 +470,46 @@ class Roles(unittest.TestCase):
470
470
  '{"old": true}\n')
471
471
  self.assertFalse((root / "publisher.pid").exists())
472
472
 
473
+ def test_watch_env_reaches_the_server_as_publisher_watch(self):
474
+ """Watch mode is the one way an edit reaches a served package."""
475
+ d = a_set(TOML)
476
+ root = pathlib.Path(tempfile.mkdtemp(prefix="serve-root-"))
477
+ pub = pathlib.Path(tempfile.mkdtemp(prefix="serve-pub-"))
478
+ (pub / "dist").mkdir()
479
+ (pub / "dist" / "server.mjs").write_text("")
480
+ stuck = [sys.executable, "-c", "import time; time.sleep(60)"]
481
+ seen = {}
482
+ real_popen = serve.subprocess.Popen
483
+
484
+ def spy(cmd, **kwargs):
485
+ seen.update(kwargs["env"])
486
+ return real_popen(cmd, **kwargs)
487
+
488
+ with mock.patch.object(serve, "listening", return_value=False), \
489
+ mock.patch.object(serve, "alive", return_value=False), \
490
+ mock.patch.object(serve, "server_cmd", return_value=stuck), \
491
+ mock.patch.object(serve.subprocess, "Popen", side_effect=spy):
492
+ serve.main(["--role", "model", "--set", str(d),
493
+ "--server-root", str(root), "--publisher-dir", str(pub),
494
+ "--wait", "1", "--no-warm-retrieval",
495
+ "--watch-env", "evals"])
496
+ self.assertEqual(seen.get("PUBLISHER_WATCH"), "evals")
497
+
498
+
499
+
500
+ class WatchNote(unittest.TestCase):
501
+ """--watch-env mounts in place only on a start that seeds the store."""
502
+
503
+ def test_a_preserved_root_is_warned(self):
504
+ note = serve.watch_note("evals", seed=False)
505
+ self.assertIn("--reinit", note)
506
+ self.assertIn("immutable version", note)
507
+
508
+ def test_a_seeding_start_is_not(self):
509
+ self.assertIsNone(serve.watch_note("evals", seed=True))
510
+
511
+ def test_no_watch_env_is_not(self):
512
+ self.assertIsNone(serve.watch_note(None, seed=False))
473
513
 
474
514
 
475
515
  class WarmByDefault(unittest.TestCase):
@@ -167,22 +167,20 @@ regardless -- so check the `PUBLISHER_READY` line for the port it actually took.
167
167
  Two more Publisher behaviours worth knowing, because both look like your edit did
168
168
  nothing.
169
169
 
170
- It serves a **snapshot copy** under `publisher_data/<env>/<pkg>/`. The `location`
171
- in `publisher.config.json` is a one-time import source, not a live mount, and
172
- `reload_package` does **not** re-fetch from it -- it answers
173
- `mode: in-place`, re-reading the snapshot. So the working recipe is sync then
174
- reload:
175
-
176
- ```bash
177
- rsync -a --delete <location>/ "$SERVER_ROOT/publisher_data/<env>/<pkg>/"
178
- # then reload_package {environmentName, packageName}
179
- ```
180
-
181
- Sync the whole package. A new model over stale CSVs fails with a column-not-found
182
- (`` `attempt_key` not found ``) that reads like a model bug rather than a stale
183
- copy. Watch `sourceContentSha` across the reload to confirm it saw the change.
184
- Restarting works too, and is the only option if you would rather not reason about
185
- which half is stale.
170
+ Unless its environment is watched, it serves a **published version**, under
171
+ `publisher_data/<env>/<pkg>/<version>/`. The `location` in `publisher.config.json`
172
+ is read once, when the version is published, and a version is immutable:
173
+ `reload_package` answers it `mode: unchanged` and never reads your files, and
174
+ neither does a restart. Never copy files into `publisher_data/` to get around
175
+ that. It holds the version folders, and a version's files are not read again.
176
+
177
+ To iterate, start the server with `--watch-env <env>`. The package is mounted in
178
+ place, and `reload_package` (`mode: in-place`) recompiles what you saved. Watch
179
+ `sourceContentSha` across the reload to confirm it saw the change. To ship an
180
+ edit to a published package, bump `version` in its `publisher.json` and publish
181
+ it again (`POST …/environments/<env>/packages` with its `location`): the new
182
+ version becomes `latest`. `eval.py package` does this for the report: each build
183
+ declares a higher version.
186
184
 
187
185
  And a package must be declared in `publisher.config.json` with an `environments`
188
186
  block -- a bare `{"frozenConfig": false}` starts cleanly and serves nothing, logging
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-report
3
3
  description: 'Write the summary a person reads after an evaluation run: what ran, what it scored, which failures were the MODEL and which were the EVAL itself (out of turns, contaminated, unestablished golden, unreadable judge), what was learned, and what to do next. Every claim links to the artifact behind it. Use at the end of any run, before quoting a number to anyone. Never scores an answer (eval-answer) or decides who owns a failure (eval-diagnose).'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  # Report a Run
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-analysis
3
3
  description: "Read before your first get_context call on any business or data question. The required workflow, not background: which source, which rows, how to verify. Skipping it causes confident wrong answers."
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  <!-- Copyright (c) Credible Data Inc. SPDX-License-Identifier: MIT -->
@@ -83,6 +83,7 @@ Your first result is a draft, not an answer. The difference between a useful ana
83
83
  - **Ask "what would make this wrong?"** then run the query that would expose that problem. A plausible-looking wrong answer is the most dangerous kind.
84
84
  - **Check the common failure modes:**
85
85
  - Fan-out / double-counting: if you joined across grain, compare `count()` with `count(base_key)`, the base source's own key (in Malloy `count(field)` is already the distinct count). A large gap means base rows are repeated and aggregates are inflated. The check reads one way only: `count(joined.field)` is a distinct count at the joined grain, so it is normally much SMALLER than `count()`, which is not missing data. Measure coverage at the base grain: `count() { where: joined.field is null }`.
86
+ - Grouping by an array: a field with `relationship: "many"` that is not reached through a join (`lines.sku`, `tags.each`) gives one group per distinct element value, and a row lands in every group its array holds, so its groups add up to more than the total, as across a one-to-many join. Do not print a total row across it, and count elements with `tags.count()`, not `count(tags.each)`, which is the distinct count.
86
87
  - Broken filters: a quick count confirms a filter narrowed the data as expected. Watch case, spelling and date-format mismatches; a filter that matches nothing still returns a result, just the wrong one. Confirm which dimension the user's word ("brand") maps to when several fields look alike, and that the field means what you assume (gross against net revenue): read its doc. Before a `pick` or `where` that groups values into categories, list the field's distinct values, since an unlisted one falls into `else` unnoticed. Apply the filter the question implies ("last quarter" with no time filter returns all-time data). A view can have a `where:` built in (`recent_orders`): read its code, and query the base source with explicit filters if it conflicts with the ask.
87
88
  - Null-driven loss: `count() { where: the_field is null }` shows how many rows a key field drops (`count(the_field)` counts distinct values, so subtracting it from `count()` is not a null count).
88
89
  - Parts that do not sum to the whole: if you split a total into categories, confirm they add up. If a doc says the parts are not additive (one event logged on two layers, a non-additive measure), do not print a total row across that dimension, least of all one nobody asked for; give the per-category figures and say why.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-analysis-report
3
3
  description: Combine validated Malloy queries into a report (a linear report, read top to bottom). Use when the user asks to "create a report", "combine these into a report", or wants a persistent multi-query artifact.
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-charts
3
3
  description: Read before choosing a chart or adding a chart annotation. Chart types, tag syntax and scale rules, KPI cards, dashboards, sparklines, and the renderer mistakes that fail silently.
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-classify
3
3
  description: 'Propose #(classification) annotations for a Malloy model from an access policy''s class scheme, backed by aggregate value evidence from the served data, and show them as a diff before writing anything. Use when the user asks to "classify the model", "tag PHI/PII", "label sensitive fields", or adds fields to a package that a policy governs, and before running malloy-compliance-review on a model nobody has classified.'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-compliance-review
3
3
  description: 'Check a Malloy package and its controls against a written access policy: turn the policy''s prose into a control register, run compliance.py check against a served package (structure, access as each role, and the values themselves), judge what needs a person, and produce a report and a signable attestation. Use when the user asks whether a model "complies with", "satisfies" or "meets" a policy, HIPAA, SOC 2, PCI or the like, asks for a compliance review or attestation, or wants the review in CI.'
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-dashboards
3
3
  description: "Renamed to malloy-reports. A dashboard is now a report with layout=grid: a .malloy file with an ## artifact tag, written at the package root. Load malloy-reports instead when the user asks for a dashboard, a filterable view, or drill-through between views."
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
 
7
7
  <!--
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-define
3
3
  description: Propose scope, a source plan and field definitions for a Malloy model. Which tables and questions, which sources at what grain, then renames, dimensions and measures, each backed by querying data.
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: malloy-define-agent
3
3
  description: Define a package agent - a named brief, with its own skills, optional schedules and a run budget, declared in a package's publisher.json. Use when the user asks to create, add, change or fix an agent in a package, or wants a recurring or unattended job. Not for adopting an agent that already exists (that is malloy-run-agent), and not for a one-off question, which you just answer.
4
- version: 0.1.51
4
+ version: 0.1.53
5
5
  ---
6
6
  <!--
7
7
  Copyright (c) Credible Data Inc.