@clawcipes/recipes 0.2.7 → 0.2.8

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,21 +2,9 @@ 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";
6
5
  import JSON5 from "json5";
7
6
  import YAML from "yaml";
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";
7
+ import { buildRemoveTeamPlan, executeRemoveTeamPlan, loadCronStore, saveCronStore } from "./src/lib/remove-team";
20
8
 
21
9
  type RecipesConfig = {
22
10
  workspaceRecipesDir?: string;
@@ -201,32 +189,23 @@ async function ensureDir(p: string) {
201
189
  await fs.mkdir(p, { recursive: true });
202
190
  }
203
191
 
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}`;
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);
220
196
  }
221
197
 
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
- }
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
+ ]);
230
209
  }
231
210
 
232
211
  type CronInstallMode = "off" | "prompt" | "on";
@@ -286,73 +265,17 @@ type OpenClawCronJob = {
286
265
  description?: string;
287
266
  };
288
267
 
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
268
+ function spawnOpenClawJson(args: string[]) {
269
+ const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
270
+ const res = spawnSync("openclaw", args, { encoding: "utf8" });
271
+ if (res.status !== 0) {
272
+ const err = new Error(`openclaw ${args.join(" ")} failed (exit=${res.status})`);
273
+ (err as any).stdout = res.stdout;
274
+ (err as any).stderr = res.stderr;
275
+ throw err;
306
276
  }
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
- };
277
+ const out = String(res.stdout ?? "").trim();
278
+ return out ? (JSON.parse(out) as any) : null;
356
279
  }
357
280
 
358
281
  function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
@@ -421,25 +344,15 @@ async function reconcileRecipeCronJobs(opts: {
421
344
  const header = `Recipe ${opts.scope.recipeId} defines ${desired.length} cron job(s).\nThese run automatically on a schedule. Install them?`;
422
345
  userOptIn = await promptYesNo(header);
423
346
  if (!userOptIn && !process.stdin.isTTY) {
424
- console.error("Non-interactive mode: skipping cron installation (no consent). Use cronInstallation=on to force install.");
347
+ console.error("Non-interactive mode: defaulting cron install to disabled.");
425
348
  }
426
349
  }
427
350
 
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
-
438
351
  const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
439
352
  const state = await loadCronMappingState(statePath);
440
353
 
441
- const store = await loadCronStore();
442
- const byId = new Map((store.jobs ?? []).map((j: any) => [j.id, j] as const));
354
+ const list = spawnOpenClawJson(["cron", "list", "--json"]) as { jobs: OpenClawCronJob[] };
355
+ const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
443
356
 
444
357
  const now = Date.now();
445
358
  const desiredIds = new Set(desired.map((j) => j.id));
@@ -456,9 +369,7 @@ async function reconcileRecipeCronJobs(opts: {
456
369
  timezone: j.timezone ?? "",
457
370
  channel: j.channel ?? "last",
458
371
  to: j.to ?? "",
459
- agentId:
460
- j.agentId ??
461
- (opts.scope.kind === "team" ? `${(opts.scope as any).teamId}-lead` : ""),
372
+ agentId: j.agentId ?? "",
462
373
  name,
463
374
  description: j.description ?? "",
464
375
  };
@@ -471,43 +382,65 @@ async function reconcileRecipeCronJobs(opts: {
471
382
  const wantEnabled = userOptIn ? Boolean(j.enabledByDefault) : false;
472
383
 
473
384
  if (!existing) {
474
- // Create new job in cron store.
475
- const newJob = buildCronJobFromSpec({
385
+ // Create new job.
386
+ const args = [
387
+ "cron",
388
+ "add",
389
+ "--json",
390
+ "--name",
476
391
  name,
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 });
392
+ "--cron",
393
+ j.schedule,
394
+ "--message",
395
+ j.message,
396
+ "--announce",
397
+ ];
398
+ if (!wantEnabled) args.push("--disabled");
399
+ if (j.description) args.push("--description", j.description);
400
+ if (j.timezone) args.push("--tz", j.timezone);
401
+ if (j.channel) args.push("--channel", j.channel);
402
+ if (j.to) args.push("--to", j.to);
403
+ if (j.agentId) args.push("--agent", j.agentId);
404
+
405
+ const created = spawnOpenClawJson(args) as any;
406
+ const newId = created?.id ?? created?.job?.id;
407
+ if (!newId) throw new Error("Failed to parse cron add output (missing id)");
408
+
409
+ state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
410
+ results.push({ action: "created", key, installedCronId: newId, enabled: wantEnabled });
489
411
  continue;
490
412
  }
491
413
 
492
414
  // Update existing job if spec changed.
493
415
  if (prev?.specHash !== specHash) {
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;
416
+ const editArgs = [
417
+ "cron",
418
+ "edit",
419
+ existing.id,
420
+ "--name",
421
+ name,
422
+ "--cron",
423
+ j.schedule,
424
+ "--message",
425
+ j.message,
426
+ "--announce",
427
+ ];
428
+ if (j.description) editArgs.push("--description", j.description);
429
+ if (j.timezone) editArgs.push("--tz", j.timezone);
430
+ if (j.channel) editArgs.push("--channel", j.channel);
431
+ if (j.to) editArgs.push("--to", j.to);
432
+ if (j.agentId) editArgs.push("--agent", j.agentId);
433
+
434
+ spawnOpenClawJson(editArgs);
501
435
  results.push({ action: "updated", key, installedCronId: existing.id });
502
436
  } else {
503
437
  results.push({ action: "unchanged", key, installedCronId: existing.id });
504
438
  }
505
439
 
506
- // Enabled precedence: if user did not opt in, force disabled.
440
+ // Enabled precedence: if user did not opt in, force disabled. Otherwise preserve current enabled state.
507
441
  if (!userOptIn) {
508
442
  if (existing.enabled) {
509
- existing.enabled = false;
510
- existing.updatedAtMs = now;
443
+ spawnOpenClawJson(["cron", "edit", existing.id, "--disable"]);
511
444
  results.push({ action: "disabled", key, installedCronId: existing.id });
512
445
  }
513
446
  }
@@ -523,8 +456,7 @@ async function reconcileRecipeCronJobs(opts: {
523
456
 
524
457
  const job = byId.get(entry.installedCronId);
525
458
  if (job && job.enabled) {
526
- job.enabled = false;
527
- job.updatedAtMs = now;
459
+ spawnOpenClawJson(["cron", "edit", job.id, "--disable"]);
528
460
  results.push({ action: "disabled-removed", key, installedCronId: job.id });
529
461
  }
530
462
 
@@ -532,7 +464,6 @@ async function reconcileRecipeCronJobs(opts: {
532
464
  }
533
465
 
534
466
  await writeJsonFile(statePath, state);
535
- await saveCronStore(store);
536
467
 
537
468
  const changed = results.some((r) => r.action === "created" || r.action === "updated" || r.action?.startsWith("disabled"));
538
469
  return { ok: true, changed, results };
@@ -654,7 +585,24 @@ function stableStringify(x: any) {
654
585
  }
655
586
 
656
587
  function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
657
- return upsertBindingInConfigCore(cfgObj, binding);
588
+ if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
589
+ const list: any[] = cfgObj.bindings;
590
+
591
+ const sig = stableStringify({ agentId: binding.agentId, match: binding.match });
592
+ const idx = list.findIndex((b) => stableStringify({ agentId: b?.agentId, match: b?.match }) === sig);
593
+
594
+ if (idx >= 0) {
595
+ // Update in place (preserve ordering)
596
+ list[idx] = { ...list[idx], ...binding };
597
+ return { changed: false, note: "already-present" as const };
598
+ }
599
+
600
+ // Most-specific-first: if a peer match is specified, insert at front so it wins.
601
+ // Otherwise append.
602
+ if (binding.match?.peer) list.unshift(binding);
603
+ else list.push(binding);
604
+
605
+ return { changed: true, note: "added" as const };
658
606
  }
659
607
 
660
608
  function removeBindingsInConfig(cfgObj: any, opts: { agentId?: string; match: BindingMatch }) {
@@ -1073,11 +1021,11 @@ const recipesPlugin = {
1073
1021
  });
1074
1022
 
1075
1023
  cmd
1076
- .command("install")
1024
+ .command("install-skill")
1077
1025
  .description(
1078
1026
  "Install a skill from ClawHub (confirmation-gated). Default: global (~/.openclaw/skills). Use --agent-id or --team-id for scoped installs.",
1079
1027
  )
1080
- .argument("<idOrSlug>", "Recipe id OR ClawHub skill slug")
1028
+ .argument("<skill>", "ClawHub skill slug (e.g. github)")
1081
1029
  .option("--yes", "Skip confirmation prompt")
1082
1030
  .option("--global", "Install into global shared skills (~/.openclaw/skills) (default when no scope flags)")
1083
1031
  .option("--agent-id <agentId>", "Install into a specific agent workspace (workspace-<agentId>)")
@@ -1186,22 +1134,28 @@ const recipesPlugin = {
1186
1134
  console.error(header);
1187
1135
  }
1188
1136
 
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
- );
1137
+ // Use clawhub CLI. Force install path based on scope.
1138
+ const { spawnSync } = await import("node:child_process");
1139
+ for (const slug of missing) {
1140
+ const res = spawnSync(
1141
+ "npx",
1142
+ ["clawhub@latest", "--workdir", workdir, "--dir", dirName, "install", slug],
1143
+ { stdio: "inherit" },
1144
+ );
1145
+ if (res.status !== 0) {
1146
+ process.exitCode = res.status ?? 1;
1147
+ console.error(`Failed installing ${slug} (exit=${process.exitCode}).`);
1148
+ return;
1149
+ }
1150
+ }
1195
1151
 
1196
1152
  console.log(
1197
1153
  JSON.stringify(
1198
1154
  {
1199
- ok: false,
1200
- reason: "manual-install-required",
1201
- missing,
1155
+ ok: true,
1156
+ installed: missing,
1202
1157
  installDir,
1203
- commands,
1204
- next: "Run the commands above, then: openclaw gateway restart",
1158
+ next: `Try: openclaw skills list (or check ${installDir})`,
1205
1159
  },
1206
1160
  null,
1207
1161
  2,
@@ -1209,6 +1163,57 @@ const recipesPlugin = {
1209
1163
  );
1210
1164
  });
1211
1165
 
1166
+ async function installMarketplaceRecipe(slug: string, options: any) {
1167
+ const cfg = getCfg(api);
1168
+ const baseWorkspace = api.config.agents?.defaults?.workspace;
1169
+ if (!baseWorkspace) throw new Error("agents.defaults.workspace is not set in config");
1170
+
1171
+ const base = String(options.registryBase ?? "").replace(/\/+$/, "");
1172
+ const s = String(slug ?? "").trim();
1173
+ if (!s) throw new Error("slug is required");
1174
+
1175
+ const metaUrl = `${base}/api/marketplace/recipes/${encodeURIComponent(s)}`;
1176
+ const metaRes = await fetch(metaUrl);
1177
+ if (!metaRes.ok) {
1178
+ const hint = `Recipe not found: ${s}. Did you mean:\n- openclaw recipes install ${s} # marketplace recipe\n- openclaw recipes install-skill ${s} # ClawHub skill`;
1179
+ throw new Error(`Registry lookup failed (${metaRes.status}): ${metaUrl}\n\n${hint}`);
1180
+ }
1181
+ const metaData = (await metaRes.json()) as any;
1182
+ const recipe = metaData?.recipe;
1183
+ const sourceUrl = String(recipe?.sourceUrl ?? "").trim();
1184
+ if (!metaData?.ok || !sourceUrl) {
1185
+ throw new Error(`Registry response missing recipe.sourceUrl for ${s}`);
1186
+ }
1187
+
1188
+ const mdRes = await fetch(sourceUrl);
1189
+ if (!mdRes.ok) throw new Error(`Failed downloading recipe markdown (${mdRes.status}): ${sourceUrl}`);
1190
+ const md = await mdRes.text();
1191
+
1192
+ const recipesDir = path.join(baseWorkspace, cfg.workspaceRecipesDir);
1193
+ await ensureDir(recipesDir);
1194
+ const destPath = path.join(recipesDir, `${s}.md`);
1195
+
1196
+ await writeFileSafely(destPath, md, options.overwrite ? "overwrite" : "createOnly");
1197
+
1198
+ console.log(JSON.stringify({ ok: true, slug: s, wrote: destPath, sourceUrl, metaUrl }, null, 2));
1199
+ }
1200
+
1201
+ cmd
1202
+ .command("install")
1203
+ .description("Install a marketplace recipe into your workspace recipes dir (by slug)")
1204
+ .argument("<idOrSlug>", "Marketplace recipe slug (e.g. development-team)")
1205
+ .option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
1206
+ .option("--overwrite", "Overwrite existing recipe file")
1207
+ .action(async (slug: string, options: any) => installMarketplaceRecipe(slug, options));
1208
+
1209
+ cmd
1210
+ .command("install-recipe")
1211
+ .description("Alias for: recipes install <slug>")
1212
+ .argument("<slug>", "Marketplace recipe slug (e.g. development-team)")
1213
+ .option("--registry-base <url>", "Marketplace API base URL", "https://clawkitchen.ai")
1214
+ .option("--overwrite", "Overwrite existing recipe file")
1215
+ .action(async (slug: string, options: any) => installMarketplaceRecipe(slug, options));
1216
+
1212
1217
  cmd
