@malloy-publisher/skills 0.1.33 → 0.1.35

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/README.md +26 -1
  2. package/dist/cli.d.ts +27 -0
  3. package/dist/cli.js +188 -0
  4. package/dist/index.d.ts +7 -8
  5. package/dist/index.js +8 -9
  6. package/dist/install.d.ts +68 -0
  7. package/dist/install.js +182 -0
  8. package/dist/payload.d.ts +8 -0
  9. package/dist/payload.js +19 -0
  10. package/package.json +4 -1
  11. package/skills/eval-answer/SKILL.md +1 -1
  12. package/skills/eval-answer/reference/ledger-schema.md +3 -3
  13. package/skills/eval-answer/scripts/check_findable.py +121 -53
  14. package/skills/eval-answer/scripts/check_findable_test.py +153 -0
  15. package/skills/eval-answer/scripts/ledger.py +2 -1
  16. package/skills/eval-answer/scripts/mcp_payload.py +13 -0
  17. package/skills/eval-diagnose/SKILL.md +2 -2
  18. package/skills/eval-import/SKILL.md +1 -1
  19. package/skills/eval-improve/SKILL.md +1 -1
  20. package/skills/eval-judge/SKILL.md +1 -1
  21. package/skills/eval-loop/SKILL.md +6 -4
  22. package/skills/eval-loop/reference/measurement.md +10 -6
  23. package/skills/eval-loop/reference/running-a-run.md +6 -6
  24. package/skills/eval-loop/scripts/agent_harness_test.py +73 -17
  25. package/skills/eval-loop/scripts/flip_table.py +20 -3
  26. package/skills/eval-loop/scripts/flip_table_test.py +15 -0
  27. package/skills/eval-loop/scripts/run_baseline.py +106 -45
  28. package/skills/eval-loop/scripts/run_baseline_test.py +16 -1
  29. package/skills/eval-loop/scripts/serve.py +63 -40
  30. package/skills/eval-loop/scripts/serve_test.py +62 -4
  31. package/skills/eval-loop/templates/eval-run-package/README.md +2 -1
  32. package/skills/eval-report/SKILL.md +1 -1
  33. package/skills/malloy/SKILL.md +2 -3
  34. package/skills/malloy-analysis/SKILL.md +6 -2
  35. package/skills/malloy-analysis-pitfalls/SKILL.md +1 -1
  36. package/skills/malloy-analysis-report/SKILL.md +6 -4
  37. package/skills/malloy-analyze/SKILL.md +4 -4
  38. package/skills/malloy-charts/SKILL.md +10 -2
  39. package/skills/malloy-dashboards/SKILL.md +99 -125
  40. package/skills/malloy-data-app-design/SKILL.md +1 -1
  41. package/skills/malloy-debug/SKILL.md +1 -1
  42. package/skills/malloy-define/SKILL.md +1 -1
  43. package/skills/malloy-discover/SKILL.md +1 -1
  44. package/skills/malloy-document/SKILL.md +2 -2
  45. package/skills/malloy-getting-started/SKILL.md +3 -1
  46. package/skills/malloy-gotchas-modeling/SKILL.md +1 -1
  47. package/skills/malloy-gotchas-queries/SKILL.md +1 -1
  48. package/skills/malloy-gotchas-rendering/SKILL.md +2 -2
  49. package/skills/malloy-html-data-app-embedding/SKILL.md +1 -1
  50. package/skills/malloy-html-data-app-runtime/SKILL.md +1 -1
  51. package/skills/malloy-html-data-apps/SKILL.md +1 -1
  52. package/skills/malloy-lookml-review/SKILL.md +1 -1
  53. package/skills/malloy-materialization/SKILL.md +1 -1
  54. package/skills/malloy-materialization-tuning/SKILL.md +1 -1
  55. package/skills/malloy-model/SKILL.md +5 -3
  56. package/skills/malloy-model/reference/analysis-to-model.md +1 -1
  57. package/skills/malloy-model-as-you-go/SKILL.md +1 -1
  58. package/skills/malloy-modeling/SKILL.md +1 -1
  59. package/skills/malloy-notebooks/SKILL.md +110 -24
  60. package/skills/malloy-patterns/SKILL.md +1 -1
  61. package/skills/malloy-phrase-detection/SKILL.md +1 -1
  62. package/skills/malloy-powerbi-review/SKILL.md +1 -1
  63. package/skills/malloy-publish/SKILL.md +3 -3
  64. package/skills/malloy-queries/SKILL.md +1 -1
  65. package/skills/malloy-review/SKILL.md +1 -1
  66. package/skills/malloy-review/reference/rubric-structure.md +1 -1
  67. package/skills/malloy-review/reference/rubric-style.md +10 -1
  68. package/skills/malloy-review/reference/severity-taxonomy.md +1 -0
  69. package/skills/malloy-scope/SKILL.md +1 -1
  70. package/skills/malloy-source-unreachable/SKILL.md +3 -3
  71. package/skills/malloy-notebook-chat/SKILL.md +0 -25
