@jiggai/recipes 0.4.40 → 0.4.41

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.
@@ -116,9 +116,39 @@ Tool nodes call a tool by name with JSON args. Example:
116
116
  - `fs.append`
117
117
  - `outbound.post`
118
118
  - `message.send`
119
+ - `exec` (shell command execution)
119
120
 
120
121
  Tool nodes support template vars inside string args.
121
122
 
123
+ #### Exec tool nodes
124
+
125
+ Nodes with `"tool": "exec"` run shell commands via the plugin runtime (not the gateway). This means **any agent** can execute them — there is no need to assign exec nodes to `main`.
126
+
127
+ Config fields:
128
+ - `args.command` (string): the shell command to run (passed to `bash -c`)
129
+ - `args.workdir` (string, optional): working directory (defaults to the team workspace)
130
+ - `args.timeout` (number, optional): timeout in seconds (default: 120)
131
+
132
+ **Agent assignment:** Assign exec nodes to the same agent that handles the surrounding workflow — typically the team lead. Avoid assigning to `main` unless the node specifically needs the personal workspace context.
133
+
134
+ ```json
135
+ {
136
+ "id": "run_script",
137
+ "type": "tool",
138
+ "name": "Run deploy script",
139
+ "config": {
140
+ "tool": "exec",
141
+ "args": {
142
+ "command": "bash scripts/deploy.sh --env={{run.id}}",
143
+ "timeout": 60
144
+ },
145
+ "agentId": "my-team-lead"
146
+ }
147
+ }
148
+ ```
149
+
150
+ > **Why not `main`?** Each agent has its own worker queue and cron. If you assign a node to `main` but there's no worker cron for `main` on that team, the task will sit in the queue indefinitely. Use the team's existing agents to keep things flowing.
151
+
122
152
  ---
123
153
 
124
154
  ### Human approval nodes
package/index.ts CHANGED
@@ -1034,6 +1034,19 @@ workflows
1034
1034
  logScaffoldResult(res, String(options.recipe));
1035
1035
  });
1036
1036
 
1037
+ cmd
1038
+ .command("kitchen-manifest")
1039
+ .description("Generate the Kitchen manifest file (pre-computed nav/shell data for ClawKitchen)")
1040
+ .option("--output <path>", "Override output path (default: ~/.openclaw/kitchen-manifest.json)")
1041
+ .action(async (options: { output?: string }) => {
1042
+ const { generateKitchenManifest } = await import("./src/lib/kitchen-manifest");
1043
+ const manifest = await generateKitchenManifest({
1044
+ api,
1045
+ outputPath: options.output || undefined,
1046
+ });
1047
+ console.log(JSON.stringify({ ok: true, generatedAt: manifest.generatedAt, teams: Object.keys(manifest.teams).length, agents: manifest.agents.length, recipes: manifest.recipes.length }));
1048
+ });
1049
+
1037
1050
  },
1038
1051
  { commands: ["recipes"] },
1039
1052
  );
@@ -2,7 +2,7 @@
2
2
  "id": "recipes",
3
3
  "name": "Recipes",
4
4
  "description": "Markdown recipes that scaffold agents and teams (workspace-local).",
5
- "version": "0.4.40",
5
+ "version": "0.4.41",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.4.40",
3
+ "version": "0.4.41",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -13,6 +13,7 @@ import { pickRecipeId } from "../lib/recipe-id";
13
13
  import { recipeIdTakenForTeam, validateRecipeAndSkills, writeWorkspaceRecipeFile } from "../lib/scaffold-utils";
14
14
  import { scaffoldAgentFromRecipe } from "./scaffold";
15
15
  import { renderTemplate } from "../lib/template";
16
+ import { scheduleManifestRegeneration } from "../lib/kitchen-manifest";
16
17
  import { reconcileRecipeCronJobs } from "./cron";
17
18
  import { lintRecipe } from "../lib/recipe-lint";
18
19
 
