@clawcipes/recipes 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -2,9 +2,22 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs/promises";
4
4
  import crypto from "node:crypto";
5
+ import os from "node:os";
5
6
  import JSON5 from "json5";
6
7
  import YAML from "yaml";
7
8
 
9
+ import { renderTeamMd, renderTicketsMd } from "./src/lib/scaffold-templates";
10
+ import { upsertBindingInConfig as upsertBindingInConfigCore } from "./src/lib/bindings";
11
+ import { handoffTicket as handoffTicketCore } from "./src/lib/ticket-workflow";
12
+ import { ensureLaneDir, RecipesCliError } from "./src/lib/lanes";
13
+ import { findTicketFile as findTicketFileAnyLane, parseOwnerFromMd } from "./src/lib/ticket-finder";
14
+ import {
15
+ DEFAULT_ALLOWED_PREFIXES,
16
+ DEFAULT_PROTECTED_TEAM_IDS,
17
+ executeWorkspaceCleanup,
18
+ planWorkspaceCleanup,
19
+ } from "./src/lib/cleanup-workspaces";
20
+
8
21
  type RecipesConfig = {
9
22
  workspaceRecipesDir?: string;
10
23
  workspaceAgentsDir?: string;
@@ -188,6 +201,34 @@ async function ensureDir(p: string) {
188
201
  await fs.mkdir(p, { recursive: true });
189
202
  }
190
203
 
204
+ function formatRecipesCliError(commandLabel: string, err: unknown) {
205
+ if (err instanceof RecipesCliError) {
206
+ const lines = [
207
+ `[recipes] ERROR: ${err.message}`,
208
+ `- command: ${err.command ?? commandLabel}`,
209
+ ...(err.missingPath ? [`- path: ${err.missingPath}`] : []),
210
+ ...(err.suggestedFix ? [`- suggested fix: ${err.suggestedFix}`] : []),
211
+ ];
212
+ return lines.join("\n");
213
+ }
214
+
215
+ if (err instanceof Error) {
216
+ return `[recipes] ERROR: ${err.message}\n- command: ${commandLabel}`;
217
+ }
218
+
219
+ return `[recipes] ERROR: ${String(err)}\n- command: ${commandLabel}`;
220
+ }
221
+
222
+ async function runRecipesCommand<T>(commandLabel: string, fn: () => Promise<T>): Promise<T | null> {
223
+ try {
224
+ return await fn();
225
+ } catch (err) {
226
+ console.error(formatRecipesCliError(commandLabel, err));
227
+ process.exitCode = process.exitCode && process.exitCode !== 0 ? process.exitCode : 1;
228
+ return null;
229
+ }
230
+ }
231
+
191
232
  type CronInstallMode = "off" | "prompt" | "on";
192
233
 
193
234
  type CronMappingStateV1 = {
@@ -245,17 +286,73 @@ type OpenClawCronJob = {
245
286
  description?: string;
246
287
  };
247
288
 
248
- function spawnOpenClawJson(args: string[]) {
249
- const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
250
- const res = spawnSync("openclaw", args, { encoding: "utf8" });
251
- if (res.status !== 0) {
252
- const err = new Error(`openclaw ${args.join(" ")} failed (exit=${res.status})`);
253
- (err as any).stdout = res.stdout;
254
- (err as any).stderr = res.stderr;
255
- throw err;
289
+ type CronStoreV1 = {
290
+ version: 1;
291
+ jobs: any[];
292
+ };
293
+
294
+ function cronStorePath() {
295
+ // Gateway cron store (default): ~/.openclaw/cron/jobs.json
296
+ return path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
297
+ }
298
+
299
+ async function loadCronStore(): Promise<CronStoreV1> {
300
+ try {
301
+ const raw = await fs.readFile(cronStorePath(), "utf8");
302
+ const parsed = JSON.parse(raw) as CronStoreV1;
303
+ if (parsed && parsed.version === 1 && Array.isArray(parsed.jobs)) return parsed;
304
+ } catch {
305
+ // ignore
256
306
  }
257
- const out = String(res.stdout ?? "").trim();
258
- return out ? (JSON.parse(out) as any) : null;
307
+ return { version: 1, jobs: [] };
308
+ }
309
+
310
+ async function saveCronStore(store: CronStoreV1) {
311
+ const p = cronStorePath();
312
+ await ensureDir(path.dirname(p));
313
+ await fs.writeFile(p, JSON.stringify(store, null, 2) + "\n", "utf8");
314
+ }
315
+
316
+ function buildCronJobPayloadAgentTurn(message: string) {
317
+ return {
318
+ kind: "agentTurn",
319
+ message,
320
+ timeoutSeconds: 60,
321
+ };
322
+ }
323
+
324
+ function buildCronJobFromSpec(opts: {
325
+ name: string;
326
+ description?: string;
327
+ scheduleExpr: string;
328
+ tz?: string;
329
+ message: string;
330
+ enabled: boolean;
331
+ agentId: string;
332
+ delivery?: { channel?: string; to?: string };
333
+ }) {
334
+ const now = Date.now();
335
+ const delivery =
336
+ opts.delivery?.channel && opts.delivery?.to
337
+ ? { mode: "announce", channel: opts.delivery.channel, to: opts.delivery.to, bestEffort: true }
338
+ : { mode: "announce" };
339
+
340
+ return {
341
+ id: crypto.randomUUID(),
342
+ agentId: opts.agentId || "main",
343
+ name: opts.name,
344
+ description: opts.description,
345
+ enabled: opts.enabled,
346
+ deleteAfterRun: false,
347
+ createdAtMs: now,
348
+ updatedAtMs: now,
349
+ schedule: { kind: "cron", expr: opts.scheduleExpr, tz: opts.tz },
350
+ sessionTarget: "isolated",
351
+ wakeMode: "next-heartbeat",
352
+ payload: buildCronJobPayloadAgentTurn(opts.message),
353
+ delivery,
354
+ state: {},
355
+ };
259
356
  }
260
357
 
261
358
  function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
@@ -324,15 +421,25 @@ async function reconcileRecipeCronJobs(opts: {
324
421
  const header = `Recipe ${opts.scope.recipeId} defines ${desired.length} cron job(s).\nThese run automatically on a schedule. Install them?`;
325
422
  userOptIn = await promptYesNo(header);
326
423
  if (!userOptIn && !process.stdin.isTTY) {
327
- console.error("Non-interactive mode: defaulting cron install to disabled.");
424
+ console.error("Non-interactive mode: skipping cron installation (no consent). Use cronInstallation=on to force install.");
328
425
  }
329
426
  }
330
427
 
428
+ // Never install cron jobs without explicit consent (unless cronInstallation=on).
429
+ if (!userOptIn) {
430
+ return {
431
+ ok: true,
432
+ changed: false,
433
+ note: "cron-installation-declined" as const,
434
+ desiredCount: desired.length,
435
+ };
436
+ }
437
+
331
438
  const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
332
439
  const state = await loadCronMappingState(statePath);
333
440
 
334
- const list = spawnOpenClawJson(["cron", "list", "--json"]) as { jobs: OpenClawCronJob[] };
335
- const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
441
+ const store = await loadCronStore();
442
+ const byId = new Map((store.jobs ?? []).map((j: any) => [j.id, j] as const));
336
443
 
337
444
  const now = Date.now();
338
445
  const desiredIds = new Set(desired.map((j) => j.id));
@@ -349,7 +456,9 @@ async function reconcileRecipeCronJobs(opts: {
349
456
  timezone: j.timezone ?? "",
350
457
  channel: j.channel ?? "last",
351
458
  to: j.to ?? "",
352
- agentId: j.agentId ?? "",
459
+ agentId:
460
+ j.agentId ??
461
+ (opts.scope.kind === "team" ? `${(opts.scope as any).teamId}-lead` : ""),
353
462
  name,
354
463
  description: j.description ?? "",
355
464
  };
@@ -362,65 +471,43 @@ async function reconcileRecipeCronJobs(opts: {
362
471
  const wantEnabled = userOptIn ? Boolean(j.enabledByDefault) : false;
363
472
 
364
473
  if (!existing) {
365
- // Create new job.
366
- const args = [
367
- "cron",
368
- "add",
369
- "--json",
370
- "--name",
474
+ // Create new job in cron store.
475
+ const newJob = buildCronJobFromSpec({
371
476
  name,
372
- "--cron",
373
- j.schedule,
374
- "--message",
375
- j.message,
376
- "--announce",
377
- ];
378
- if (!wantEnabled) args.push("--disabled");
379
- if (j.description) args.push("--description", j.description);
380
- if (j.timezone) args.push("--tz", j.timezone);
381
- if (j.channel) args.push("--channel", j.channel);
382
- if (j.to) args.push("--to", j.to);
383
- if (j.agentId) args.push("--agent", j.agentId);
384
-
385
- const created = spawnOpenClawJson(args) as any;
386
- const newId = created?.id ?? created?.job?.id;
387
- if (!newId) throw new Error("Failed to parse cron add output (missing id)");
388
-
389
- state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
390
- results.push({ action: "created", key, installedCronId: newId, enabled: wantEnabled });
477
+ description: j.description,
478
+ scheduleExpr: j.schedule,
479
+ tz: j.timezone,
480
+ message: j.message,
481
+ enabled: wantEnabled,
482
+ agentId: desiredSpec.agentId || "main",
483
+ delivery: j.channel && j.to ? { channel: j.channel, to: j.to } : undefined,
484
+ });
485
+
486
+ store.jobs.push(newJob);
487
+ state.entries[key] = { installedCronId: newJob.id, specHash, updatedAtMs: now, orphaned: false };
488
+ results.push({ action: "created", key, installedCronId: newJob.id, enabled: wantEnabled });
391
489
  continue;
392
490
  }
393
491
 
394
492
  // Update existing job if spec changed.
395
493
  if (prev?.specHash !== specHash) {
396
- const editArgs = [
397
- "cron",
398
- "edit",
399
- existing.id,
400
- "--name",
401
- name,
402
- "--cron",
403
- j.schedule,
404
- "--message",
405
- j.message,
406
- "--announce",
407
- ];
408
- if (j.description) editArgs.push("--description", j.description);
409
- if (j.timezone) editArgs.push("--tz", j.timezone);
410
- if (j.channel) editArgs.push("--channel", j.channel);
411
- if (j.to) editArgs.push("--to", j.to);
412
- if (j.agentId) editArgs.push("--agent", j.agentId);
413
-
414
- spawnOpenClawJson(editArgs);
494
+ existing.name = name;
495
+ existing.description = j.description ?? existing.description;
496
+ existing.schedule = { kind: "cron", expr: j.schedule, tz: j.timezone };
497
+ existing.payload = buildCronJobPayloadAgentTurn(j.message);
498
+ existing.agentId = desiredSpec.agentId || existing.agentId || "main";
499
+ if (j.channel && j.to) existing.delivery = { mode: "announce", channel: j.channel, to: j.to, bestEffort: true };
500
+ existing.updatedAtMs = now;
415
501
  results.push({ action: "updated", key, installedCronId: existing.id });
416
502
  } else {
417
503
  results.push({ action: "unchanged", key, installedCronId: existing.id });
418
504
  }
419
505
 
420
- // Enabled precedence: if user did not opt in, force disabled. Otherwise preserve current enabled state.
506
+ // Enabled precedence: if user did not opt in, force disabled.
421
507
  if (!userOptIn) {
422
508
  if (existing.enabled) {
423
- spawnOpenClawJson(["cron", "edit", existing.id, "--disable"]);
509
+ existing.enabled = false;
510
+ existing.updatedAtMs = now;
424
511
  results.push({ action: "disabled", key, installedCronId: existing.id });
425
512
  }
426
513
  }
@@ -436,7 +523,8 @@ async function reconcileRecipeCronJobs(opts: {
436
523
 
437
524
  const job = byId.get(entry.installedCronId);
438
525
  if (job && job.enabled) {
439
- spawnOpenClawJson(["cron", "edit", job.id, "--disable"]);
526
+ job.enabled = false;
527
+ job.updatedAtMs = now;
440
528
  results.push({ action: "disabled-removed", key, installedCronId: job.id });
441
529
  }
442
530
 
@@ -444,6 +532,7 @@ async function reconcileRecipeCronJobs(opts: {
444
532
  }
445
533
 
446
534
  await writeJsonFile(statePath, state);
535
+ await saveCronStore(store);
447
536
 
448
537
  const changed = results.some((r) => r.action === "created" || r.action === "updated" || r.action?.startsWith("disabled"));
449
538
  return { ok: true, changed, results };
@@ -565,24 +654,7 @@ function stableStringify(x: any) {
565
654
  }
566
655
 
567
656
  function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
568
- if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
569
- const list: any[] = cfgObj.bindings;
570
-
571
- const sig = stableStringify({ agentId: binding.agentId, match: binding.match });
572
- const idx = list.findIndex((b) => stableStringify({ agentId: b?.agentId, match: b?.match }) === sig);
573
-
574
- if (idx >= 0) {
575
- // Update in place (preserve ordering)
576
- list[idx] = { ...list[idx], ...binding };
577
- return { changed: false, note: "already-present" as const };
578
- }
579
-
580
- // Most-specific-first: if a peer match is specified, insert at front so it wins.
581
- // Otherwise append.
582
- if (binding.match?.peer) list.unshift(binding);
583
- else list.push(binding);
584
-
585
- return { changed: true, note: "added" as const };
657
+ return upsertBindingInConfigCore(cfgObj, binding);
586
658
  }
587
659
 
588
660
  function removeBindingsInConfig(cfgObj: any, opts: { agentId?: string; match: BindingMatch }) {
@@ -1114,28 +1186,22 @@ const recipesPlugin = {
1114
1186
  console.error(header);
1115
1187
  }
1116
1188
 
1117
- // Use clawhub CLI. Force install path based on scope.
1118
- const { spawnSync } = await import("node:child_process");
1119
- for (const slug of missing) {
1120
- const res = spawnSync(
1121
- "npx",
1122
- ["clawhub@latest", "--workdir", workdir, "--dir", dirName, "install", slug],
1123
- { stdio: "inherit" },
1124
- );
1125
- if (res.status !== 0) {
1126
- process.exitCode = res.status ?? 1;
1127
- console.error(`Failed installing ${slug} (exit=${process.exitCode}).`);
1128
- return;
1129
- }
1130
- }
1189
+ // SECURITY NOTE: avoid shelling out from plugins.
1190
+ // We intentionally do NOT auto-run `npx clawhub ...` here.
1191
+ // Instead, print the exact commands to run manually.
1192
+ const commands = missing.map(
1193
+ (slug) => `npx clawhub@latest --workdir "${workdir}" --dir "${dirName}" install "${slug}"`,
1194
+ );
1131
1195
 
1132
1196
  console.log(
1133
1197
  JSON.stringify(
1134
1198
  {
1135
- ok: true,
1136
- installed: missing,
1199
+ ok: false,
1200
+ reason: "manual-install-required",
1201
+ missing,
1137
1202
  installDir,
1138
- next: `Try: openclaw skills list (or check ${installDir})`,
1203
+ commands,
1204
+ next: "Run the commands above, then: openclaw gateway restart",
1139
1205
  },
1140
1206
  null,
1141
1207
  2,
@@ -1307,15 +1373,26 @@ const recipesPlugin = {
1307
1373
  const readTickets = async (dir: string, stage: "backlog" | "in-progress" | "testing" | "done") => {
1308
1374
  if (!(await fileExists(dir))) return [] as any[];
1309
1375
  const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
1310
- return files.map((f) => {
1311
- const m = f.match(/^(\d{4})-(.+)\.md$/);
1312
- return {
1313
- stage,
1314
- number: m ? Number(m[1]) : null,
1315
- id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
1316
- file: path.join(dir, f),
1317
- };
1318
- });
1376
+ return Promise.all(
1377
+ files.map(async (f) => {
1378
+ const m = f.match(/^(\d{4})-(.+)\.md$/);
1379
+ const file = path.join(dir, f);
1380
+ let owner: string | null = null;
1381
+ try {
1382
+ const md = await fs.readFile(file, 'utf8');
1383
+ owner = parseOwnerFromMd(md);
1384
+ } catch {
1385
+ owner = null;
1386
+ }
1387
+ return {
1388
+ stage,
1389
+ number: m ? Number(m[1]) : null,
1390
+ id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
1391
+ owner,
1392
+ file,
1393
+ };
1394
+ }),
1395
+ );
1319
1396
  };
1320
1397
 
1321
1398
  const out = {
@@ -1350,11 +1427,12 @@ const recipesPlugin = {
1350
1427
  .requiredOption("--to <stage>", "Destination stage: backlog|in-progress|testing|done")
1351
1428
  .option("--completed", "When moving to done, add Completed: timestamp")
1352
1429
  .option("--yes", "Skip confirmation")
1353
- .action(async (options: any) => {
1354
- const workspaceRoot = api.config.agents?.defaults?.workspace;
1355
- if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1356
- const teamId = String(options.teamId);
1357
- const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1430
+ .action(async (options: any) =>
1431
+ runRecipesCommand("openclaw recipes move-ticket", async () => {
1432
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1433
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1434
+ const teamId = String(options.teamId);
1435
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1358
1436
 
1359
1437
  const dest = String(options.to);
1360
1438
  if (!['backlog','in-progress','testing','done'].includes(dest)) {
@@ -1391,7 +1469,11 @@ const recipesPlugin = {
1391
1469
  if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1392
1470
 
1393
1471
  const destDir = stageDir(dest);
1394
- await ensureDir(destDir);
1472
+ if (dest === 'testing') {
1473
+ await ensureLaneDir({ teamDir, lane: 'testing', command: 'openclaw recipes move-ticket' });
1474
+ } else {
1475
+ await ensureDir(destDir);
1476
+ }
1395
1477
  const filename = path.basename(srcPath);
1396
1478
  const destPath = path.join(destDir, filename);
1397
1479
 
@@ -1450,7 +1532,7 @@ const recipesPlugin = {
1450
1532
  }
1451
1533
 
1452
1534
  console.log(JSON.stringify({ ok: true, moved: plan }, null, 2));
1453
- });
1535
+ }));
1454
1536
 
1455
1537
  cmd
1456
1538
  .command("assign")
@@ -1471,36 +1553,13 @@ const recipesPlugin = {
1471
1553
  throw new Error("--owner must be one of: dev, devops, lead, test");
1472
1554
  }
1473
1555
 
1474
- const stageDir = (stage: string) => {
1475
- if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1476
- if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1477
- if (stage === 'done') return path.join(teamDir, 'work', 'done');
1478
- throw new Error(`Unknown stage: ${stage}`);
1479
- };
1480
- const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('done')];
1481
-
1482
1556
  const ticketArg = String(options.ticket);
1483
- const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1484
-
1485
- const findTicketFile = async () => {
1486
- for (const dir of searchDirs) {
1487
- if (!(await fileExists(dir))) continue;
1488
- const files = await fs.readdir(dir);
1489
- for (const f of files) {
1490
- if (!f.endsWith('.md')) continue;
1491
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1492
- if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1493
- }
1494
- }
1495
- return null;
1496
- };
1497
-
1498
- const ticketPath = await findTicketFile();
1557
+ const ticketPath = await findTicketFileAnyLane({ teamDir, ticket: ticketArg });
1499
1558
  if (!ticketPath) throw new Error(`Ticket not found: ${ticketArg}`);
1500
1559
 
1501
1560
  const filename = path.basename(ticketPath);
1502
1561
  const m = filename.match(/^(\d{4})-(.+)\.md$/);
1503
- const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1562
+ const ticketNumStr = m?.[1] ?? '0000';
1504
1563
  const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1505
1564
 
1506
1565
  const assignmentsDir = path.join(teamDir, 'work', 'assignments');
@@ -1545,6 +1604,72 @@ const recipesPlugin = {
1545
1604
  console.log(JSON.stringify({ ok: true, plan }, null, 2));
1546
1605
  });
1547
1606
 
1607
+ cmd
1608
+ .command("handoff")
1609
+ .description("QA handoff: assign ticket to tester + move to testing")
1610
+ .requiredOption("--team-id <teamId>", "Team id")
1611
+ .requiredOption("--ticket <ticket>", "Ticket id or number")
1612
+ .option("--tester <tester>", "Tester/owner (default: test)", "test")
1613
+ .option("--overwrite", "Overwrite existing assignment file")
1614
+ .option("--yes", "Skip confirmation")
1615
+ .action(async (options: any) =>
1616
+ runRecipesCommand("openclaw recipes handoff", async () => {
1617
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1618
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1619
+ const teamId = String(options.teamId);
1620
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1621
+
1622
+ const tester = String(options.tester ?? "test");
1623
+
1624
+ const plan = {
1625
+ teamId,
1626
+ ticket: String(options.ticket),
1627
+ tester,
1628
+ overwriteAssignment: !!options.overwrite,
1629
+ };
1630
+
1631
+ if (!options.yes && process.stdin.isTTY) {
1632
+ console.log(JSON.stringify({ plan }, null, 2));
1633
+ const readline = await import("node:readline/promises");
1634
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1635
+ try {
1636
+ const ans = await rl.question(`Handoff to ${tester} and move to testing? (y/N) `);
1637
+ const ok = ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes";
1638
+ if (!ok) {
1639
+ console.error("Aborted; no changes made.");
1640
+ return;
1641
+ }
1642
+ } finally {
1643
+ rl.close();
1644
+ }
1645
+ } else if (!options.yes && !process.stdin.isTTY) {
1646
+ console.error("Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.");
1647
+ process.exitCode = 2;
1648
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
1649
+ return;
1650
+ }
1651
+
1652
+ const res = await handoffTicketCore({
1653
+ teamDir,
1654
+ ticket: String(options.ticket),
1655
+ tester,
1656
+ overwriteAssignment: !!options.overwrite,
1657
+ });
1658
+
1659
+ console.log(
1660
+ JSON.stringify(
1661
+ {
1662
+ ok: true,
1663
+ moved: res.moved ? { from: res.srcPath, to: res.destPath } : null,
1664
+ ticket: path.relative(teamDir, res.destPath),
1665
+ assignment: path.relative(teamDir, res.assignmentPath),
1666
+ },
1667
+ null,
1668
+ 2,
1669
+ ),
1670
+ );
1671
+ }));
1672
+
1548
1673
  cmd
1549
1674
  .command("take")
1550
1675
  .description("Shortcut: assign ticket to owner + move to in-progress")
@@ -1563,31 +1688,8 @@ const recipesPlugin = {
1563
1688
  throw new Error("--owner must be one of: dev, devops, lead, test");
1564
1689
  }
1565
1690
 
1566
- const stageDir = (stage: string) => {
1567
- if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1568
- if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1569
- if (stage === 'done') return path.join(teamDir, 'work', 'done');
1570
- throw new Error(`Unknown stage: ${stage}`);
1571
- };
1572
- const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('done')];
1573
-
1574
1691
  const ticketArg = String(options.ticket);
1575
- const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1576
-
1577
- const findTicketFile = async () => {
1578
- for (const dir of searchDirs) {
1579
- if (!(await fileExists(dir))) continue;
1580
- const files = await fs.readdir(dir);
1581
- for (const f of files) {
1582
- if (!f.endsWith('.md')) continue;
1583
- if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1584
- if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1585
- }
1586
- }
1587
- return null;
1588
- };
1589
-
1590
- const srcPath = await findTicketFile();
1692
+ const srcPath = await findTicketFileAnyLane({ teamDir, ticket: ticketArg });
1591
1693
  if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1592
1694
 
1593
1695
  const destDir = stageDir('in-progress');
@@ -1638,7 +1740,7 @@ const recipesPlugin = {
1638
1740
  }
1639
1741
 
1640
1742
  const m = filename.match(/^(\d{4})-(.+)\.md$/);
1641
- const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1743
+ const ticketNumStr = m?.[1] ?? '0000';
1642
1744
  const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1643
1745
  const assignmentsDir = path.join(teamDir, 'work', 'assignments');
1644
1746
  await ensureDir(assignmentsDir);
@@ -1655,26 +1757,61 @@ const recipesPlugin = {
1655
1757
  .requiredOption("--team-id <teamId>", "Team id")
1656
1758
  .requiredOption("--ticket <ticket>", "Ticket id or number")
1657
1759
  .option("--yes", "Skip confirmation")
1658
- .action(async (options: any) => {
1659
- const args = [
1660
- 'recipes',
1661
- 'move-ticket',
1662
- '--team-id',
1663
- String(options.teamId),
1664
- '--ticket',
1665
- String(options.ticket),
1666
- '--to',
1667
- 'done',
1668
- '--completed',
1669
- ];
1670
- if (options.yes) args.push('--yes');
1671
-
1672
- const { spawnSync } = await import('node:child_process');
1673
- const res = spawnSync('openclaw', args, { stdio: 'inherit' });
1674
- if (res.status !== 0) {
1675
- process.exitCode = res.status ?? 1;
1676
- }
1677
- });
1760
+ .action(async (options: any) =>
1761
+ runRecipesCommand("openclaw recipes complete", async () => {
1762
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1763
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1764
+ const teamId = String(options.teamId);
1765
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1766
+
1767
+ const ticketArg = String(options.ticket);
1768
+ const srcPath = await findTicketFileAnyLane({ teamDir, ticket: ticketArg });
1769
+ if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1770
+
1771
+ const destDir = path.join(teamDir, 'work', 'done');
1772
+ await ensureDir(destDir);
1773
+
1774
+ const filename = path.basename(srcPath);
1775
+ const destPath = path.join(destDir, filename);
1776
+
1777
+ const plan = { from: srcPath, to: destPath, completed: true };
1778
+
1779
+ if (!options.yes && process.stdin.isTTY) {
1780
+ console.log(JSON.stringify({ plan }, null, 2));
1781
+ const readline = await import('node:readline/promises');
1782
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1783
+ try {
1784
+ const ans = await rl.question(`Move to done and mark completed? (y/N) `);
1785
+ const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1786
+ if (!ok) {
1787
+ console.error('Aborted; no changes made.');
1788
+ return;
1789
+ }
1790
+ } finally {
1791
+ rl.close();
1792
+ }
1793
+ } else if (!options.yes && !process.stdin.isTTY) {
1794
+ console.error('Refusing to complete without confirmation in non-interactive mode. Re-run with --yes.');
1795
+ process.exitCode = 2;
1796
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
1797
+ return;
1798
+ }
1799
+
1800
+ const md = await fs.readFile(srcPath, 'utf8');
1801
+ let out = md;
1802
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: done`);
1803
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: done\n`);
1804
+
1805
+ const completedIso = new Date().toISOString();
1806
+ if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completedIso}`);
1807
+ else out = out.replace(/^(# .+\n)/, `$1\nCompleted: ${completedIso}\n`);
1808
+
1809
+ await fs.writeFile(srcPath, out, 'utf8');
1810
+ if (srcPath !== destPath) await fs.rename(srcPath, destPath);
1811
+
1812
+ console.log(JSON.stringify({ ok: true, moved: { from: srcPath, to: destPath }, completed: completedIso }, null, 2));
1813
+ }),
1814
+ );
1678
1815
 
1679
1816
  cmd
1680
1817
  .command("scaffold")
@@ -1772,6 +1909,7 @@ const recipesPlugin = {
1772
1909
  const workDir = path.join(teamDir, "work");
1773
1910
  const backlogDir = path.join(workDir, "backlog");
1774
1911
  const inProgressDir = path.join(workDir, "in-progress");
1912
+ const testingDir = path.join(workDir, "testing");
1775
1913
  const doneDir = path.join(workDir, "done");
1776
1914
  const assignmentsDir = path.join(workDir, "assignments");
1777
1915
 
@@ -1798,6 +1936,7 @@ const recipesPlugin = {
1798
1936
  ensureDir(workDir),
1799
1937
  ensureDir(backlogDir),
1800
1938
  ensureDir(inProgressDir),
1939
+ ensureDir(testingDir),
1801
1940
  ensureDir(doneDir),
1802
1941
  ensureDir(assignmentsDir),
1803
1942
  ]);
@@ -1809,14 +1948,17 @@ const recipesPlugin = {
1809
1948
 
1810
1949
  const planPath = path.join(notesDir, "plan.md");
1811
1950
  const statusPath = path.join(notesDir, "status.md");
1951
+ const qaChecklistPath = path.join(notesDir, "QA_CHECKLIST.md");
1812
1952
  const ticketsPath = path.join(teamDir, "TICKETS.md");
1813
1953
 
1814
1954
  const planMd = `# Plan — ${teamId}\n\n- (empty)\n`;
1815
1955
  const statusMd = `# Status — ${teamId}\n\n- (empty)\n`;
1816
- const ticketsMd = `# Tickets ${teamId}\n\n## Naming\n- Backlog tickets live in work/backlog/\n- Filename ordering is the queue: 0001-..., 0002-...\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`;
1956
+ const qaChecklistMd = `# QA checklist (template)\n\nUse this checklist for any ticket in work/testing/ before moving it to work/done/.\n\n## Where verification results live\nPreferred (canonical): create a sibling verification note:\n- work/testing/<ticket>.testing-verified.md\n\nAlternative (allowed for tiny changes): add a \"## QA verification\" section directly in the ticket file.\n\n## Rule: when a ticket may move to done\nA ticket may move testing → done only when a verification record exists.\n\n## Copy/paste template\n\n\`\`\`md\n# QA verification — <ticket-id>\n\nTicket: <relative-path-to-ticket>\nVerified by: <name/role>\nDate: <YYYY-MM-DD>\n\n## Environment\n- Machine: <host / OS>\n- Repo(s): <repo + path>\n- Branch/commit: <branch + sha>\n- Build/version: <version if applicable>\n\n## Test plan\n### Commands run\n- <command 1>\n- <command 2>\n\n### Manual checks\n- [ ] Acceptance criteria verified\n- [ ] Negative case / failure mode checked (if applicable)\n- [ ] No unexpected file changes\n\n## Results\nStatus: PASS | FAIL\n\n### Notes\n- <what you observed>\n\n### Evidence\n- Logs/snippets:\n - <paste key excerpt>\n- Links:\n - <PR/issue/build link>\n\n## If FAIL\n- What broke:\n- How to reproduce:\n- Suggested fix / owner:\n\`\`\`\n`;
1957
+ const ticketsMd = renderTicketsMd(teamId);
1817
1958
 
1818
1959
  await writeFileSafely(planPath, planMd, overwrite ? "overwrite" : "createOnly");
1819
1960
  await writeFileSafely(statusPath, statusMd, overwrite ? "overwrite" : "createOnly");
1961
+ await writeFileSafely(qaChecklistPath, qaChecklistMd, overwrite ? "overwrite" : "createOnly");
1820
1962
  await writeFileSafely(ticketsPath, ticketsMd, overwrite ? "overwrite" : "createOnly");
1821
1963
 
1822
1964
  const agents = recipe.agents ?? [];
@@ -1866,7 +2008,7 @@ const recipesPlugin = {
1866
2008
 
1867
2009
  // Create a minimal TEAM.md
1868
2010
  const teamMdPath = path.join(teamDir, "TEAM.md");
1869
- 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`;
2011
+ const teamMd = renderTeamMd(teamId);
1870
2012
  await writeFileSafely(teamMdPath, teamMd, options.overwrite ? "overwrite" : "createOnly");
1871
2013
 
1872
2014
  if (options.applyConfig) {
@@ -1899,6 +2041,74 @@ const recipesPlugin = {
1899
2041
  ),
1900
2042
  );
1901
2043
  });
2044
+
2045
+ cmd
2046
+ .command("cleanup-workspaces")
2047
+ .description("Dry-run (default) or delete temporary scaffold/test workspaces under ~/.openclaw with safety rails")
2048
+ .option("--yes", "Actually delete eligible workspaces (otherwise: dry-run)")
2049
+ .option("--dry-run", "Force dry-run (lists candidates; deletes nothing)")
2050
+ .option(
2051
+ "--prefix <prefix>",
2052
+ `Allowed teamId prefix (repeatable). Default: ${DEFAULT_ALLOWED_PREFIXES.join(", ")}`,
2053
+ (val: string, acc: string[]) => {
2054
+ acc.push(String(val));
2055
+ return acc;
2056
+ },
2057
+ [] as string[],
2058
+ )
2059
+ .option("--json", "Output JSON")
2060
+ .action(async (options: any) =>
2061
+ runRecipesCommand("openclaw recipes cleanup-workspaces", async () => {
2062
+ const baseWorkspace = api.config.agents?.defaults?.workspace;
2063
+ if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
2064
+
2065
+ // Workspaces live alongside the default workspace (same parent dir): ~/.openclaw/workspace-*
2066
+ const rootDir = path.resolve(baseWorkspace, "..");
2067
+
2068
+ const prefixes: string[] = (options.prefix as string[])?.length ? (options.prefix as string[]) : [...DEFAULT_ALLOWED_PREFIXES];
2069
+ const protectedTeamIds: string[] = [...DEFAULT_PROTECTED_TEAM_IDS];
2070
+
2071
+ const plan = await planWorkspaceCleanup({ rootDir, prefixes, protectedTeamIds });
2072
+ const yes = Boolean(options.yes) && !Boolean(options.dryRun);
2073
+ const result = await executeWorkspaceCleanup(plan, { yes });
2074
+
2075
+ if (options.json) {
2076
+ console.log(JSON.stringify(result, null, 2));
2077
+ return;
2078
+ }
2079
+
2080
+ const candidates = result.candidates;
2081
+ const skipped = result.skipped;
2082
+
2083
+ console.log(`Root: ${result.rootDir}`);
2084
+ console.log(`Mode: ${result.dryRun ? "dry-run" : "delete"}`);
2085
+ console.log(`Allowed prefixes: ${prefixes.join(", ")}`);
2086
+ console.log(`Protected teams: ${protectedTeamIds.join(", ")}`);
2087
+
2088
+ console.log(`\nCandidates (${candidates.length})`);
2089
+ for (const c of candidates) console.log(`- ${c.dirName}`);
2090
+
2091
+ console.log(`\nSkipped (${skipped.length})`);
2092
+ for (const s of skipped) {
2093
+ const label = s.dirName + (s.teamId ? ` (${s.teamId})` : "");
2094
+ console.log(`- ${label}: ${s.reason}`);
2095
+ }
2096
+
2097
+ if (result.dryRun) {
2098
+ console.log(`\nDry-run complete. Re-run with --yes to delete the ${candidates.length} candidate(s).`);
2099
+ return;
2100
+ }
2101
+
2102
+ console.log(`\nDeleted (${result.deleted.length})`);
2103
+ for (const p of result.deleted) console.log(`- ${p}`);
2104
+
2105
+ if ((result as any).deleteErrors?.length) {
2106
+ console.log(`\nDelete errors (${(result as any).deleteErrors.length})`);
2107
+ for (const e of (result as any).deleteErrors) console.log(`- ${e.path}: ${e.error}`);
2108
+ process.exitCode = 1;
2109
+ }
2110
+ }),
2111
+ );
1902
2112
  },
1903
2113
  { commands: ["recipes"] },
1904
2114
  );