@jiggai/recipes 0.2.22 → 0.2.24

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 (55) hide show
  1. package/README.md +10 -7
  2. package/docs/AGENTS_AND_SKILLS.md +21 -9
  3. package/docs/BUNDLED_RECIPES.md +12 -7
  4. package/docs/CLEANUP_TODO.md +31 -0
  5. package/docs/CODE_SMELLS_TRACKER.md +42 -0
  6. package/docs/SMELLS_TODO.md +23 -0
  7. package/docs/TEAM_WORKFLOW.md +3 -1
  8. package/docs/TEST_COVERAGE_PROGRESS.md +37 -0
  9. package/index.ts +386 -2156
  10. package/package.json +21 -2
  11. package/recipes/default/business-team.md +166 -51
  12. package/recipes/default/clinic-team.md +161 -29
  13. package/recipes/default/construction-team.md +167 -39
  14. package/recipes/default/crypto-trader-team.md +161 -29
  15. package/recipes/default/customer-support-team.md +140 -69
  16. package/recipes/default/development-team.md +50 -118
  17. package/recipes/default/financial-planner-team.md +167 -38
  18. package/recipes/default/law-firm-team.md +167 -40
  19. package/recipes/default/marketing-team.md +420 -123
  20. package/recipes/default/product-team.md +160 -93
  21. package/recipes/default/research-team.md +140 -66
  22. package/recipes/default/researcher.md +1 -1
  23. package/recipes/default/social-team.md +649 -60
  24. package/recipes/default/stock-trader-team.md +189 -38
  25. package/recipes/default/writing-team.md +143 -55
  26. package/src/handlers/cron.ts +309 -0
  27. package/src/handlers/install.ts +160 -0
  28. package/src/handlers/recipes.ts +119 -0
  29. package/src/handlers/scaffold.ts +141 -0
  30. package/src/handlers/team.ts +395 -0
  31. package/src/handlers/tickets.ts +304 -0
  32. package/src/lib/agent-config.ts +48 -0
  33. package/src/lib/bindings.ts +9 -59
  34. package/src/lib/cleanup-workspaces.ts +4 -4
  35. package/src/lib/config.ts +47 -0
  36. package/src/lib/constants.ts +11 -0
  37. package/src/lib/cron-utils.ts +54 -0
  38. package/src/lib/fs-utils.ts +33 -0
  39. package/src/lib/json-utils.ts +17 -0
  40. package/src/lib/lanes.ts +14 -12
  41. package/src/lib/prompt.ts +47 -0
  42. package/src/lib/recipe-frontmatter.ts +65 -21
  43. package/src/lib/recipe-id.ts +49 -0
  44. package/src/lib/recipes-config.ts +166 -0
  45. package/src/lib/recipes.ts +57 -0
  46. package/src/lib/remove-team.ts +17 -23
  47. package/src/lib/scaffold-utils.ts +95 -0
  48. package/src/lib/skill-install.ts +22 -0
  49. package/src/lib/stable-stringify.ts +21 -0
  50. package/src/lib/template.ts +10 -0
  51. package/src/lib/ticket-finder.ts +40 -23
  52. package/src/lib/ticket-workflow.ts +32 -65
  53. package/src/lib/workspace.ts +33 -0
  54. package/src/marketplaceFetch.ts +1 -1
  55. package/src/toolsInvoke.ts +41 -32
package/index.ts CHANGED
@@ -1,759 +1,54 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import path from "node:path";
3
- import fs from "node:fs/promises";
4
- import crypto from "node:crypto";
5
3
  import JSON5 from "json5";
