toolfactory 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bindings/typescript.js +1 -1
- package/dist/bindings/typescript.test.js +29 -1
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +13 -2
- package/dist/hosts/openclaw.d.ts +8 -0
- package/dist/hosts/openclaw.js +70 -4
- package/dist/hosts/openclaw.test.d.ts +1 -0
- package/dist/hosts/openclaw.test.js +58 -0
- package/dist/hosts/web.d.ts +1 -0
- package/dist/hosts/web.js +6 -3
- package/dist/hosts/web.test.d.ts +1 -0
- package/dist/hosts/web.test.js +8 -0
- package/dist/model.d.ts +12 -0
- package/dist/model.js +4 -0
- package/dist/ops.d.ts +26 -0
- package/dist/ops.js +25 -1
- package/dist/project/gate.js +7 -1
- package/dist/project/history.d.ts +1 -0
- package/dist/project/load.js +19 -2
- package/dist/prompt/review.d.ts +23 -0
- package/dist/prompt/review.js +103 -0
- package/dist/prompt/review.test.d.ts +1 -0
- package/dist/prompt/review.test.js +55 -0
- package/dist/prompt/standard.d.ts +26 -0
- package/dist/prompt/standard.js +68 -0
- package/dist/surfaces/openclaw-native.d.ts +19 -2
- package/dist/surfaces/openclaw-native.js +43 -14
- package/dist/surfaces/openclaw-native.test.js +15 -5
- package/dist/surfaces/web.d.ts +6 -6
- package/dist/surfaces/web.js +7 -7
- package/dist/surfaces/web.test.js +1 -1
- package/dist/surfaces/workflows.js +2 -2
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +1 -1
- package/src/bindings/typescript.test.ts +32 -1
- package/src/bindings/typescript.ts +1 -1
- package/src/commands.ts +14 -2
- package/src/hosts/openclaw.test.ts +62 -0
- package/src/hosts/openclaw.ts +73 -5
- package/src/hosts/web.test.ts +9 -0
- package/src/hosts/web.ts +6 -3
- package/src/model.ts +15 -0
- package/src/ops.ts +30 -1
- package/src/project/gate.ts +7 -1
- package/src/project/load.ts +23 -2
- package/src/prompt/review.test.ts +68 -0
- package/src/prompt/review.ts +148 -0
- package/src/prompt/standard.ts +93 -0
- package/src/surfaces/openclaw-native.test.ts +15 -5
- package/src/surfaces/openclaw-native.ts +48 -14
- package/src/surfaces/web.test.ts +1 -1
- package/src/surfaces/web.ts +7 -7
- package/src/surfaces/workflows.ts +2 -2
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/{index-BHR6gCfm.js → index-D95aMjZ6.js} +2 -2
- package/web/dist/index.html +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The draft -> evaluate -> iterate loop's evaluator, as `review-prompt`. Pure and deterministic:
|
|
3
|
+
* it scans the draft (and, when given, the fetched reference prompt) against the
|
|
4
|
+
* codex-prompt-standard rubric and returns findings plus concrete next-iteration directives.
|
|
5
|
+
* The taste stays with the calling model; this makes the checklist the skill teaches impossible
|
|
6
|
+
* to skip.
|
|
7
|
+
*/
|
|
8
|
+
import { ANATOMY, type PromptSection } from "./standard.js";
|
|
9
|
+
|
|
10
|
+
export interface PromptReviewInput {
|
|
11
|
+
prompt: string;
|
|
12
|
+
/** The closest reference prompt's full text (canon §1: fetch it before evaluating). */
|
|
13
|
+
reference?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CheckResult {
|
|
17
|
+
id: string;
|
|
18
|
+
status: "pass" | "warn" | "fail";
|
|
19
|
+
detail: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PromptReviewReport {
|
|
23
|
+
verdict: "pass" | "polish" | "revise";
|
|
24
|
+
sections: { present: string[]; missing: string[] };
|
|
25
|
+
checks: CheckResult[];
|
|
26
|
+
directives: string[];
|
|
27
|
+
reference?: { sectionsMissingHere: string[] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HARD_WORDS = /\b(?:MUST|NEVER|ALWAYS)\b/g;
|
|
31
|
+
const HEDGES =
|
|
32
|
+
/\b(?:try to|tries to|consider|maybe|perhaps|probably|if possible|ideally|when appropriate|as needed)\b/gi;
|
|
33
|
+
const PRECEDENCE =
|
|
34
|
+
/\bpreceden|overrid|takes? precedence|in case of (?:a )?conflict|conflicts? with|direct instructions/i;
|
|
35
|
+
const EXAMPLES = /\b(?:example|for instance|good:|bad:)\b/i;
|
|
36
|
+
const DONE_WHEN = /done when|definition of done|success criteria|checkable|acceptance criteria/i;
|
|
37
|
+
const INJECTION = /ignore (?:all |any |the )?(?:previous|prior|above|earlier)\s+instructions/i;
|
|
38
|
+
const CODEX_SPECIFIC = /\bapply_patch\b|\bcodex-rs\b|\bopenai codex cli\b/i;
|
|
39
|
+
|
|
40
|
+
const has = (text: string, section: PromptSection): boolean =>
|
|
41
|
+
section.anchors.some((anchor) => anchor.test(text));
|
|
42
|
+
|
|
43
|
+
export function reviewPrompt(input: PromptReviewInput): PromptReviewReport {
|
|
44
|
+
const { prompt, reference } = input;
|
|
45
|
+
const present = ANATOMY.filter((section) => has(prompt, section));
|
|
46
|
+
const missing = ANATOMY.filter((section) => !has(prompt, section));
|
|
47
|
+
|
|
48
|
+
const checks: CheckResult[] = [];
|
|
49
|
+
const hard = prompt.match(HARD_WORDS)?.length ?? 0;
|
|
50
|
+
checks.push(
|
|
51
|
+
hard > 15
|
|
52
|
+
? {
|
|
53
|
+
id: "must-never-discipline",
|
|
54
|
+
status: "fail",
|
|
55
|
+
detail: `${hard} hard imperatives: MUST/NEVER are reserved for the few things that must never happen — demote the rest to positive imperatives.`,
|
|
56
|
+
}
|
|
57
|
+
: hard > 8
|
|
58
|
+
? {
|
|
59
|
+
id: "must-never-discipline",
|
|
60
|
+
status: "warn",
|
|
61
|
+
detail: `${hard} hard imperatives is close to noise; keep MUST/NEVER for the few things that must never happen.`,
|
|
62
|
+
}
|
|
63
|
+
: { id: "must-never-discipline", status: "pass", detail: `${hard} hard imperatives.` },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const hedges = prompt.match(HEDGES)?.length ?? 0;
|
|
67
|
+
checks.push(
|
|
68
|
+
hedges >= 5
|
|
69
|
+
? {
|
|
70
|
+
id: "positive-imperatives",
|
|
71
|
+
status: "warn",
|
|
72
|
+
detail: `${hedges} hedge phrases — every sentence should change behavior; prefer positive imperatives ("run X, then verify Y").`,
|
|
73
|
+
}
|
|
74
|
+
: { id: "positive-imperatives", status: "pass", detail: `${hedges} hedge phrases.` },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (prompt.length > 2000 && !PRECEDENCE.test(prompt))
|
|
78
|
+
checks.push({
|
|
79
|
+
id: "precedence",
|
|
80
|
+
status: "warn",
|
|
81
|
+
detail:
|
|
82
|
+
"No instruction-precedence rule in a prompt of this size — say what wins when sources conflict (direct instructions > project files > defaults).",
|
|
83
|
+
});
|
|
84
|
+
if (prompt.length > 1500 && !EXAMPLES.test(prompt))
|
|
85
|
+
checks.push({
|
|
86
|
+
id: "examples",
|
|
87
|
+
status: "warn",
|
|
88
|
+
detail:
|
|
89
|
+
"No examples in a prompt this long — good-vs-bad examples are how subjective qualities (tone, plan quality) get taught.",
|
|
90
|
+
});
|
|
91
|
+
if (!DONE_WHEN.test(prompt))
|
|
92
|
+
checks.push({
|
|
93
|
+
id: "checkable-criteria",
|
|
94
|
+
status: "warn",
|
|
95
|
+
detail: 'No "done when" — every branch of the task should have a checkable criterion.',
|
|
96
|
+
});
|
|
97
|
+
if (INJECTION.test(prompt))
|
|
98
|
+
checks.push({
|
|
99
|
+
id: "injection-hygiene",
|
|
100
|
+
status: "fail",
|
|
101
|
+
detail:
|
|
102
|
+
"The draft instructs its reader to discard instructions — a prompt must never undermine itself; state precedence instead.",
|
|
103
|
+
});
|
|
104
|
+
if (CODEX_SPECIFIC.test(prompt))
|
|
105
|
+
checks.push({
|
|
106
|
+
id: "harness-adaptation",
|
|
107
|
+
status: "warn",
|
|
108
|
+
detail:
|
|
109
|
+
"Codex-specific names appear — adapt to the target harness's tools and delivery rules; never copy Codex-specific tool names.",
|
|
110
|
+
});
|
|
111
|
+
if (prompt.length < 200)
|
|
112
|
+
checks.push({
|
|
113
|
+
id: "length",
|
|
114
|
+
status: "warn",
|
|
115
|
+
detail:
|
|
116
|
+
"Thinner than any Codex prompt section — say who the agent is, what it may do, and how it validates.",
|
|
117
|
+
});
|
|
118
|
+
else if (prompt.length > 40_000)
|
|
119
|
+
checks.push({
|
|
120
|
+
id: "length",
|
|
121
|
+
status: "warn",
|
|
122
|
+
detail:
|
|
123
|
+
"Very long — output depth and context are budget; cut anything the reference would not need.",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const referenceMissing = reference
|
|
127
|
+
? ANATOMY.filter((section) => has(reference, section) && !has(prompt, section))
|
|
128
|
+
: [];
|
|
129
|
+
|
|
130
|
+
const directives = [
|
|
131
|
+
...missing.map((section) => section.directive),
|
|
132
|
+
...referenceMissing.map(
|
|
133
|
+
(section) => `The reference prompt treats ${section.title}; the draft has no such section.`,
|
|
134
|
+
),
|
|
135
|
+
...checks.filter((check) => check.status !== "pass").map((check) => check.detail),
|
|
136
|
+
];
|
|
137
|
+
const revise =
|
|
138
|
+
missing.length > 0 || referenceMissing.length > 0 || checks.some((c) => c.status === "fail");
|
|
139
|
+
const verdict = revise ? "revise" : checks.some((c) => c.status === "warn") ? "polish" : "pass";
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
verdict,
|
|
143
|
+
sections: { present: present.map((s) => s.id), missing: missing.map((s) => s.id) },
|
|
144
|
+
checks,
|
|
145
|
+
directives,
|
|
146
|
+
...(reference ? { reference: { sectionsMissingHere: referenceMissing.map((s) => s.id) } } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The codex-prompt-standard rubric, as data. The prose canon is
|
|
3
|
+
* `.agents/skills/codex-prompt-standard/SKILL.md` — it ships inside the OpenClaw plugin and
|
|
4
|
+
* syncs to every dev harness; this module is the same guidance shaped for `review-prompt` to
|
|
5
|
+
* evaluate against. `review.test.ts` pins every anchor below to the canon text so the two
|
|
6
|
+
* cannot drift apart silently.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface PromptSection {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
/** Substring of the canon SKILL.md this section is pinned to (drift guard). */
|
|
13
|
+
canonAnchor: string;
|
|
14
|
+
/** Any hit marks the section present in a draft or a reference prompt. */
|
|
15
|
+
anchors: RegExp[];
|
|
16
|
+
/** The next-iteration instruction when the section is missing. */
|
|
17
|
+
directive: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The anatomy a Codex-class prompt reliably contains, in order (canon §2). */
|
|
21
|
+
export const ANATOMY: PromptSection[] = [
|
|
22
|
+
{
|
|
23
|
+
id: "identity",
|
|
24
|
+
title: "one-sentence identity",
|
|
25
|
+
canonAnchor: "One-sentence identity",
|
|
26
|
+
anchors: [/\byou are\b/i, /\bacting (?:for|on behalf of|as)\b/i],
|
|
27
|
+
directive:
|
|
28
|
+
"Open with one sentence of identity: who the model is, where it runs, how it is expected to behave.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "capabilities",
|
|
32
|
+
title: "capability inventory",
|
|
33
|
+
canonAnchor: "Capability inventory",
|
|
34
|
+
anchors: [/\byou can\b/i, /\bcapabilit/i, /\b(?:available|your) tools?\b/i],
|
|
35
|
+
directive: "State the capability inventory: what the agent can receive and emit.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "how-you-work",
|
|
39
|
+
title: '"How you work" sections',
|
|
40
|
+
canonAnchor: '"How you work" sections',
|
|
41
|
+
anchors: [/how you work/i, /\bpreceden/i, /\bpersisten/i, /\bautonom/i, /\btone\b/i],
|
|
42
|
+
directive:
|
|
43
|
+
'Add "how you work" sections: personality and tone, instruction precedence, the autonomy and persistence contract.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "planning",
|
|
47
|
+
title: "planning guidance",
|
|
48
|
+
canonAnchor: "Planning guidance",
|
|
49
|
+
anchors: [/\bplan\b/i, /\bpersist until\b/i, /before (?:acting|starting|diving)/i],
|
|
50
|
+
directive:
|
|
51
|
+
"Add planning guidance with explicit high-quality vs low-quality examples for what a good plan is.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "execution-criteria",
|
|
55
|
+
title: "execution criteria",
|
|
56
|
+
canonAnchor: "Execution criteria",
|
|
57
|
+
anchors: [/\bMUST\b/, /\bNEVER\b/, /scope creep/i, /no unrelated changes/i],
|
|
58
|
+
directive:
|
|
59
|
+
"Add execution criteria: hard MUST/NEVER lists, root-cause fixes, no scope creep, no unrelated changes.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "validation",
|
|
63
|
+
title: "validation philosophy",
|
|
64
|
+
canonAnchor: "Validation philosophy",
|
|
65
|
+
anchors: [/\bverify\b/i, /\bvalidat/i, /before claiming done/i, /\bdone when\b/i],
|
|
66
|
+
directive:
|
|
67
|
+
"Add the validation philosophy: start specific, broaden as confidence grows, verify before claiming done.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "final-message",
|
|
71
|
+
title: "final-message guidance",
|
|
72
|
+
canonAnchor: "Final-message guidance",
|
|
73
|
+
anchors: [/final message/i, /\boutput depth\b/i, /\bfinal reply\b/i],
|
|
74
|
+
directive:
|
|
75
|
+
"Add final-message guidance: output depth matches task size; skip formatting for trivial replies.",
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export interface PromptConvention {
|
|
80
|
+
id: string;
|
|
81
|
+
/** Substring of the canon SKILL.md this convention is pinned to (drift guard). */
|
|
82
|
+
canonAnchor: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Convention anchors the canon must mention; the checks in review.ts implement them. */
|
|
86
|
+
export const CONVENTIONS: PromptConvention[] = [
|
|
87
|
+
{ id: "positive-imperatives", canonAnchor: "Positive imperatives" },
|
|
88
|
+
{ id: "precedence", canonAnchor: "Explicit precedence" },
|
|
89
|
+
{ id: "must-never-discipline", canonAnchor: "MUST/NEVER reserved" },
|
|
90
|
+
{ id: "examples", canonAnchor: "Good-vs-bad examples" },
|
|
91
|
+
{ id: "harness-adaptation", canonAnchor: "Adapt to the target harness" },
|
|
92
|
+
{ id: "checkable-criteria", canonAnchor: "checkable criterion" },
|
|
93
|
+
];
|
|
@@ -122,7 +122,7 @@ describe("openclaw-native", () => {
|
|
|
122
122
|
it("projects only the operations OpenClaw can run and pins the scaffold in one place", () => {
|
|
123
123
|
const files = emitted(project());
|
|
124
124
|
const manifest = JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}");
|
|
125
|
-
expect(manifest.contracts).toEqual({ tools: ["
|
|
125
|
+
expect(manifest.contracts).toEqual({ tools: ["hello_echo"] });
|
|
126
126
|
expect(manifest.activation).toEqual({ onStartup: true });
|
|
127
127
|
expect(surface.verdict?.(shoot, project())).toEqual({
|
|
128
128
|
kind: "excluded",
|
|
@@ -133,6 +133,7 @@ describe("openclaw-native", () => {
|
|
|
133
133
|
expect(files[`${HOST_DIR}/README.md`]).toContain("`shoot` — requires browser");
|
|
134
134
|
|
|
135
135
|
const pkg = JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}");
|
|
136
|
+
expect(pkg.devDependencies).toMatchObject(OPENCLAW_SCAFFOLD.devDependencies);
|
|
136
137
|
expect(pkg.openclaw).toEqual({
|
|
137
138
|
extensions: ["./dist/index.js"],
|
|
138
139
|
compat: { pluginApi: OPENCLAW_SCAFFOLD.pluginApi },
|
|
@@ -168,7 +169,7 @@ describe("openclaw-native", () => {
|
|
|
168
169
|
expect(index).toContain("api.registerControlUiDescriptor(descriptor)");
|
|
169
170
|
// Neither registrar is a manifest contract key; both are what the inspector expects to see.
|
|
170
171
|
expect(JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}").contracts).toEqual({
|
|
171
|
-
tools: ["
|
|
172
|
+
tools: ["hello_echo"],
|
|
172
173
|
});
|
|
173
174
|
expect(
|
|
174
175
|
JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}").pluginInspector.plugin.expect
|
|
@@ -212,6 +213,10 @@ describe("openclaw-native", () => {
|
|
|
212
213
|
|
|
213
214
|
it("drives manifest, inspector and test from one `registers` declaration", () => {
|
|
214
215
|
const target = voice();
|
|
216
|
+
target.tool.openclaw = {
|
|
217
|
+
...target.tool.openclaw,
|
|
218
|
+
devDependencies: { openclaw: "2026.9.2", "@types/ws": "^8.18.0" },
|
|
219
|
+
};
|
|
215
220
|
const files = emitted(target);
|
|
216
221
|
const manifest = JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}");
|
|
217
222
|
expect(manifest.contracts).toEqual({ tools: [], realtimeVoiceProviders: ["codex"] });
|
|
@@ -223,6 +228,11 @@ describe("openclaw-native", () => {
|
|
|
223
228
|
});
|
|
224
229
|
// The author's extra dependency merges after the scaffold's; the core is not imported at all.
|
|
225
230
|
expect(pkg.dependencies).toEqual({ ...OPENCLAW_SCAFFOLD.dependencies, ws: "^8.18.0" });
|
|
231
|
+
expect(pkg.devDependencies).toEqual({
|
|
232
|
+
...OPENCLAW_SCAFFOLD.devDependencies,
|
|
233
|
+
openclaw: "2026.9.2",
|
|
234
|
+
"@types/ws": "^8.18.0",
|
|
235
|
+
});
|
|
226
236
|
expect(pkg.peerDependencies.openclaw).toBe(">=2026.8.2");
|
|
227
237
|
expect(pkg.openclaw.compat.pluginApi).toBe(">=2026.8.2");
|
|
228
238
|
|
|
@@ -249,8 +259,8 @@ describe("openclaw-native", () => {
|
|
|
249
259
|
// when the tool's own result carries a name its output schema promises; leg 3 catches the rest.
|
|
250
260
|
expect(fixtures.fixtures).toEqual([
|
|
251
261
|
{
|
|
252
|
-
match: { toolName: "
|
|
253
|
-
response: { toolCalls: [{ name: "
|
|
262
|
+
match: { toolName: "hello_echo", hasToolResult: false },
|
|
263
|
+
response: { toolCalls: [{ name: "hello_echo", arguments: { text: "hello" } }] },
|
|
254
264
|
},
|
|
255
265
|
{
|
|
256
266
|
match: { hasToolResult: true, toolResultContains: "echoed" },
|
|
@@ -312,7 +322,7 @@ function hasOpenclaw(): boolean {
|
|
|
312
322
|
}
|
|
313
323
|
|
|
314
324
|
describe.skipIf(!hasOpenclaw())("openclaw scaffold", () => {
|
|
315
|
-
it("still matches `openclaw plugins init --type tool`", () => {
|
|
325
|
+
it("still matches `openclaw plugins init --type tool`", { timeout: 60_000 }, () => {
|
|
316
326
|
expect(scaffoldDrift(project())).toEqual([]);
|
|
317
327
|
// A declared `pluginApi` is an intentional override, not drift.
|
|
318
328
|
expect(scaffoldDrift(voice())).toEqual([]);
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
/** Transcribed from `openclaw plugins init --type tool`; `validate()` proves them still current. */
|
|
36
36
|
export const OPENCLAW_SCAFFOLD = {
|
|
37
37
|
/** The OpenClaw release the scaffold was taken from (`package.json openclaw.build`). */
|
|
38
|
-
openclawVersion: "2026.9.
|
|
38
|
+
openclawVersion: "2026.9.4",
|
|
39
39
|
/** `peerDependencies.openclaw` and `openclaw.compat.pluginApi`. */
|
|
40
40
|
pluginApi: ">=2026.5.17",
|
|
41
41
|
private: true,
|
|
@@ -86,6 +86,27 @@ export default defineConfig({
|
|
|
86
86
|
* survive into the projection but allows extra ones: upstream stays the source of truth for
|
|
87
87
|
* everything it does write.
|
|
88
88
|
*/
|
|
89
|
+
/**
|
|
90
|
+
* The version `openclaw plugins init` stamps into its scaffold is the CLI's own version, so
|
|
91
|
+
* the generated manifest carries the openclaw this project actually pins (author override via
|
|
92
|
+
* `openclaw.devDependencies.openclaw`), and the scaffold default only when the project rides
|
|
93
|
+
* `latest`. hosts/openclaw.ts runs the same pin in its drift probe, so the upstream/generated
|
|
94
|
+
* comparison is identical in every environment.
|
|
95
|
+
*/
|
|
96
|
+
export function openclawVersion(project: Project): string {
|
|
97
|
+
return project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.openclawVersion;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The gateway has one flat tool namespace, so the plugin's tool names are the tool id prefix
|
|
102
|
+
* plus the operation name with dots flattened: unprefixed names collide across plugins (two
|
|
103
|
+
* `web`s) and dotted ones fold onto core tools (`sessions.list` → `sessions_list`). The
|
|
104
|
+
* kernel, CLI and MCP server keep the bare operation names; only this host renames.
|
|
105
|
+
*/
|
|
106
|
+
export function toolName(project: Project, operation: Operation): string {
|
|
107
|
+
return `${project.identity.name}_${operation.name.replace(/\./g, "_")}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
export const OPENCLAW_ADDITIONS = {
|
|
90
111
|
/** Without it `tsc` emits a `dist/` from source that failed type-checking. */
|
|
91
112
|
compilerOptions: { noEmitOnError: true },
|
|
@@ -123,6 +144,11 @@ export function registrations(project: Project): Registration[] {
|
|
|
123
144
|
return project.tool.openclaw?.registers ?? [];
|
|
124
145
|
}
|
|
125
146
|
|
|
147
|
+
/** Whether the plugin ships `.agents/skills/` directories (`openclaw.skills` in tool.json). */
|
|
148
|
+
export function shipsSkills(project: Project): boolean {
|
|
149
|
+
return (project.tool.openclaw?.skills?.length ?? 0) > 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
/**
|
|
127
153
|
* The gateway route and the Control UI tab the plugin adds when the tool also has a `web`
|
|
128
154
|
* surface. Only for the in-process binding: the route hands requests to the core package's own
|
|
@@ -257,7 +283,7 @@ function packageJson(
|
|
|
257
283
|
type: "module",
|
|
258
284
|
private: OPENCLAW_SCAFFOLD.private,
|
|
259
285
|
scripts: { ...OPENCLAW_SCAFFOLD.scripts, ...e2e },
|
|
260
|
-
files: OPENCLAW_SCAFFOLD.files,
|
|
286
|
+
files: shipsSkills(project) ? [...OPENCLAW_SCAFFOLD.files, "skills"] : OPENCLAW_SCAFFOLD.files,
|
|
261
287
|
peerDependencies: { openclaw: pluginApi(project), ...extra?.peerDependencies },
|
|
262
288
|
dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
|
|
263
289
|
devDependencies: {
|
|
@@ -268,7 +294,7 @@ function packageJson(
|
|
|
268
294
|
openclaw: {
|
|
269
295
|
extensions: [OPENCLAW_SCAFFOLD.entry],
|
|
270
296
|
compat: { pluginApi: pluginApi(project) },
|
|
271
|
-
build: { openclawVersion:
|
|
297
|
+
build: { openclawVersion: openclawVersion(project) },
|
|
272
298
|
},
|
|
273
299
|
pluginInspector: expectedRegistrations(project, operations).length
|
|
274
300
|
? pluginInspector(project, operations)
|
|
@@ -299,8 +325,9 @@ function pluginManifest(project: Project, operations: Operation[]): Record<strin
|
|
|
299
325
|
configSchema: configSchema(project),
|
|
300
326
|
uiHints: sensitive.length ? Object.fromEntries(sensitive) : undefined,
|
|
301
327
|
activation: activation(project),
|
|
328
|
+
skills: shipsSkills(project) ? ["./skills"] : undefined,
|
|
302
329
|
contracts: {
|
|
303
|
-
tools: operations.map((operation) => operation
|
|
330
|
+
tools: operations.map((operation) => toolName(project, operation)),
|
|
304
331
|
...Object.fromEntries(declared),
|
|
305
332
|
},
|
|
306
333
|
}) as Record<string, unknown>;
|
|
@@ -404,7 +431,7 @@ const execFileAsync = promisify(execFile);
|
|
|
404
431
|
${pythonKernel(project)}`;
|
|
405
432
|
const tools = operations.map(
|
|
406
433
|
(operation) => ` tool({
|
|
407
|
-
name: ${JSON.stringify(operation
|
|
434
|
+
name: ${JSON.stringify(toolName(project, operation))},
|
|
408
435
|
description: ${JSON.stringify(operation.description ?? operation.name)},
|
|
409
436
|
parameters: Type.Unsafe(${literal(normalizeSchema(operation.inputSchema), 6)}),
|
|
410
437
|
${executeBody(project, operation)}
|
|
@@ -565,7 +592,7 @@ import entry from "./index.js";
|
|
|
565
592
|
|
|
566
593
|
describe(${JSON.stringify(project.identity.name)}, () => {
|
|
567
594
|
it("declares tool metadata", () => {
|
|
568
|
-
expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => operation
|
|
595
|
+
expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
|
|
569
596
|
});
|
|
570
597
|
${registrationTest}${web}});
|
|
571
598
|
`;
|
|
@@ -611,8 +638,8 @@ function e2eCase(project: Project, operations: Operation[]): E2eCase | undefined
|
|
|
611
638
|
* would answer OK even for a tool that threw, because the host reports that in a tool message too.
|
|
612
639
|
* An operation that promises no output names has nothing to gate on, so it gets no failure leg.
|
|
613
640
|
*/
|
|
614
|
-
function e2eFixtures(kase: E2eCase): string {
|
|
615
|
-
const name = kase.operation
|
|
641
|
+
function e2eFixtures(project: Project, kase: E2eCase): string {
|
|
642
|
+
const name = toolName(project, kase.operation);
|
|
616
643
|
return json({
|
|
617
644
|
fixtures: [
|
|
618
645
|
{
|
|
@@ -758,19 +785,19 @@ describe(${JSON.stringify(`${id} in a real OpenClaw agent turn`)}, () => {
|
|
|
758
785
|
it("registers its tools at runtime", async () => {
|
|
759
786
|
const inspected = JSON.parse(await oc(["plugins", "inspect", ${JSON.stringify(id)}, "--runtime", "--json"]));
|
|
760
787
|
expect(inspected.plugin.status).toBe("loaded");
|
|
761
|
-
expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => operation
|
|
788
|
+
expect(inspected.plugin.toolNames).toEqual(${JSON.stringify(operations.map((operation) => toolName(project, operation)))});
|
|
762
789
|
});
|
|
763
790
|
|
|
764
|
-
it(${JSON.stringify(`the model calls ${kase.operation
|
|
791
|
+
it(${JSON.stringify(`the model calls ${toolName(project, kase.operation)} and the tool's result reaches the reply`)}, async () => {
|
|
765
792
|
const turn = JSON.parse(
|
|
766
793
|
await oc([
|
|
767
794
|
"agent", "--local", "--agent", "main", "--session-id", \`e2e-\${Date.now()}\`,
|
|
768
795
|
"--model", MODEL, "--thinking", "off", "--timeout", "120", "--json",
|
|
769
796
|
"--message",
|
|
770
|
-
${JSON.stringify(`Call the tool named ${kase.operation
|
|
797
|
+
${JSON.stringify(`Call the tool named ${toolName(project, kase.operation)}. Reply with only the exact text returned by that tool.`)},
|
|
771
798
|
]),
|
|
772
799
|
);
|
|
773
|
-
expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(kase.operation
|
|
800
|
+
expect(turn.meta.agentMeta.terminalReceipt.successfulToolNames).toContain(${JSON.stringify(toolName(project, kase.operation))});
|
|
774
801
|
expect(turn.payloads[0].text).toBe(${JSON.stringify(kase.ok)});
|
|
775
802
|
}, 180_000);
|
|
776
803
|
});
|
|
@@ -860,7 +887,7 @@ function readme(project: Project, operations: Operation[], kase?: E2eCase): stri
|
|
|
860
887
|
"`npm run test:e2e` runs one real OpenClaw agent turn against a scripted OpenAI-compatible",
|
|
861
888
|
"model (`@copilotkit/aimock`, the same package OpenClaw's own QA lane uses). `e2e/fixtures.json`",
|
|
862
889
|
"is projected from `dev.toolfactory/ops.json` and `tool.json`'s `tests.examples`: the model asks",
|
|
863
|
-
`for \`${kase.operation
|
|
890
|
+
`for \`${toolName(project, kase.operation)}\` with those arguments, and answers \`${kase.ok}\` only when the tool's own`,
|
|
864
891
|
"result comes back carrying what its output schema promises.",
|
|
865
892
|
"",
|
|
866
893
|
);
|
|
@@ -955,6 +982,13 @@ export const surface: Surface = {
|
|
|
955
982
|
path: `${HOST_DIR}/vitest.config.ts`,
|
|
956
983
|
content: OPENCLAW_SCAFFOLD.vitestConfig,
|
|
957
984
|
},
|
|
985
|
+
...(project.skills ?? []).flatMap((skill) =>
|
|
986
|
+
skill.files.map((file) => ({
|
|
987
|
+
kind: "file" as const,
|
|
988
|
+
path: `${HOST_DIR}/skills/${skill.name}/${file.path}`,
|
|
989
|
+
content: file.content,
|
|
990
|
+
})),
|
|
991
|
+
),
|
|
958
992
|
{
|
|
959
993
|
kind: "merge",
|
|
960
994
|
path: `${HOST_DIR}/openclaw.plugin.json`,
|
|
@@ -984,7 +1018,7 @@ export const surface: Surface = {
|
|
|
984
1018
|
{
|
|
985
1019
|
kind: "file" as const,
|
|
986
1020
|
path: `${HOST_DIR}/e2e/fixtures.json`,
|
|
987
|
-
content: e2eFixtures(kase),
|
|
1021
|
+
content: e2eFixtures(project, kase),
|
|
988
1022
|
},
|
|
989
1023
|
{
|
|
990
1024
|
kind: "file" as const,
|
package/src/surfaces/web.test.ts
CHANGED
|
@@ -192,7 +192,7 @@ describe("web", () => {
|
|
|
192
192
|
expect(app).toContain("Authorization: `Bearer ${TOKEN}`");
|
|
193
193
|
// The Secrets panel is the developer's `.env`, and it is the kernel's `/env` that answers.
|
|
194
194
|
expect(app).toContain('new URL("env", document.baseURI).href');
|
|
195
|
-
expect(app).toContain("toolfactory secrets check");
|
|
195
|
+
expect(app).toContain("toolfactory secrets-usage check");
|
|
196
196
|
expect(app).toContain('type="password"');
|
|
197
197
|
|
|
198
198
|
// The smoke drives the real kernel over --http instead of a static server of its own.
|
package/src/surfaces/web.ts
CHANGED
|
@@ -153,8 +153,8 @@ export default defineConfig({
|
|
|
153
153
|
"@fontsource-variable/geist": "^5.3.0",
|
|
154
154
|
"@tailwindcss/vite": "^4.3.3",
|
|
155
155
|
"class-variance-authority": "^0.7.1",
|
|
156
|
-
cn: "^0.
|
|
157
|
-
"lucide-react": "^1.
|
|
156
|
+
cn: "^0.3.0",
|
|
157
|
+
"lucide-react": "^1.46.0",
|
|
158
158
|
react: "^19.2.8",
|
|
159
159
|
"react-dom": "^19.2.8",
|
|
160
160
|
shadcn: "^4.21.0",
|
|
@@ -164,11 +164,11 @@ export default defineConfig({
|
|
|
164
164
|
devDependencies: {
|
|
165
165
|
"@types/node": "^24.13.3",
|
|
166
166
|
"@types/react": "^19.2.18",
|
|
167
|
-
"@types/react-dom": "^19.2.
|
|
168
|
-
"@vitejs/plugin-react": "^6.1.
|
|
169
|
-
oxlint: "^1.
|
|
167
|
+
"@types/react-dom": "^19.2.7",
|
|
168
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
169
|
+
oxlint: "^1.81.0",
|
|
170
170
|
typescript: "~6.0.2",
|
|
171
|
-
vite: "^8.
|
|
171
|
+
vite: "^8.3.0",
|
|
172
172
|
},
|
|
173
173
|
},
|
|
174
174
|
componentsJson: {
|
|
@@ -637,7 +637,7 @@ function SecretsPanel() {
|
|
|
637
637
|
<CardTitle>Secrets</CardTitle>
|
|
638
638
|
<CardDescription>
|
|
639
639
|
Written to this checkout's gitignored <code>.env</code>, which the live tests and
|
|
640
|
-
<code> toolfactory bootstrap-repo</code> read. Run <code>toolfactory secrets check</code>{" "}
|
|
640
|
+
<code> toolfactory bootstrap-repo</code> read. Run <code>toolfactory secrets-usage check</code>{" "}
|
|
641
641
|
to ask each registry whether it accepts them.
|
|
642
642
|
</CardDescription>
|
|
643
643
|
</CardHeader>
|
|
@@ -625,7 +625,7 @@ function releaseDocument(
|
|
|
625
625
|
"`gh workflow run release.yml -f tag=vX.Y.Z` — never `gh run rerun`, which replays the",
|
|
626
626
|
"original run's secret snapshot. `release` first retracts every registry a surface dropped",
|
|
627
627
|
"since the previous tag published to (git is the ledger; `toolfactory unpublish`), and",
|
|
628
|
-
"`toolfactory secrets status` / `bootstrap-repo` cover the one-time steps that are left.",
|
|
628
|
+
"`toolfactory secrets-usage status` / `bootstrap-repo` cover the one-time steps that are left.",
|
|
629
629
|
];
|
|
630
630
|
if (clawhubSelected) {
|
|
631
631
|
jobs["publish-clawhub"] = compact({
|
|
@@ -904,7 +904,7 @@ function envExample(project: Project): string {
|
|
|
904
904
|
lines.push(
|
|
905
905
|
"",
|
|
906
906
|
"# Release credentials (repository secrets; `toolfactory bootstrap-repo` pushes them from .env,",
|
|
907
|
-
"# `toolfactory secrets status` explains each). Leave one empty to skip that registry.",
|
|
907
|
+
"# `toolfactory secrets-usage status` explains each). Leave one empty to skip that registry.",
|
|
908
908
|
);
|
|
909
909
|
for (const row of release) {
|
|
910
910
|
lines.push("", `# ${row.id}: ${row.url}`, ...row.secrets.map((name) => `${name}=`));
|
package/src/toolfactory/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@ const operations = [...authored, web];
|
|
|
13
13
|
const program = new Command()
|
|
14
14
|
.name("toolfactory")
|
|
15
15
|
.description("Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.")
|
|
16
|
-
.version("0.
|
|
16
|
+
.version("0.3.1");
|
|
17
17
|
|
|
18
18
|
for (const op of operations) {
|
|
19
19
|
if (!serves(op, "cli")) continue;
|
package/src/toolfactory/mcp.ts
CHANGED
|
@@ -33,7 +33,7 @@ const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
|
|
|
33
33
|
|
|
34
34
|
/** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
|
|
35
35
|
function createServer(): McpServer {
|
|
36
|
-
const server = new McpServer({ name: "toolfactory", version: "0.
|
|
36
|
+
const server = new McpServer({ name: "toolfactory", version: "0.3.1" });
|
|
37
37
|
for (const op of operations) {
|
|
38
38
|
if (!introspecting && !serves(op, "mcp")) continue;
|
|
39
39
|
server.registerTool(
|