@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/README.md +17 -3
- package/docs/AGENTS_AND_SKILLS.md +2 -1
- package/docs/CLAWCIPES_KITCHEN.md +1 -1
- package/docs/COMMANDS.md +39 -2
- package/docs/verify-built-in-team-recipes.md +65 -0
- package/index.ts +394 -184
- package/package.json +9 -3
- package/recipes/default/customer-support-team.md +26 -4
- package/recipes/default/development-team.md +14 -0
- package/recipes/default/product-team.md +32 -15
- package/recipes/default/research-team.md +21 -1
- package/recipes/default/social-team.md +89 -5
- package/recipes/default/writing-team.md +23 -2
- package/src/lib/bindings.ts +59 -0
- package/src/lib/cleanup-workspaces.ts +173 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/lanes.ts +63 -0
- package/src/lib/recipe-frontmatter.ts +59 -0
- package/src/lib/scaffold-templates.ts +7 -0
- package/src/lib/shared-context.ts +52 -0
- package/src/lib/ticket-finder.ts +60 -0
- package/src/lib/ticket-workflow.ts +94 -0
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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:
|
|
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
|
|
335
|
-
const byId = new Map((
|
|
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:
|
|
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
|
|
367
|
-
"cron",
|
|
368
|
-
"add",
|
|
369
|
-
"--json",
|
|
370
|
-
"--name",
|
|
474
|
+
// Create new job in cron store.
|
|
475
|
+
const newJob = buildCronJobFromSpec({
|
|
371
476
|
name,
|
|
372
|
-
|
|
373
|
-
j.schedule,
|
|
374
|
-
|
|
375
|
-
j.message,
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
506
|
+
// Enabled precedence: if user did not opt in, force disabled.
|
|
421
507
|
if (!userOptIn) {
|
|
422
508
|
if (existing.enabled) {
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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:
|
|
1136
|
-
|
|
1199
|
+
ok: false,
|
|
1200
|
+
reason: "manual-install-required",
|
|
1201
|
+
missing,
|
|
1137
1202
|
installDir,
|
|
1138
|
-
|
|
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
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
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
|
|
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] ??
|
|
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
|
|
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] ??
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
String(options.ticket)
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
|
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 =
|
|
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
|
);
|