package/README.md CHANGED
@@ -5,6 +5,31 @@ SPDX-License-Identifier: MIT
5
5
 
6
6
  # @malloy-publisher/skills
7
7
 
8
+ ## Start here
9
+
10
+ 1. **Learn.** Read the getting-started skill. Nothing to install, no server:
11
+
12
+ <https://unpkg.com/@malloy-publisher/skills@latest/skills/malloy-getting-started/SKILL.md>
13
+
14
+ Every skill reads the same way. Put its name in place of `malloy-getting-started`.
15
+
16
+ 2. **Install.** If your agent has a shell, copy every skill to where it loads them:
17
+
18
+ ```bash
19
+ npx -y @malloy-publisher/skills@latest install # into this project
20
+ npx -y @malloy-publisher/skills@latest install --global # into your home directory
21
+ npx -y @malloy-publisher/skills@latest list # every skill, with a description
22
+ ```
23
+
24
+ Keep the `@latest`. Without it, npx may reuse an old cached copy and install old skills.
25
+
26
+ `install` picks the agent from what it finds. A `CLAUDE.md` means `.claude/skills/`. An
27
+ `AGENTS.md` or `.cursor/` means `.agents/skills/`. With `--global` it looks for `~/.claude/`,
28
+ `~/.agents/`, or `~/.cursor/` instead. If it finds none, it stops and asks you to name one:
29
+ `install claude` or `install agents`.
30
+
31
+ ## What this package is
32
+
8
33
  The agent skills that ship with [Malloy Publisher](https://github.com/malloydata/publisher), as
9
34
  files you can install. They are task-specific guides for writing Malloy, building and reviewing
10
35
  models, exploring data, and authoring HTML data apps.
@@ -14,7 +39,7 @@ files. This package exists for the cases MCP does not cover: a host that reads s
14
39
  (Claude Code and the Publisher plugin), a scaffolder that installs them into a new project, or any
15
40
  tool that wants the files themselves rather than prompt text.
16
41
 
17
- ## Install
42
+ ## Use the files from code
18
43
 
19
44
  ```bash
20
45
  npm install @malloy-publisher/skills
package/dist/cli.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /** Where each host looks for skills, relative to a project or to the home dir. */
3
+ declare const TARGETS: Record<string, string>;
4
+ /**
5
+ * What says a host is set up, per scope. The two differ because the hosts keep
6
+ * their user-level config in a directory rather than a top-level file: Claude
7
+ * Code reads `~/.claude/CLAUDE.md`, never `~/CLAUDE.md`, so the project markers
8
+ * would miss almost everyone who has Claude set up.
9
+ */
10
+ declare const MARKERS: Record<"project" | "global", Record<string, string[]>>;
11
+ /**
12
+ * Where a skill can be read with nothing installed. `@latest` rather than this
13
+ * package's own version, so a link copied from an old cached copy of this CLI
14
+ * still serves the current skill.
15
+ */
16
+ declare function skillUrl(name: string): string;
17
+ /**
18
+ * Which hosts this directory is set up for.
19
+ *
20
+ * Detection only ever chooses between hosts the user already has; it never
21
+ * decides *whether* to write. When it finds nothing, install asks rather than
22
+ * picking one, because a skills directory in the wrong place is invisible: the
23
+ * agent simply never loads them and nothing says why.
24
+ */
25
+ declare function detectTargets(root: string, scope: keyof typeof MARKERS): string[];
26
+ declare function main(argv: string[]): void;
27
+ export { TARGETS, detectTargets, main, skillUrl };
package/dist/cli.js ADDED
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) Credible Data Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ /**
5
+ * `malloy-skills` -- list the agent skills, and install them, with nothing
6
+ * running.
7
+ *
8
+ * This exists so an agent meeting Publisher for the first time can get the
9
+ * skills before it installs or starts anything. Every other route to them is
10
+ * gated: the MCP prompt channel needs the server up, and the server is a large
11
+ * install that clones its example packages over the network.
12
+ *
13
+ * Reading one skill needs no command at all: the package ships the files, so
14
+ * unpkg serves each SKILL.md at a stable URL (`skillUrl`). That works in a chat
15
+ * with no shell, which `npx` cannot. This CLI covers what a URL cannot: listing
16
+ * what ships, and copying the whole tree, `reference/` files included, onto
17
+ * disk through the hardened copy in install.ts.
18
+ *
19
+ * Which is why this lives in @malloy-publisher/skills and not in the server or a
20
+ * new package: this one already ships the skill files, has no runtime
21
+ * dependencies and no native code, so `npx` is fast and cannot fail on a build
22
+ * step or a platform binary. Adding a CLI framework here would cost that, for
23
+ * two commands, so the argument parsing below is by hand.
24
+ */
25
+ import * as fs from "node:fs";
26
+ import * as os from "node:os";
27
+ import * as path from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { listSkills } from "./index.js";
30
+ import { installSkills } from "./install.js";
31
+ import { skillsDir } from "./payload.js";
32
+ /** Where each host looks for skills, relative to a project or to the home dir. */
33
+ const TARGETS = {
34
+ claude: path.join(".claude", "skills"),
35
+ agents: path.join(".agents", "skills"),
36
+ };
37
+ /**
38
+ * What says a host is set up, per scope. The two differ because the hosts keep
39
+ * their user-level config in a directory rather than a top-level file: Claude
40
+ * Code reads `~/.claude/CLAUDE.md`, never `~/CLAUDE.md`, so the project markers
41
+ * would miss almost everyone who has Claude set up.
42
+ */
43
+ const MARKERS = {
44
+ project: { claude: ["CLAUDE.md"], agents: ["AGENTS.md", ".cursor"] },
45
+ global: { claude: [".claude"], agents: [".agents", ".cursor"] },
46
+ };
47
+ /**
48
+ * Where a skill can be read with nothing installed. `@latest` rather than this
49
+ * package's own version, so a link copied from an old cached copy of this CLI
50
+ * still serves the current skill.
51
+ */
52
+ function skillUrl(name) {
53
+ return `https://unpkg.com/@malloy-publisher/skills@latest/skills/${name}/SKILL.md`;
54
+ }
55
+ const USAGE = `malloy-skills - list and install the Malloy Publisher agent skills
56
+
57
+ Usage:
58
+ npx -y @malloy-publisher/skills@latest [list] List every skill.
59
+ npx -y @malloy-publisher/skills@latest install [host] Copy the skills onto disk.
60
+
61
+ Keep the @latest: without it npx may reuse an old cached copy.
62
+
63
+ Install targets:
64
+ claude .claude/skills/ (detected from a CLAUDE.md)
65
+ agents .agents/skills/ (detected from an AGENTS.md or .cursor/)
66
+ --global install into your home directory instead of this project
67
+ (detected from ~/.claude/, ~/.agents/, or ~/.cursor/)
68
+
69
+ Read one skill without installing anything:
70
+ ${skillUrl("<name>")}
71
+
72
+ Nothing here needs a running server or an account.`;
73
+ function fail(message) {
74
+ console.error(message);
75
+ process.exit(1);
76
+ }
77
+ /** Print the name and description of every shipped skill. */
78
+ function list() {
79
+ const skills = listSkills();
80
+ if (skills.length === 0) {
81
+ fail(`No skills found in ${skillsDir}. This install of ` +
82
+ `@malloy-publisher/skills is incomplete; reinstall and try again.`);
83
+ }
84
+ for (const skill of skills) {
85
+ console.log(skill.name);
86
+ console.log(` ${skill.description}`);
87
+ }
88
+ console.log("");
89
+ console.log(`Read one: ${skillUrl("<name>")}`);
90
+ console.log("Install them all: npx -y @malloy-publisher/skills@latest install");
91
+ }
92
+ /**
93
+ * Which hosts this directory is set up for.
94
+ *
95
+ * Detection only ever chooses between hosts the user already has; it never
96
+ * decides *whether* to write. When it finds nothing, install asks rather than
97
+ * picking one, because a skills directory in the wrong place is invisible: the
98
+ * agent simply never loads them and nothing says why.
99
+ */
100
+ function detectTargets(root, scope) {
101
+ return Object.entries(MARKERS[scope])
102
+ .filter(([, markers]) => markers.some((marker) => fs.existsSync(path.join(root, marker))))
103
+ .map(([host]) => host);
104
+ }
105
+ /** Say what an install did, including what it cost. */
106
+ function report(target, dir, result) {
107
+ if (result.refused) {
108
+ fail(`Did not install into ${dir}: ${result.refused}.`);
109
+ }
110
+ console.log(`Installed ${result.installed} skills into ${dir}`);
111
+ if (result.skipped.length > 0) {
112
+ console.log(` left alone (symlinked): ${result.skipped.join(", ")}`);
113
+ }
114
+ if (result.refreshed.length > 0) {
115
+ console.log(` replaced: ${result.refreshed.join(", ")}`);
116
+ }
117
+ // Not a footnote: these are files the user put there that no copy puts back.
118
+ for (const gone of result.removed) {
119
+ console.log(` removed (not in the shipped skill): ${target}/${gone}`);
120
+ }
121
+ }
122
+ function install(hosts, global) {
123
+ const root = global ? os.homedir() : process.cwd();
124
+ const scope = global ? "global" : "project";
125
+ let chosen = hosts;
126
+ if (chosen.length === 0) {
127
+ chosen = detectTargets(root, scope);
128
+ if (chosen.length === 0) {
129
+ const looked = Object.values(MARKERS[scope]).flat().join(", ");
130
+ const flag = global ? " --global" : "";
131
+ fail(`Could not tell which agent to install for: no ${looked} in ${root}.\n` +
132
+ `Name one: npx -y @malloy-publisher/skills@latest install claude${flag} (or: install agents${flag})`);
133
+ }
134
+ }
135
+ for (const host of chosen) {
136
+ const dir = path.join(root, TARGETS[host]);
137
+ report(host, dir, installSkills(dir, root));
138
+ }
139
+ }
140
+ function main(argv) {
141
+ const global = argv.includes("--global");
142
+ const args = argv.filter((arg) => arg !== "--global");
143
+ const [command = "list", ...rest] = args;
144
+ if (command === "--help" || command === "-h" || command === "help") {
145
+ console.log(USAGE);
146
+ return;
147
+ }
148
+ if (command === "install") {
149
+ for (const host of rest) {
150
+ if (!(host in TARGETS)) {
151
+ fail(`Unknown install target '${host}'. ` +
152
+ `Expected one of: ${Object.keys(TARGETS).join(", ")}.`);
153
+ }
154
+ }
155
+ return install(rest, global);
156
+ }
157
+ // Anything else is refused rather than skipped: a silently ignored argument
158
+ // is how a typo turns into a command that looks like it worked.
159
+ if (command !== "list") {
160
+ // A skill name is the likeliest mistake, from an agent that expects this
161
+ // to print one, so it gets the URL that does.
162
+ const skill = listSkills().find((s) => s.name === command);
163
+ fail(skill
164
+ ? `There is no command to print a skill. Read it at ${skillUrl(command)}`
165
+ : `Unknown command or option '${command}'.\n\n${USAGE}`);
166
+ }
167
+ if (rest.length > 0) {
168
+ fail(`'list' takes no arguments, got: ${rest.join(" ")}.`);
169
+ }
170
+ if (global)
171
+ fail("--global only applies to install.");
172
+ return list();
173
+ }
174
+ // Only when run as the command, so importing this module for a test does not
175
+ // parse the test runner's own argv and exit the process.
176
+ if (process.argv[1] &&
177
+ realpath(process.argv[1]) === realpath(fileURLToPath(import.meta.url))) {
178
+ main(process.argv.slice(2));
179
+ }
180
+ function realpath(target) {
181
+ try {
182
+ return fs.realpathSync(target);
183
+ }
184
+ catch {
185
+ return target;
186
+ }
187
+ }
188
+ export { TARGETS, detectTargets, main, skillUrl };
package/dist/index.d.ts CHANGED
@@ -5,14 +5,6 @@ export interface Skill {
5
5
  /** Absolute path to the skill's directory: SKILL.md and any reference/. */
6
6
  dir: string;
7
7
  }