@@ -510,6 +511,7 @@ export async function handleScaffoldTeam(
510
511
  scope: { kind: "team", teamId, recipeId: recipe.id, stateDir: teamDir },
511
512
  cronInstallation: cfg.cronInstallation,
512
513
  });
514
+ scheduleManifestRegeneration(api);
513
515
  return {
514
516
  ok: true as const,
515
517
  teamId,
@@ -645,6 +647,7 @@ export async function executeMigrateTeamPlan(
645
647
  await applyAgentSnippetsToOpenClawConfig(api, agentSnippets);
646
648
  }
647
649
 
650
+ scheduleManifestRegeneration(api);
648
651
  return { ok: true as const, migrated: plan.teamId, destTeamDir: dest.teamDir, agentIds: plan.agentIds };
649
652
  }
650
653
 
@@ -707,5 +710,6 @@ export async function handleRemoveTeam(
707
710
  });
708
711
  await writeOpenClawConfig(api, cfgObj);
709
712
  await saveCronStore(cronJobsPath, cronStore);
713
+ scheduleManifestRegeneration(api);
710
714
  return { ok: true as const, result };
711
715
  }
@@ -7,6 +7,7 @@ import { ticketStageDir } from "../lib/lanes";
7
7
  import { computeNextTicketNumber, TICKET_FILENAME_REGEX } from "../lib/ticket-finder";
8
8
  import { resolveTeamContext } from "../lib/workspace";
9
9
  import { VALID_ROLES, VALID_STAGES } from "../lib/constants";
10
+ import { scheduleManifestRegeneration } from "../lib/kitchen-manifest";
10
11
 
11
12
  export function patchTicketField(md: string, key: string, value: string): string {
12
13
  const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
@@ -110,6 +111,7 @@ export async function handleMoveTicket(
110
111
 
111
112
  // Assignment stubs are deprecated; no archival behavior.
112
113
 
114
+ scheduleManifestRegeneration(api);
113
115
  return { ok: true, from: srcPath, to: destPath };
114
116
  }
115
117
 
@@ -293,6 +295,7 @@ export async function handleDispatch(
293
295
  // Dispatch still succeeds; nudgeQueued stays false so caller knows the nudge was skipped.
294
296
  nudgeQueued = false;
295
297
  }
298
+ scheduleManifestRegeneration(api);
296
299
  return { ok: true as const, wrote: plan.files.map((f) => f.path), nudgeQueued };
297
300
  }
298
301
 
