@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/README.md +30 -26
- package/docs/AGENTS_AND_SKILLS.md +14 -19
- package/docs/BUNDLED_RECIPES.md +1 -1
- package/docs/CLAWCIPES_KITCHEN.md +5 -5
- package/docs/INSTALLATION.md +5 -5
- package/docs/RECIPE_FORMAT.md +1 -1
- package/docs/TEAM_WORKFLOW.md +5 -2
- package/docs/TUTORIAL_CREATE_RECIPE.md +15 -6
- package/index.ts +493 -389
- package/package.json +3 -3
- package/recipes/default/development-team.md +1 -1
- package/src/lib/remove-team.ts +200 -0
- package/src/lib/ticket-workflow.ts +59 -0
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
442
|
-
const byId = new Map((
|
|
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
|
|
475
|
-
const
|
|
385
|
+
// Create new job.
|
|
386
|
+
const args = [
|
|
387
|
+
"cron",
|
|
388
|
+
"add",
|
|
389
|
+
"--json",
|
|
390
|
+
"--name",
|
|
476
391
|
name,
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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("<
|
|
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
|
-
//
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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:
|
|
1200
|
-
|
|
1201
|
-
missing,
|
|
1155
|
+
ok: true,
|
|
1156
|
+
installed: missing,
|
|
1202
1157
|
installDir,
|
|
1203
|
-
|
|
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
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
1609
|
-
.description("
|
|
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("--
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
1723
|
+
await ensureTicketStageDirs(teamDir);
|
|
1623
1724
|
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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(
|
|
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(`
|
|
1637
|
-
const ok = ans.trim().toLowerCase() ===
|
|
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(
|
|
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(
|
|
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
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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("
|
|
1675
|
-
.description("
|
|
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("--
|
|
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
|
|
1687
|
-
if (!['dev','devops','lead','test'].includes(
|
|
1688
|
-
throw new Error("--
|
|
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
|
|
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
|
|
1696
|
-
|
|
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(
|
|
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: ${
|
|
1703
|
-
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${
|
|
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:
|
|
1706
|
-
else out = out.replace(/^(# .+\n)/, `$1\nStatus:
|
|
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 = {
|
|
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(`
|
|
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
|
|
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-${
|
|
1748
|
-
const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${
|
|
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
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
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
|
|
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 =
|
|
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;
|