8
- /**
9
- * Absolute path to the directory holding the skill directories.
10
- *
11
- * Resolved from this module's own URL, which works both from dist/ once
12
- * installed and from src/ in the repo, since both sit one level under the
13
- * package root.
14
- */
15
- export declare const skillsDir: string;
16
8
  /**
17
9
  * Read every skill's frontmatter from the shipped files.
18
10
  *
@@ -20,3 +12,10 @@ export declare const skillsDir: string;
20
12
  * second copy of this metadata to regenerate or to drift.
21
13
  */
22
14
  export declare function listSkills(): Skill[];
15
+ /**
16
+ * The installer is re-exported here so `@malloy-publisher/skills` has one entry
17
+ * point: consumers get `skillsDir` to read from and `installSkills` to write
18
+ * with, without reaching into a subpath.
19
+ */
20
+ export { skillsDir } from "./payload.js";
21
+ export { countSkills, installSkills, isWithinDirectory, type SkillInstall, } from "./install.js";
package/dist/index.js CHANGED
@@ -6,15 +6,7 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
- /**
11
- * Absolute path to the directory holding the skill directories.
12
- *
13
- * Resolved from this module's own URL, which works both from dist/ once
14
- * installed and from src/ in the repo, since both sit one level under the
15
- * package root.
16
- */
17
- export const skillsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "skills");
9
+ import { skillsDir } from "./payload.js";
18
10
  function unquote(value) {
19
11
  return value.trim().replace(/^["']|["']$/g, "");
20
12
  }
@@ -47,3 +39,10 @@ export function listSkills() {
47
39
  // platforms and must not depend on the runtime's locale.
48
40
  return skills.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
49
41
  }
42
+ /**
43
+ * The installer is re-exported here so `@malloy-publisher/skills` has one entry
44
+ * point: consumers get `skillsDir` to read from and `installSkills` to write
45
+ * with, without reaching into a subpath.
46
+ */
47
+ export { skillsDir } from "./payload.js";
48
+ export { countSkills, installSkills, isWithinDirectory, } from "./install.js";
@@ -0,0 +1,68 @@
1
+ export interface SkillInstall {
2
+ /** How many skills were copied in. */
3
+ installed: number;
4
+ /** Skill names left alone because the target is a symlink. */
5
+ skipped: string[];
6
+ /** Skill names whose existing files the bundled copies replaced. */
7
+ refreshed: string[];
8
+ /**
9
+ * Paths that were inside a skill directory, are not in the bundled copy of
10
+ * that skill, and so were removed and not written back. Relative to the
11
+ * skills directory (`malloy-review/NOTES.md`), directories with a trailing
12
+ * separator.
13
+ *
14
+ * Reported separately from `refreshed` because they are a different loss.
15
+ * "Refreshed" is read as "my edits to the skill files were reverted", which
16
+ * is recoverable knowledge; a file the user put in the directory themselves
17
+ * is simply gone, and nothing named it.
18
+ */
19
+ removed: string[];
20
+ /** Why nothing at all was installed, when nothing was. */
21
+ refused?: string;
22
+ }
23
+ /**
24
+ * Copy every agent skill into targetDir as real files. The generated project is
25
+ * standalone and the user may move or commit it, so this is a recursive copy, not
26
+ * a symlink: a symlink into node_modules would dangle the moment the project is
27
+ * moved or its dependencies pruned. skillsDir already excludes non-shipping skills
28
+ * and the internal README, so a straight copy is clean.
29
+ *
30
+ * A skill directory in the target that is itself a symlink is left alone. That is
31
+ * how this repo's own contributors wire skills into their agent (skills/README.md
32
+ * tells them to), and copying through the link would overwrite the source of truth
33
+ * rather than the workspace.
34
+ *
35
+ * targetDir has to resolve inside workspaceRoot. A `.claude` that is itself a
36
+ * symlink elsewhere is not caught by the per-skill check above, and used to write
37
+ * every skill outside the workspace while reporting them installed.
38
+ *
39
+ * An existing skill directory is removed before the copy rather than copied over.
40
+ * fs.cpSync follows a symlink it finds at the destination, so checking only the
41
+ * skill directory itself left every path inside it writable through a link: a
42
+ * `.claude/skills/malloy-review/SKILL.md` linked elsewhere had the file it points
43
+ * at replaced, and a linked `reference/` (four shipped skills have one) had files
44
+ * written into whatever directory it named, all under a green
45
+ * "Installed N Malloy skills". `.claude/skills/` is shared agent territory where
46
+ * links are an expected state, so the check has to cover the whole subtree, and
47
+ * removing first is the only form of that which cannot be one level short.
48
+ *
49
+ * The copy is unconditional, so an edited skill is replaced on every run. That is
50
+ * deliberate (an existing workspace picks up skill updates), but it is reported:
51
+ * `refreshed` names what was overwritten. Because the directory is removed and
52
+ * not merged, that goes further than reverting an edit: a file the user added
53
+ * inside a skill directory is gone, and no bundled copy puts it back. Those paths
54
+ * are collected into `removed` so the caller can name them, rather than leaving
55
+ * them under a word ("refreshed") that reads as "your edits were reverted". Note
56
+ * the copy never prunes whole skills, so one dropped from a later release stays in
57
+ * the workspace until deleted by hand.
58
+ */
59
+ export declare function installSkills(targetDir: string, workspaceRoot: string): SkillInstall;
60
+ /** Count directories under dir that contain a SKILL.md, i.e. real skills. */
61
+ export declare function countSkills(dir: string): number;
62
+ /**
63
+ * Whether target, which need not exist yet, names a path inside root once every
64
+ * symlink on the way has been followed. A path utility rather than a skills
65
+ * concept, but it lives beside the only call that needs it, and exporting it
66
+ * keeps the scaffolder's own path checks on the same implementation.
67
+ */
68
+ export declare function isWithinDirectory(target: string, root: string): boolean;
@@ -0,0 +1,182 @@
1
+ // Copyright (c) Credible Data Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { skillsDir } from "./payload.js";
6
+ /**
7
+ * Copy every agent skill into targetDir as real files. The generated project is
8
+ * standalone and the user may move or commit it, so this is a recursive copy, not
9
+ * a symlink: a symlink into node_modules would dangle the moment the project is
10
+ * moved or its dependencies pruned. skillsDir already excludes non-shipping skills
11
+ * and the internal README, so a straight copy is clean.
12
+ *
13
+ * A skill directory in the target that is itself a symlink is left alone. That is
14
+ * how this repo's own contributors wire skills into their agent (skills/README.md
15
+ * tells them to), and copying through the link would overwrite the source of truth
16
+ * rather than the workspace.
17
+ *
18
+ * targetDir has to resolve inside workspaceRoot. A `.claude` that is itself a
19
+ * symlink elsewhere is not caught by the per-skill check above, and used to write
20
+ * every skill outside the workspace while reporting them installed.
21
+ *
22
+ * An existing skill directory is removed before the copy rather than copied over.
23
+ * fs.cpSync follows a symlink it finds at the destination, so checking only the
24
+ * skill directory itself left every path inside it writable through a link: a
25
+ * `.claude/skills/malloy-review/SKILL.md` linked elsewhere had the file it points
26
+ * at replaced, and a linked `reference/` (four shipped skills have one) had files
27
+ * written into whatever directory it named, all under a green
28
+ * "Installed N Malloy skills". `.claude/skills/` is shared agent territory where
29
+ * links are an expected state, so the check has to cover the whole subtree, and
30
+ * removing first is the only form of that which cannot be one level short.
31
+ *
32
+ * The copy is unconditional, so an edited skill is replaced on every run. That is
33
+ * deliberate (an existing workspace picks up skill updates), but it is reported:
34
+ * `refreshed` names what was overwritten. Because the directory is removed and
35
+ * not merged, that goes further than reverting an edit: a file the user added
36
+ * inside a skill directory is gone, and no bundled copy puts it back. Those paths
37
+ * are collected into `removed` so the caller can name them, rather than leaving
38
+ * them under a word ("refreshed") that reads as "your edits were reverted". Note
39
+ * the copy never prunes whole skills, so one dropped from a later release stays in
40
+ * the workspace until deleted by hand.
41
+ */
42
+ export function installSkills(targetDir, workspaceRoot) {
43
+ const empty = { installed: 0, skipped: [], refreshed: [], removed: [] };
44
+ if (isSymlink(targetDir)) {
45
+ return {
46
+ ...empty,
47
+ refused: "it is a symlink, so writing into it would edit whatever it points at",
48
+ };
49
+ }
50
+ if (!isWithinDirectory(targetDir, workspaceRoot)) {
51
+ return {
52
+ ...empty,
53
+ refused: "it resolves outside the workspace, so writing into it would put " +
54
+ "files somewhere else on this machine",
55
+ };
56
+ }
57
+ fs.mkdirSync(targetDir, { recursive: true });
58
+ const skipped = [];
59
+ const refreshed = [];
60
+ const removed = [];
61
+ let installed = 0;
62
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
63
+ if (!entry.isDirectory()) {
64
+ continue;
65
+ }
66
+ const source = path.join(skillsDir, entry.name);
67
+ if (!fs.existsSync(path.join(source, "SKILL.md"))) {
68
+ continue;
69
+ }
70
+ const target = path.join(targetDir, entry.name);
71
+ if (isSymlink(target)) {
72
+ skipped.push(entry.name);
73
+ continue;
74
+ }
75
+ if (fs.existsSync(target)) {
76
+ refreshed.push(entry.name);
77
+ // Read before the removal, because after it there is nothing to read:
78
+ // these are the paths the copy below does not put back.
79
+ for (const relative of pathsNotIn(target, source)) {
80
+ removed.push(path.join(entry.name, relative));
81
+ }
82
+ }
83
+ // target is not a symlink (checked above) and sits directly under a
84
+ // targetDir that resolves inside the workspace, so this removes files in
85
+ // the workspace and, for any symlink further down, the link itself rather
86
+ // than what it points at.
87
+ fs.rmSync(target, { recursive: true, force: true });
88
+ fs.cpSync(source, target, { recursive: true });
89
+ installed += 1;
90
+ }
91
+ return { installed, skipped, refreshed, removed };
92
+ }
93
+ /**
94
+ * Every path under target that source does not also hold, relative to target.
95
+ *
96
+ * A directory that source does not hold is named once and not walked: what the
97
+ * user has to look for is the directory, and walking it would turn one dropped
98
+ * folder into a hundred lines. Nothing here follows a link out of target either,
99
+ * because a symlink is compared by its own path, never by what it points at.
100
+ */
101
+ function pathsNotIn(target, source, prefix = "") {
102
+ let entries;
103
+ try {
104
+ entries = fs.readdirSync(path.join(target, prefix), {
105
+ withFileTypes: true,
106
+ });
107
+ }
108
+ catch {
109
+ // Unreadable: nothing can be said about what is in it.
110
+ return [];
111
+ }
112
+ const missing = [];
113
+ for (const entry of entries) {
114
+ const relative = path.join(prefix, entry.name);
115
+ if (!fs.existsSync(path.join(source, relative))) {
116
+ missing.push(entry.isDirectory() ? relative + path.sep : relative);
117
+ continue;
118
+ }
119
+ if (entry.isDirectory()) {
120
+ missing.push(...pathsNotIn(target, source, relative));
121
+ }
122
+ }
123
+ return missing;
124
+ }
125
+ /** Count directories under dir that contain a SKILL.md, i.e. real skills. */
126
+ export function countSkills(dir) {
127
+ if (!fs.existsSync(dir)) {
128
+ return 0;
129
+ }
130
+ return fs
131
+ .readdirSync(dir, { withFileTypes: true })
132
+ .filter((entry) => entry.isDirectory() &&
133
+ fs.existsSync(path.join(dir, entry.name, "SKILL.md"))).length;
134
+ }
135
+ /**
136
+ * Whether target, which need not exist yet, names a path inside root once every
137
+ * symlink on the way has been followed. A path utility rather than a skills
138
+ * concept, but it lives beside the only call that needs it, and exporting it
139
+ * keeps the scaffolder's own path checks on the same implementation.
140
+ */
141
+ export function isWithinDirectory(target, root) {
142
+ const realRoot = realPathOf(root);
143
+ const realTarget = realPathOf(target);
144
+ return realTarget === realRoot || realTarget.startsWith(realRoot + path.sep);
145
+ }
146
+ /**
147
+ * The path target really names. fs.realpathSync cannot be used: it throws on a
148
+ * path that does not exist yet (every file we are about to create) and on a
149
+ * dangling symlink, which is precisely the case that matters, because
150
+ * fs.writeFileSync follows a dangling link and creates the file wherever it
151
+ * points. So resolve the parent first, then follow one link at a time.
152
+ */
153
+ function realPathOf(target, hops = 0) {
154
+ const resolved = path.resolve(target);
155
+ const parent = path.dirname(resolved);
156
+ if (parent === resolved) {
157
+ return resolved;
158
+ }
159
+ const here = path.join(realPathOf(parent, hops), path.basename(resolved));
160
+ if (hops >= 40) {
161
+ // A symlink cycle. Stop following and judge the path as it stands.
162
+ return here;
163
+ }
164
+ try {
165
+ if (!fs.lstatSync(here).isSymbolicLink()) {
166
+ return here;
167
+ }
168
+ return realPathOf(path.resolve(path.dirname(here), fs.readlinkSync(here)), hops + 1);
169
+ }
170
+ catch {
171
+ // Does not exist yet, or is unreadable: it is the path it looks like.
172
+ return here;
173
+ }
174
+ }
175
+ function isSymlink(target) {
176
+ try {
177
+ return fs.lstatSync(target).isSymbolicLink();
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Absolute path to the directory holding the skill directories.
3
+ *
4
+ * Resolved from this module's own URL, which works both from dist/ once
5
+ * installed and from src/ in the repo, since both sit one level under the
6
+ * package root.
7
+ */
8
+ export declare const skillsDir: string;
@@ -0,0 +1,19 @@
1
+ // Copyright (c) Credible Data Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Where the shipped skill files are.
5
+ *
6
+ * Its own module, rather than a constant in index.ts, because both the reader
7
+ * (`listSkills`) and the writer (`installSkills`) need it and index.ts re-exports
8
+ * the writer: importing it from there would make the two modules a cycle.
9
+ */
10
+ import * as path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ /**
13
+ * Absolute path to the directory holding the skill directories.
14
+ *
15
+ * Resolved from this module's own URL, which works both from dist/ once
16
+ * installed and from src/ in the repo, since both sit one level under the
17
+ * package root.
18
+ */
19
+ export const skillsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "skills");
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.33",
4
+ "version": "0.1.35",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
+ "bin": {
10
+ "malloy-skills": "dist/cli.js"
11
+ },
9
12
  "exports": {
10
13
  ".": {
11
14
  "types": "./dist/index.d.ts",
@@ -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.33
4
+ version: 0.1.35
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -140,9 +140,9 @@ on that axis.
140
140
  | `evalConfig` | The set's `eval.toml` (or `eval.json`) the run read, as `{path, values}`; `path` is null when the set has none. |
141
141
  | `predictionsReExecuted` | Whether re-execution was POSSIBLE: the server was serving the bytes this run is pinned to. It is one bool for the whole run and says nothing about any given query, so it reads stronger than it is; `reExecution` below is what actually happened. |
142
142
  | `reExecution` | `{attempted, ok, failed, noQuery, notReExecuted, notJudged, missing}` over the run's predictions. `failed` means the query ran and the server returned an error, which is a different thing from an answer with no query at all. `notJudged` is a case the judge was never handed: a golden refused before judging (`provisional`, `invalid`, `ambiguous`, `verified_wrong`, missing), an attempt that submitted nothing, or every case under `--no-judge`. Those used to land in `missing` and read as corrupt artifacts on a freshly imported set. `ok`, `failed`, `noQuery`, `notReExecuted`, `notJudged` and `missing` partition the cases, so they sum to the case count; `attempted` is `ok + failed` and sits outside that sum. |
143
- | `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |
144
- | `retrievalCalls` | `{semantic, lexical, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |
145
- | `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart are lexical by design and an arm started immediately measures two retrievers. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |
143
+ | `retrievalMode` | Which retriever answered: `semantic`, `lexical`, `mixed` (it changed mid-run, so nothing can be averaged across it), `unavailable` (at least one search was answered `indexing` or `error`, so it returned nothing to score and reads as a miss) or `unreported` (nothing ranked, which on a configured server means no embedding provider). Only a RANKING call names a retriever, so a call that enumerates a type or looks a name up is `unreported` on a fully semantic server and must not make the run read as `mixed`. Local retrieval degrades to lexical SILENTLY without an embedding key, and a pair compared across that reads as a model change, so `flip_table.py` refuses the pair unless both sides match. |
144
+ | `retrievalCalls` | `{semantic, lexical, indexing, error, unreported}` call counts behind `retrievalMode`. `unreported` counts the calls that did not rank, which is a different thing from calls that fell back. |
145
+ | `retrievalGate` | What the pre-arm retrieval gate found, or why it did not run. A run is held until `get_context` answers semantic twice in a row, because the first calls after a restart return `retrieval: "indexing"` with no sources and an arm started immediately measures empty results as well as real ones. A `retrieval: "error"` result stops the gate with its `retrieval_reason` instead of waiting. `not run (no answering phase)` under `--rebuild`/`--rejudge`, and `skipped by --no-retrieval-gate` when the operator opted out -- which is a different fact from a gate that passed, and the field is what tells them apart. |
146
146
  | `coverageReport` | The `check_coverage.py --out` report this run read, when `--coverage` named one: its path, the model version it was stamped with, its `agentModel`, and `decided` of `cases`. Coverage is a read of the MODEL, so it is measured separately and costs a judge call per case; the run does not run it, it consumes it. Its per-case verdict then beats the authored `coverage` label in retrieval attribution, and each `score_retrieval` row records `coverage_source` (`measured`, `authored`, `none`) so nobody has to guess which one charged the failure. Absent when no report was given, which is a different fact from a report that found everything covered. |
147
147
  | `label` | `<set>-<phase>-<nn>`, assigned by `run_baseline.py` from the set name, the run's phase and the next free number beside it (`ecommerce-baseline-01`, `ecommerce-baseline-02`, `ecommerce-blind_gate-01`). The A/A pair is two runs of the same phase; the post-edit arms are two runs of `blind_gate`. Hand-typed names do not survive one afternoon of runs -- `base`, `rejudged2`, `r3`, `post1` sort wrongly, group not at all, and cannot be matched to an arm. `--label` overrides for a run that genuinely needs a human name. |
148
148
  | `effort` | |