toolfactory 0.2.0 → 0.2.4

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 (63) hide show
  1. package/README.md +1 -1
  2. package/dist/bindings/typescript.js +1 -1
  3. package/dist/bindings/typescript.test.js +29 -1
  4. package/dist/commands.d.ts +6 -0
  5. package/dist/commands.js +13 -2
  6. package/dist/hosts/openclaw.d.ts +8 -0
  7. package/dist/hosts/openclaw.js +70 -4
  8. package/dist/hosts/openclaw.test.d.ts +1 -0
  9. package/dist/hosts/openclaw.test.js +58 -0
  10. package/dist/hosts/web.d.ts +1 -0
  11. package/dist/hosts/web.js +6 -3
  12. package/dist/hosts/web.test.d.ts +1 -0
  13. package/dist/hosts/web.test.js +8 -0
  14. package/dist/model.d.ts +12 -0
  15. package/dist/model.js +4 -0
  16. package/dist/ops.d.ts +26 -0
  17. package/dist/ops.js +25 -1
  18. package/dist/project/gate.js +7 -1
  19. package/dist/project/history.d.ts +1 -0
  20. package/dist/project/load.js +19 -2
  21. package/dist/prompt/review.d.ts +23 -0
  22. package/dist/prompt/review.js +103 -0
  23. package/dist/prompt/review.test.d.ts +1 -0
  24. package/dist/prompt/review.test.js +55 -0
  25. package/dist/prompt/standard.d.ts +26 -0
  26. package/dist/prompt/standard.js +68 -0
  27. package/dist/surfaces/dsh.js +2 -1
  28. package/dist/surfaces/dsh.test.js +30 -1
  29. package/dist/surfaces/openclaw-native.d.ts +11 -1
  30. package/dist/surfaces/openclaw-native.js +23 -3
  31. package/dist/surfaces/openclaw-native.test.js +11 -1
  32. package/dist/surfaces/web.d.ts +6 -6
  33. package/dist/surfaces/web.js +7 -7
  34. package/dist/surfaces/web.test.js +1 -1
  35. package/dist/surfaces/workflows.js +2 -2
  36. package/dist/toolfactory/cli.js +1 -1
  37. package/dist/toolfactory/mcp.js +1 -1
  38. package/package.json +1 -1
  39. package/src/bindings/typescript.test.ts +32 -1
  40. package/src/bindings/typescript.ts +1 -1
  41. package/src/commands.ts +14 -2
  42. package/src/hosts/openclaw.test.ts +62 -0
  43. package/src/hosts/openclaw.ts +73 -5
  44. package/src/hosts/web.test.ts +9 -0
  45. package/src/hosts/web.ts +6 -3
  46. package/src/model.ts +15 -0
  47. package/src/ops.ts +30 -1
  48. package/src/project/gate.ts +7 -1
  49. package/src/project/load.ts +23 -2
  50. package/src/prompt/review.test.ts +68 -0
  51. package/src/prompt/review.ts +148 -0
  52. package/src/prompt/standard.ts +93 -0
  53. package/src/surfaces/dsh.test.ts +31 -1
  54. package/src/surfaces/dsh.ts +2 -1
  55. package/src/surfaces/openclaw-native.test.ts +11 -1
  56. package/src/surfaces/openclaw-native.ts +27 -3
  57. package/src/surfaces/web.test.ts +1 -1
  58. package/src/surfaces/web.ts +7 -7
  59. package/src/surfaces/workflows.ts +2 -2
  60. package/src/toolfactory/cli.ts +1 -1
  61. package/src/toolfactory/mcp.ts +1 -1
  62. package/web/dist/assets/{index-CVm6m4s0.js → index-GlWisnfJ.js} +2 -2
  63. package/web/dist/index.html +1 -1
package/README.md CHANGED
@@ -22,7 +22,7 @@ generated and every surface still installs, builds and publishes.
22
22
  - **Gemini CLI extension** — `gemini extensions install https://github.com/GoatInAHat/toolfactory`
23
23
  - **OpenClaw plugin** — `openclaw plugins install --link hosts/openclaw` from a checkout
24
24
  - **Hermes plugin** — `hermes plugins install https://github.com/GoatInAHat/toolfactory#hosts/hermes/toolfactory_hermes`
25
- - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.2.0.tgz`
25
+ - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.2.4.tgz`
26
26
  - **Web app** — `npx -y toolfactory mcp --http --open` serves the operations page beside the
27
27
  MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and
28
28
  returns the URL.