1213
1218
  .command("dispatch")
1214
1219
  .description("Lead/dispatcher: turn a natural-language request into inbox + backlog ticket(s) + assignment stubs")
@@ -1352,6 +1357,82 @@ const recipesPlugin = {
1352
1357
  console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
1353
1358
  });
1354
1359
 
1360
+ cmd
1361
+ .command("remove-team")
1362
+ .description("Safe uninstall: remove a scaffolded team workspace + agents + stamped cron jobs")
1363
+ .requiredOption("--team-id <teamId>", "Team id")
1364
+ .option("--plan", "Print plan and exit")
1365
+ .option("--json", "Output JSON")
1366
+ .option("--yes", "Skip confirmation (apply destructive changes)")
1367
+ .option("--include-ambiguous", "Also remove cron jobs that only loosely match the team (dangerous)")
1368
+ .action(async (options: any) => {
1369
+ const teamId = String(options.teamId);
1370
+
1371
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1372
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1373
+
1374
+ const cronJobsPath = path.resolve(workspaceRoot, "..", "cron", "jobs.json");
1375
+
1376
+ const current = (api.runtime as any).config?.loadConfig?.();
1377
+ if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
1378
+ const cfgObj = (current.cfg ?? current) as any;
1379
+
1380
+ const cronStore = await loadCronStore(cronJobsPath);
1381
+
1382
+ const plan = await buildRemoveTeamPlan({
1383
+ teamId,
1384
+ workspaceRoot,
1385
+ openclawConfigPath: "(managed by api.runtime.config)",
1386
+ cronJobsPath,
1387
+ cfgObj,
1388
+ cronStore,
1389
+ });
1390
+
1391
+ const wantsJson = Boolean(options.json);
1392
+
1393
+ if (options.plan) {
1394
+ console.log(JSON.stringify({ ok: true, plan }, null, 2));
1395
+ return;
1396
+ }
1397
+
1398
+ if (!options.yes && !process.stdin.isTTY) {
1399
+ console.error("Refusing to prompt (non-interactive). Re-run with --yes or --plan.");
1400
+ process.exitCode = 2;
1401
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
1402
+ return;
1403
+ }
1404
+
1405
+ if (!options.yes && process.stdin.isTTY) {
1406
+ console.log(JSON.stringify({ plan }, null, 2));
1407
+ const ok = await promptYesNo(
1408
+ `This will DELETE workspace-${teamId}, remove matching agents from openclaw config, and remove stamped cron jobs.`,
1409
+ );
1410
+ if (!ok) {
1411
+ console.error("Aborted; no changes made.");
1412
+ return;
1413
+ }
1414
+ }
1415
+
1416
+ const includeAmbiguous = Boolean(options.includeAmbiguous);
1417
+
1418
+ const result = await executeRemoveTeamPlan({
1419
+ plan,
1420
+ includeAmbiguous,
1421
+ cfgObj,
1422
+ cronStore,
1423
+ });
1424
+
1425
+ await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
1426
+ await saveCronStore(cronJobsPath, cronStore);
1427
+
1428
+ if (wantsJson) {
1429
+ console.log(JSON.stringify(result, null, 2));
1430
+ } else {
1431
+ console.log(JSON.stringify(result, null, 2));
1432
+ console.error("Restart required: openclaw gateway restart");
1433
+ }
1434
+ });
1435
+
1355
1436
  cmd