@@ -0,0 +1,222 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
5
+
6
+ // ── Manifest types ──────────────────────────────────────────────────────────
7
+
8
+ export interface KitchenManifest {
9
+ version: 1;
10
+ generatedAt: string;
11
+ teams: Record<string, TeamManifestEntry>;
12
+ agents: AgentManifestEntry[];
13
+ recipes: RecipeManifestEntry[];
14
+ }
15
+
16
+ export interface TeamManifestEntry {
17
+ teamId: string;
18
+ displayName: string | null;
19
+ roles: string[];
20
+ ticketCounts: {
21
+ backlog: number;
22
+ 'in-progress': number;
23
+ testing: number;
24
+ done: number;
25
+ total: number;
26
+ };
27
+ activeRunCount: number;
28
+ }
29
+
30
+ export interface AgentManifestEntry {
31
+ id: string;
32
+ identityName?: string;
33
+ workspace?: string;
34
+ model?: string;
35
+ isDefault?: boolean;
36
+ }
37
+
38
+ export interface RecipeManifestEntry {
39
+ id: string;
40
+ name: string;
41
+ kind: 'agent' | 'team';
42
+ source: 'builtin' | 'workspace';
43
+ }
44
+
45
+ // ── Helpers ─────────────────────────────────────────────────────────────────
46
+
47
+ const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
48
+ const MANIFEST_FILENAME = 'kitchen-manifest.json';
49
+
50
+ export function defaultManifestPath(): string {
51
+ return path.join(OPENCLAW_DIR, MANIFEST_FILENAME);
52
+ }
53
+
54
+ async function countMdFiles(dir: string): Promise<number> {
55
+ try {
56
+ const entries = await fs.readdir(dir);
57
+ return entries.filter((e) => e.endsWith('.md')).length;
58
+ } catch {
59
+ return 0;
60
+ }
61
+ }
62
+
63
+ async function countActiveRuns(teamDir: string): Promise<number> {
64
+ const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
65
+ let runDirs: string[];
66
+ try {
67
+ runDirs = await fs.readdir(runsDir);
68
+ } catch {
69
+ return 0;
70
+ }
71
+
72
+ let count = 0;
73
+ for (const d of runDirs) {
74
+ const runJson = path.join(runsDir, d, 'run.json');
75
+ try {
76
+ const raw = await fs.readFile(runJson, 'utf8');
77
+ const run = JSON.parse(raw) as { status?: string };
78
+ const s = run.status ?? '';
79
+ if (s === 'running' || s === 'waiting_workers' || s === 'waiting_for_approval') {
80
+ count++;
81
+ }
82
+ } catch {
83
+ // run.json missing or malformed — skip
84
+ }
85
+ }
86
+ return count;
87
+ }
88
+
89
+ async function listRoles(teamDir: string): Promise<string[]> {
90
+ const rolesDir = path.join(teamDir, 'roles');
91
+ try {
92
+ const entries = await fs.readdir(rolesDir, { withFileTypes: true });
93
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
98
+
99
+ async function readTeamDisplayName(teamDir: string): Promise<string | null> {
100
+ const teamJsonPath = path.join(teamDir, 'shared-context', 'workflows', 'team.json');
101
+ try {
102
+ const raw = await fs.readFile(teamJsonPath, 'utf8');
103
+ const parsed = JSON.parse(raw) as { recipeName?: string };
104
+ return parsed.recipeName?.trim() || null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ // ── Generator ───────────────────────────────────────────────────────────────
111
+
112
+ export interface GenerateManifestOptions {
113
+ api: OpenClawPluginApi;
114
+ outputPath?: string;
115
+ }
116
+
117
+ export async function generateKitchenManifest(opts: GenerateManifestOptions): Promise<KitchenManifest> {
118
+ const { api } = opts;
119
+ const outputPath = opts.outputPath ?? defaultManifestPath();
120
+
121
+ // Discover teams
122
+ let dirEntries: string[];
123
+ try {
124
+ dirEntries = await fs.readdir(OPENCLAW_DIR);
125
+ } catch {
126
+ dirEntries = [];
127
+ }
128
+
129
+ const teamDirNames = dirEntries.filter((e) => e.startsWith('workspace-'));
130
+ const teams: Record<string, TeamManifestEntry> = {};
131
+
132
+ for (const dirName of teamDirNames) {
133
+ const teamId = dirName.slice('workspace-'.length);
134
+ if (!teamId) continue;
135
+
136
+ const teamDir = path.join(OPENCLAW_DIR, dirName);
137
+ const workDir = path.join(teamDir, 'work');
138
+
139
+ const [backlog, inProgress, testing, done, activeRunCount, roles, displayName] = await Promise.all([
140
+ countMdFiles(path.join(workDir, 'backlog')),
141
+ countMdFiles(path.join(workDir, 'in-progress')),
142
+ countMdFiles(path.join(workDir, 'testing')),
143
+ countMdFiles(path.join(workDir, 'done')),
144
+ countActiveRuns(teamDir),
145
+ listRoles(teamDir),
146
+ readTeamDisplayName(teamDir),
147
+ ]);
148
+
149
+ teams[teamId] = {
150
+ teamId,
151
+ displayName,
152
+ roles,
153
+ ticketCounts: {
154
+ backlog,
155
+ 'in-progress': inProgress,
156
+ testing,
157
+ done,
158
+ total: backlog + inProgress + testing + done,
159
+ },
160
+ activeRunCount,
161
+ };
162
+ }
163
+
164
+ // Fetch agents and recipes via CLI (reuses existing OpenClaw infrastructure)
165
+ let agents: AgentManifestEntry[] = [];
166
+ try {
167
+ const res = await api.runtime.system.runCommandWithTimeout(
168
+ ['openclaw', 'agents', 'list', '--json'],
169
+ { timeoutMs: 15_000 },
170
+ );
171
+ if (res.code === 0 && res.stdout) {
172
+ agents = JSON.parse(res.stdout) as AgentManifestEntry[];
173
+ }
174
+ } catch { /* best-effort */ }
175
+
176
+ let recipes: RecipeManifestEntry[] = [];
177
+ try {
178
+ const res = await api.runtime.system.runCommandWithTimeout(
179
+ ['openclaw', 'recipes', 'list'],
180
+ { timeoutMs: 15_000 },
181
+ );
182
+ if (res.code === 0 && res.stdout) {
183
+ recipes = JSON.parse(res.stdout) as RecipeManifestEntry[];
184
+ }
185
+ } catch { /* best-effort */ }
186
+
187
+ const manifest: KitchenManifest = {
188
+ version: 1,
189
+ generatedAt: new Date().toISOString(),
190
+ teams,
191
+ agents,
192
+ recipes,
193
+ };
194
+
195
+ // Atomic write: tmp file + rename
196
+ const tmpPath = outputPath + '.tmp';
197
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
198
+ await fs.writeFile(tmpPath, JSON.stringify(manifest, null, 2), 'utf8');
199
+ await fs.rename(tmpPath, outputPath);
200
+
201
+ return manifest;
202
+ }
203
+
204
+ // ── Debounced regeneration ──────────────────────────────────────────────────
205
+
206
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
207
+ const DEBOUNCE_MS = 500;
208
+
209
+ /**
210
+ * Schedule a manifest regeneration. Multiple calls within DEBOUNCE_MS are
211
+ * coalesced into a single generation. Fire-and-forget — errors are logged
212
+ * but never propagated.
213
+ */
214
+ export function scheduleManifestRegeneration(api: OpenClawPluginApi): void {
215
+ if (debounceTimer) clearTimeout(debounceTimer);
216
+ debounceTimer = setTimeout(() => {
217
+ debounceTimer = null;
218
+ generateKitchenManifest({ api }).catch((err) => {
219
+ console.error('[kitchen-manifest] regeneration failed:', err instanceof Error ? err.message : String(err));
220
+ });
221
+ }, DEBOUNCE_MS);
222
+ }
@@ -192,7 +192,7 @@ export async function moveRunTicket(opts: {
192
192
  return { ticketPath: dest };
193
193
  }
194
194
 
195
- export function loadNodeStatesFromRun(run: RunLog): Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> {
195
+ export function loadNodeStatesFromRun(run: RunLog, opts?: { workflow?: Workflow }): Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> {
196
196
  const out: Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> = {};
197
197
 
198
198
  const cur = run.nodeStates;
@@ -217,6 +217,19 @@ export function loadNodeStatesFromRun(run: RunLog): Record<string, { status: 'su
217
217
  if (type === 'node.approved') out[nodeId] = { status: 'success', ts };
218
218
  }
219
219
 
220
+ // Revision semantics: when a run is in needs_revision, the approval handler
221
+ // clears nodeStates from nextNodeIndex onward, but events are append-only.
222
+ // The event-based reconstruction above would re-populate states that were
223
+ // deliberately cleared. Remove them so the stale-task guard in the worker
224
+ // does not reject re-enqueued revision tasks.
225
+ if (run.status === 'needs_revision' && typeof run.nextNodeIndex === 'number' && opts?.workflow) {
226
+ const nodes = Array.isArray(opts.workflow.nodes) ? opts.workflow.nodes : [];
227
+ for (let i = Math.max(0, run.nextNodeIndex); i < nodes.length; i++) {
228
+ const id = asString(nodes[i]?.id).trim();
229
+ if (id) delete out[id];
230
+ }
231
+ }
232
+
220
233
  return out;
221
234
  }
222
235
 
@@ -669,7 +669,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
669
669
  // cursor. Before executing a dequeued task, verify that this node is still actually runnable
670
670
  // for the current run state. Otherwise we can resurrect pre-approval work and overwrite
671
671
  // canonical node outputs for runs that already advanced.
672
- const currentNodeStates = loadNodeStatesFromRun(run);
672
+ const currentNodeStates = loadNodeStatesFromRun(run, { workflow });
673
673
  const currentStatus = currentNodeStates[String(node.id)]?.status;
674
674
  const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run });
675
675
  if (
@@ -749,9 +749,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
749
749
  'workflow.id': String(workflow.id ?? ''),
750
750
  'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
751
751
  };
752
-
752
+
753
753
  // Load node outputs and make them available as template variables
754
754
  const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
755
+
756
+ // Expose triggerInput as template variables (for handoff-injected data)
757
+ if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
758
+ for (const [key, value] of Object.entries(runSnap.triggerInput)) {
759
+ if (typeof value === 'string') {
760
+ vars[`trigger.${key}`] = value;
761
+ } else if (value !== null && value !== undefined) {
762
+ vars[`trigger.${key}`] = JSON.stringify(value);
763
+ }
764
+ }
765
+ }
755
766
  for (const nr of (runSnap.nodeResults ?? [])) {
756
767
  const nid = String((nr as Record<string, unknown>).nodeId ?? '');
757
768
  const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
@@ -1258,9 +1269,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1258
1269
  'workflow.id': String(workflow.id ?? ''),
1259
1270
  'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
1260
1271
  };
1261
-
1272
+
1262
1273
  // Load node outputs and make them available as template variables
1263
1274
  const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
1275
+
1276
+ // Expose triggerInput as template variables (for handoff-injected data)
1277
+ if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
1278
+ for (const [key, value] of Object.entries(runSnap.triggerInput)) {
1279
+ if (typeof value === 'string') {
1280
+ vars[`trigger.${key}`] = value;
1281
+ } else if (value !== null && value !== undefined) {
1282
+ vars[`trigger.${key}`] = JSON.stringify(value);
1283
+ }
1284
+ }
1285
+ }
1264
1286
  for (const nr of (runSnap.nodeResults ?? [])) {
1265
1287
  const nid = String((nr as Record<string, unknown>).nodeId ?? '');
1266
1288
  const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
@@ -1329,10 +1351,28 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1329
1351
  }
1330
1352
  }
1331
1353
 
1332
- const toolRes = await toolsInvoke<unknown>(api, {
1333
- tool: toolName,
1334
- args: processedToolArgs,
1335
- });
1354
+ let toolRes: unknown;
1355
+ if (toolName === 'exec') {
1356
+ // Route exec tool calls through the plugin SDK runtime instead of
1357
+ // the gateway — the gateway exec tool is session-gated and unavailable
1358
+ // to most workflow worker sessions.
1359
+ const command = String((processedToolArgs as Record<string, unknown>).command ?? '');
1360
+ const workdir = String((processedToolArgs as Record<string, unknown>).workdir ?? teamDir);
1361
+ const timeoutSec = Number((processedToolArgs as Record<string, unknown>).timeout) || 120;
1362
+ const result = await api.runtime.system.runCommandWithTimeout(
1363
+ ['bash', '-c', command],
1364
+ { timeoutMs: timeoutSec * 1000, cwd: workdir },
1365
+ );
1366
+ if (result.code !== 0) {
1367
+ throw new Error(`exec failed (code=${result.code}):\n${result.stderr || result.stdout}`);
1368
+ }
1369
+ toolRes = { stdout: result.stdout, stderr: result.stderr, code: result.code };
1370
+ } else {
1371
+ toolRes = await toolsInvoke<unknown>(api, {
1372
+ tool: toolName,
1373
+ args: processedToolArgs,
1374
+ });
1375
+ }
1336
1376
 
1337
1377
  await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: processedToolArgs, result: toolRes }, null, 2) + '\n', 'utf8');
1338
1378
  }