@@ -719,7 +719,7 @@ export function scaffold(project) {
719
719
  engines: { node: NODE_ENGINES },
720
720
  scripts: {
721
721
  build: "tsc -p tsconfig.json",
722
- test: "vitest run --passWithNoTests",
722
+ test: "vitest run --dir src --passWithNoTests",
723
723
  "test:live": LIVE_TEST_SCRIPT,
724
724
  },
725
725
  dependencies: {
@@ -1,9 +1,10 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync, } from "node:fs";
3
3
  import { createServer as createNetServer } from "node:http";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { stripVTControlCharacters } from "node:util";
7
8
  import { describe, expect, it } from "vitest";
8
9
  import { ToolConfigSchema } from "../model.js";
9
10
  import { cliCommand, cli as cliFiles, kernel, kernelCommand, liveTest, scaffold, } from "./typescript.js";
@@ -198,6 +199,33 @@ async function waitForInitialize(url, deadlineMs, token) {
198
199
  }
199
200
  }
200
201
  }
202
+ describe.skipIf(!existsSync(repoNodeModules))("typescript scaffold test commands", () => {
203
+ it("runs core tests without collecting host or live tests, preserving the explicit live lane", () => {
204
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-test-scope-"));
205
+ symlinkSync(repoNodeModules, join(root, "node_modules"), "dir");
206
+ writeFileSync(join(root, "package.json"), text(scaffold(project(["cli"])), "package.json"));
207
+ const writeTest = (path, body) => {
208
+ mkdirSync(dirname(join(root, path)), { recursive: true });
209
+ writeFileSync(join(root, path), body);
210
+ };
211
+ const passing = 'import { it } from "vitest"; it("core receipt", () => {});';
212
+ const failing = 'import { it } from "vitest"; it("scope sentinel", () => { throw new Error("outside core"); });';
213
+ writeTest("src/core.test.ts", passing);
214
+ writeTest("hosts/openclaw/src/index.test.ts", failing);
215
+ writeTest("hosts/browser/tests/background.test.ts", failing);
216
+ writeTest("tests/live.test.ts", failing);
217
+ const run = (script) => spawnSync("npm", ["run", script], { cwd: root, encoding: "utf8" });
218
+ const core = run("test");
219
+ expect(core.status, core.stdout + core.stderr).toBe(0);
220
+ expect(stripVTControlCharacters(core.stdout)).toMatch(/Tests\s+1 passed/);
221
+ writeTest("src/core.test.ts", failing);
222
+ expect(run("test").status).toBe(1);
223
+ writeTest("tests/live.test.ts", passing);
224
+ const live = run("test:live");
225
+ expect(live.status, live.stdout + live.stderr).toBe(0);
226
+ expect(stripVTControlCharacters(live.stdout)).toMatch(/Tests\s+1 passed/);
227
+ });
228
+ });
201
229
  describe.skipIf(!existsSync(repoNodeModules))("typescript kernel, really run over http", () => {
202
230
  it("serves tools/list over --http, and requires the token minted by --pair", {
203
231
  timeout: 60_000,
@@ -2,6 +2,7 @@ import { type BootstrapResult, type CreateRepoResult } from "./hosts/github.js";
2
2
  import type { Binding, Command, Project, SurfaceId } from "./model.js";
3
3
  import { type Drift } from "./project/apply.js";
4
4
  import { type GateStep } from "./project/gate.js";
5
+ import { type PromptReviewReport } from "./prompt/review.js";
5
6
  import { type Coverage } from "./report/coverage.js";
6
7
  export interface InitOptions {
7
8
  root: string;
@@ -186,3 +187,8 @@ export declare function bootstrapRepo(args: {
186
187
  }): BootstrapResult;
187
188
  export declare function doctor(): DoctorReport;
188
189
  export declare const SURFACES: readonly ["skill", "agent-plugins", "claude", "codex", "cursor", "mcp", "mcp-registry", "cli", "npm", "pypi", "openclaw-native", "hermes-native", "clawhub", "web", "dsh", "workflows", "agents", "readme", "gemini", "mcpb", "browser-extension", "vscode-extension", "cargo", "nuget", "maven-central", "rubygems", "packagist", "go-module", "homebrew", "winget", "scoop", "chocolatey", "apt", "rpm"];
190
+ /** `review-prompt`: the codex-prompt-standard loop's evaluator (see `src/prompt/review.ts`). */
191
+ export declare function reviewPrompt(input: {
192
+ prompt: string;
193
+ reference?: string;
194
+ }): PromptReviewReport;
package/dist/commands.js CHANGED
@@ -10,6 +10,7 @@ import { parseEnv } from "node:util";
10
10
  import { getBinding } from "./bindings/index.js";
11
11
  import { LIVE_TEST_COMMAND } from "./bindings/python.js";
12
12
  import { createRepo, githubSlug, LIVE_ENVIRONMENT, bootstrapRepo as prepareRepository, } from "./hosts/github.js";
13
+ import { coreCopyDrift } from "./hosts/openclaw.js";
13
14
  import { assertValidName, projectName } from "./identity/name.js";
14
15
  import { introspect as runIntrospect, snapshot } from "./introspect/index.js";
15
16
  import { assertNoSensitiveArgument, defaultSurfaces, isInstructionOnly, SURFACE_IDS, ToolConfigSchema, } from "./model.js";
@@ -19,6 +20,7 @@ import { droppedSurfaces, previousTag, toolAtRef } from "./project/history.js";
19
20
  import { loadProject, OPS_PATH, TOOL_PATH, TOOLFACTORY_VERSION, toOperation, } from "./project/load.js";
20
21
  import { readLock } from "./project/lock.js";
21
22
  import { buildPlan, TOOL_SCHEMA_PATH } from "./project/plan.js";
23
+ import { reviewPrompt as evaluatePrompt } from "./prompt/review.js";
22
24
  import { computeCoverage } from "./report/coverage.js";
23
25
  import { PLUGIN_SCHEMA_ID } from "./surfaces/agent-plugins.js";
24
26
  import { reloadLine, SETUP_PATH } from "./surfaces/agents.js";
@@ -237,11 +239,16 @@ export async function check(root = ".") {
237
239
  const project = loadProject(root);
238
240
  assertNoSensitiveArgument(project.tool.config, project.operations);
239
241
  const drift = checkPlan(project.root, buildPlan(project), TOOLFACTORY_VERSION);
242
+ const hostDrift = coreCopyDrift(project.root);
240
243
  const ops = isInstructionOnly(project.tool) ? undefined : await snapshot(project);
241
244
  if (ops?.changed)
242
245
  drift.unshift({ kind: "changed", path: OPS_PATH });
246
+ drift.push(...hostDrift);
243
247
  if (drift.length) {
244
- throw new Error(`${drift.length} generated file(s) out of date; run \`toolfactory ${ops?.changed ? "introspect" : "build"}\`:\n${drift.map((d) => ` ${d.kind}: ${d.path}`).join("\n")}`);
248
+ const remedy = hostDrift.length
249
+ ? "\nthe materialized host core copy is stale: cp -r dist/. hosts/openclaw/node_modules/toolfactory/dist/"
250
+ : "";
251
+ throw new Error(`${drift.length} generated file(s) out of date; run \`toolfactory ${ops?.changed ? "introspect" : "build"}\`:\n${drift.map((d) => ` ${d.kind}: ${d.path}`).join("\n")}${remedy}`);
245
252
  }
246
253
  return { project, drift };
247
254
  }
@@ -549,7 +556,7 @@ function secretsSummary(report) {
549
556
  if (!rows.length)
550
557
  return undefined;
551
558
  const missing = rows.filter((row) => !row.local).map((row) => row.name);
552
- return `Secrets: ${rows.length - missing.length} of ${rows.length} present in .env${missing.length ? ` (missing ${missing.join(", ")})` : ""}. \`toolfactory secrets\` prints where each one is set, per host and per registry.`;
559
+ return `Secrets: ${rows.length - missing.length} of ${rows.length} present in .env${missing.length ? ` (missing ${missing.join(", ")})` : ""}. \`toolfactory secrets-usage\` prints where each one is set, per host and per registry.`;
553
560
  }
554
561
  /** The project as the previous tag left it, plus the surfaces it published that are now gone. */
555
562
  function retractable(project, tag, tool) {
@@ -660,3 +667,7 @@ export function doctor() {
660
667
  return { toolfactory: TOOLFACTORY_VERSION, node: process.version, tools };
661
668
  }
662
669
  export const SURFACES = SURFACE_IDS;
670
+ /** `review-prompt`: the codex-prompt-standard loop's evaluator (see `src/prompt/review.ts`). */
671
+ export function reviewPrompt(input) {
672
+ return evaluatePrompt(input);
673
+ }
@@ -1,4 +1,12 @@
1
1
  import type { Project } from "../model.js";
2
+ import type { Drift } from "../project/apply.js";
2
3
  export declare const DRIFT_ENTRY: string;
3
4
  /** Human-readable drift lines; empty means the generator still mirrors upstream. */
4
5
  export declare function scaffoldDrift(project: Project): string[];
6
+ /**
7
+ * The copy is what the plugin's runtime lookup resolves, and nothing syncs it: after the core
8
+ * gains an operation, a stale copy still registers the tool but every call fails with
9
+ * "operation … is missing from the core package". Opt-in via a MATERIALIZED marker, so
10
+ * npm-installed cores in generated tools are never compared against a workspace build.
11
+ */
12
+ export declare function coreCopyDrift(root: string): Drift[];
@@ -8,13 +8,13 @@
8
8
  * `validate()` step reaches it: one Command like every other validator.
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
11
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { argv, exit, stderr, stdout } from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { loadProject } from "../project/load.js";
17
- import { HOST_DIR, surface } from "../surfaces/openclaw-native.js";
17
+ import { HOST_DIR, OPENCLAW_SCAFFOLD, surface } from "../surfaces/openclaw-native.js";
18
18
  export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
19
19
  /** Per-tool values: the scaffold writes its own, and the projection is expected to differ. */
20
20
  const PER_TOOL_KEYS = new Set(["name", "version", "description"]);
@@ -33,6 +33,9 @@ function overridden(project) {
33
33
  paths.add(".peerDependencies.openclaw");
34
34
  paths.add(".openclaw.compat.pluginApi");
35
35
  }
36
+ // Shipped skills append to `files`; upstream's entries must still survive (checked below).
37
+ if (openclaw?.skills?.length)
38
+ paths.add(".files");
36
39
  return paths;
37
40
  }
38
41
  /**
@@ -68,10 +71,25 @@ function planned(files, path) {
68
71
  export function scaffoldDrift(project) {
69
72
  const files = surface.plan(project);
70
73
  const directory = mkdtempSync(join(tmpdir(), "toolfactory-openclaw-"));
71
- // Prefer the openclaw the plugin pins as a devDependency over whatever is on PATH.
74
+ // The probe must run the exact openclaw this project pins (author override or the
75
+ // scaffold default) - never whatever happens to be on PATH, which made the drift verdict
76
+ // environment-dependent: the scaffold openclawVersion is the running CLI own version.
77
+ const pin = project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.devDependencies.openclaw;
72
78
  const pinned = join(project.root, HOST_DIR, "node_modules/.bin/openclaw");
73
79
  try {
74
- execFileSync(existsSync(pinned) ? pinned : "openclaw", ["plugins", "init", "tf-probe", "--type", "tool", "--directory", join(directory, "probe")], { stdio: "pipe" });
80
+ const probe = existsSync(pinned)
81
+ ? { command: pinned, pre: [] }
82
+ : { command: "npx", pre: ["--yes", `openclaw@${pin}`] };
83
+ execFileSync(probe.command, [
84
+ ...probe.pre,
85
+ "plugins",
86
+ "init",
87
+ "tf-probe",
88
+ "--type",
89
+ "tool",
90
+ "--directory",
91
+ join(directory, "probe"),
92
+ ], { stdio: "pipe" });
75
93
  const scaffold = (path) => readFileSync(join(directory, "probe", path), "utf8");
76
94
  const skip = overridden(project);
77
95
  const drift = [
@@ -80,12 +98,60 @@ export function scaffoldDrift(project) {
80
98
  ? []
81
99
  : ["vitest.config.ts: upstream template changed"]),
82
100
  ];
101
+ if (project.tool.openclaw?.skills?.length) {
102
+ const upstream = JSON.parse(scaffold("package.json")).files ?? [];
103
+ const ours = JSON.parse(planned(files, "package.json")).files ?? [];
104
+ const lost = upstream.filter((entry) => !ours.includes(entry));
105
+ if (lost.length)
106
+ drift.push(`package.json.files lost upstream entries: ${lost.join(", ")}`);
107
+ }
83
108
  return drift;
84
109
  }
85
110
  finally {
86
111
  rmSync(directory, { recursive: true, force: true });
87
112
  }
88
113
  }
114
+ /**
115
+ * The copy is what the plugin's runtime lookup resolves, and nothing syncs it: after the core
116
+ * gains an operation, a stale copy still registers the tool but every call fails with
117
+ * "operation … is missing from the core package". Opt-in via a MATERIALIZED marker, so
118
+ * npm-installed cores in generated tools are never compared against a workspace build.
119
+ */
120
+ export function coreCopyDrift(root) {
121
+ // Resolves HOST_DIR lazily: this module sits in an import cycle with
122
+ // surfaces/openclaw-native.js (DRIFT_ENTRY), so nothing may touch its exports at module scope.
123
+ const copy = join(root, HOST_DIR, "node_modules", "toolfactory");
124
+ if (!existsSync(join(copy, "MATERIALIZED")))
125
+ return [];
126
+ const source = join(root, "dist");
127
+ if (!existsSync(source))
128
+ return [];
129
+ return treeDrift(source, join(copy, "dist"), join(HOST_DIR, "node_modules", "toolfactory", "dist"));
130
+ }
131
+ /** Byte-compare two trees in both directions; `label` prefixes every reported path. */
132
+ function treeDrift(source, copy, label) {
133
+ if (!existsSync(copy))
134
+ return [{ kind: "changed", path: label }];
135
+ const drift = [];
136
+ for (const name of new Set([...readdirSync(source), ...readdirSync(copy)])) {
137
+ const ours = join(source, name);
138
+ const theirs = join(copy, name);
139
+ const oursDir = existsSync(ours) && statSync(ours).isDirectory();
140
+ const theirsDir = existsSync(theirs) && statSync(theirs).isDirectory();
141
+ if (oursDir || theirsDir) {
142
+ if (oursDir && theirsDir)
143
+ drift.push(...treeDrift(ours, theirs, join(label, name)));
144
+ else
145
+ drift.push({ kind: "changed", path: join(label, name) });
146
+ }
147
+ else if (!existsSync(ours) ||
148
+ !existsSync(theirs) ||
149
+ !readFileSync(ours).equals(readFileSync(theirs))) {
150
+ drift.push({ kind: "changed", path: join(label, name) });
151
+ }
152
+ }
153
+ return drift;
154
+ }
89
155
  if (argv[1] === DRIFT_ENTRY) {
90
156
  const drift = scaffoldDrift(loadProject(argv[2] ?? "."));
91
157
  if (drift.length) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { coreCopyDrift } from "./openclaw.js";
6
+ const roots = [];
7
+ function staged() {
8
+ const root = mkdtempSync(join(tmpdir(), "tf-core-copy-"));
9
+ roots.push(root);
10
+ return root;
11
+ }
12
+ function put(root, path, content) {
13
+ const file = join(root, path);
14
+ mkdirSync(dirname(file), { recursive: true });
15
+ writeFileSync(file, content);
16
+ }
17
+ afterEach(() => {
18
+ for (const root of roots.splice(0))
19
+ rmSync(root, { recursive: true, force: true });
20
+ });
21
+ describe("coreCopyDrift", () => {
22
+ it("skips copies without the MATERIALIZED marker (npm-installed cores)", () => {
23
+ const root = staged();
24
+ put(root, "dist/ops.js", "new");
25
+ put(root, "hosts/openclaw/node_modules/toolfactory/dist/ops.js", "old");
26
+ expect(coreCopyDrift(root)).toEqual([]);
27
+ });
28
+ it("skips when the workspace has no dist yet", () => {
29
+ const root = staged();
30
+ put(root, "hosts/openclaw/node_modules/toolfactory/MATERIALIZED", "");
31
+ expect(coreCopyDrift(root)).toEqual([]);
32
+ });
33
+ it("passes when the materialized copy matches the dist", () => {
34
+ const root = staged();
35
+ put(root, "dist/ops.js", "same");
36
+ put(root, "dist/deep/command.js", "same");
37
+ put(root, "hosts/openclaw/node_modules/toolfactory/MATERIALIZED", "");
38
+ put(root, "hosts/openclaw/node_modules/toolfactory/dist/ops.js", "same");
39
+ put(root, "hosts/openclaw/node_modules/toolfactory/dist/deep/command.js", "same");
40
+ expect(coreCopyDrift(root)).toEqual([]);
41
+ });
42
+ it("flags stale, missing, and extra files in the copy", () => {
43
+ const root = staged();
44
+ put(root, "dist/ops.js", "new");
45
+ put(root, "dist/gone.js", "still here");
46
+ put(root, "hosts/openclaw/node_modules/toolfactory/MATERIALIZED", "");
47
+ put(root, "hosts/openclaw/node_modules/toolfactory/dist/ops.js", "old");
48
+ put(root, "hosts/openclaw/node_modules/toolfactory/dist/extra.js", "leftover");
49
+ const paths = coreCopyDrift(root)
50
+ .map((d) => d.path)
51
+ .sort();
52
+ expect(paths).toEqual([
53
+ "hosts/openclaw/node_modules/toolfactory/dist/extra.js",
54
+ "hosts/openclaw/node_modules/toolfactory/dist/gone.js",
55
+ "hosts/openclaw/node_modules/toolfactory/dist/ops.js",
56
+ ]);
57
+ });
58
+ });
@@ -1,4 +1,5 @@
1
1
  import type { Project } from "../model.js";
2
2
  export declare const DRIFT_ENTRY: string;
3
+ export declare function differs(label: string, upstream: unknown, pinned: unknown): string[];
3
4
  /** Human-readable drift lines; empty means the generators still write what WEB_SCAFFOLD says. */
4
5
  export declare function scaffoldDrift(project: Project): string[];
package/dist/hosts/web.js CHANGED
@@ -15,6 +15,7 @@ import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { argv, exit, stderr, stdout } from "node:process";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { inspect, isDeepStrictEqual } from "node:util";
18
19
  import { loadProject } from "../project/load.js";
19
20
  import { surface, TAILWIND_CSS, WEB_DIR, WEB_SCAFFOLD } from "../surfaces/web.js";
20
21
  export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
@@ -30,10 +31,12 @@ function planned(files, path) {
30
31
  function parse(text) {
31
32
  return JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""));
32
33
  }
33
- function differs(label, upstream, pinned) {
34
- return JSON.stringify(upstream) === JSON.stringify(pinned)
34
+ export function differs(label, upstream, pinned) {
35
+ return isDeepStrictEqual(upstream, pinned)
35
36
  ? []
36
- : [`${label}: upstream and WEB_SCAFFOLD no longer agree`];
37
+ : [
38
+ `${label}: upstream ${inspect(upstream, { sorted: true })}, pinned ${inspect(pinned, { sorted: true })}`,
39
+ ];
37
40
  }
38
41
  /** Human-readable drift lines; empty means the generators still write what WEB_SCAFFOLD says. */
39
42
  export function scaffoldDrift(project) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { expect, it } from "vitest";
2
+ import { differs } from "./web.js";
3
+ it("compares scaffold objects by values, not key insertion order", () => {
4
+ expect(differs("deps", { a: "1", b: "2" }, { b: "2", a: "1" })).toEqual([]);
5
+ expect(differs("deps", { a: "1" }, { a: "2" })).toHaveLength(1);
6
+ expect(differs("deps", { a: "1" }, { a: "1", b: "2" })).toHaveLength(1);
7
+ expect(differs("array", ["a", "b"], ["b", "a"])).toHaveLength(1);
8
+ });
package/dist/model.d.ts CHANGED
@@ -162,6 +162,7 @@ export declare const ToolConfigSchema: z.ZodObject<{
162
162
  }, z.core.$strip>>>;
163
163
  activation: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
164
164
  pluginApi: z.ZodOptional<z.ZodString>;
165
+ skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
165
166
  dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
166
167
  peerDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
167
168
  devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
@@ -257,6 +258,15 @@ export interface Command {
257
258
  cwd?: string;
258
259
  env?: Record<string, string>;
259
260
  }
261
+ /** One `.agents/skills/<name>/` directory a plugin surface ships, read at load time. */
262
+ export interface ProjectSkill {
263
+ name: string;
264
+ /** Files of the skill directory, relative and sorted; content as UTF-8 text. */
265
+ files: {
266
+ path: string;
267
+ content: string;
268
+ }[];
269
+ }
260
270
  export interface Project {
261
271
  root: string;
262
272
  tool: ToolConfig;
@@ -267,6 +277,8 @@ export interface Project {
267
277
  toolfactoryVersion: string;
268
278
  /** From the root package.json `packageManager` field; npm when absent. */
269
279
  packageManager?: PackageManager;
280
+ /** Skills `openclaw.skills` declares, read once so projectors stay pure. */
281
+ skills?: ProjectSkill[];
270
282
  }
271
283
  export interface Surface {
272
284
  id: SurfaceId;
package/dist/model.js CHANGED
@@ -176,6 +176,10 @@ export const ToolConfigSchema = z
176
176
  .string()
177
177
  .optional()
178
178
  .describe("Plugin API range for compat.pluginApi and the openclaw peer dependency."),
179
+ skills: z
180
+ .array(z.string())
181
+ .optional()
182
+ .describe("Skill directories under `.agents/skills/` that the OpenClaw plugin ships; OpenClaw loads them through the manifest's `skills` roots."),
179
183
  dependencies: z.record(z.string(), z.string()).optional(),
180
184
  peerDependencies: z.record(z.string(), z.string()).optional(),
181
185
  devDependencies: z.record(z.string(), z.string()).optional(),
package/dist/ops.d.ts CHANGED
@@ -151,6 +151,32 @@ export declare const operations: (import("./toolfactory/types.js").Operation<z.Z
151
151
  reason: z.ZodOptional<z.ZodString>;
152
152
  }, z.core.$strip>>;
153
153
  }, z.core.$strip>>;
154
+ }, z.core.$strip>> | import("./toolfactory/types.js").Operation<z.ZodObject<{
155
+ prompt: z.ZodString;
156
+ reference: z.ZodOptional<z.ZodString>;
157
+ }, z.core.$strip>, z.ZodObject<{
158
+ verdict: z.ZodEnum<{
159
+ pass: "pass";
160
+ polish: "polish";
161
+ revise: "revise";
162
+ }>;
163
+ sections: z.ZodObject<{
164
+ present: z.ZodArray<z.ZodString>;
165
+ missing: z.ZodArray<z.ZodString>;
166
+ }, z.core.$strip>;
167
+ checks: z.ZodArray<z.ZodObject<{
168
+ id: z.ZodString;
169
+ status: z.ZodEnum<{
170
+ fail: "fail";
171
+ pass: "pass";
172
+ warn: "warn";
173
+ }>;
174
+ detail: z.ZodString;
175
+ }, z.core.$strip>>;
176
+ directives: z.ZodArray<z.ZodString>;
177
+ reference: z.ZodOptional<z.ZodObject<{
178
+ sectionsMissingHere: z.ZodArray<z.ZodString>;
179
+ }, z.core.$strip>>;
154
180
  }, z.core.$strip>> | import("./toolfactory/types.js").Operation<z.ZodObject<{
155
181
  root: z.ZodDefault<z.ZodString>;
156
182
  path: z.ZodString;
package/dist/ops.js CHANGED
@@ -153,6 +153,30 @@ export const operations = [
153
153
  annotations: { readOnlyHint: true },
154
154
  handler: async ({ root }) => commands.coverage(root),
155
155
  }),
156
+ operation({
157
+ name: "review-prompt",
158
+ description: "Evaluate a prompt draft against the codex-prompt-standard rubric: anatomy sections, convention checks, and concrete directives. Draft, run this, fix what it flags, and re-run until it passes.",
159
+ input: z.object({
160
+ prompt: z.string().min(1).describe("The prompt draft to evaluate"),
161
+ reference: z
162
+ .string()
163
+ .optional()
164
+ .describe("The closest reference prompt's full text, fetched per the standard (an OpenAI Codex core prompt); sections it has that the draft lacks become the bar"),
165
+ }),
166
+ output: z.object({
167
+ verdict: z.enum(["pass", "polish", "revise"]),
168
+ sections: z.object({ present: z.array(z.string()), missing: z.array(z.string()) }),
169
+ checks: z.array(z.object({
170
+ id: z.string(),
171
+ status: z.enum(["pass", "warn", "fail"]),
172
+ detail: z.string(),
173
+ })),
174
+ directives: z.array(z.string()),
175
+ reference: z.object({ sectionsMissingHere: z.array(z.string()) }).optional(),
176
+ }),
177
+ annotations: { readOnlyHint: true, idempotentHint: true },
178
+ handler: async ({ prompt, reference }) => commands.reviewPrompt({ prompt, reference }),
179
+ }),
156
180
  operation({
157
181
  name: "adopt",
158
182
  description: "Stop regenerating one file; it becomes the author's (recorded as manual in the lock).",
@@ -208,7 +232,7 @@ export const operations = [
208
232
  handler: async (args) => commands.bootstrapRepo(args),
209
233
  }),
210
234
  operation({
211
- name: "secrets",
235
+ name: "secrets-usage",
212
236
  description: "Every credential this project's surfaces need — the tool's own sensitive config keys and the release registries' tokens — with where each one is set, whether it is present locally and on GitHub, and (check) whether the registry accepts it. Never a value.",
213
237
  input: z.object({
214
238
  root,
@@ -250,13 +250,19 @@ export function packageSteps(project, options = {}) {
250
250
  }
251
251
  if (has(project, "openclaw-native")) {
252
252
  // ClawHub's reusable workflow has no build step of its own, so the tarball it publishes has
253
- // to arrive already built (`package_artifact_name`).
253
+ // to arrive already built (`package_artifact_name`). Consumers install this tarball outside
254
+ // the monorepo, where the checkout-relative `file:../..` core dependency cannot resolve, so
255
+ // pack against the npm-published core of the same release and restore the workspace manifest.
254
256
  steps.push({
255
257
  name: "OpenClaw plugin tarball",
256
258
  run: [
257
259
  `npm --prefix ${OPENCLAW_HOST_DIR} install`,
258
260
  `npm --prefix ${OPENCLAW_HOST_DIR} run build`,
261
+ `v=$(node -p "require('./package.json').version")`,
262
+ `cp ${OPENCLAW_HOST_DIR}/package.json ${OPENCLAW_HOST_DIR}/package.json.prepack`,
263
+ `(cd ${OPENCLAW_HOST_DIR} && npm pkg set dependencies.toolfactory=$v)`,
259
264
  `npm pack ./${OPENCLAW_HOST_DIR} --pack-destination ${RELEASE_DIR}`,
265
+ `mv ${OPENCLAW_HOST_DIR}/package.json.prepack ${OPENCLAW_HOST_DIR}/package.json`,
260
266
  ].join(" && "),
261
267
  });
262
268
  }
@@ -82,6 +82,7 @@ export declare function toolAtRef(root: string, ref: string): {
82
82
  }[] | undefined;
83
83
  activation?: Record<string, unknown> | undefined;
84
84
  pluginApi?: string | undefined;
85
+ skills?: string[] | undefined;
85
86
  dependencies?: Record<string, string> | undefined;
86
87
  peerDependencies?: Record<string, string> | undefined;
87
88
  devDependencies?: Record<string, string> | undefined;
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
- import { join, resolve } from "node:path";
3
+ import { join, relative, resolve, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { readIdentityFile } from "../identity/read.js";
6
6
  import { CAPABILITIES, isInstructionOnly, PACKAGE_MANAGERS, ToolConfigSchema, } from "../model.js";
@@ -62,6 +62,22 @@ function readPackageManager(root) {
62
62
  }
63
63
  return name;
64
64
  }
65
+ /** `.agents/skills/<name>/` as a shippable unit: every file, sorted, content in memory. */
66
+ function readProjectSkill(root, name) {
67
+ const directory = join(root, ".agents", "skills", name);
68
+ if (!existsSync(directory)) {
69
+ throw new Error(`openclaw.skills declares "${name}" but ${join(".agents", "skills", name)} does not exist`);
70
+ }
71
+ const files = readdirSync(directory, { recursive: true, withFileTypes: true })
72
+ .filter((entry) => entry.isFile())
73
+ .map((entry) => join(entry.parentPath, entry.name))
74
+ .sort()
75
+ .map((absolute) => ({
76
+ path: relative(directory, absolute).replaceAll(sep, "/"),
77
+ content: readFileSync(absolute, "utf8"),
78
+ }));
79
+ return { name, files };
80
+ }
65
81
  export function loadProject(rootInput = ".") {
66
82
  const root = resolve(rootInput);
67
83
  const toolPath = join(root, TOOL_PATH);
@@ -76,6 +92,7 @@ export function loadProject(rootInput = ".") {
76
92
  identity: identityFile.identity,
77
93
  identityExtra: identityFile.extra,
78
94
  operations: isInstructionOnly(tool) ? [] : readOps(root),
95
+ skills: (tool.openclaw?.skills ?? []).map((name) => readProjectSkill(root, name)),
79
96
  toolfactoryVersion: TOOLFACTORY_VERSION,
80
97
  packageManager: readPackageManager(root),
81
98
  };
@@ -0,0 +1,23 @@
1
+ export interface PromptReviewInput {
2
+ prompt: string;
3
+ /** The closest reference prompt's full text (canon §1: fetch it before evaluating). */
4
+ reference?: string;
5
+ }
6
+ export interface CheckResult {
7
+ id: string;
8
+ status: "pass" | "warn" | "fail";
9
+ detail: string;
10
+ }
11
+ export interface PromptReviewReport {
12
+ verdict: "pass" | "polish" | "revise";
13
+ sections: {
14
+ present: string[];
15
+ missing: string[];
16
+ };
17
+ checks: CheckResult[];
18
+ directives: string[];
19
+ reference?: {
20
+ sectionsMissingHere: string[];
21
+ };
22
+ }
23
+ export declare function reviewPrompt(input: PromptReviewInput): PromptReviewReport;