1356
1437
  .command("tickets")
1357
1438
  .description("List tickets for a team (backlog / in-progress / testing / done)")
@@ -1363,6 +1444,8 @@ const recipesPlugin = {
1363
1444
  const teamId = String(options.teamId);
1364
1445
  const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1365
1446
 
1447
+ await ensureTicketStageDirs(teamDir);
1448
+
1366
1449
  const dirs = {
1367
1450
  backlog: path.join(teamDir, "work", "backlog"),
1368
1451
  inProgress: path.join(teamDir, "work", "in-progress"),
@@ -1373,34 +1456,31 @@ const recipesPlugin = {
1373
1456
  const readTickets = async (dir: string, stage: "backlog" | "in-progress" | "testing" | "done") => {
1374
1457
  if (!(await fileExists(dir))) return [] as any[];
1375
1458
  const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
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
- );
1459
+ return files.map((f) => {
1460
+ const m = f.match(/^(\d{4})-(.+)\.md$/);
1461
+ return {
1462
+ stage,
1463
+ number: m ? Number(m[1]) : null,
1464
+ id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
1465
+ file: path.join(dir, f),
1466
+ };
1467
+ });
1396
1468
  };
1397
1469
 
1470
+ const backlog = await readTickets(dirs.backlog, "backlog");
1471
+ const inProgress = await readTickets(dirs.inProgress, "in-progress");
1472
+ const testing = await readTickets(dirs.testing, "testing");
1473
+ const done = await readTickets(dirs.done, "done");
1474
+
1398
1475
  const out = {
1399
1476
  teamId,
1400
- backlog: await readTickets(dirs.backlog, "backlog"),
1401
- inProgress: await readTickets(dirs.inProgress, "in-progress"),
1402
- testing: await readTickets(dirs.testing, "testing"),
1403
- done: await readTickets(dirs.done, "done"),
1477
+ // Stable, machine-friendly list for consumers (watchers, dashboards)
1478
+ // Keep the per-lane arrays for backwards-compat.
1479
+ tickets: [...backlog, ...inProgress, ...testing, ...done],
1480
+ backlog,
1481
+ inProgress,
1482
+ testing,
1483
+ done,
1404
1484
  };
1405
1485
 
1406
1486
  if (options.json) {
@@ -1427,12 +1507,13 @@ const recipesPlugin = {
1427
1507
  .requiredOption("--to <stage>", "Destination stage: backlog|in-progress|testing|done")
1428
1508
  .option("--completed", "When moving to done, add Completed: timestamp")
1429
1509
  .option("--yes", "Skip confirmation")
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}`);
1510
+ .action(async (options: any) => {
1511
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1512
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1513
+ const teamId = String(options.teamId);
1514
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1515
+
1516
+ await ensureTicketStageDirs(teamDir);
1436
1517
 
1437
1518
  const dest = String(options.to);
1438
1519
  if (!['backlog','in-progress','testing','done'].includes(dest)) {
@@ -1469,11 +1550,7 @@ const recipesPlugin = {
1469
1550
  if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1470
1551
 
1471
1552
  const destDir = stageDir(dest);
1472
- if (dest === 'testing') {
1473
- await ensureLaneDir({ teamDir, lane: 'testing', command: 'openclaw recipes move-ticket' });
1474
- } else {
1475
- await ensureDir(destDir);
1476
- }
1553
+ await ensureDir(destDir);
1477
1554
  const filename = path.basename(srcPath);
1478
1555
  const destPath = path.join(destDir, filename);
1479
1556
 
@@ -1532,7 +1609,7 @@ const recipesPlugin = {
1532
1609
  }
1533
1610
 
1534
1611
  console.log(JSON.stringify({ ok: true, moved: plan }, null, 2));
1535
- }));
1612
+ });
1536
1613
 
1537
1614
  cmd
1538
1615
  .command("assign")
@@ -1548,18 +1625,44 @@ const recipesPlugin = {
1548
1625
  const teamId = String(options.teamId);
1549
1626
  const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1550
1627
 
1628
+ await ensureTicketStageDirs(teamDir);
1629
+
1551
1630
  const owner = String(options.owner);
1552
1631
  if (!['dev','devops','lead','test'].includes(owner)) {
1553
1632
  throw new Error("--owner must be one of: dev, devops, lead, test");
1554
1633
  }
1555
1634
 
1635
+ const stageDir = (stage: string) => {
1636
+ if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1637
+ if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1638
+ if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1639
+ if (stage === 'done') return path.join(teamDir, 'work', 'done');
1640
+ throw new Error(`Unknown stage: ${stage}`);
1641
+ };
1642
+ const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
1643
+
1556
1644
  const ticketArg = String(options.ticket);
1557
- const ticketPath = await findTicketFileAnyLane({ teamDir, ticket: ticketArg });
1645
+ const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1646
+
1647
+ const findTicketFile = async () => {
1648
+ for (const dir of searchDirs) {
1649
+ if (!(await fileExists(dir))) continue;
1650
+ const files = await fs.readdir(dir);
1651
+ for (const f of files) {
1652
+ if (!f.endsWith('.md')) continue;
1653
+ if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1654
+ if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1655
+ }
1656
+ }
1657
+ return null;
1658
+ };
1659
+
1660
+ const ticketPath = await findTicketFile();
1558
1661
  if (!ticketPath) throw new Error(`Ticket not found: ${ticketArg}`);
1559
1662
 
1560
1663
  const filename = path.basename(ticketPath);
1561
1664
  const m = filename.match(/^(\d{4})-(.+)\.md$/);
1562
- const ticketNumStr = m?.[1] ?? '0000';
1665
+ const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1563
1666
  const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1564
1667
 
1565
1668
  const assignmentsDir = path.join(teamDir, 'work', 'assignments');
@@ -1605,77 +1708,119 @@ const recipesPlugin = {
1605
1708
  });
1606
1709
 
1607
1710
  cmd
1608
- .command("handoff")
1609
- .description("QA handoff: assign ticket to tester + move to testing")
1711
+ .command("take")
1712
+ .description("Shortcut: assign ticket to owner + move to in-progress")
1610
1713
  .requiredOption("--team-id <teamId>", "Team id")
1611
1714
  .requiredOption("--ticket <ticket>", "Ticket id or number")
1612
- .option("--tester <tester>", "Tester/owner (default: test)", "test")
1613
- .option("--overwrite", "Overwrite existing assignment file")
1715
+ .option("--owner <owner>", "Owner: dev|devops|lead|test", "dev")
1614
1716
  .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}`);
1717
+ .action(async (options: any) => {
1718
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1719
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1720
+ const teamId = String(options.teamId);
1721
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1621
1722
 
1622
- const tester = String(options.tester ?? "test");
1723
+ await ensureTicketStageDirs(teamDir);
1623
1724
 
1624
- const plan = {
1625
- teamId,
1626
- ticket: String(options.ticket),
1627
- tester,
1628
- overwriteAssignment: !!options.overwrite,
1725
+ const owner = String(options.owner ?? 'dev');
1726
+ if (!['dev','devops','lead','test'].includes(owner)) {
1727
+ throw new Error("--owner must be one of: dev, devops, lead, test");
1728
+ }
1729
+
1730
+ const stageDir = (stage: string) => {
1731
+ if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1732
+ if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1733
+ if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1734
+ if (stage === 'done') return path.join(teamDir, 'work', 'done');
1735
+ throw new Error(`Unknown stage: ${stage}`);
1629
1736
  };
1737
+ const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
1738
+
1739
+ const ticketArg = String(options.ticket);
1740
+ const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1741
+
1742
+ const findTicketFile = async () => {
1743
+ for (const dir of searchDirs) {
1744
+ if (!(await fileExists(dir))) continue;
1745
+ const files = await fs.readdir(dir);
1746
+ for (const f of files) {
1747
+ if (!f.endsWith('.md')) continue;
1748
+ if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1749
+ if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1750
+ }
1751
+ }
1752
+ return null;
1753
+ };
1754
+
1755
+ const srcPath = await findTicketFile();
1756
+ if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1757
+
1758
+ const destDir = stageDir('in-progress');
1759
+ await ensureDir(destDir);
1760
+ const filename = path.basename(srcPath);
1761
+ const destPath = path.join(destDir, filename);
1762
+
1763
+ const patch = (md: string) => {
1764
+ let out = md;
1765
+ if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
1766
+ else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
1767
+
1768
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: in-progress`);
1769
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
1770
+
1771
+ return out;
1772
+ };
1773
+
1774
+ const plan = { from: srcPath, to: destPath, owner };
1630
1775
 
1631
1776
  if (!options.yes && process.stdin.isTTY) {
1632
1777
  console.log(JSON.stringify({ plan }, null, 2));
1633
- const readline = await import("node:readline/promises");
1778
+ const readline = await import('node:readline/promises');
1634
1779
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1635
1780
  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";
1781
+ const ans = await rl.question(`Assign to ${owner} and move to in-progress? (y/N) `);
1782
+ const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1638
1783
  if (!ok) {
1639
- console.error("Aborted; no changes made.");
1784
+ console.error('Aborted; no changes made.');
1640
1785
  return;
1641
1786
  }
1642
1787
  } finally {
1643
1788
  rl.close();
1644
1789
  }
1645
1790
  } else if (!options.yes && !process.stdin.isTTY) {
1646
- console.error("Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.");
1791
+ console.error('Refusing to take without confirmation in non-interactive mode. Re-run with --yes.');
1647
1792
  process.exitCode = 2;
1648
1793
  console.log(JSON.stringify({ ok: false, plan }, null, 2));
1649
1794
  return;
1650
1795
  }
1651
1796
 
1652
- const res = await handoffTicketCore({
1653
- teamDir,
1654
- ticket: String(options.ticket),
1655
- tester,
1656
- overwriteAssignment: !!options.overwrite,
1657
- });
1797
+ const md = await fs.readFile(srcPath, 'utf8');
1798
+ const nextMd = patch(md);
1799
+ await fs.writeFile(srcPath, nextMd, 'utf8');
1658
1800
 
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
- }));
1801
+ if (srcPath !== destPath) {
1802
+ await fs.rename(srcPath, destPath);
1803
+ }
1804
+
1805
+ const m = filename.match(/^(\d{4})-(.+)\.md$/);
1806
+ const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1807
+ const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1808
+ const assignmentsDir = path.join(teamDir, 'work', 'assignments');
1809
+ await ensureDir(assignmentsDir);
1810
+ const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
1811
+ 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`;
1812
+ await writeFileSafely(assignmentPath, assignmentMd, 'createOnly');
1813
+
1814
+ console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
1815
+ });
1672
1816
 
1673
1817
  cmd
1674
- .command("take")
1675
- .description("Shortcut: assign ticket to owner + move to in-progress")
1818
+ .command("handoff")
1819
+ .description("QA handoff: move ticket to testing + assign to tester")
1676
1820
  .requiredOption("--team-id <teamId>", "Team id")
1677
1821
  .requiredOption("--ticket <ticket>", "Ticket id or number")
1678
- .option("--owner <owner>", "Owner: dev|devops|lead|test", "dev")
1822
+ .option("--tester <owner>", "Tester owner (default: test)", "test")
1823
+ .option("--overwrite", "Overwrite destination ticket file / assignment stub if they already exist")
1679
1824
  .option("--yes", "Skip confirmation")
1680
1825
  .action(async (options: any) => {
1681
1826
  const workspaceRoot = api.config.agents?.defaults?.workspace;
@@ -1683,39 +1828,73 @@ const recipesPlugin = {
1683
1828
  const teamId = String(options.teamId);
1684
1829
  const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1685
1830
 
1686
- const owner = String(options.owner ?? 'dev');
1687
- if (!['dev','devops','lead','test'].includes(owner)) {
1688
- throw new Error("--owner must be one of: dev, devops, lead, test");
1831
+ const tester = String(options.tester ?? "test");
1832
+ if (!['dev','devops','lead','test'].includes(tester)) {
1833
+ throw new Error("--tester must be one of: dev, devops, lead, test");
1689
1834
  }
1690
1835
 
1836
+ const stageDir = (stage: string) => {
1837
+ if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1838
+ if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1839
+ throw new Error(`Unknown stage: ${stage}`);
1840
+ };
1841
+
1691
1842
  const ticketArg = String(options.ticket);
1692
- const srcPath = await findTicketFileAnyLane({ teamDir, ticket: ticketArg });
1693
- if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1843
+ const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1694
1844
 
1695
- const destDir = stageDir('in-progress');
1696
- await ensureDir(destDir);
1845
+ const findTicketFile = async (dir: string) => {
1846
+ if (!(await fileExists(dir))) return null;
1847
+ const files = await fs.readdir(dir);
1848
+ for (const f of files) {
1849
+ if (!f.endsWith('.md')) continue;
1850
+ if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1851
+ if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1852
+ }
1853
+ return null;
1854
+ };
1855
+
1856
+ const inProgressDir = stageDir('in-progress');
1857
+ const testingDir = stageDir('testing');
1858
+ await ensureDir(testingDir);
1859
+
1860
+ const srcInProgress = await findTicketFile(inProgressDir);
1861
+ const srcTesting = await findTicketFile(testingDir);
1862
+
1863
+ if (!srcInProgress && !srcTesting) {
1864
+ throw new Error(`Ticket not found in in-progress/testing: ${ticketArg}`);
1865
+ }
1866
+ if (!srcInProgress && srcTesting) {
1867
+ // already in testing (idempotent path)
1868
+ }
1869
+
1870
+ const srcPath = srcInProgress ?? srcTesting!;
1697
1871
  const filename = path.basename(srcPath);
1698
- const destPath = path.join(destDir, filename);
1872
+ const destPath = path.join(testingDir, filename);
1699
1873
 
1700
1874
  const patch = (md: string) => {
1701
1875
  let out = md;
1702
- if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
1703
- else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
1876
+ if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${tester}`);
1877
+ else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${tester}\n`);
1704
1878
 
1705
- if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: in-progress`);
1706
- else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
1879
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: testing`);
1880
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: testing\n`);
1707
1881
 
1708
1882
  return out;
1709
1883
  };
1710
1884
 
1711
- const plan = { from: srcPath, to: destPath, owner };
1885
+ const plan = {
1886
+ from: srcPath,
1887
+ to: destPath,
1888
+ tester,
1889
+ note: srcTesting ? 'already-in-testing' : 'move-to-testing',
1890
+ };
1712
1891
 
1713
1892
  if (!options.yes && process.stdin.isTTY) {
1714
1893
  console.log(JSON.stringify({ plan }, null, 2));
1715
1894
  const readline = await import('node:readline/promises');
1716
1895
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1717
1896
  try {
1718
- const ans = await rl.question(`Assign to ${owner} and move to in-progress? (y/N) `);
1897
+ const ans = await rl.question(`Move to testing + assign to ${tester}? (y/N) `);
1719
1898
  const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1720
1899
  if (!ok) {
1721
1900
  console.error('Aborted; no changes made.');
@@ -1725,28 +1904,37 @@ const recipesPlugin = {
1725
1904
  rl.close();
1726
1905
  }
1727
1906
  } else if (!options.yes && !process.stdin.isTTY) {
1728
- console.error('Refusing to take without confirmation in non-interactive mode. Re-run with --yes.');
1907
+ console.error('Refusing to handoff without confirmation in non-interactive mode. Re-run with --yes.');
1729
1908
  process.exitCode = 2;
1730
1909
  console.log(JSON.stringify({ ok: false, plan }, null, 2));
1731
1910
  return;
1732
1911
  }
1733
1912
 
1913
+ if (srcInProgress && srcPath !== destPath) {
1914
+ if (!options.overwrite && (await fileExists(destPath))) {
1915
+ throw new Error(`Destination exists: ${destPath} (re-run with --overwrite to replace)`);
1916
+ }
1917
+ }
1918
+
1734
1919
  const md = await fs.readFile(srcPath, 'utf8');
1735
1920
  const nextMd = patch(md);
1736
1921
  await fs.writeFile(srcPath, nextMd, 'utf8');
1737
1922
 
1738
- if (srcPath !== destPath) {
1923
+ if (srcInProgress && srcPath !== destPath) {
1924
+ if (options.overwrite && (await fileExists(destPath))) {
1925
+ await fs.rm(destPath);
1926
+ }
1739
1927
  await fs.rename(srcPath, destPath);
1740
1928
  }
1741
1929
 
1742
1930
  const m = filename.match(/^(\d{4})-(.+)\.md$/);
1743
- const ticketNumStr = m?.[1] ?? '0000';
1931
+ const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1744
1932
  const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1745
1933
  const assignmentsDir = path.join(teamDir, 'work', 'assignments');
1746
1934
  await ensureDir(assignmentsDir);
1747
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
1748
- 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`;
1749
- await writeFileSafely(assignmentPath, assignmentMd, 'createOnly');
1935
+ const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${tester}.md`);
1936
+ 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`;
1937
+ await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? 'overwrite' : 'createOnly');
1750
1938
 
1751
1939
  console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
1752
1940
  });
@@ -1757,61 +1945,26 @@ const recipesPlugin = {
1757
1945
  .requiredOption("--team-id <teamId>", "Team id")
1758
1946
  .requiredOption("--ticket <ticket>", "Ticket id or number")
1759
1947
  .option("--yes", "Skip confirmation")
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
- );
1948
+ .action(async (options: any) => {
1949
+ const args = [
1950
+ 'recipes',
1951
+ 'move-ticket',
1952
+ '--team-id',
1953
+ String(options.teamId),
1954
+ '--ticket',
1955
+ String(options.ticket),
1956
+ '--to',
1957
+ 'done',
1958
+ '--completed',
1959
+ ];
1960
+ if (options.yes) args.push('--yes');
1961
+
1962
+ const { spawnSync } = await import('node:child_process');
1963
+ const res = spawnSync('openclaw', args, { stdio: 'inherit' });
1964
+ if (res.status !== 0) {
1965
+ process.exitCode = res.status ?? 1;
1966
+ }
1967
+ });
1815
1968
 
1816
1969
  cmd
1817
1970
  .command("scaffold")
@@ -1948,17 +2101,14 @@ const recipesPlugin = {
1948
2101
 
1949
2102
  const planPath = path.join(notesDir, "plan.md");
1950
2103
  const statusPath = path.join(notesDir, "status.md");
1951
- const qaChecklistPath = path.join(notesDir, "QA_CHECKLIST.md");
1952
2104
  const ticketsPath = path.join(teamDir, "TICKETS.md");
1953
2105
 
1954
2106
  const planMd = `# Plan — ${teamId}\n\n- (empty)\n`;
1955
2107
  const statusMd = `# Status — ${teamId}\n\n- (empty)\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);
2108
+ 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`;
1958
2109
 
1959
2110
  await writeFileSafely(planPath, planMd, overwrite ? "overwrite" : "createOnly");
1960
2111
  await writeFileSafely(statusPath, statusMd, overwrite ? "overwrite" : "createOnly");
1961
- await writeFileSafely(qaChecklistPath, qaChecklistMd, overwrite ? "overwrite" : "createOnly");
1962
2112
  await writeFileSafely(ticketsPath, ticketsMd, overwrite ? "overwrite" : "createOnly");
1963
2113
 
1964
2114
  const agents = recipe.agents ?? [];
@@ -2008,7 +2158,7 @@ const recipesPlugin = {
2008
2158
 
2009
2159
  // Create a minimal TEAM.md
2010
2160
  const teamMdPath = path.join(teamDir, "TEAM.md");
2011
- const teamMd = renderTeamMd(teamId);
2161
+ 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`;
2012
2162
  await writeFileSafely(teamMdPath, teamMd, options.overwrite ? "overwrite" : "createOnly");
2013
2163
 
2014
2164
  if (options.applyConfig) {
@@ -2041,78 +2191,32 @@ const recipesPlugin = {
2041
2191
  ),
2042
2192
  );
2043
2193
  });
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
- );
2112
2194
  },
2113
2195
  { commands: ["recipes"] },
2114
2196
  );
2115
2197
  },
2116
2198
  };
2117
2199
 
2200
+ // Internal helpers used by unit tests. Not part of the public plugin API.
2201
+ export const __internal = {
2202
+ ensureMainFirstInAgentsList,
2203
+ upsertBindingInConfig,
2204
+ removeBindingsInConfig,
2205
+ stableStringify,
2206
+
2207
+ patchTicketField(md: string, key: string, value: string) {
2208
+ const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
2209
+ if (md.match(lineRe)) return md.replace(lineRe, `${key}: ${value}`);
2210
+ return md.replace(/^(# .+\n)/, `$1\n${key}: ${value}\n`);
2211
+ },
2212
+
2213
+ patchTicketOwner(md: string, owner: string) {
2214
+ return this.patchTicketField(md, "Owner", owner);
2215
+ },
2216
+
2217
+ patchTicketStatus(md: string, status: string) {
2218
+ return this.patchTicketField(md, "Status", status);
2219
+ },
2220
+ };
2221
+
2118
2222
  export default recipesPlugin;