6
- import YAML from "yaml";
7
- import { buildRemoveTeamPlan, executeRemoveTeamPlan, loadCronStore, saveCronStore } from "./src/lib/remove-team";
8
-
9
- type RecipesConfig = {
10
- workspaceRecipesDir?: string;
11
- workspaceAgentsDir?: string;
12
- workspaceSkillsDir?: string;
13
- workspaceTeamsDir?: string;
14
- autoInstallMissingSkills?: boolean;
15
- confirmAutoInstall?: boolean;
16
-
17
- /** Cron installation behavior during scaffold/scaffold-team. */
18
- cronInstallation?: "off" | "prompt" | "on";
19
- };
20
-
21
- type CronJobSpec = {
22
- /** Stable id within the recipe (used for idempotent reconciliation). */
23
- id: string;
24
- /** 5-field cron expression */
25
- schedule: string;
26
- /** Agent message payload */
27
- message: string;
28
-
29
- name?: string;
30
- description?: string;
31
- timezone?: string;
32
-
33
- /** Delivery routing (optional; defaults to OpenClaw "last"). */
34
- channel?: string;
35
- to?: string;
36
-
37
- /** Which agent should execute this job (optional). */
38
- agentId?: string;
39
-
40
- /** If true, install enabled when cronInstallation=on (or prompt-yes). Default false. */
41
- enabledByDefault?: boolean;
42
- };
43
-
44
- type RecipeFrontmatter = {
45
- id: string;
46
- name?: string;
47
- version?: string;
48
- description?: string;
49
- kind?: "agent" | "team";
50
-
51
- /** Optional recipe-defined cron jobs to reconcile during scaffold. */
52
- cronJobs?: CronJobSpec[];
53
-
54
- // skill deps (installed into workspace-local skills dir)
55
- requiredSkills?: string[];
56
- optionalSkills?: string[];
57
-
58
- // Team recipe: defines a team workspace + multiple agents.
59
- team?: {
60
- teamId: string; // must end with -team
61
- name?: string;
62
- description?: string;
63
- };
64
- agents?: Array<{
65
- role: string;
66
- agentId?: string; // default: <teamId>-<role>
67
- name?: string; // display name
68
- // Optional per-role tool policy override (else uses top-level tools)
69
- tools?: {
70
- profile?: string;
71
- allow?: string[];
72
- deny?: string[];
73
- };
74
- }>;
75
-
76
- // Agent recipe: templates + files to write in the agent folder.
77
- // For team recipes, templates can be namespaced by role, e.g. "lead.soul", "writer.agents".
78
- templates?: Record<string, string>;
79
- files?: Array<{
80
- path: string;
81
- template: string; // key in templates map
82
- mode?: "createOnly" | "overwrite";
83
- }>;
84
-
85
- // Tool policy (applies to agent recipe; team recipes can override per agent)
86
- tools?: {
87
- profile?: string;
88
- allow?: string[];
89
- deny?: string[];
90
- };
91
- };
92
-
93
- function getCfg(api: OpenClawPluginApi): Required<RecipesConfig> {
94
- const cfg = (api.config.plugins?.entries?.["recipes"]?.config ??
95
- api.config.plugins?.entries?.recipes?.config ??
96
- {}) as RecipesConfig;
97
-
98
- return {
99
- workspaceRecipesDir: cfg.workspaceRecipesDir ?? "recipes",
100
- workspaceAgentsDir: cfg.workspaceAgentsDir ?? "agents",
101
- workspaceSkillsDir: cfg.workspaceSkillsDir ?? "skills",
102
- workspaceTeamsDir: cfg.workspaceTeamsDir ?? "teams",
103
- autoInstallMissingSkills: cfg.autoInstallMissingSkills ?? false,
104
- confirmAutoInstall: cfg.confirmAutoInstall ?? true,
105
- cronInstallation: cfg.cronInstallation ?? "prompt",
106
- };
107
- }
108
-
109
- function workspacePath(api: OpenClawPluginApi, ...parts: string[]) {
110
- const root = api.config.agents?.defaults?.workspace;
111
- if (!root) throw new Error("agents.defaults.workspace is not set in config");
112
- return path.join(root, ...parts);
113
- }
114
-
115
- async function fileExists(p: string) {
116
- try {
117
- await fs.stat(p);
118
- return true;
119
- } catch {
120
- return false;
121
- }
122
- }
123
-
124
- async function listRecipeFiles(api: OpenClawPluginApi, cfg: Required<RecipesConfig>) {
125
- const builtinDir = path.join(__dirname, "recipes", "default");
126
- const workspaceDir = workspacePath(api, cfg.workspaceRecipesDir);
127
-
128
- const out: Array<{ source: "builtin" | "workspace"; path: string }> = [];
129
-
130
- if (await fileExists(builtinDir)) {
131
- const files = await fs.readdir(builtinDir);
132
- for (const f of files) if (f.endsWith(".md")) out.push({ source: "builtin", path: path.join(builtinDir, f) });
133
- }
134
-
135
- if (await fileExists(workspaceDir)) {
136
- const files = await fs.readdir(workspaceDir);
137
- for (const f of files) if (f.endsWith(".md")) out.push({ source: "workspace", path: path.join(workspaceDir, f) });
138
- }
139
-
140
- return out;
141
- }
142
-
143
- function parseFrontmatter(md: string): { frontmatter: RecipeFrontmatter; body: string } {
144
- // very small frontmatter parser: expects ---\nYAML\n---\n
145
- if (!md.startsWith("---\n")) {
146
- throw new Error("Recipe markdown must start with YAML frontmatter (---)");
147
- }
148
- const end = md.indexOf("\n---\n", 4);
149
- if (end === -1) throw new Error("Recipe frontmatter not terminated (---)");
150
- const yamlText = md.slice(4, end + 1); // include trailing newline
151
- const body = md.slice(end + 5);
152
- const frontmatter = YAML.parse(yamlText) as RecipeFrontmatter;
153
- if (!frontmatter?.id) throw new Error("Recipe frontmatter must include id");
154
- return { frontmatter, body };
155
- }
156
-
157
- async function loadRecipeById(api: OpenClawPluginApi, recipeId: string) {
158
- const cfg = getCfg(api);
159
- const files = await listRecipeFiles(api, cfg);
160
- for (const f of files) {
161
- const md = await fs.readFile(f.path, "utf8");
162
- const { frontmatter } = parseFrontmatter(md);
163
- if (frontmatter.id === recipeId) return { file: f, md, ...parseFrontmatter(md) };
164
- }
165
- throw new Error(`Recipe not found: ${recipeId}`);
166
- }
167
-
168
- function skillInstallCommands(cfg: Required<RecipesConfig>, skills: string[]) {
169
- // We standardize on clawhub CLI. Workspace-local install path is implicit by running from workspace
170
- // OR by environment var if clawhub supports it (unknown). For now: cd workspace + install.
171
- // We'll refine once we lock exact clawhub CLI flags.
172
- const lines = [
173
- `cd "${"$WORKSPACE"}" # set WORKSPACE=~/.openclaw/workspace`,
174
- ...skills.map((s) => `npx clawhub@latest install ${s}`),
175
- ];
176
- return lines;
177
- }
178
-
179
- async function detectMissingSkills(installDir: string, skills: string[]) {
180
- const missing: string[] = [];
181
- for (const s of skills) {
182
- const p = path.join(installDir, s);
183
- if (!(await fileExists(p))) missing.push(s);
184
- }
185
- return missing;
186
- }
187
-
188
- async function ensureDir(p: string) {
189
- await fs.mkdir(p, { recursive: true });
190
- }
191
-
192
- function ticketStageDir(teamDir: string, stage: "backlog" | "in-progress" | "testing" | "done" | "assignments") {
193
- return stage === "assignments"
194
- ? path.join(teamDir, "work", "assignments")
195
- : path.join(teamDir, "work", stage);
196
- }
197
-
198
- async function ensureTicketStageDirs(teamDir: string) {
199
- // Idempotent. Used to harden ticket commands for older team workspaces.
200
- // NOTE: creating these directories is safe even if empty.
201
- await Promise.all([
202
- ensureDir(path.join(teamDir, "work")),
203
- ensureDir(ticketStageDir(teamDir, "backlog")),
204
- ensureDir(ticketStageDir(teamDir, "in-progress")),
205
- ensureDir(ticketStageDir(teamDir, "testing")),
206
- ensureDir(ticketStageDir(teamDir, "done")),
207
- ensureDir(ticketStageDir(teamDir, "assignments")),
208
- ]);
209
- }
210
-
211
- type CronInstallMode = "off" | "prompt" | "on";
212
-
213
- type CronMappingStateV1 = {
214
- version: 1;
215
- entries: Record<
216
- string,
217
- {
218
- installedCronId: string;
219
- specHash: string;
220
- orphaned?: boolean;
221
- updatedAtMs: number;
222
- }
223
- >;
224
- };
225
-
226
- function cronKey(scope: { kind: "team"; teamId: string; recipeId: string } | { kind: "agent"; agentId: string; recipeId: string }, cronJobId: string) {
227
- return scope.kind === "team"
228
- ? `team:${scope.teamId}:recipe:${scope.recipeId}:cron:${cronJobId}`
229
- : `agent:${scope.agentId}:recipe:${scope.recipeId}:cron:${cronJobId}`;
230
- }
231
-
232
- function hashSpec(spec: unknown) {
233
- const json = stableStringify(spec);
234
- return crypto.createHash("sha256").update(json, "utf8").digest("hex");
235
- }
236
-
237
- async function readJsonFile<T>(p: string): Promise<T | null> {
238
- try {
239
- const raw = await fs.readFile(p, "utf8");
240
- return JSON.parse(raw) as T;
241
- } catch {
242
- return null;
243
- }
244
- }
245
-
246
- async function writeJsonFile(p: string, data: unknown) {
247
- await ensureDir(path.dirname(p));
248
- await fs.writeFile(p, JSON.stringify(data, null, 2) + "\n", "utf8");
249
- }
250
-
251
- async function loadCronMappingState(statePath: string): Promise<CronMappingStateV1> {
252
- const existing = await readJsonFile<CronMappingStateV1>(statePath);
253
- if (existing && existing.version === 1 && existing.entries && typeof existing.entries === "object") return existing;
254
- return { version: 1, entries: {} };
255
- }
256
-
257
- type OpenClawCronJob = {
258
- id: string;
259
- name?: string;
260
- enabled?: boolean;
261
- schedule?: any;
262
- payload?: any;
263
- delivery?: any;
264
- agentId?: string | null;
265
- description?: string;
266
- };
267
-
268
- import { toolsInvoke, type ToolTextResult, type ToolsInvokeRequest } from "./src/toolsInvoke";
269
-
270
- function parseToolTextJson(text: string, label: string) {
271
- const trimmed = String(text ?? "").trim();
272
- if (!trimmed) return null;
273
- try {
274
- return JSON.parse(trimmed) as any;
275
- } catch (e) {
276
- const err = new Error(`Failed parsing JSON from tool text (${label})`);
277
- (err as any).text = text;
278
- (err as any).cause = e;
279
- throw err;
280
- }
281
- }
282
-
283
- async function cronList(api: any) {
284
- const result = await toolsInvoke<ToolTextResult>(api, {
285
- tool: "cron",
286
- args: { action: "list", includeDisabled: true },
287
- });
288
- const text = result?.content?.find((c) => c.type === "text")?.text;
289
- const parsed = text ? (parseToolTextJson(text, "cron.list") as { jobs?: OpenClawCronJob[] }) : null;
290
- return { jobs: parsed?.jobs ?? [] };
291
- }
292
-
293
- async function cronAdd(api: any, job: any) {
294
- const result = await toolsInvoke<ToolTextResult>(api, { tool: "cron", args: { action: "add", job } });
295
- const text = result?.content?.find((c) => c.type === "text")?.text;
296
- return text ? parseToolTextJson(text, "cron.add") : null;
297
- }
298
-
299
- async function cronUpdate(api: any, jobId: string, patch: any) {
300
- const result = await toolsInvoke<ToolTextResult>(api, {
301
- tool: "cron",
302
- args: { action: "update", jobId, patch },
303
- });
304
- const text = result?.content?.find((c) => c.type === "text")?.text;
305
- return text ? parseToolTextJson(text, "cron.update") : null;
306
- }
307
-
308
- function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
309
- const raw = frontmatter.cronJobs;
310
- if (!raw) return [];
311
- if (!Array.isArray(raw)) throw new Error("frontmatter.cronJobs must be an array");
312
-
313
- const out: CronJobSpec[] = [];
314
- const seen = new Set<string>();
315
- for (const j of raw as any[]) {
316
- if (!j || typeof j !== "object") throw new Error("cronJobs entries must be objects");
317
- const id = String((j as any).id ?? "").trim();
318
- if (!id) throw new Error("cronJobs[].id is required");
319
- if (seen.has(id)) throw new Error(`Duplicate cronJobs[].id: ${id}`);
320
- seen.add(id);
321
-
322
- const schedule = String((j as any).schedule ?? "").trim();
323
- const message = String((j as any).message ?? (j as any).task ?? (j as any).prompt ?? "").trim();
324
- if (!schedule) throw new Error(`cronJobs[${id}].schedule is required`);
325
- if (!message) throw new Error(`cronJobs[${id}].message is required`);
326
-
327
- out.push({
328
- id,
329
- schedule,
330
- message,
331
- name: (j as any).name ? String((j as any).name) : undefined,
332
- description: (j as any).description ? String((j as any).description) : undefined,
333
- timezone: (j as any).timezone ? String((j as any).timezone) : undefined,
334
- channel: (j as any).channel ? String((j as any).channel) : undefined,
335
- to: (j as any).to ? String((j as any).to) : undefined,
336
- agentId: (j as any).agentId ? String((j as any).agentId) : undefined,
337
- enabledByDefault: Boolean((j as any).enabledByDefault ?? false),
338
- });
339
- }
340
- return out;
341
- }
342
-
343
- async function promptYesNo(header: string) {
344
- if (!process.stdin.isTTY) return false;
345
- const readline = await import("node:readline/promises");
346
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
347
- try {
348
- const ans = await rl.question(`${header}\nProceed? (y/N) `);
349
- return ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
350
- } finally {
351
- rl.close();
352
- }
353
- }
354
-
355
- async function reconcileRecipeCronJobs(opts: {
356
- api: OpenClawPluginApi;
357
- recipe: RecipeFrontmatter;
358
- scope: { kind: "team"; teamId: string; recipeId: string; stateDir: string } | { kind: "agent"; agentId: string; recipeId: string; stateDir: string };
359
- cronInstallation: CronInstallMode;
360
- }) {
361
- const desired = normalizeCronJobs(opts.recipe);
362
- if (!desired.length) return { ok: true, changed: false, note: "no-cron-jobs" as const };
363
-
364
- const mode = opts.cronInstallation;
365
- if (mode === "off") {
366
- return { ok: true, changed: false, note: "cron-installation-off" as const, desiredCount: desired.length };
367
- }
368
-
369
- // Decide whether jobs should be enabled on creation. Default is conservative.
370
- let userOptIn = mode === "on";
371
- if (mode === "prompt") {
372
- const header = `Recipe ${opts.scope.recipeId} defines ${desired.length} cron job(s).\nThese run automatically on a schedule. Install them?`;
373
- userOptIn = await promptYesNo(header);
374
-
375
- // If the user declines, skip all cron reconciliation entirely. This avoids a
376
- // potentially slow gateway cron.list call and matches user intent.
377
- if (!userOptIn) {
378
- return { ok: true, changed: false, note: "cron-installation-declined" as const, desiredCount: desired.length };
379
- }
380
-
381
- if (!process.stdin.isTTY) {
382
- console.error("Non-interactive mode: defaulting cron install to disabled.");
383
- }
384
- }
385
-
386
- const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
387
- const state = await loadCronMappingState(statePath);
388
-
389
- // Fast path: if we have no prior installed ids for these desired jobs, skip cron.list.
390
- // cron.list can be slow/hang on some setups; we can still create jobs and record ids.
391
- const desiredKeys = desired.map((j) => cronKey(opts.scope as any, j.id));
392
- const hasAnyInstalled = desiredKeys.some((k) => Boolean(state.entries[k]?.installedCronId));
393
-
394
- const list = hasAnyInstalled ? await cronList(opts.api) : { jobs: [] };
395
- const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
396
-
397
- const now = Date.now();
398
- const desiredIds = new Set(desired.map((j) => j.id));
399
-
400
- const results: any[] = [];
401
-
402
- for (const j of desired) {
403
- const key = cronKey(opts.scope as any, j.id);
404
- const name = j.name ?? `${opts.scope.kind === "team" ? (opts.scope as any).teamId : (opts.scope as any).agentId} • ${opts.scope.recipeId} • ${j.id}`;
405
-
406
- const desiredSpec = {
407
- schedule: j.schedule,
408
- message: j.message,
409
- timezone: j.timezone ?? "",
410
- channel: j.channel ?? "last",
411
- to: j.to ?? "",
412
- agentId: j.agentId ?? "",
413
- name,
414
- description: j.description ?? "",
415
- };
416
- const specHash = hashSpec(desiredSpec);
417
-
418
- const prev = state.entries[key];
419
- const installedId = prev?.installedCronId;
420
- const existing = installedId ? byId.get(installedId) : undefined;
421
-
422
- const wantEnabled = userOptIn ? Boolean(j.enabledByDefault) : false;
423
-
424
- if (!existing) {
425
- // Create new job.
426
- const sessionTarget = j.agentId ? "isolated" : "main";
427
- const job = {
428
- name,
429
- agentId: j.agentId ?? null,
430
- description: j.description ?? "",
431
- enabled: wantEnabled,
432
- wakeMode: "next-heartbeat",
433
- sessionTarget,
434
- schedule: { kind: "cron", expr: j.schedule, ...(j.timezone ? { tz: j.timezone } : {}) },
435
- payload: j.agentId
436
- ? { kind: "agentTurn", message: j.message }
437
- : { kind: "systemEvent", text: j.message },
438
- ...(j.channel || j.to
439
- ? {
440
- delivery: {
441
- mode: "announce",
442
- ...(j.channel ? { channel: j.channel } : {}),
443
- ...(j.to ? { to: j.to } : {}),
444
- bestEffort: true,
445
- },
446
- }
447
- : {}),
448
- };
449
-
450
- const created = await cronAdd(opts.api, job);
451
- const newId = (created as any)?.id ?? (created as any)?.job?.id;
452
- if (!newId) throw new Error("Failed to parse cron add output (missing id)");
453
-
454
- state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
455
- results.push({ action: "created", key, installedCronId: newId, enabled: wantEnabled });
456
- continue;
457
- }
458
-
459
- // Update existing job if spec changed.
460
- if (prev?.specHash !== specHash) {
461
- const patch: any = {
462
- name,
463
- agentId: j.agentId ?? null,
464
- description: j.description ?? "",
465
- sessionTarget: j.agentId ? "isolated" : "main",
466
- wakeMode: "next-heartbeat",
467
- schedule: { kind: "cron", expr: j.schedule, ...(j.timezone ? { tz: j.timezone } : {}) },
468
- payload: j.agentId ? { kind: "agentTurn", message: j.message } : { kind: "systemEvent", text: j.message },
469
- };
470
- if (j.channel || j.to) {
471
- patch.delivery = {
472
- mode: "announce",
473
- ...(j.channel ? { channel: j.channel } : {}),
474
- ...(j.to ? { to: j.to } : {}),
475
- bestEffort: true,
476
- };
477
- }
478
-
479
- await cronUpdate(opts.api, existing.id, patch);
480
- results.push({ action: "updated", key, installedCronId: existing.id });
481
- } else {
482
- results.push({ action: "unchanged", key, installedCronId: existing.id });
483
- }
484
-
485
- // Enabled precedence: if user did not opt in, force disabled. Otherwise preserve current enabled state.
486
- if (!userOptIn) {
487
- if (existing.enabled) {
488
- await cronUpdate(opts.api, existing.id, { enabled: false });
489
- results.push({ action: "disabled", key, installedCronId: existing.id });
490
- }
491
- }
492
-
493
- state.entries[key] = { installedCronId: existing.id, specHash, updatedAtMs: now, orphaned: false };
494
- }
495
-
496
- // Handle removed jobs: disable safely.
497
- for (const [key, entry] of Object.entries(state.entries)) {
498
- if (!key.includes(`:recipe:${opts.scope.recipeId}:cron:`)) continue;
499
- const cronId = key.split(":cron:")[1] ?? "";
500
- if (!cronId || desiredIds.has(cronId)) continue;
501
-
502
- const job = byId.get(entry.installedCronId);
503
- if (job && job.enabled) {
504
- await cronUpdate(api, job.id, { enabled: false });
505
- results.push({ action: "disabled-removed", key, installedCronId: job.id });
506
- }
507
-
508
- state.entries[key] = { ...entry, orphaned: true, updatedAtMs: now };
509
- }
510
-
511
- await writeJsonFile(statePath, state);
512
-
513
- const changed = results.some((r) => r.action === "created" || r.action === "updated" || r.action?.startsWith("disabled"));
514
- return { ok: true, changed, results };
515
- }
516
-
517
- function renderTemplate(raw: string, vars: Record<string, string>) {
518
- // Tiny, safe template renderer: replaces {{key}}.
519
- // No conditionals, no eval.
520
- return raw.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_m, key) => {
521
- const v = vars[key];
522
- return typeof v === "string" ? v : "";
523
- });
524
- }
525
-
526
- async function writeFileSafely(p: string, content: string, mode: "createOnly" | "overwrite") {
527
- if (mode === "createOnly" && (await fileExists(p))) return { wrote: false, reason: "exists" as const };
528
- await ensureDir(path.dirname(p));
529
- await fs.writeFile(p, content, "utf8");
530
- return { wrote: true, reason: "ok" as const };
531
- }
532
-
533
- type AgentConfigSnippet = {
534
- id: string;
535
- workspace: string;
536
- identity?: { name?: string };
537
- tools?: { profile?: string; allow?: string[]; deny?: string[] };
538
- };
539
-
540
- type BindingMatch = {
541
- channel: string;
542
- accountId?: string;
543
- // OpenClaw config schema uses: dm | group | channel
544
- peer?: { kind: "dm" | "group" | "channel"; id: string };
545
- guildId?: string;
546
- teamId?: string;
547
- };
548
-
549
- type BindingSnippet = {
550
- agentId: string;
551
- match: BindingMatch;
552
- };
553
-
554
- function upsertAgentInConfig(cfgObj: any, snippet: AgentConfigSnippet) {
555
- if (!cfgObj.agents) cfgObj.agents = {};
556
- if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
557
-
558
- const list: any[] = cfgObj.agents.list;
559
- const idx = list.findIndex((a) => a?.id === snippet.id);
560
- const prev = idx >= 0 ? list[idx] : {};
561
- const nextAgent = {
562
- ...prev,
563
- id: snippet.id,
564
- workspace: snippet.workspace,
565
- // identity: merge (safe)
566
- identity: {
567
- ...(prev?.identity ?? {}),
568
- ...(snippet.identity ?? {}),
569
- },
570
- // tools: replace when provided (so stale deny/allow don’t linger)
571
- tools: snippet.tools ? { ...snippet.tools } : prev?.tools,
572
- };
573
-
574
- if (idx >= 0) {
575
- list[idx] = nextAgent;
576
- return;
577
- }
578
-
579
- // New agent: append to end of list.
580
- // (We still separately enforce that main exists and stays first/default.)
581
- list.push(nextAgent);
582
- }
583
-
584
- function ensureMainFirstInAgentsList(cfgObj: any, api: OpenClawPluginApi) {
585
- if (!cfgObj.agents) cfgObj.agents = {};
586
- if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
587
-
588
- const list: any[] = cfgObj.agents.list;
589
-
590
- const workspaceRoot =
591
- cfgObj.agents?.defaults?.workspace ??
592
- api.config.agents?.defaults?.workspace ??
593
- "~/.openclaw/workspace";
594
-
595
- const idx = list.findIndex((a) => a?.id === "main");
596
- const prevMain = idx >= 0 ? list[idx] : {};
597
-
598
- // Enforce: main exists, is first, and is the default.
599
- const main = {
600
- ...prevMain,
601
- id: "main",
602
- default: true,
603
- workspace: prevMain?.workspace ?? workspaceRoot,
604
- sandbox: prevMain?.sandbox ?? { mode: "off" },
605
- };
606
-
607
- // Ensure only one default.
608
- for (const a of list) {
609
- if (a?.id !== "main" && a?.default) a.default = false;
610
- }
611
-
612
- if (idx >= 0) list.splice(idx, 1);
613
- list.unshift(main);
614
- }
615
-
616
- function stableStringify(x: any) {
617
- const seen = new WeakSet();
618
- const sortObj = (v: any): any => {
619
- if (v && typeof v === "object") {
620
- if (seen.has(v)) return "[Circular]";
621
- seen.add(v);
622
- if (Array.isArray(v)) return v.map(sortObj);
623
- const out: any = {};
624
- for (const k of Object.keys(v).sort()) out[k] = sortObj(v[k]);
625
- return out;
626
- }
627
- return v;
628
- };
629
- return JSON.stringify(sortObj(x));
630
- }
631
-
632
- function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
633
- if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
634
- const list: any[] = cfgObj.bindings;
635
-
636
- const sig = stableStringify({ agentId: binding.agentId, match: binding.match });
637
- const idx = list.findIndex((b) => stableStringify({ agentId: b?.agentId, match: b?.match }) === sig);
638
-
639
- if (idx >= 0) {
640
- // Update in place (preserve ordering)
641
- list[idx] = { ...list[idx], ...binding };
642
- return { changed: false, note: "already-present" as const };
643
- }
644
-
645
- // Most-specific-first: if a peer match is specified, insert at front so it wins.
646
- // Otherwise append.
647
- if (binding.match?.peer) list.unshift(binding);
648
- else list.push(binding);
649
-
650
- return { changed: true, note: "added" as const };
651
- }
652
-
653
- function removeBindingsInConfig(cfgObj: any, opts: { agentId?: string; match: BindingMatch }) {
654
- if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
655
- const list: any[] = cfgObj.bindings;
656
-
657
- const targetMatchSig = stableStringify(opts.match);
658
-
659
- const before = list.length;
660
- const kept: any[] = [];
661
- const removed: any[] = [];
662
-
663
- for (const b of list) {
664
- const sameAgent = opts.agentId ? String(b?.agentId ?? "") === opts.agentId : true;
665
- const sameMatch = stableStringify(b?.match ?? {}) === targetMatchSig;
666
- if (sameAgent && sameMatch) removed.push(b);
667
- else kept.push(b);
668
- }
669
-
670
- cfgObj.bindings = kept;
671
- return { removedCount: before - kept.length, removed };
672
- }
673
-
674
- async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: AgentConfigSnippet[]) {
675
- // Load the latest config from disk (not the snapshot in api.config).
676
- const current = (api.runtime as any).config?.loadConfig?.();
677
- if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
678
-
679
- // Some loaders return { cfg, ... }. If so, normalize.
680
- const cfgObj = (current.cfg ?? current) as any;
681
-
682
- // Always keep main first/default when multi-agent workflows are in play.
683
- ensureMainFirstInAgentsList(cfgObj, api);
684
-
685
- for (const s of snippets) upsertAgentInConfig(cfgObj, s);
686
-
687
- // Re-assert ordering/default after upserts.
688
- ensureMainFirstInAgentsList(cfgObj, api);
689
-
690
- await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
691
- return { updatedAgents: snippets.map((s) => s.id) };
692
- }
693
-
694
- async function applyBindingSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: BindingSnippet[]) {
695
- const current = (api.runtime as any).config?.loadConfig?.();
696
- if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
697
- const cfgObj = (current.cfg ?? current) as any;
698
-
699
- const results: any[] = [];
700
- for (const s of snippets) {
701
- results.push({ ...s, result: upsertBindingInConfig(cfgObj, s) });
702
- }
703
-
704
- await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
705
- return { updatedBindings: results };
706
- }
707
-
708
- async function scaffoldAgentFromRecipe(
709
- api: OpenClawPluginApi,
710
- recipe: RecipeFrontmatter,
711
- opts: {
712
- agentId: string;
713
- agentName?: string;
714
- update?: boolean;
715
- vars?: Record<string, string>;
716
-
717
- // Where to write the scaffolded files (may be a shared team workspace role folder)
718
- filesRootDir: string;
719
-
720
- // What to set in agents.list[].workspace (may be shared team workspace root)
721
- workspaceRootDir: string;
722
- },
723
- ) {
724
- await ensureDir(opts.filesRootDir);
725
-
726
- const templates = recipe.templates ?? {};
727
- const files = recipe.files ?? [];
728
- const vars = opts.vars ?? {};
729
-
730
- const fileResults: Array<{ path: string; wrote: boolean; reason: string }> = [];
731
- for (const f of files) {
732
- const raw = templates[f.template];
733
- if (typeof raw !== "string") throw new Error(`Missing template: ${f.template}`);
734
- const rendered = renderTemplate(raw, vars);
735
- const target = path.join(opts.filesRootDir, f.path);
736
- const mode = opts.update ? (f.mode ?? "overwrite") : (f.mode ?? "createOnly");
737
- const r = await writeFileSafely(target, rendered, mode);
738
- fileResults.push({ path: target, wrote: r.wrote, reason: r.reason });
739
- }
740
-
741
- const configSnippet: AgentConfigSnippet = {
742
- id: opts.agentId,
743
- workspace: opts.workspaceRootDir,
744
- identity: { name: opts.agentName ?? recipe.name ?? opts.agentId },
745
- tools: recipe.tools ?? {},
746
- };
747
-
748
- return {
749
- filesRootDir: opts.filesRootDir,
750
- workspaceRootDir: opts.workspaceRootDir,
751
- fileResults,
752
- next: {
753
- configSnippet,
754
- },
755
- };
756
- }
4
+ import {
5
+ applyAgentSnippetsToOpenClawConfig,
6
+ ensureMainFirstInAgentsList,
7
+ loadOpenClawConfig,
8
+ removeBindingsInConfig,
9
+ type BindingMatch,
10
+ upsertBindingInConfig,
11
+ writeOpenClawConfig,
12
+ } from "./src/lib/recipes-config";
13
+ import { stableStringify } from "./src/lib/stable-stringify";
14
+ import { promptConfirmWithPlan, promptYesNo } from "./src/lib/prompt";
15
+ import {
16
+ handleRecipesBind,
17
+ handleRecipesBindings,
18
+ handleRecipesList,
19
+ handleRecipesShow,
20
+ handleRecipesStatus,
21
+ handleRecipesUnbind,
22
+ } from "./src/handlers/recipes";
23
+ import {
24
+ handleInstallMarketplaceRecipe,
25
+ handleInstallSkill,
26
+ } from "./src/handlers/install";
27
+ import {
28
+ handleAssign,
29
+ handleDispatch,
30
+ handleHandoff,
31
+ handleMoveTicket,
32
+ handleTake,
33
+ handleTickets,
34
+ patchTicketField,
35
+ patchTicketOwner,
36
+ patchTicketStatus,
37
+ } from "./src/handlers/tickets";
38
+ import {
39
+ handleMigrateTeamPlan,
40
+ handleRemoveTeam,
41
+ handleScaffoldTeam,
42
+ executeMigrateTeamPlan,
43
+ } from "./src/handlers/team";
44
+ import { handleScaffold, scaffoldAgentFromRecipe } from "./src/handlers/scaffold";
45
+ import { reconcileRecipeCronJobs } from "./src/handlers/cron";
46
+ import { listRecipeFiles, loadRecipeById, workspacePath } from "./src/lib/recipes";
47
+ import {
48
+ executeWorkspaceCleanup,
49
+ planWorkspaceCleanup,
50
+ } from "./src/lib/cleanup-workspaces";
51
+ import { resolveWorkspaceRoot } from "./src/lib/workspace";
757
52
 
758
53
  const recipesPlugin = {
759
54
  id: "recipes",
@@ -769,16 +64,13 @@ const recipesPlugin = {
769
64
  // This is idempotent and only writes if a change is required.
770
65
  (async () => {
771
66
  try {
772
- const current = (api.runtime as any).config?.loadConfig?.();
773
- if (!current) return;
774
- const cfgObj = (current.cfg ?? current) as any;
775
-
67
+ const cfgObj = await loadOpenClawConfig(api);
776
68
  const before = JSON.stringify(cfgObj.agents?.list ?? null);
777
69
  ensureMainFirstInAgentsList(cfgObj, api);
778
70
  const after = JSON.stringify(cfgObj.agents?.list ?? null);
779
71
 
780
72
  if (before !== after) {
781
- await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
73
+ await writeOpenClawConfig(api, cfgObj);
782
74
  console.error("[recipes] ensured agents.list includes main as first/default");
783
75
  }
784
76
  } catch (e) {
@@ -796,18 +88,7 @@ const recipesPlugin = {
796
88
  .command("list")
797
89
  .description("List available recipes (builtin + workspace)")
798
90
  .action(async () => {
799
- const cfg = getCfg(api);
800
- const files = await listRecipeFiles(api, cfg);
801
- const rows: Array<{ id: string; name?: string; kind?: string; source: string }> = [];
802
- for (const f of files) {
803
- try {
804
- const md = await fs.readFile(f.path, "utf8");
805
- const { frontmatter } = parseFrontmatter(md);
806
- rows.push({ id: frontmatter.id, name: frontmatter.name, kind: frontmatter.kind, source: f.source });
807
- } catch (e) {
808
- rows.push({ id: path.basename(f.path), name: `INVALID: ${(e as Error).message}`, kind: "invalid", source: f.source });
809
- }
810
- }
91
+ const rows = await handleRecipesList(api);
811
92
  console.log(JSON.stringify(rows, null, 2));
812
93
  });
813
94
 
@@ -816,8 +97,8 @@ const recipesPlugin = {
816
97
  .description("Show a recipe by id")
817
98
  .argument("<id>", "Recipe id")
818
99
  .action(async (id: string) => {
819
- const r = await loadRecipeById(api, id);
820
- console.log(r.md);
100
+ const md = await handleRecipesShow(api, id);
101
+ console.log(md);
821
102
  });
822
103
 
823
104
  cmd
@@ -825,31 +106,21 @@ const recipesPlugin = {
825
106
  .description("Check for missing skills for a recipe (or all)")
826
107
  .argument("[id]", "Recipe id")
827
108
  .action(async (id?: string) => {
828
- const cfg = getCfg(api);
829
- const files = await listRecipeFiles(api, cfg);
830
- const out: any[] = [];
831
-
832
- for (const f of files) {
833
- const md = await fs.readFile(f.path, "utf8");
834
- const { frontmatter } = parseFrontmatter(md);
835
- if (id && frontmatter.id !== id) continue;
836
- const req = frontmatter.requiredSkills ?? [];
837
- const workspaceRoot = api.config.agents?.defaults?.workspace;
838
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
839
- const installDir = path.join(workspaceRoot, cfg.workspaceSkillsDir);
840
- const missing = await detectMissingSkills(installDir, req);
841
- out.push({
842
- id: frontmatter.id,
843
- requiredSkills: req,
844
- missingSkills: missing,
845
- installCommands: missing.length ? skillInstallCommands(cfg, missing) : [],
846
- });
847
- }
848
-
109
+ const out = await handleRecipesStatus(api, id);
849
110
  console.log(JSON.stringify(out, null, 2));
850
111
  });
851
112
 
852
- const parseMatchFromOptions = (options: any): BindingMatch => {
113
+ type BindOptions = {
114
+ match?: string;
115
+ channel?: string;
116
+ accountId?: string;
117
+ guildId?: string;
118
+ teamId?: string;
119
+ peerKind?: string;
120
+ peerId?: string;
121
+ };
122
+
123
+ const parseMatchFromOptions = (options: BindOptions): BindingMatch => {
853
124
  if (options.match) {
854
125
  return JSON5.parse(String(options.match)) as BindingMatch;
855
126
  }
@@ -888,12 +159,10 @@ const recipesPlugin = {
888
159
  .option("--guild-id <guildId>", "Discord guildId")
889
160
  .option("--team-id <teamId>", "Slack teamId")
890
161
  .option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
891
- .action(async (options: any) => {
892
- const agentId = String(options.agentId);
162
+ .action(async (options: BindOptions & { agentId?: string }) => {
163
+ if (!options.agentId) throw new Error("--agent-id is required");
893
164
  const match = parseMatchFromOptions(options);
894
- if (!match?.channel) throw new Error("match.channel is required");
895
-
896
- const res = await applyBindingSnippetsToOpenClawConfig(api, [{ agentId, match }]);
165
+ const res = await handleRecipesBind(api, { agentId: options.agentId, match });
897
166
  console.log(JSON.stringify(res, null, 2));
898
167
  console.error("Binding written. Restart gateway if required for changes to take effect.");
899
168
  });
@@ -909,18 +178,9 @@ const recipesPlugin = {
909
178
  .option("--guild-id <guildId>", "Discord guildId")
910
179
  .option("--team-id <teamId>", "Slack teamId")
911
180
  .option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
912
- .action(async (options: any) => {
913
- const agentId = typeof options.agentId === "string" ? String(options.agentId) : undefined;
181
+ .action(async (options: BindOptions & { agentId?: string }) => {
914
182
  const match = parseMatchFromOptions(options);
915
- if (!match?.channel) throw new Error("match.channel is required");
916
-
917
- const current = (api.runtime as any).config?.loadConfig?.();
918
- if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
919
- const cfgObj = (current.cfg ?? current) as any;
920
-
921
- const res = removeBindingsInConfig(cfgObj, { agentId, match });
922
- await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
923
-
183
+ const res = await handleRecipesUnbind(api, { agentId: typeof options.agentId === "string" ? options.agentId : undefined, match });
924
184
  console.log(JSON.stringify({ ok: true, ...res }, null, 2));
925
185
  console.error("Binding(s) removed. Restart gateway if required for changes to take effect.");
926
186
  });
@@ -929,10 +189,8 @@ const recipesPlugin = {
929
189
  .command("bindings")
930
190
  .description("Show current bindings from openclaw config")
931
191
  .action(async () => {
932
- const current = (api.runtime as any).config?.loadConfig?.();
933
- if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
934
- const cfgObj = (current.cfg ?? current) as any;
935
- console.log(JSON.stringify(cfgObj.bindings ?? [], null, 2));
192
+ const bindings = await handleRecipesBindings(api);
193
+ console.log(JSON.stringify(bindings, null, 2));
936
194
  });
937
195
 
938
196
  cmd
@@ -942,129 +200,65 @@ const recipesPlugin = {
942
200
  .option("--mode <mode>", "move|copy", "move")
943
201
  .option("--dry-run", "Print the plan without writing anything", false)
944
202
  .option("--overwrite", "Allow merging into an existing destination (dangerous)", false)
945
- .action(async (options: any) => {
946
- const teamId = String(options.teamId);
947
- if (!teamId.endsWith("-team")) throw new Error("teamId must end with -team");
948
-
949
- const mode = String(options.mode ?? "move");
950
- if (mode !== "move" && mode !== "copy") throw new Error("--mode must be move|copy");
951
-
952
- const baseWorkspace = api.config.agents?.defaults?.workspace;
953
- if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
954
-
955
- const legacyTeamDir = path.resolve(baseWorkspace, "teams", teamId);
956
- const legacyAgentsDir = path.resolve(baseWorkspace, "agents");
957
-
958
- const destTeamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
959
- const destRolesDir = path.join(destTeamDir, "roles");
960
-
961
- const exists = async (p: string) => fileExists(p);
962
-
963
- // Build migration plan
964
- const plan: any = {
965
- teamId,
966
- mode,
967
- legacy: { teamDir: legacyTeamDir, agentsDir: legacyAgentsDir },
968
- dest: { teamDir: destTeamDir, rolesDir: destRolesDir },
969
- steps: [] as any[],
970
- agentIds: [] as string[],
971
- };
972
-
973
- const legacyTeamExists = await exists(legacyTeamDir);
974
- if (!legacyTeamExists) {
975
- throw new Error(`Legacy team directory not found: ${legacyTeamDir}`);
976
- }
977
-
978
- const destExists = await exists(destTeamDir);
979
- if (destExists && !options.overwrite) {
980
- throw new Error(`Destination already exists: ${destTeamDir} (re-run with --overwrite to merge)`);
981
- }
982
-
983
- // 1) Move/copy team shared workspace
984
- plan.steps.push({ kind: "teamDir", from: legacyTeamDir, to: destTeamDir });
985
-
986
- // 2) Move/copy each role agent directory into roles/<role>/
987
- const legacyAgentsExist = await exists(legacyAgentsDir);
988
- let legacyAgentFolders: string[] = [];
989
- if (legacyAgentsExist) {
990
- legacyAgentFolders = (await fs.readdir(legacyAgentsDir)).filter((x) => x.startsWith(`${teamId}-`));
991
- }
992
-
993
- for (const folder of legacyAgentFolders) {
994
- const agentId = folder;
995
- const role = folder.slice((teamId + "-").length);
996
- const from = path.join(legacyAgentsDir, folder);
997
- const to = path.join(destRolesDir, role);
998
- plan.agentIds.push(agentId);
999
- plan.steps.push({ kind: "roleDir", agentId, role, from, to });
1000
- }
1001
-
203
+ .action(async (options: { teamId?: string; mode?: string; dryRun?: boolean; overwrite?: boolean }) => {
204
+ if (!options.teamId) throw new Error("--team-id is required");
205
+ const plan = await handleMigrateTeamPlan(api, { teamId: options.teamId, mode: options.mode, overwrite: options.overwrite });
1002
206
  const dryRun = !!options.dryRun;
1003
207
  if (dryRun) {
1004
208
  console.log(JSON.stringify({ ok: true, dryRun: true, plan }, null, 2));
1005
209
  return;
1006
210
  }
211
+ const result = await executeMigrateTeamPlan(api, plan);
212
+ console.log(JSON.stringify(result, null, 2));
213
+ });
1007
214
 
1008
- // Helpers
1009
- const copyDirRecursive = async (src: string, dst: string) => {
1010
- await ensureDir(dst);
1011
- const entries = await fs.readdir(src, { withFileTypes: true });
1012
- for (const ent of entries) {
1013
- const s = path.join(src, ent.name);
1014
- const d = path.join(dst, ent.name);
1015
- if (ent.isDirectory()) await copyDirRecursive(s, d);
1016
- else if (ent.isSymbolicLink()) {
1017
- const link = await fs.readlink(s);
1018
- await fs.symlink(link, d);
1019
- } else {
1020
- await ensureDir(path.dirname(d));
1021
- await fs.copyFile(s, d);
1022
- }
215
+ cmd
216
+ .command("cleanup-workspaces")
217
+ .description(
218
+ "List (dry-run, default) or delete (with --yes) temporary test/scaffold team workspaces under your OpenClaw home directory"
219
+ )
220
+ .option("--yes", "Actually delete eligible workspaces")
221
+ .option("--prefix <prefix>", "Allowed team id prefix (repeatable)", (v: string, acc: string[]) => [...(acc ?? []), v], [])
222
+ .option("--json", "Output JSON")
223
+ .action(async (options: { yes?: boolean; prefix?: string[]; json?: boolean }) => {
224
+ const workspaceRoot = resolveWorkspaceRoot(api);
225
+ const rootDir = path.resolve(workspaceRoot, "..");
226
+ const prefixes = Array.isArray(options.prefix) && options.prefix.length
227
+ ? options.prefix
228
+ : undefined;
229
+ const plan = await planWorkspaceCleanup({ rootDir, prefixes });
230
+ const yes = !!options.yes;
231
+ const result = await executeWorkspaceCleanup(plan, { yes });
232
+ if (options.json) {
233
+ console.log(JSON.stringify(result, null, 2));
234
+ return;
235
+ }
236
+ if (result.dryRun) {
237
+ const candidates = result.candidates;
238
+ const skipped = result.skipped;
239
+ if (candidates.length === 0 && skipped.length === 0) {
240
+ console.log("No workspace-* directories found matching cleanup criteria.");
241
+ return;
1023
242
  }
1024
- };
1025
-
1026
- const removeDirRecursive = async (p: string) => {
1027
- // node 25 supports fs.rm
1028
- await fs.rm(p, { recursive: true, force: true });
1029
- };
1030
-
1031
- const moveDir = async (src: string, dst: string) => {
1032
- await ensureDir(path.dirname(dst));
1033
- try {
1034
- await fs.rename(src, dst);
1035
- } catch {
1036
- // cross-device or existing: fallback to copy+remove
1037
- await copyDirRecursive(src, dst);
1038
- await removeDirRecursive(src);
243
+ if (candidates.length) {
244
+ console.log(`Would delete (${candidates.length}):`);
245
+ for (const c of candidates) console.log(` - ${c.dirName}`);
246
+ }
247
+ if (skipped.length) {
248
+ console.log(`Skipped (${skipped.length}):`);
249
+ for (const s of skipped) console.log(` - ${s.dirName}: ${s.reason}`);
1039
250
  }
1040
- };
1041
-
1042
- // Execute plan
1043
- if (mode === "copy") {
1044
- await copyDirRecursive(legacyTeamDir, destTeamDir);
1045
251
  } else {
1046
- await moveDir(legacyTeamDir, destTeamDir);
1047
- }
1048
-
1049
- // Ensure roles dir exists
1050
- await ensureDir(destRolesDir);
1051
-
1052
- for (const step of plan.steps.filter((s: any) => s.kind === "roleDir")) {
1053
- if (!(await exists(step.from))) continue;
1054
- if (mode === "copy") await copyDirRecursive(step.from, step.to);
1055
- else await moveDir(step.from, step.to);
1056
- }
1057
-
1058
- // Update config: set each team agent's workspace to destTeamDir (shared)
1059
- const agentSnippets: AgentConfigSnippet[] = plan.agentIds.map((agentId: string) => ({
1060
- id: agentId,
1061
- workspace: destTeamDir,
1062
- }));
1063
- if (agentSnippets.length) {
1064
- await applyAgentSnippetsToOpenClawConfig(api, agentSnippets);
252
+ if (result.deleted?.length) {
253
+ console.log(`Deleted: ${result.deleted.join(", ")}`);
254
+ }
255
+ if (result.deleteErrors?.length) {
256
+ for (const e of result.deleteErrors) {
257
+ console.error(`Error deleting ${e.path}: ${e.error}`);
258
+ }
259
+ process.exitCode = 1;
260
+ }
1065
261
  }
1066
-
1067
- console.log(JSON.stringify({ ok: true, migrated: teamId, destTeamDir, agentIds: plan.agentIds }, null, 2));
1068
262
  });
1069
263
 
1070
264
  cmd
@@ -1077,157 +271,49 @@ const recipesPlugin = {
1077
271
  .option("--global", "Install into global shared skills (~/.openclaw/skills) (default when no scope flags)")
1078
272
  .option("--agent-id <agentId>", "Install into a specific agent workspace (workspace-<agentId>)")
1079
273
  .option("--team-id <teamId>", "Install into a team workspace (workspace-<teamId>)")
1080
- .action(async (idOrSlug: string, options: any) => {
1081
- const cfg = getCfg(api);
1082
-
1083
- // Phase 1: accept skill slug directly.
1084
- // If the arg matches a recipe id and the recipe declares skill deps, we install those deps.
1085
- // (In the future we can add explicit mapping via frontmatter like skillSlug: <slug>.)
1086
- let recipe: RecipeFrontmatter | null = null;
1087
- try {
1088
- const loaded = await loadRecipeById(api, idOrSlug);
1089
- recipe = loaded.frontmatter;
1090
- } catch {
1091
- recipe = null;
1092
- }
1093
-
1094
- const baseWorkspace = api.config.agents?.defaults?.workspace;
1095
- if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
1096
-
1097
- const stateDir = path.resolve(baseWorkspace, ".."); // ~/.openclaw
1098
-
1099
- const scopeFlags = [options.global ? "global" : null, options.agentId ? "agent" : null, options.teamId ? "team" : null].filter(Boolean);
1100
- if (scopeFlags.length > 1) {
1101
- throw new Error("Use only one of: --global, --agent-id, --team-id");
1102
- }
1103
-
1104
- const agentIdOpt = typeof options.agentId === "string" ? options.agentId.trim() : "";
1105
- const teamIdOpt = typeof options.teamId === "string" ? options.teamId.trim() : "";
1106
-
1107
- // Default is global install when no scope is provided.
1108
- const scope = scopeFlags[0] ?? "global";
1109
-
1110
- let workdir: string;
1111
- let dirName: string;
1112
- let installDir: string;
1113
-
1114
- if (scope === "agent") {
1115
- if (!agentIdOpt) throw new Error("--agent-id cannot be empty");
1116
- const agentWorkspace = path.resolve(stateDir, `workspace-${agentIdOpt}`);
1117
- workdir = agentWorkspace;
1118
- dirName = cfg.workspaceSkillsDir;
1119
- installDir = path.join(agentWorkspace, dirName);
1120
- } else if (scope === "team") {
1121
- if (!teamIdOpt) throw new Error("--team-id cannot be empty");
1122
- const teamWorkspace = path.resolve(stateDir, `workspace-${teamIdOpt}`);
1123
- workdir = teamWorkspace;
1124
- dirName = cfg.workspaceSkillsDir;
1125
- installDir = path.join(teamWorkspace, dirName);
1126
- } else {
1127
- workdir = stateDir;
1128
- dirName = "skills";
1129
- installDir = path.join(stateDir, dirName);
1130
- }
1131
-
1132
- await ensureDir(installDir);
1133
-
1134
- const skillsToInstall = recipe
1135
- ? Array.from(new Set([...(recipe.requiredSkills ?? []), ...(recipe.optionalSkills ?? [])])).filter(Boolean)
1136
- : [idOrSlug];
274
+ .action(async (idOrSlug: string, options: { yes?: boolean; global?: boolean; agentId?: string; teamId?: string }) => {
275
+ const res = await handleInstallSkill(api, {
276
+ idOrSlug,
277
+ yes: options.yes,
278
+ global: options.global,
279
+ agentId: options.agentId,
280
+ teamId: options.teamId,
281
+ });
1137
282
 
1138
- if (!skillsToInstall.length) {
1139
- console.log(JSON.stringify({ ok: true, installed: [], note: "Nothing to install." }, null, 2));
283
+ if (res.ok) {
284
+ console.log(JSON.stringify(res, null, 2));
1140
285
  return;
1141
286
  }
1142
287
 
1143
- const missing = await detectMissingSkills(installDir, skillsToInstall);
1144
- const already = skillsToInstall.filter((s) => !missing.includes(s));
1145
-
1146
- if (already.length) {
1147
- console.error(`Already present in skills dir (${installDir}): ${already.join(", ")}`);
288
+ if (res.aborted === "non-interactive") {
289
+ console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
290
+ process.exitCode = 2;
291
+ return;
1148
292
  }
1149
293
 
1150
- if (!missing.length) {
1151
- console.log(JSON.stringify({ ok: true, installed: [], alreadyInstalled: already }, null, 2));
294
+ if (res.aborted === "user-declined") {
295
+ console.error("Aborted; nothing installed.");
1152
296
  return;
1153
297
  }
1154
298
 
1155
- const targetLabel = scope === "agent" ? `agent:${agentIdOpt}` : scope === "team" ? `team:${teamIdOpt}` : "global";
1156
- const header = recipe
1157
- ? `Install skills for recipe ${recipe.id} into ${installDir} (${targetLabel})?\n- ${missing.join("\n- ")}`
1158
- : `Install skill into ${installDir} (${targetLabel})?\n- ${missing.join("\n- ")}`;
1159
-
1160
- const requireConfirm = !options.yes;
1161
- if (requireConfirm) {
1162
- if (!process.stdin.isTTY) {
1163
- console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
1164
- process.exitCode = 2;
1165
- return;
1166
- }
1167
-
1168
- const readline = await import("node:readline/promises");
1169
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1170
- try {
1171
- const ans = await rl.question(`${header}\nProceed? (y/N) `);
1172
- const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
1173
- if (!ok) {
1174
- console.error("Aborted; nothing installed.");
1175
- return;
1176
- }
1177
- } finally {
1178
- rl.close();
299
+ if (res.needCli) {
300
+ console.error("\nSkill install requires the ClawHub CLI. Run the following then re-run this command:\n");
301
+ for (const cmd of res.installCommands) {
302
+ console.error(" " + cmd);
1179
303
  }
1180
- } else {
1181
- console.error(header);
1182
- }
1183
-
1184
- // Avoid spawning subprocesses from plugins (triggers OpenClaw dangerous-pattern warnings).
1185
- // For now, print the exact commands the user should run.
1186
- console.error("\nSkill install requires the ClawHub CLI. Run the following then re-run this command:\n");
1187
- for (const slug of missing) {
1188
- console.error(
1189
- ` npx clawhub@latest --workdir ${JSON.stringify(workdir)} --dir ${JSON.stringify(dirName)} install ${JSON.stringify(slug)}`,
1190
- );
304
+ process.exitCode = 2;
305
+ return;
1191
306
  }
1192
- process.exitCode = 2;
1193
- return;
1194
-
1195
- console.log(
1196
- JSON.stringify(
1197
- {
1198
- ok: true,
1199
- installed: missing,
1200
- installDir,
1201
- next: `Try: openclaw skills list (or check ${installDir})`,
1202
- },
1203
- null,
1204
- 2,
1205
- ),
1206
- );
1207
307
  });
1208
308
 
1209
- async function installMarketplaceRecipe(slug: string, options: any) {
1210
- const cfg = getCfg(api);
1211
- const baseWorkspace = api.config.agents?.defaults?.workspace;
1212
- if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
1213
-
1214
- // Avoid network calls living in this file (it also reads files), since `openclaw security audit`
1215
- // heuristics can flag "file read + network send".
1216
- const { fetchMarketplaceRecipeMarkdown } = await import("./src/marketplaceFetch");
1217
- const { md, metaUrl, sourceUrl } = await fetchMarketplaceRecipeMarkdown({
1218
- registryBase: options.registryBase,
309
+ const runInstallRecipe = async (slug: string, opts: { registryBase?: string; overwrite?: boolean }) => {
310
+ const res = await handleInstallMarketplaceRecipe(api, {
1219
311
  slug,
312
+ registryBase: opts.registryBase,
313
+ overwrite: opts.overwrite,
1220
314
  });
1221
-
1222
- const s = String(slug ?? "").trim();
1223
- const recipesDir = path.join(baseWorkspace, cfg.workspaceRecipesDir);
1224
- await ensureDir(recipesDir);
1225
- const destPath = path.join(recipesDir, `${s}.md`);
1226
-
1227
- await writeFileSafely(destPath, md, options.overwrite ? "overwrite" : "createOnly");
1228
-
1229
- console.log(JSON.stringify({ ok: true, slug: s, wrote: destPath, sourceUrl, metaUrl }, null, 2));
1230
- }
315
+ console.log(JSON.stringify(res, null, 2));
316
+ };
1231
317
 
1232
318
  cmd
1233
319
  .command("install")
@@ -1235,7 +321,9 @@ const recipesPlugin = {
1235
321
  .argument("<idOrSlug>", "Marketplace recipe slug (e.g. development-team)")
1236
322
  .option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
1237
323
  .option("--overwrite", "Overwrite existing recipe file")
1238
- .action(async (slug: string, options: any) => installMarketplaceRecipe(slug, options));
324
+ .action((slug: string, options: { registryBase?: string; overwrite?: boolean }) =>
325
+ runInstallRecipe(slug, options)
326
+ );
1239
327
 
1240
328
  cmd
1241
329
  .command("install-recipe")
@@ -1243,7 +331,9 @@ const recipesPlugin = {
1243
331
  .argument("<slug>", "Marketplace recipe slug (e.g. development-team)")
1244
332
  .option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
1245
333
  .option("--overwrite", "Overwrite existing recipe file")
1246
- .action(async (slug: string, options: any) => installMarketplaceRecipe(slug, options));
334
+ .action((slug: string, options: { registryBase?: string; overwrite?: boolean }) =>
335
+ runInstallRecipe(slug, options)
336
+ );
1247
337
 
1248
338
  cmd
1249
339
  .command("dispatch")
@@ -1252,57 +342,8 @@ const recipesPlugin = {
1252
342
  .option("--request <text>", "Natural-language request (if omitted, will prompt in TTY)")
1253
343
  .option("--owner <owner>", "Ticket owner: dev|devops|lead|test", "dev")
1254
344
  .option("--yes", "Skip review and write files without prompting")
1255
- .action(async (options: any) => {
1256
- const cfg = getCfg(api);
1257
-
1258
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1259
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1260
-
1261
- const teamId = String(options.teamId);
1262
- // Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
1263
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1264
-
1265
- const inboxDir = path.join(teamDir, "inbox");
1266
- const backlogDir = path.join(teamDir, "work", "backlog");
1267
- const assignmentsDir = path.join(teamDir, "work", "assignments");
1268
-
1269
- const owner = String(options.owner ?? "dev");
1270
- if (!['dev','devops','lead','test'].includes(owner)) {
1271
- throw new Error("--owner must be one of: dev, devops, lead, test");
1272
- }
1273
-
1274
- const slugify = (s: string) =>
1275
- s
1276
- .toLowerCase()
1277
- .replace(/[^a-z0-9]+/g, "-")
1278
- .replace(/(^-|-$)/g, "")
1279
- .slice(0, 60) || "request";
1280
-
1281
- const nowKey = () => {
1282
- const d = new Date();
1283
- const pad = (n: number) => String(n).padStart(2, "0");
1284
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
1285
- };
1286
-
1287
- const nextTicketNumber = async () => {
1288
- const dirs = [
1289
- backlogDir,
1290
- path.join(teamDir, "work", "in-progress"),
1291
- path.join(teamDir, "work", "testing"),
1292
- path.join(teamDir, "work", "done"),
1293
- ];
1294
- let max = 0;
1295
- for (const dir of dirs) {
1296
- if (!(await fileExists(dir))) continue;
1297
- const files = await fs.readdir(dir);
1298
- for (const f of files) {
1299
- const m = f.match(/^(\d{4})-/);
1300
- if (m) max = Math.max(max, Number(m[1]));
1301
- }
1302
- }
1303
- return max + 1;
1304
- };
1305
-
345
+ .action(async (options: { teamId?: string; request?: string; owner?: string; yes?: boolean }) => {
346
+ if (!options.teamId) throw new Error("--team-id is required");
1306
347
  let requestText = typeof options.request === "string" ? options.request.trim() : "";
1307
348
  if (!requestText) {
1308
349
  if (!process.stdin.isTTY) {
@@ -1316,95 +357,39 @@ const recipesPlugin = {
1316
357
  rl.close();
1317
358
  }
1318
359
  }
1319
- if (!requestText) throw new Error("Request cannot be empty");
1320
-
1321
- // Minimal heuristic: one ticket per request.
1322
- const ticketNum = await nextTicketNumber();
1323
- const ticketNumStr = String(ticketNum).padStart(4, '0');
1324
- const title = requestText.length > 80 ? requestText.slice(0, 77) + "…" : requestText;
1325
- const baseSlug = slugify(title);
1326
-
1327
- const inboxPath = path.join(inboxDir, `${nowKey()}-${baseSlug}.md`);
1328
- const ticketPath = path.join(backlogDir, `${ticketNumStr}-${baseSlug}.md`);
1329
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
1330
-
1331
- const receivedIso = new Date().toISOString();
1332
-
1333
- const inboxMd = `# Inbox — ${teamId}\n\nReceived: ${receivedIso}\n\n## Request\n${requestText}\n\n## Proposed work\n- Ticket: ${ticketNumStr}-${baseSlug}\n- Owner: ${owner}\n\n## Links\n- Ticket: ${path.relative(teamDir, ticketPath)}\n- Assignment: ${path.relative(teamDir, assignmentPath)}\n`;
1334
-
1335
- const ticketMd = `# ${ticketNumStr}-${baseSlug}\n\nCreated: ${receivedIso}\nOwner: ${owner}\nStatus: queued\nInbox: ${path.relative(teamDir, inboxPath)}\nAssignment: ${path.relative(teamDir, assignmentPath)}\n\n## Context\n${requestText}\n\n## Requirements\n- (fill in)\n\n## Acceptance criteria\n- (fill in)\n\n## Tasks\n- [ ] (fill in)\n\n## Comments\n- (use this section for @mentions, questions, decisions, and dated replies)\n`;
1336
-
1337
- const assignmentMd = `# Assignment — ${ticketNumStr}-${baseSlug}\n\nCreated: ${receivedIso}\nAssigned: ${owner}\n\n## Goal\n${title}\n\n## Ticket\n${path.relative(teamDir, ticketPath)}\n\n## Notes\n- Created by: openclaw recipes dispatch\n`;
1338
-
1339
- const plan = {
1340
- teamId,
1341
- request: requestText,
1342
- files: [
1343
- { path: inboxPath, kind: "inbox", summary: title },
1344
- { path: ticketPath, kind: "backlog-ticket", summary: title },
1345
- { path: assignmentPath, kind: "assignment", summary: owner },
1346
- ],
1347
- };
1348
-
1349
- const doWrite = async () => {
1350
- await ensureDir(inboxDir);
1351
- await ensureDir(backlogDir);
1352
- await ensureDir(assignmentsDir);
1353
-
1354
- // createOnly to avoid accidental overwrite
1355
- await writeFileSafely(inboxPath, inboxMd, "createOnly");
1356
- await writeFileSafely(ticketPath, ticketMd, "createOnly");
1357
- await writeFileSafely(assignmentPath, assignmentMd, "createOnly");
1358
-
1359
- // Best-effort nudge: enqueue a system event for the team lead session.
1360
- // This does not spawn the lead; it ensures that when the lead session runs next,
1361
- // it sees the dispatch immediately.
1362
- try {
1363
- const leadAgentId = `${teamId}-lead`;
1364
- api.runtime.system.enqueueSystemEvent(
1365
- [
1366
- `Dispatch created new intake for team: ${teamId}`,
1367
- `- Inbox: ${path.relative(teamDir, inboxPath)}`,
1368
- `- Backlog: ${path.relative(teamDir, ticketPath)}`,
1369
- `- Assignment: ${path.relative(teamDir, assignmentPath)}`,
1370
- `Action: please triage/normalize the ticket (fill Requirements/AC/tasks) and move it through the workflow.`,
1371
- ].join("\n"),
1372
- { sessionKey: `agent:${leadAgentId}:main` },
1373
- );
1374
- } catch {
1375
- // ignore: dispatch should still succeed even if system event enqueue fails
1376
- }
1377
- };
1378
-
1379
- if (options.yes) {
1380
- await doWrite();
1381
- console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
1382
- return;
1383
- }
1384
-
1385
- if (!process.stdin.isTTY) {
1386
- console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
1387
- process.exitCode = 2;
1388
- console.log(JSON.stringify({ ok: false, plan }, null, 2));
1389
- return;
1390
- }
1391
-
1392
- console.log(JSON.stringify({ plan }, null, 2));
1393
- const readline = await import("node:readline/promises");
1394
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1395
- try {
1396
- const ans = await rl.question("Write these files? (y/N) ");
1397
- const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
1398
- if (!ok) {
360
+ const dry = await handleDispatch(api, {
361
+ teamId: options.teamId,
362
+ requestText,
363
+ owner: options.owner,
364
+ dryRun: true,
365
+ });
366
+ const plan = (dry as { plan: unknown }).plan;
367
+ const ok = await promptConfirmWithPlan(plan, "Write these files? (y/N)", { yes: options.yes });
368
+ if (!ok) {
369
+ if (!options.yes && !process.stdin.isTTY) {
370
+ process.exitCode = 2;
371
+ console.error("Refusing to prompt (non-interactive). Re-run with --yes.");
372
+ } else {
1399
373
  console.error("Aborted; no files written.");
1400
- return;
1401
374
  }
1402
- } finally {
1403
- rl.close();
375
+ return;
1404
376
  }
1405
-
1406
- await doWrite();
1407
- console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
377
+ const res = await handleDispatch(api, {
378
+ teamId: options.teamId,
379
+ requestText,
380
+ owner: options.owner,
381
+ });
382
+ if (res.nudgeQueued) {
383
+ console.error(`[dispatch] Nudge queued: system event → agent:${options.teamId}-lead:main`);
384
+ } else {
385
+ console.error(`[dispatch] NOTE: Could not auto-nudge ${options.teamId}-lead (best-effort). Next steps:`);
386
+ console.error(`- Option A (recommended): ensure the lead triage cron job is installed/enabled (lead-triage-loop).`);
387
+ console.error(` - If you declined cron installation during scaffold, re-run scaffold with cron installation enabled, or enable it in settings.`);
388
+ console.error(`- Option B: manually run/open the lead once so it sees inbox/backlog updates.`);
389
+ console.error(`- Option C (advanced): allow subagent messaging (if you want direct pings). Add allowAgents in config and restart gateway.`);
390
+ console.error(` { agents: { list: [ { id: "main", subagents: { allowAgents: ["${options.teamId}-lead"] } } ] } }`);
391
+ }
392
+ console.log(JSON.stringify({ ok: true, wrote: res.wrote }, null, 2));
1408
393
  });
1409
394
 
1410
395
  cmd
@@ -1415,70 +400,24 @@ const recipesPlugin = {
1415
400
  .option("--json", "Output JSON")
1416
401
  .option("--yes", "Skip confirmation (apply destructive changes)")
1417
402
  .option("--include-ambiguous", "Also remove cron jobs that only loosely match the team (dangerous)")
1418
- .action(async (options: any) => {
1419
- const teamId = String(options.teamId);
1420
-
1421
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1422
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1423
-
1424
- const cronJobsPath = path.resolve(workspaceRoot, "..", "cron", "jobs.json");
1425
-
1426
- const current = (api.runtime as any).config?.loadConfig?.();
1427
- if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
1428
- const cfgObj = (current.cfg ?? current) as any;
1429
-
1430
- const cronStore = await loadCronStore(cronJobsPath);
1431
-
1432
- const plan = await buildRemoveTeamPlan({
1433
- teamId,
1434
- workspaceRoot,
1435
- openclawConfigPath: "(managed by api.runtime.config)",
1436
- cronJobsPath,
1437
- cfgObj,
1438
- cronStore,
403
+ .action(async (options: { teamId?: string; plan?: boolean; yes?: boolean; includeAmbiguous?: boolean }) => {
404
+ if (!options.teamId) throw new Error("--team-id is required");
405
+ const out = await handleRemoveTeam(api, {
406
+ teamId: options.teamId,
407
+ plan: options.plan,
408
+ yes: options.yes,
409
+ includeAmbiguous: options.includeAmbiguous,
1439
410
  });
1440
-
1441
- const wantsJson = Boolean(options.json);
1442
-
1443
- if (options.plan) {
1444
- console.log(JSON.stringify({ ok: true, plan }, null, 2));
1445
- return;
1446
- }
1447
-
1448
- if (!options.yes && !process.stdin.isTTY) {
411
+ if (out.ok === false && out.aborted === "non-interactive") {
1449
412
  console.error("Refusing to prompt (non-interactive). Re-run with --yes or --plan.");
1450
413
  process.exitCode = 2;
1451
- console.log(JSON.stringify({ ok: false, plan }, null, 2));
1452
- return;
1453
414
  }
1454
-
1455
- if (!options.yes && process.stdin.isTTY) {
1456
- console.log(JSON.stringify({ plan }, null, 2));
1457
- const ok = await promptYesNo(
1458
- `This will DELETE workspace-${teamId}, remove matching agents from openclaw config, and remove stamped cron jobs.`,
1459
- );
1460
- if (!ok) {
1461
- console.error("Aborted; no changes made.");
1462
- return;
1463
- }
415
+ if (out.ok === false && out.aborted === "user-declined") {
416
+ console.error("Aborted; no changes made.");
1464
417
  }
1465
-
1466
- const includeAmbiguous = Boolean(options.includeAmbiguous);
1467
-
1468
- const result = await executeRemoveTeamPlan({
1469
- plan,
1470
- includeAmbiguous,
1471
- cfgObj,
1472
- cronStore,
1473
- });
1474
-
1475
- await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
1476
- await saveCronStore(cronJobsPath, cronStore);
1477
-
1478
- if (wantsJson) {
1479
- console.log(JSON.stringify(result, null, 2));
1480
- } else {
1481
- console.log(JSON.stringify(result, null, 2));
418
+ const payload = "result" in out ? out.result : out;
419
+ console.log(JSON.stringify(payload, null, 2));
420
+ if (out.ok && "result" in out) {
1482
421
  console.error("Restart required: openclaw gateway restart");
1483
422
  }
1484
423
  });
@@ -1488,151 +427,24 @@ const recipesPlugin = {
1488
427
  .description("List tickets for a team (backlog / in-progress / testing / done)")
1489
428
  .requiredOption("--team-id <teamId>", "Team id")
1490
429
  .option("--json", "Output JSON")
1491
- .action(async (options: any) => {
1492
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1493
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1494
- const teamId = String(options.teamId);
1495
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1496
-
1497
- await ensureTicketStageDirs(teamDir);
1498
-
1499
- const dirs = {
1500
- backlog: path.join(teamDir, "work", "backlog"),
1501
- inProgress: path.join(teamDir, "work", "in-progress"),
1502
- testing: path.join(teamDir, "work", "testing"),
1503
- done: path.join(teamDir, "work", "done"),
1504
- } as const;
1505
-
1506
- const readTickets = async (dir: string, stage: "backlog" | "in-progress" | "testing" | "done") => {
1507
- if (!(await fileExists(dir))) return [] as any[];
1508
- const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
1509
- return files.map((f) => {
1510
- const m = f.match(/^(\d{4})-(.+)\.md$/);
1511
- return {
1512
- stage,
1513
- number: m ? Number(m[1]) : null,
1514
- id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
1515
- file: path.join(dir, f),
1516
- };
1517
- });
1518
- };
1519
-
1520
- const backlog = await readTickets(dirs.backlog, "backlog");
1521
- const inProgress = await readTickets(dirs.inProgress, "in-progress");
1522
- const testing = await readTickets(dirs.testing, "testing");
1523
- const done = await readTickets(dirs.done, "done");
1524
-
1525
- const out = {
1526
- teamId,
1527
- // Stable, machine-friendly list for consumers (watchers, dashboards)
1528
- // Keep the per-lane arrays for backwards-compat.
1529
- tickets: [...backlog, ...inProgress, ...testing, ...done],
1530
- backlog,
1531
- inProgress,
1532
- testing,
1533
- done,
1534
- };
1535
-
430
+ .action(async (options: { teamId?: string; json?: boolean }) => {
431
+ if (!options.teamId) throw new Error("--team-id is required");
432
+ const out = await handleTickets(api, { teamId: options.teamId });
1536
433
  if (options.json) {
1537
434
  console.log(JSON.stringify(out, null, 2));
1538
435
  return;
1539
436
  }
1540
-
1541
- const print = (label: string, items: any[]) => {
437
+ const print = (label: string, items: Array<{ id: string }>) => {
1542
438
  console.log(`\n${label} (${items.length})`);
1543
439
  for (const t of items) console.log(`- ${t.id}`);
1544
440
  };
1545
- console.log(`Team: ${teamId}`);
441
+ console.log(`Team: ${out.teamId}`);
1546
442
  print("Backlog", out.backlog);
1547
443
  print("In progress", out.inProgress);
1548
444
  print("Testing", out.testing);
1549
445
  print("Done", out.done);
1550
446
  });
1551
447
 
1552
- async function moveTicketCore(options: any) {
1553
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1554
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1555
- const teamId = String(options.teamId);
1556
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1557
-
1558
- await ensureTicketStageDirs(teamDir);
1559
-
1560
- const dest = String(options.to);
1561
- if (!["backlog", "in-progress", "testing", "done"].includes(dest)) {
1562
- throw new Error("--to must be one of: backlog, in-progress, testing, done");
1563
- }
1564
-
1565
- const ticketArgRaw = String(options.ticket);
1566
- const ticketArg = ticketArgRaw.match(/^\d+$/) && ticketArgRaw.length < 4 ? ticketArgRaw.padStart(4, "0") : ticketArgRaw;
1567
- const ticketNum = ticketArg.match(/^\d{4}$/)
1568
- ? ticketArg
1569
- : ticketArg.match(/^(\d{4})-/)?.[1] ?? null;
1570
-
1571
- const stageDir = (stage: string) => {
1572
- if (stage === "backlog") return path.join(teamDir, "work", "backlog");
1573
- if (stage === "in-progress") return path.join(teamDir, "work", "in-progress");
1574
- if (stage === "testing") return path.join(teamDir, "work", "testing");
1575
- if (stage === "done") return path.join(teamDir, "work", "done");
1576
- throw new Error(`Unknown stage: ${stage}`);
1577
- };
1578
-
1579
- const searchDirs = [stageDir("backlog"), stageDir("in-progress"), stageDir("testing"), stageDir("done")];
1580
-
1581
- const findTicketFile = async () => {
1582
- for (const dir of searchDirs) {
1583
- if (!(await fileExists(dir))) continue;
1584
- const files = await fs.readdir(dir);
1585
- for (const f of files) {
1586
- if (!f.endsWith(".md")) continue;
1587
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1588
- if (!ticketNum && f.replace(/\.md$/, "") === ticketArg) return path.join(dir, f);
1589
- }
1590
- }
1591
- return null;
1592
- };
1593
-
1594
- const srcPath = await findTicketFile();
1595
- if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1596
-
1597
- const destDir = stageDir(dest);
1598
- await ensureDir(destDir);
1599
- const filename = path.basename(srcPath);
1600
- const destPath = path.join(destDir, filename);
1601
-
1602
- const patchStatus = (md: string) => {
1603
- const nextStatus =
1604
- dest === "backlog"
1605
- ? "queued"
1606
- : dest === "in-progress"
1607
- ? "in-progress"
1608
- : dest === "testing"
1609
- ? "testing"
1610
- : "done";
1611
-
1612
- let out = md;
1613
- if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
1614
- else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
1615
-
1616
- if (dest === "done" && options.completed) {
1617
- const completed = new Date().toISOString();
1618
- if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
1619
- else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
1620
- }
1621
-
1622
- return out;
1623
- };
1624
-
1625
- const md = await fs.readFile(srcPath, "utf8");
1626
- const patched = patchStatus(md);
1627
- await fs.writeFile(srcPath, patched, "utf8");
1628
-
1629
- if (srcPath !== destPath) {
1630
- await fs.rename(srcPath, destPath);
1631
- }
1632
-
1633
- return { ok: true, from: srcPath, to: destPath };
1634
- }
1635
-
1636
448
  cmd
1637
449
  .command("move-ticket")
1638
450
  .description("Move a ticket between backlog/in-progress/testing/done (updates Status: line)")
@@ -1641,110 +453,36 @@ const recipesPlugin = {
1641
453
  .requiredOption("--to <stage>", "Destination stage: backlog|in-progress|testing|done")
1642
454
  .option("--completed", "When moving to done, add Completed: timestamp")
1643
455
  .option("--yes", "Skip confirmation")
1644
- .action(async (options: any) => {
1645
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1646
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1647
- const teamId = String(options.teamId);
1648
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1649
-
1650
- await ensureTicketStageDirs(teamDir);
1651
-
1652
- const dest = String(options.to);
1653
- if (!['backlog','in-progress','testing','done'].includes(dest)) {
1654
- throw new Error("--to must be one of: backlog, in-progress, testing, done");
1655
- }
1656
-
1657
- const ticketArg = String(options.ticket);
1658
- const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1659
-
1660
- const stageDir = (stage: string) => {
1661
- if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1662
- if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1663
- if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1664
- if (stage === 'done') return path.join(teamDir, 'work', 'done');
1665
- throw new Error(`Unknown stage: ${stage}`);
1666
- };
1667
-
1668
- const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
1669
-
1670
- const findTicketFile = async () => {
1671
- for (const dir of searchDirs) {
1672
- if (!(await fileExists(dir))) continue;
1673
- const files = await fs.readdir(dir);
1674
- for (const f of files) {
1675
- if (!f.endsWith('.md')) continue;
1676
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1677
- if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1678
- }
1679
- }
1680
- return null;
1681
- };
1682
-
1683
- const srcPath = await findTicketFile();
1684
- if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1685
-
1686
- const destDir = stageDir(dest);
1687
- await ensureDir(destDir);
1688
- const filename = path.basename(srcPath);
1689
- const destPath = path.join(destDir, filename);
1690
-
1691
- const patchStatus = (md: string) => {
1692
- const nextStatus =
1693
- dest === 'backlog'
1694
- ? 'queued'
1695
- : dest === 'in-progress'
1696
- ? 'in-progress'
1697
- : dest === 'testing'
1698
- ? 'testing'
1699
- : 'done';
1700
-
1701
- let out = md;
1702
- if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
1703
- else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
1704
-
1705
- if (dest === 'done' && options.completed) {
1706
- const completed = new Date().toISOString();
1707
- if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
1708
- else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
1709
- }
1710
-
1711
- return out;
1712
- };
1713
-
1714
- const plan = { from: srcPath, to: destPath };
1715
-
1716
- if (!options.yes && process.stdin.isTTY) {
1717
- console.log(JSON.stringify({ plan }, null, 2));
1718
- const readline = await import('node:readline/promises');
1719
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1720
- try {
1721
- const ans = await rl.question(`Move ticket to ${dest}? (y/N) `);
1722
- const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1723
- if (!ok) {
1724
- console.error('Aborted; no changes made.');
1725
- return;
1726
- }
1727
- } finally {
1728
- rl.close();
456
+ .action(async (options: { teamId?: string; ticket?: string; to?: string; completed?: boolean; yes?: boolean }) => {
457
+ if (!options.teamId || !options.ticket || !options.to) throw new Error("--team-id, --ticket, and --to are required");
458
+ const dry = await handleMoveTicket(api, {
459
+ teamId: options.teamId,
460
+ ticket: options.ticket,
461
+ to: options.to,
462
+ completed: options.completed,
463
+ dryRun: true,
464
+ });
465
+ const plan = (dry as { plan: { from: string; to: string } }).plan;
466
+ const ok = await promptConfirmWithPlan(plan, `Move ticket to ${options.to}? (y/N)`, { yes: options.yes });
467
+ if (!ok) {
468
+ if (!options.yes && !process.stdin.isTTY) {
469
+ process.exitCode = 2;
470
+ console.error("Refusing to move without confirmation in non-interactive mode. Re-run with --yes.");
471
+ } else {
472
+ console.error("Aborted; no changes made.");
1729
473
  }
1730
- } else if (!options.yes && !process.stdin.isTTY) {
1731
- console.error('Refusing to move without confirmation in non-interactive mode. Re-run with --yes.');
1732
- process.exitCode = 2;
1733
- console.log(JSON.stringify({ ok: false, plan }, null, 2));
1734
474
  return;
1735
475
  }
1736
-
1737
- const md = await fs.readFile(srcPath, 'utf8');
1738
- const nextMd = patchStatus(md);
1739
- await fs.writeFile(srcPath, nextMd, 'utf8');
1740
-
1741
- if (srcPath !== destPath) {
1742
- await fs.rename(srcPath, destPath);
1743
- }
1744
-
1745
- console.log(JSON.stringify({ ok: true, moved: plan }, null, 2));
476
+ const res = await handleMoveTicket(api, {
477
+ teamId: options.teamId,
478
+ ticket: options.ticket,
479
+ to: options.to,
480
+ completed: options.completed,
481
+ });
482
+ console.log(JSON.stringify({ ok: true, moved: { from: res.from, to: res.to } }, null, 2));
1746
483
  });
1747
484
 
485
+
1748
486
  cmd
1749
487
  .command("assign")
1750
488
  .description("Assign a ticket to an owner (writes assignment stub + updates Owner: in ticket)")
@@ -1753,92 +491,32 @@ const recipesPlugin = {
1753
491
  .requiredOption("--owner <owner>", "Owner: dev|devops|lead|test")
1754
492
  .option("--overwrite", "Overwrite existing assignment file")
1755
493
  .option("--yes", "Skip confirmation")
1756
- .action(async (options: any) => {
1757
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1758
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1759
- const teamId = String(options.teamId);
1760
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1761
-
1762
- await ensureTicketStageDirs(teamDir);
1763
-
1764
- const owner = String(options.owner);
1765
- if (!['dev','devops','lead','test'].includes(owner)) {
1766
- throw new Error("--owner must be one of: dev, devops, lead, test");
1767
- }
1768
-
1769
- const stageDir = (stage: string) => {
1770
- if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1771
- if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1772
- if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1773
- if (stage === 'done') return path.join(teamDir, 'work', 'done');
1774
- throw new Error(`Unknown stage: ${stage}`);
1775
- };
1776
- const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
1777
-
1778
- const ticketArg = String(options.ticket);
1779
- const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1780
-
1781
- const findTicketFile = async () => {
1782
- for (const dir of searchDirs) {
1783
- if (!(await fileExists(dir))) continue;
1784
- const files = await fs.readdir(dir);
1785
- for (const f of files) {
1786
- if (!f.endsWith('.md')) continue;
1787
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1788
- if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1789
- }
1790
- }
1791
- return null;
1792
- };
1793
-
1794
- const ticketPath = await findTicketFile();
1795
- if (!ticketPath) throw new Error(`Ticket not found: ${ticketArg}`);
1796
-
1797
- const filename = path.basename(ticketPath);
1798
- const m = filename.match(/^(\d{4})-(.+)\.md$/);
1799
- const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1800
- const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1801
-
1802
- const assignmentsDir = path.join(teamDir, 'work', 'assignments');
1803
- await ensureDir(assignmentsDir);
1804
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
1805
-
1806
- const patchOwner = (md: string) => {
1807
- if (md.match(/^Owner:\s.*$/m)) return md.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
1808
- return md.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
1809
- };
1810
-
1811
- const plan = { ticketPath, assignmentPath, owner };
1812
-
1813
- if (!options.yes && process.stdin.isTTY) {
1814
- console.log(JSON.stringify({ plan }, null, 2));
1815
- const readline = await import('node:readline/promises');
1816
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1817
- try {
1818
- const ans = await rl.question(`Assign ticket to ${owner}? (y/N) `);
1819
- const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1820
- if (!ok) {
1821
- console.error('Aborted; no changes made.');
1822
- return;
1823
- }
1824
- } finally {
1825
- rl.close();
494
+ .action(async (options: { teamId?: string; ticket?: string; owner?: string; overwrite?: boolean; yes?: boolean }) => {
495
+ if (!options.teamId || !options.ticket || !options.owner) throw new Error("--team-id, --ticket, and --owner are required");
496
+ const { plan } = await handleAssign(api, {
497
+ teamId: options.teamId,
498
+ ticket: options.ticket,
499
+ owner: options.owner,
500
+ overwrite: options.overwrite,
501
+ dryRun: true,
502
+ });
503
+ const ok = await promptConfirmWithPlan(plan, `Assign ticket to ${options.owner}? (y/N)`, { yes: options.yes });
504
+ if (!ok) {
505
+ if (!options.yes && !process.stdin.isTTY) {
506
+ process.exitCode = 2;
507
+ console.error("Refusing to assign without confirmation in non-interactive mode. Re-run with --yes.");
508
+ } else {
509
+ console.error("Aborted; no changes made.");
1826
510
  }
1827
- } else if (!options.yes && !process.stdin.isTTY) {
1828
- console.error('Refusing to assign without confirmation in non-interactive mode. Re-run with --yes.');
1829
- process.exitCode = 2;
1830
- console.log(JSON.stringify({ ok: false, plan }, null, 2));
1831
511
  return;
1832
512
  }
1833
-
1834
- const md = await fs.readFile(ticketPath, 'utf8');
1835
- const nextMd = patchOwner(md);
1836
- await fs.writeFile(ticketPath, nextMd, 'utf8');
1837
-
1838
- const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${owner}\n\n## Ticket\n${path.relative(teamDir, ticketPath)}\n\n## Notes\n- Created by: openclaw recipes assign\n`;
1839
- await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? 'overwrite' : 'createOnly');
1840
-
1841
- console.log(JSON.stringify({ ok: true, plan }, null, 2));
513
+ const res = await handleAssign(api, {
514
+ teamId: options.teamId,
515
+ ticket: options.ticket,
516
+ owner: options.owner,
517
+ overwrite: options.overwrite,
518
+ });
519
+ console.log(JSON.stringify(res, null, 2));
1842
520
  });
1843
521
 
1844
522
  cmd
@@ -1848,106 +526,43 @@ const recipesPlugin = {
1848
526
  .requiredOption("--ticket <ticket>", "Ticket id or number")
1849
527
  .option("--owner <owner>", "Owner: dev|devops|lead|test", "dev")
1850
528
  .option("--yes", "Skip confirmation")
1851
- .action(async (options: any) => {
1852
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1853
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1854
- const teamId = String(options.teamId);
1855
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1856
-
1857
- await ensureTicketStageDirs(teamDir);
1858
-
1859
- const owner = String(options.owner ?? 'dev');
1860
- if (!['dev','devops','lead','test'].includes(owner)) {
1861
- throw new Error("--owner must be one of: dev, devops, lead, test");
1862
- }
1863
-
1864
- const stageDir = (stage: string) => {
1865
- if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1866
- if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1867
- if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1868
- if (stage === 'done') return path.join(teamDir, 'work', 'done');
1869
- throw new Error(`Unknown stage: ${stage}`);
1870
- };
1871
- const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
1872
-
1873
- const ticketArg = String(options.ticket);
1874
- const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1875
-
1876
- const findTicketFile = async () => {
1877
- for (const dir of searchDirs) {
1878
- if (!(await fileExists(dir))) continue;
1879
- const files = await fs.readdir(dir);
1880
- for (const f of files) {
1881
- if (!f.endsWith('.md')) continue;
1882
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1883
- if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1884
- }
1885
- }
1886
- return null;
1887
- };
1888
-
1889
- const srcPath = await findTicketFile();
1890
- if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1891
-
1892
- const destDir = stageDir('in-progress');
1893
- await ensureDir(destDir);
1894
- const filename = path.basename(srcPath);
1895
- const destPath = path.join(destDir, filename);
1896
-
1897
- const patch = (md: string) => {
1898
- let out = md;
1899
- if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
1900
- else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
1901
-
1902
- if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: in-progress`);
1903
- else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
1904
-
1905
- return out;
1906
- };
1907
-
1908
- const plan = { from: srcPath, to: destPath, owner };
1909
-
1910
- if (!options.yes && process.stdin.isTTY) {
1911
- console.log(JSON.stringify({ plan }, null, 2));
1912
- const readline = await import('node:readline/promises');
1913
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1914
- try {
1915
- const ans = await rl.question(`Assign to ${owner} and move to in-progress? (y/N) `);
1916
- const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1917
- if (!ok) {
1918
- console.error('Aborted; no changes made.');
1919
- return;
1920
- }
1921
- } finally {
1922
- rl.close();
529
+ .action(async (options: { teamId?: string; ticket?: string; owner?: string; overwrite?: boolean; yes?: boolean }) => {
530
+ if (!options.teamId || !options.ticket) throw new Error("--team-id and --ticket are required");
531
+ const dry = await handleTake(api, {
532
+ teamId: options.teamId,
533
+ ticket: options.ticket,
534
+ owner: options.owner,
535
+ overwrite: options.overwrite,
536
+ dryRun: true,
537
+ });
538
+ const plan = (dry as { plan: { from: string; to: string; owner: string } }).plan;
539
+ const ok = await promptConfirmWithPlan(plan, `Assign to ${plan.owner} and move to in-progress? (y/N)`, { yes: options.yes });
540
+ if (!ok) {
541
+ if (!options.yes && !process.stdin.isTTY) {
542
+ process.exitCode = 2;
543
+ console.error("Refusing to take without confirmation in non-interactive mode. Re-run with --yes.");
544
+ } else {
545
+ console.error("Aborted; no changes made.");
1923
546
  }
1924
- } else if (!options.yes && !process.stdin.isTTY) {
1925
- console.error('Refusing to take without confirmation in non-interactive mode. Re-run with --yes.');
1926
- process.exitCode = 2;
1927
- console.log(JSON.stringify({ ok: false, plan }, null, 2));
1928
547
  return;
1929
548
  }
1930
-
1931
- const md = await fs.readFile(srcPath, 'utf8');
1932
- const nextMd = patch(md);
1933
- await fs.writeFile(srcPath, nextMd, 'utf8');
1934
-
1935
- if (srcPath !== destPath) {
1936
- await fs.rename(srcPath, destPath);
1937
- }
1938
-
1939
- const m = filename.match(/^(\d{4})-(.+)\.md$/);
1940
- const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1941
- const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1942
- const assignmentsDir = path.join(teamDir, 'work', 'assignments');
1943
- await ensureDir(assignmentsDir);
1944
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
1945
- const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${owner}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes take\n`;
1946
- await writeFileSafely(assignmentPath, assignmentMd, 'createOnly');
1947
-
1948
- console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
549
+ const res = await handleTake(api, {
550
+ teamId: options.teamId,
551
+ ticket: options.ticket,
552
+ owner: options.owner,
553
+ overwrite: options.overwrite,
554
+ });
555
+ if (!("srcPath" in res)) throw new Error("Unexpected take result");
556
+ console.log(
557
+ JSON.stringify(
558
+ { ok: true, plan: { from: res.srcPath, to: res.destPath, owner: options.owner ?? "dev" }, assignmentPath: res.assignmentPath },
559
+ null,
560
+ 2
561
+ )
562
+ );
1949
563
  });
1950
564
 
565
+
1951
566
  cmd
1952
567
  .command("handoff")
1953
568
  .description("QA handoff: move ticket to testing + assign to tester")
@@ -1956,282 +571,100 @@ const recipesPlugin = {
1956
571
  .option("--tester <owner>", "Tester owner (default: test)", "test")
1957
572
  .option("--overwrite", "Overwrite destination ticket file / assignment stub if they already exist")
1958
573
  .option("--yes", "Skip confirmation")
1959
- .action(async (options: any) => {
1960
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1961
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1962
- const teamId = String(options.teamId);
1963
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1964
-
1965
- const tester = String(options.tester ?? "test");
1966
- if (!['dev','devops','lead','test'].includes(tester)) {
1967
- throw new Error("--tester must be one of: dev, devops, lead, test");
1968
- }
1969
-
1970
- const stageDir = (stage: string) => {
1971
- if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1972
- if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1973
- throw new Error(`Unknown stage: ${stage}`);
1974
- };
1975
-
1976
- const ticketArg = String(options.ticket);
1977
- const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1978
-
1979
- const findTicketFile = async (dir: string) => {
1980
- if (!(await fileExists(dir))) return null;
1981
- const files = await fs.readdir(dir);
1982
- for (const f of files) {
1983
- if (!f.endsWith('.md')) continue;
1984
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1985
- if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1986
- }
1987
- return null;
1988
- };
1989
-
1990
- const inProgressDir = stageDir('in-progress');
1991
- const testingDir = stageDir('testing');
1992
- await ensureDir(testingDir);
1993
-
1994
- const srcInProgress = await findTicketFile(inProgressDir);
1995
- const srcTesting = await findTicketFile(testingDir);
1996
-
1997
- if (!srcInProgress && !srcTesting) {
1998
- throw new Error(`Ticket not found in in-progress/testing: ${ticketArg}`);
1999
- }
2000
- if (!srcInProgress && srcTesting) {
2001
- // already in testing (idempotent path)
2002
- }
2003
-
2004
- const srcPath = srcInProgress ?? srcTesting!;
2005
- const filename = path.basename(srcPath);
2006
- const destPath = path.join(testingDir, filename);
2007
-
2008
- const patch = (md: string) => {
2009
- let out = md;
2010
- if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${tester}`);
2011
- else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${tester}\n`);
2012
-
2013
- if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: testing`);
2014
- else out = out.replace(/^(# .+\n)/, `$1\nStatus: testing\n`);
2015
-
2016
- return out;
2017
- };
2018
-
2019
- const plan = {
2020
- from: srcPath,
2021
- to: destPath,
2022
- tester,
2023
- note: srcTesting ? 'already-in-testing' : 'move-to-testing',
2024
- };
2025
-
2026
- if (!options.yes && process.stdin.isTTY) {
2027
- console.log(JSON.stringify({ plan }, null, 2));
2028
- const readline = await import('node:readline/promises');
2029
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2030
- try {
2031
- const ans = await rl.question(`Move to testing + assign to ${tester}? (y/N) `);
2032
- const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
2033
- if (!ok) {
2034
- console.error('Aborted; no changes made.');
2035
- return;
2036
- }
2037
- } finally {
2038
- rl.close();
574
+ .action(async (options: { teamId?: string; ticket?: string; tester?: string; overwrite?: boolean; yes?: boolean }) => {
575
+ if (!options.teamId || !options.ticket) throw new Error("--team-id and --ticket are required");
576
+ const dry = await handleHandoff(api, {
577
+ teamId: options.teamId,
578
+ ticket: options.ticket,
579
+ tester: options.tester,
580
+ overwrite: options.overwrite,
581
+ dryRun: true,
582
+ });
583
+ const plan = (dry as { plan: { from: string; to: string; tester: string; note?: string } }).plan;
584
+ const ok = await promptConfirmWithPlan(plan, `Move to testing + assign to ${plan.tester}? (y/N)`, { yes: options.yes });
585
+ if (!ok) {
586
+ if (!options.yes && !process.stdin.isTTY) {
587
+ process.exitCode = 2;
588
+ console.error("Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.");
589
+ } else {
590
+ console.error("Aborted; no changes made.");
2039
591
  }
2040
- } else if (!options.yes && !process.stdin.isTTY) {
2041
- console.error('Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.');
2042
- process.exitCode = 2;
2043
- console.log(JSON.stringify({ ok: false, plan }, null, 2));
2044
592
  return;
2045
593
  }
2046
-
2047
- if (srcInProgress && srcPath !== destPath) {
2048
- if (!options.overwrite && (await fileExists(destPath))) {
2049
- throw new Error(`Destination exists: ${destPath} (re-run with --overwrite to replace)`);
2050
- }
2051
- }
2052
-
2053
- const md = await fs.readFile(srcPath, 'utf8');
2054
- const nextMd = patch(md);
2055
- await fs.writeFile(srcPath, nextMd, 'utf8');
2056
-
2057
- if (srcInProgress && srcPath !== destPath) {
2058
- if (options.overwrite && (await fileExists(destPath))) {
2059
- await fs.rm(destPath);
2060
- }
2061
- await fs.rename(srcPath, destPath);
2062
- }
2063
-
2064
- const m = filename.match(/^(\d{4})-(.+)\.md$/);
2065
- const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
2066
- const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
2067
- const assignmentsDir = path.join(teamDir, 'work', 'assignments');
2068
- await ensureDir(assignmentsDir);
2069
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${tester}.md`);
2070
- const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${tester}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes handoff\n`;
2071
- await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? 'overwrite' : 'createOnly');
2072
-
2073
- console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
594
+ const res = await handleHandoff(api, {
595
+ teamId: options.teamId,
596
+ ticket: options.ticket,
597
+ tester: options.tester,
598
+ overwrite: options.overwrite,
599
+ });
600
+ if (!("srcPath" in res)) throw new Error("Unexpected handoff result");
601
+ console.log(
602
+ JSON.stringify(
603
+ { ok: true, plan: { from: res.srcPath, to: res.destPath, tester: options.tester ?? "test" }, assignmentPath: res.assignmentPath },
604
+ null,
605
+ 2
606
+ )
607
+ );
2074
608
  });
2075
609
 
2076
610
  cmd
2077
611
  .command("complete")
2078
- .description("Complete a ticket (move to done, set Status: done, and add Completed: timestamp)")
612
+ .description("Complete a ticket (move to done, set Status: done, and add Completed: timestamp). No confirmation prompt.")
2079
613
  .requiredOption("--team-id <teamId>", "Team id")
2080
614
  .requiredOption("--ticket <ticket>", "Ticket id or number")
2081
- .option("--yes", "Skip confirmation")
2082
- .action(async (options: any) => {
2083
- const args = [
2084
- 'recipes',
2085
- 'move-ticket',
2086
- '--team-id',
2087
- String(options.teamId),
2088
- '--ticket',
2089
- String(options.ticket),
2090
- '--to',
2091
- 'done',
2092
- '--completed',
2093
- ];
2094
- if (options.yes) args.push('--yes');
2095
-
615
+ .option("--yes", "No-op for backward compatibility (complete has no confirmation)")
616
+ .action(async (options: { teamId?: string; ticket?: string }) => {
617
+ if (!options.teamId || !options.ticket) throw new Error("--team-id and --ticket are required");
2096
618
  try {
2097
- await moveTicketCore({
619
+ const res = await handleMoveTicket(api, {
2098
620
  teamId: options.teamId,
2099
621
  ticket: options.ticket,
2100
622
  to: "done",
2101
623
  completed: true,
2102
- yes: options.yes,
2103
624
  });
625
+ console.log(JSON.stringify({ ok: true, moved: { from: res.from, to: res.to } }, null, 2));
2104
626
  } catch (e) {
2105
627
  process.exitCode = 1;
2106
628
  throw e;
2107
629
  }
2108
630
  });
2109
631
 
632
+ const logScaffoldResult = (
633
+ res: { ok: boolean; missingSkills?: string[]; installCommands?: string[] },
634
+ recipeId: string
635
+ ) => {
636
+ if (res.ok === false && res.missingSkills && res.installCommands) {
637
+ console.error(`Missing skills for recipe ${recipeId}: ${res.missingSkills.join(", ")}`);
638
+ console.error(`Install commands (workspace-local):\n${res.installCommands.join("\n")}`);
639
+ process.exitCode = 2;
640
+ return;
641
+ }
642
+ console.log(JSON.stringify(res, null, 2));
643
+ };
644
+
2110
645
  cmd
2111
646
  .command("scaffold")
2112
647
  .description("Scaffold an agent from a recipe")
2113
648
  .argument("<recipeId>", "Recipe id")
2114
649
  .requiredOption("--agent-id <id>", "Agent id")
2115
650
  .option("--name <name>", "Agent display name")
2116
- .option("--recipe-id <recipeId>", "Workspace recipe id to write (default: <agentId>)")
651
+ .option("--recipe-id <recipeId>", "Custom workspace recipe id to write (default: <agentId>)")
2117
652
  .option("--overwrite", "Overwrite existing recipe-managed files")
2118
653
  .option("--overwrite-recipe", "Overwrite the generated workspace recipe file (workspace/recipes/<agentId>.md) if it already exists")
2119
654
  .option("--auto-increment", "If the workspace recipe id is taken, pick the next available <agentId>-2/-3/...")
2120
655
  .option("--apply-config", "Write the agent into openclaw config (agents.list)")
2121
- .action(async (recipeId: string, options: any) => {
2122
- const loaded = await loadRecipeById(api, recipeId);
2123
- const recipe = loaded.frontmatter;
2124
- if ((recipe.kind ?? "agent") !== "agent") {
2125
- throw new Error(`Recipe is not an agent recipe: kind=${recipe.kind}`);
2126
- }
2127
-
2128
- const cfg = getCfg(api);
2129
- const workspaceRoot = api.config.agents?.defaults?.workspace;
2130
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
2131
- const installDir = path.join(workspaceRoot, cfg.workspaceSkillsDir);
2132
- const missing = await detectMissingSkills(installDir, recipe.requiredSkills ?? []);
2133
- if (missing.length) {
2134
- console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
2135
- console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
2136
- process.exitCode = 2;
2137
- return;
2138
- }
2139
-
2140
- const agentId = String(options.agentId);
2141
- const baseWorkspace = api.config.agents?.defaults?.workspace ?? "~/.openclaw/workspace";
2142
- // Put standalone agent workspaces alongside the default workspace (same parent dir).
2143
- const resolvedWorkspaceRoot = path.resolve(baseWorkspace, "..", `workspace-${agentId}`);
2144
-
2145
- // Also create a workspace recipe file for this installed agent.
2146
- // This establishes a stable, editable recipe id that matches the agent id (no custom- prefix).
2147
- const recipesDir = path.join(workspaceRoot, "recipes");
2148
- await ensureDir(recipesDir);
2149
-
2150
- const overwriteRecipe = !!options.overwriteRecipe;
2151
- const autoIncrement = !!options.autoIncrement;
2152
-
2153
- function suggestedRecipeIds(baseId: string) {
2154
- return [`${baseId}-2`, `${baseId}-3`, `${baseId}-4`];
2155
- }
2156
-
2157
- async function recipeIdTaken(id: string) {
2158
- const filePath = path.join(recipesDir, `${id}.md`);
2159
- if (await fileExists(filePath)) return true;
2160
- try {
2161
- await loadRecipeById(api, id);
2162
- return true;
2163
- } catch {
2164
- return false;
2165
- }
2166
- }
2167
-
2168
- async function pickRecipeId(baseId: string) {
2169
- if (!(await recipeIdTaken(baseId))) return baseId;
2170
- if (overwriteRecipe) {
2171
- const basePath = path.join(recipesDir, `${baseId}.md`);
2172
- if (!(await fileExists(basePath))) {
2173
- throw new Error(
2174
- `Recipe id is already taken by a non-workspace recipe: ${baseId}. Choose a different id (e.g. ${baseId}-2) or pass --auto-increment.`,
2175
- );
2176
- }
2177
- return baseId;
2178
- }
2179
-
2180
- if (autoIncrement) {
2181
- let n = 2;
2182
- while (n < 1000) {
2183
- const candidate = `${baseId}-${n}`;
2184
- if (!(await recipeIdTaken(candidate))) return candidate;
2185
- n += 1;
2186
- }
2187
- throw new Error(`No available recipe id found for ${baseId} (tried up to -999)`);
2188
- }
2189
-
2190
- const suggestions = suggestedRecipeIds(baseId);
2191
- const msg = [
2192
- `Recipe id already exists: ${baseId}`,
2193
- `Refusing to overwrite workspace recipe: recipes/${baseId}.md`,
2194
- `Pick a different recipe id and re-run with --recipe-id. Suggestions: ${suggestions.join(", ")}`,
2195
- ...suggestions.map((s) => ` openclaw recipes scaffold ${recipeId} --agent-id ${agentId} --recipe-id ${s}`),
2196
- `Or re-run with --auto-increment to pick ${baseId}-2/-3/... automatically, or pass --overwrite-recipe to overwrite the existing workspace recipe file.`,
2197
- ].join("\n");
2198
- throw new Error(msg);
2199
- }
2200
-
2201
- const explicitRecipeId = typeof options.recipeId === "string" ? String(options.recipeId).trim() : "";
2202
- const baseRecipeId = explicitRecipeId || agentId;
2203
- const workspaceRecipeId = await pickRecipeId(baseRecipeId);
2204
-
2205
- const recipeFilePath = path.join(recipesDir, `${workspaceRecipeId}.md`);
2206
- const parsed = parseFrontmatter(loaded.md);
2207
- const fm = { ...parsed.frontmatter, id: workspaceRecipeId, name: parsed.frontmatter.name ?? recipe.name ?? workspaceRecipeId };
2208
- const nextMd = `---\n${YAML.stringify(fm)}---\n${parsed.body}`;
2209
- await writeFileSafely(recipeFilePath, nextMd, overwriteRecipe ? "overwrite" : "createOnly");
2210
-
2211
- const result = await scaffoldAgentFromRecipe(api, recipe, {
2212
- agentId,
2213
- agentName: options.name,
2214
- update: !!options.overwrite,
2215
- filesRootDir: resolvedWorkspaceRoot,
2216
- workspaceRootDir: resolvedWorkspaceRoot,
2217
- vars: {
2218
- agentId,
2219
- agentName: options.name ?? recipe.name ?? agentId,
2220
- },
2221
- });
2222
-
2223
- if (options.applyConfig) {
2224
- await applyAgentSnippetsToOpenClawConfig(api, [result.next.configSnippet]);
2225
- }
2226
-
2227
- const cron = await reconcileRecipeCronJobs({
2228
- api,
2229
- recipe,
2230
- scope: { kind: "agent", agentId: String(options.agentId), recipeId: recipe.id, stateDir: resolvedWorkspaceRoot },
2231
- cronInstallation: getCfg(api).cronInstallation,
656
+ .action(async (recipeId: string, options: { agentId: string; name?: string; recipeId?: string; overwrite?: boolean; overwriteRecipe?: boolean; autoIncrement?: boolean; applyConfig?: boolean }) => {
657
+ const res = await handleScaffold(api, {
658
+ recipeId,
659
+ agentId: options.agentId,
660
+ name: options.name,
661
+ recipeIdExplicit: options.recipeId,
662
+ overwrite: options.overwrite,
663
+ overwriteRecipe: options.overwriteRecipe,
664
+ autoIncrement: options.autoIncrement,
665
+ applyConfig: options.applyConfig,
2232
666
  });
2233
-
2234
- console.log(JSON.stringify({ ...result, cron }, null, 2));
667
+ logScaffoldResult(res, recipeId);
2235
668
  });
2236
669
 
2237
670
  cmd
@@ -2244,235 +677,17 @@ const recipesPlugin = {
2244
677
  .option("--overwrite-recipe", "Overwrite the generated workspace recipe file (workspace/recipes/<teamId>.md) if it already exists")
2245
678
  .option("--auto-increment", "If the workspace recipe id is taken, pick the next available <teamId>-2/-3/...")
2246
679
  .option("--apply-config", "Write all team agents into openclaw config (agents.list)")
2247
- .action(async (recipeId: string, options: any) => {
2248
- const loaded = await loadRecipeById(api, recipeId);
2249
- const recipe = loaded.frontmatter;
2250
- if ((recipe.kind ?? "team") !== "team") {
2251
- throw new Error(`Recipe is not a team recipe: kind=${recipe.kind}`);
2252
- }
2253
- const teamId = String(options.teamId);
2254
-
2255
- const cfg = getCfg(api);
2256
- const baseWorkspace = api.config.agents?.defaults?.workspace;
2257
- if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
2258
- const installDir = path.join(baseWorkspace, cfg.workspaceSkillsDir);
2259
- const missing = await detectMissingSkills(installDir, recipe.requiredSkills ?? []);
2260
- if (missing.length) {
2261
- console.error(`Missing skills for recipe ${recipeId}: ${missing.join(", ")}`);
2262
- console.error(`Install commands (workspace-local):\n${skillInstallCommands(cfg, missing).join("\n")}`);
2263
- process.exitCode = 2;
2264
- return;
2265
- }
2266
-
2267
- // Team workspace root (shared by all role agents): ~/.openclaw/workspace-<teamId>
2268
- const teamDir = path.resolve(baseWorkspace, "..", `workspace-${teamId}`);
2269
- await ensureDir(teamDir);
2270
-
2271
-
2272
- // Also create a workspace recipe file for this installed team.
2273
- // This establishes a stable, editable recipe id that matches the team id (no custom- prefix).
2274
- const recipesDir = path.join(baseWorkspace, "recipes");
2275
- await ensureDir(recipesDir);
2276
-
2277
- const overwriteRecipe = !!options.overwriteRecipe;
2278
- const autoIncrement = !!options.autoIncrement;
2279
-
2280
- function suggestedRecipeIds(baseId: string) {
2281
- const today = new Date().toISOString().slice(0, 10);
2282
- return [`${baseId}-v2`, `${baseId}-${today}`, `${baseId}-alt`];
2283
- }
2284
-
2285
- async function pickRecipeId(baseId: string) {
2286
- const basePath = path.join(recipesDir, `${baseId}.md`);
2287
- if (!(await fileExists(basePath))) return baseId;
2288
- if (overwriteRecipe) return baseId;
2289
- if (autoIncrement) {
2290
- let n = 2;
2291
- while (n < 1000) {
2292
- const candidate = `${baseId}-${n}`;
2293
- const candidatePath = path.join(recipesDir, `${candidate}.md`);
2294
- if (!(await fileExists(candidatePath))) return candidate;
2295
- n += 1;
2296
- }
2297
- throw new Error(`No available recipe id found for ${baseId} (tried up to -999)`);
2298
- }
2299
-
2300
- const suggestions = suggestedRecipeIds(baseId);
2301
- const msg = [
2302
- `Workspace recipe already exists: recipes/${baseId}.md`,
2303
- `Choose a different recipe id (recommended) and re-run with --recipe-id, for example:`,
2304
- ...suggestions.map((s) => ` openclaw recipes scaffold-team ${recipeId} -t ${teamId} --recipe-id ${s}`),
2305
- `Or re-run with --auto-increment to pick ${baseId}-2/-3/... automatically, or --overwrite-recipe to overwrite the existing file.`,
2306
- ].join("\n");
2307
- throw new Error(msg);
2308
- }
2309
-
2310
- const explicitRecipeId = typeof options.recipeId === "string" ? String(options.recipeId).trim() : "";
2311
- const baseRecipeId = explicitRecipeId || teamId;
2312
- const workspaceRecipeId = await pickRecipeId(baseRecipeId);
2313
-
2314
- // Write the recipe file, copying the source recipe markdown but forcing frontmatter.id to the chosen id.
2315
- // Default: createOnly; overwrite only when --overwrite-recipe is set.
2316
- const recipeFilePath = path.join(recipesDir, `${workspaceRecipeId}.md`);
2317
- const parsed = parseFrontmatter(loaded.md);
2318
- const fm = { ...parsed.frontmatter, id: workspaceRecipeId, name: parsed.frontmatter.name ?? recipe.name ?? workspaceRecipeId };
2319
- const nextMd = `---\n${YAML.stringify(fm)}---\n${parsed.body}`;
2320
- await writeFileSafely(recipeFilePath, nextMd, overwriteRecipe ? "overwrite" : "createOnly");
2321
-
2322
- const rolesDir = path.join(teamDir, "roles");
2323
- await ensureDir(rolesDir);
2324
- const notesDir = path.join(teamDir, "notes");
2325
- const workDir = path.join(teamDir, "work");
2326
- const backlogDir = path.join(workDir, "backlog");
2327
- const inProgressDir = path.join(workDir, "in-progress");
2328
- const testingDir = path.join(workDir, "testing");
2329
- const doneDir = path.join(workDir, "done");
2330
- const assignmentsDir = path.join(workDir, "assignments");
2331
-
2332
- // Seed standard team files (createOnly unless --overwrite)
2333
- const overwrite = !!options.overwrite;
2334
-
2335
- const sharedContextDir = path.join(teamDir, "shared-context");
2336
- const sharedContextOutputsDir = path.join(sharedContextDir, "agent-outputs");
2337
- const sharedContextFeedbackDir = path.join(sharedContextDir, "feedback");
2338
- const sharedContextKpisDir = path.join(sharedContextDir, "kpis");
2339
- const sharedContextCalendarDir = path.join(sharedContextDir, "calendar");
2340
-
2341
- await Promise.all([
2342
- // Back-compat: keep existing shared/ folder, but shared-context/ is canonical going forward.
2343
- ensureDir(path.join(teamDir, "shared")),
2344
- ensureDir(sharedContextDir),
2345
- ensureDir(sharedContextOutputsDir),
2346
- ensureDir(sharedContextFeedbackDir),
2347
- ensureDir(sharedContextKpisDir),
2348
- ensureDir(sharedContextCalendarDir),
2349
- ensureDir(path.join(teamDir, "inbox")),
2350
- ensureDir(path.join(teamDir, "outbox")),
2351
- ensureDir(notesDir),
2352
- ensureDir(workDir),
2353
- ensureDir(backlogDir),
2354
- ensureDir(inProgressDir),
2355
- ensureDir(testingDir),
2356
- ensureDir(doneDir),
2357
- ensureDir(assignmentsDir),
2358
- ]);
2359
-
2360
- // Seed shared-context starter schema (createOnly unless --overwrite)
2361
- const sharedPrioritiesPath = path.join(sharedContextDir, "priorities.md");
2362
- const prioritiesMd = `# Priorities — ${teamId}\n\n- (empty)\n\n## Notes\n- Lead curates this file.\n- Non-lead roles should append updates to shared-context/agent-outputs/ instead.\n`;
2363
- await writeFileSafely(sharedPrioritiesPath, prioritiesMd, overwrite ? "overwrite" : "createOnly");
2364
-
2365
- const planPath = path.join(notesDir, "plan.md");
2366
- const statusPath = path.join(notesDir, "status.md");
2367
- const goalsIndexPath = path.join(notesDir, "GOALS.md");
2368
- const goalsDir = path.join(notesDir, "goals");
2369
- const goalsReadmePath = path.join(goalsDir, "README.md");
2370
- const ticketsPath = path.join(teamDir, "TICKETS.md");
2371
-
2372
- const planMd = `# Plan — ${teamId}\n\n- (empty)\n`;
2373
- const statusMd = `# Status — ${teamId}\n\n- (empty)\n`;
2374
- const goalsIndexMd = `# Goals — ${teamId}\n\nThis folder is the canonical home for goals.\n\n## How to use\n- Create one markdown file per goal under: notes/goals/\n- Add a link here for discoverability\n\n## Goals\n- (empty)\n`;
2375
- const goalsReadmeMd = `# Goals folder — ${teamId}\n\nCreate one markdown file per goal in this directory.\n\nRecommended file naming:\n- short, kebab-case, no leading numbers (e.g. \`reduce-support-backlog.md\`)\n\nLink goals from:\n- notes/GOALS.md\n`;
2376
- const ticketsMd = `# Tickets — ${teamId}\n\n## Naming\n- Backlog tickets live in work/backlog/\n- In-progress tickets live in work/in-progress/\n- Testing tickets live in work/testing/\n- Done tickets live in work/done/\n- Filename ordering is the queue: 0001-..., 0002-...\n\n## Stages\n- backlog → in-progress → testing → done\n\n## QA handoff\n- When work is ready for QA: move the ticket to \`work/testing/\` and assign to test.\n\n## Required fields\nEach ticket should include:\n- Title\n- Context\n- Requirements\n- Acceptance criteria\n- Owner (dev/devops/lead/test)\n- Status (queued/in-progress/testing/done)\n\n## Example\n\n\`\`\`md\n# 0001-example-ticket\n\nOwner: dev\nStatus: queued\n\n## Context\n...\n\n## Requirements\n- ...\n\n## Acceptance criteria\n- ...\n\`\`\`\n`;
2377
-
2378
- await ensureDir(goalsDir);
2379
- await writeFileSafely(planPath, planMd, overwrite ? "overwrite" : "createOnly");
2380
- await writeFileSafely(statusPath, statusMd, overwrite ? "overwrite" : "createOnly");
2381
- await writeFileSafely(goalsIndexPath, goalsIndexMd, overwrite ? "overwrite" : "createOnly");
2382
- await writeFileSafely(goalsReadmePath, goalsReadmeMd, overwrite ? "overwrite" : "createOnly");
2383
- await writeFileSafely(ticketsPath, ticketsMd, overwrite ? "overwrite" : "createOnly");
2384
-
2385
- const agents = recipe.agents ?? [];
2386
- if (!agents.length) throw new Error("Team recipe must include agents[]");
2387
-
2388
- const results: any[] = [];
2389
- for (const a of agents) {
2390
- const role = a.role;
2391
- const agentId = a.agentId ?? `${teamId}-${role}`;
2392
- const agentName = a.name ?? `${teamId} ${role}`;
2393
-
2394
- // For team recipes, we namespace template keys like: "lead.soul".
2395
- const scopedRecipe: RecipeFrontmatter = {
2396
- id: `${recipe.id}:${role}`,
2397
- name: agentName,
2398
- kind: "agent",
2399
- requiredSkills: recipe.requiredSkills,
2400
- optionalSkills: recipe.optionalSkills,
2401
- templates: recipe.templates,
2402
- files: (recipe.files ?? []).map((f) => ({
2403
- ...f,
2404
- template: f.template.includes(".") ? f.template : `${role}.${f.template}`,
2405
- })),
2406
- tools: a.tools ?? recipe.tools,
2407
- };
2408
-
2409
- const roleDir = path.join(rolesDir, role);
2410
- const r = await scaffoldAgentFromRecipe(api, scopedRecipe, {
2411
- agentId,
2412
- agentName,
2413
- update: !!options.overwrite,
2414
- // Write role-specific files under roles/<role>/
2415
- filesRootDir: roleDir,
2416
- // But set the agent workspace root to the shared team workspace
2417
- workspaceRootDir: teamDir,
2418
- vars: {
2419
- teamId,
2420
- teamDir,
2421
- role,
2422
- agentId,
2423
- agentName,
2424
- roleDir,
2425
- },
2426
- });
2427
- results.push({ role, agentId, ...r });
2428
- }
2429
-
2430
- // Create a minimal TEAM.md
2431
- const teamMdPath = path.join(teamDir, "TEAM.md");
2432
- const teamMd = `# ${teamId}\n\nShared workspace for this agent team.\n\n## Folders\n- inbox/ — requests\n- outbox/ — deliverables\n- shared-context/ — curated shared context + append-only agent outputs\n- shared/ — legacy shared artifacts (back-compat)\n- notes/ — plan + status\n- work/ — working files\n`;
2433
- await writeFileSafely(teamMdPath, teamMd, options.overwrite ? "overwrite" : "createOnly");
2434
-
2435
- // Persist provenance (parent recipe) for UIs like ClawKitchen.
2436
- // This avoids brittle heuristics like teamId==recipeId guessing.
2437
- const teamMetaPath = path.join(teamDir, "team.json");
2438
- const teamMeta = {
2439
- teamId,
2440
- recipeId: recipe.id,
2441
- recipeName: recipe.name ?? "",
2442
- scaffoldedAt: new Date().toISOString(),
2443
- };
2444
- await writeJsonFile(teamMetaPath, teamMeta);
2445
-
2446
- if (options.applyConfig) {
2447
- const snippets: AgentConfigSnippet[] = results.map((x: any) => x.next.configSnippet);
2448
- await applyAgentSnippetsToOpenClawConfig(api, snippets);
2449
- }
2450
-
2451
- const cron = await reconcileRecipeCronJobs({
2452
- api,
2453
- recipe,
2454
- scope: { kind: "team", teamId, recipeId: recipe.id, stateDir: teamDir },
2455
- cronInstallation: getCfg(api).cronInstallation,
680
+ .action(async (recipeId: string, options: { teamId: string; recipeId?: string; overwrite?: boolean; overwriteRecipe?: boolean; autoIncrement?: boolean; applyConfig?: boolean }) => {
681
+ const res = await handleScaffoldTeam(api, {
682
+ recipeId,
683
+ teamId: String(options.teamId),
684
+ recipeIdExplicit: options.recipeId,
685
+ overwrite: !!options.overwrite,
686
+ overwriteRecipe: !!options.overwriteRecipe,
687
+ autoIncrement: !!options.autoIncrement,
688
+ applyConfig: !!options.applyConfig,
2456
689
  });
2457
-
2458
- console.log(
2459
- JSON.stringify(
2460
- {
2461
- teamId,
2462
- teamDir,
2463
- agents: results,
2464
- cron,
2465
- next: {
2466
- note:
2467
- options.applyConfig
2468
- ? "agents.list[] updated in openclaw config"
2469
- : "Run again with --apply-config to write agents into openclaw config.",
2470
- },
2471
- },
2472
- null,
2473
- 2,
2474
- ),
2475
- );
690
+ logScaffoldResult(res, recipeId);
2476
691
  });
2477
692
  },
2478
693
  { commands: ["recipes"] },
@@ -2486,20 +701,35 @@ export const __internal = {
2486
701
  upsertBindingInConfig,
2487
702
  removeBindingsInConfig,
2488
703
  stableStringify,
2489
-
2490
- patchTicketField(md: string, key: string, value: string) {
2491
- const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
2492
- if (md.match(lineRe)) return md.replace(lineRe, `${key}: ${value}`);
2493
- return md.replace(/^(# .+\n)/, `$1\n${key}: ${value}\n`);
2494
- },
2495
-
2496
- patchTicketOwner(md: string, owner: string) {
2497
- return this.patchTicketField(md, "Owner", owner);
2498
- },
2499
-
2500
- patchTicketStatus(md: string, status: string) {
2501
- return this.patchTicketField(md, "Status", status);
2502
- },
704
+ workspacePath,
705
+ listRecipeFiles,
706
+ loadRecipeById,
707
+ handleRecipesList,
708
+ handleRecipesShow,
709
+ handleRecipesStatus,
710
+ handleRecipesBind,
711
+ handleRecipesUnbind,
712
+ handleRecipesBindings,
713
+ handleTickets,
714
+ handleMoveTicket,
715
+ handleMigrateTeamPlan,
716
+ executeMigrateTeamPlan,
717
+ handleRemoveTeam,
718
+ handleAssign,
719
+ handleTake,
720
+ handleHandoff,
721
+ handleDispatch,
722
+ handleInstallSkill,
723
+ handleInstallMarketplaceRecipe,
724
+ handleScaffold,
725
+ handleScaffoldTeam,
726
+ scaffoldAgentFromRecipe,
727
+ promptYesNo,
728
+ reconcileRecipeCronJobs,
729
+ applyAgentSnippetsToOpenClawConfig,
730
+ patchTicketField,
731
+ patchTicketOwner,
732
+ patchTicketStatus,
2503
733
  };
2504
734
 
2505
735
  export default recipesPlugin;