@clawcipes/recipes 0.2.5 → 0.2.7
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 +162 -113
- package/package.json +1 -1
- package/recipes/default/development-team.md +26 -0
- package/recipes/default/product-team.md +26 -0
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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
|
|
|
@@ -285,17 +286,73 @@ type OpenClawCronJob = {
|
|
|
285
286
|
description?: string;
|
|
286
287
|
};
|
|
287
288
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
296
306
|
}
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
};
|
|
299
356
|
}
|
|
300
357
|
|
|
301
358
|
function normalizeCronJobs(frontmatter: RecipeFrontmatter): CronJobSpec[] {
|
|
@@ -381,25 +438,8 @@ async function reconcileRecipeCronJobs(opts: {
|
|
|
381
438
|
const statePath = path.join(opts.scope.stateDir, "notes", "cron-jobs.json");
|
|
382
439
|
const state = await loadCronMappingState(statePath);
|
|
383
440
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
list = spawnOpenClawJson(["cron", "list", "--all", "--json"]) as { jobs: OpenClawCronJob[] };
|
|
387
|
-
} catch (err: any) {
|
|
388
|
-
// Cron reconciliation should not prevent scaffolding from completing.
|
|
389
|
-
// If the gateway is restarting or cron RPC is temporarily unavailable, skip reconciliation.
|
|
390
|
-
console.error(`Warning: failed to list cron jobs; skipping cron reconciliation for ${opts.scope.kind} ${
|
|
391
|
-
(opts.scope as any).teamId ?? (opts.scope as any).agentId
|
|
392
|
-
} (${opts.scope.recipeId}).`);
|
|
393
|
-
if (err?.stderr) console.error(String(err.stderr).trim());
|
|
394
|
-
return {
|
|
395
|
-
ok: false,
|
|
396
|
-
changed: false,
|
|
397
|
-
note: "cron-list-failed" as const,
|
|
398
|
-
desiredCount: desired.length,
|
|
399
|
-
error: { message: String(err?.message ?? err) },
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
const byId = new Map((list?.jobs ?? []).map((j) => [j.id, j] as const));
|
|
441
|
+
const store = await loadCronStore();
|
|
442
|
+
const byId = new Map((store.jobs ?? []).map((j: any) => [j.id, j] as const));
|
|
403
443
|
|
|
404
444
|
const now = Date.now();
|
|
405
445
|
const desiredIds = new Set(desired.map((j) => j.id));
|
|
@@ -431,65 +471,43 @@ async function reconcileRecipeCronJobs(opts: {
|
|
|
431
471
|
const wantEnabled = userOptIn ? Boolean(j.enabledByDefault) : false;
|
|
432
472
|
|
|
433
473
|
if (!existing) {
|
|
434
|
-
// Create new job.
|
|
435
|
-
const
|
|
436
|
-
"cron",
|
|
437
|
-
"add",
|
|
438
|
-
"--json",
|
|
439
|
-
"--name",
|
|
474
|
+
// Create new job in cron store.
|
|
475
|
+
const newJob = buildCronJobFromSpec({
|
|
440
476
|
name,
|
|
441
|
-
|
|
442
|
-
j.schedule,
|
|
443
|
-
|
|
444
|
-
j.message,
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const created = spawnOpenClawJson(args) as any;
|
|
455
|
-
const newId = created?.id ?? created?.job?.id;
|
|
456
|
-
if (!newId) throw new Error("Failed to parse cron add output (missing id)");
|
|
457
|
-
|
|
458
|
-
state.entries[key] = { installedCronId: newId, specHash, updatedAtMs: now, orphaned: false };
|
|
459
|
-
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 });
|
|
460
489
|
continue;
|
|
461
490
|
}
|
|
462
491
|
|
|
463
492
|
// Update existing job if spec changed.
|
|
464
493
|
if (prev?.specHash !== specHash) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
j.schedule,
|
|
473
|
-
"--message",
|
|
474
|
-
j.message,
|
|
475
|
-
"--announce",
|
|
476
|
-
];
|
|
477
|
-
if (j.description) editArgs.push("--description", j.description);
|
|
478
|
-
if (j.timezone) editArgs.push("--tz", j.timezone);
|
|
479
|
-
if (j.channel) editArgs.push("--channel", j.channel);
|
|
480
|
-
if (j.to) editArgs.push("--to", j.to);
|
|
481
|
-
if (desiredSpec.agentId) editArgs.push("--agent", desiredSpec.agentId);
|
|
482
|
-
|
|
483
|
-
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;
|
|
484
501
|
results.push({ action: "updated", key, installedCronId: existing.id });
|
|
485
502
|
} else {
|
|
486
503
|
results.push({ action: "unchanged", key, installedCronId: existing.id });
|
|
487
504
|
}
|
|
488
505
|
|
|
489
|
-
// Enabled precedence: if user did not opt in, force disabled.
|
|
506
|
+
// Enabled precedence: if user did not opt in, force disabled.
|
|
490
507
|
if (!userOptIn) {
|
|
491
508
|
if (existing.enabled) {
|
|
492
|
-
|
|
509
|
+
existing.enabled = false;
|
|
510
|
+
existing.updatedAtMs = now;
|
|
493
511
|
results.push({ action: "disabled", key, installedCronId: existing.id });
|
|
494
512
|
}
|
|
495
513
|
}
|
|
@@ -505,7 +523,8 @@ async function reconcileRecipeCronJobs(opts: {
|
|
|
505
523
|
|
|
506
524
|
const job = byId.get(entry.installedCronId);
|
|
507
525
|
if (job && job.enabled) {
|
|
508
|
-
|
|
526
|
+
job.enabled = false;
|
|
527
|
+
job.updatedAtMs = now;
|
|
509
528
|
results.push({ action: "disabled-removed", key, installedCronId: job.id });
|
|
510
529
|
}
|
|
511
530
|
|
|
@@ -513,6 +532,7 @@ async function reconcileRecipeCronJobs(opts: {
|
|
|
513
532
|
}
|
|
514
533
|
|
|
515
534
|
await writeJsonFile(statePath, state);
|
|
535
|
+
await saveCronStore(store);
|
|
516
536
|
|
|
517
537
|
const changed = results.some((r) => r.action === "created" || r.action === "updated" || r.action?.startsWith("disabled"));
|
|
518
538
|
return { ok: true, changed, results };
|
|
@@ -1166,28 +1186,22 @@ const recipesPlugin = {
|
|
|
1166
1186
|
console.error(header);
|
|
1167
1187
|
}
|
|
1168
1188
|
|
|
1169
|
-
//
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
{ stdio: "inherit" },
|
|
1176
|
-
);
|
|
1177
|
-
if (res.status !== 0) {
|
|
1178
|
-
process.exitCode = res.status ?? 1;
|
|
1179
|
-
console.error(`Failed installing ${slug} (exit=${process.exitCode}).`);
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
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
|
+
);
|
|
1183
1195
|
|
|
1184
1196
|
console.log(
|
|
1185
1197
|
JSON.stringify(
|
|
1186
1198
|
{
|
|
1187
|
-
ok:
|
|
1188
|
-
|
|
1199
|
+
ok: false,
|
|
1200
|
+
reason: "manual-install-required",
|
|
1201
|
+
missing,
|
|
1189
1202
|
installDir,
|
|
1190
|
-
|
|
1203
|
+
commands,
|
|
1204
|
+
next: "Run the commands above, then: openclaw gateway restart",
|
|
1191
1205
|
},
|
|
1192
1206
|
null,
|
|
1193
1207
|
2,
|
|
@@ -1743,26 +1757,61 @@ const recipesPlugin = {
|
|
|
1743
1757
|
.requiredOption("--team-id <teamId>", "Team id")
|
|
1744
1758
|
.requiredOption("--ticket <ticket>", "Ticket id or number")
|
|
1745
1759
|
.option("--yes", "Skip confirmation")
|
|
1746
|
-
.action(async (options: any) =>
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
String(options.ticket)
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
+
);
|
|
1766
1815
|
|
|
1767
1816
|
cmd
|
|
1768
1817
|
.command("scaffold")
|
package/package.json
CHANGED
|
@@ -317,6 +317,32 @@ templates:
|
|
|
317
317
|
4) If it fails:
|
|
318
318
|
- Move the ticket back to `work/in-progress/` and assign to the right owner.
|
|
319
319
|
|
|
320
|
+
## Cleanup after testing
|
|
321
|
+
|
|
322
|
+
If your test involved creating temporary resources (e.g., scaffolding test teams, creating test workspaces), **clean them up** after verification:
|
|
323
|
+
|
|
324
|
+
1) Remove test workspaces:
|
|
325
|
+
```bash
|
|
326
|
+
rm -rf ~/.openclaw/workspace-<test-team-id>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
2) Remove test agents from config (agents whose id starts with the test team id):
|
|
330
|
+
- Edit `~/.openclaw/openclaw.json` and remove entries from `agents.list[]`
|
|
331
|
+
- Or wait for `openclaw recipes remove-team` (once available)
|
|
332
|
+
|
|
333
|
+
3) Remove any cron jobs created for the test team:
|
|
334
|
+
```bash
|
|
335
|
+
openclaw cron list --all --json | grep "<test-team-id>"
|
|
336
|
+
openclaw cron remove <jobId>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
4) Restart the gateway if you modified config:
|
|
340
|
+
```bash
|
|
341
|
+
openclaw gateway restart
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Naming convention:** When scaffolding test teams, use a prefix like `qa-<ticketNum>-` (e.g., `qa-0017-social-team`) so cleanup is easier.
|
|
345
|
+
|
|
320
346
|
test.tools: |
|
|
321
347
|
# TOOLS.md
|
|
322
348
|
|
|
@@ -170,6 +170,32 @@ templates:
|
|
|
170
170
|
Output conventions:
|
|
171
171
|
- Test plans (optional) go in work/test-plans/
|
|
172
172
|
|
|
173
|
+
## Cleanup after testing
|
|
174
|
+
|
|
175
|
+
If your test involved creating temporary resources (e.g., scaffolding test teams, creating test workspaces), **clean them up** after verification:
|
|
176
|
+
|
|
177
|
+
1) Remove test workspaces:
|
|
178
|
+
```bash
|
|
179
|
+
rm -rf ~/.openclaw/workspace-<test-team-id>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
2) Remove test agents from config (agents whose id starts with the test team id):
|
|
183
|
+
- Edit `~/.openclaw/openclaw.json` and remove entries from `agents.list[]`
|
|
184
|
+
- Or wait for `openclaw recipes remove-team` (once available)
|
|
185
|
+
|
|
186
|
+
3) Remove any cron jobs created for the test team:
|
|
187
|
+
```bash
|
|
188
|
+
openclaw cron list --all --json | grep "<test-team-id>"
|
|
189
|
+
openclaw cron remove <jobId>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
4) Restart the gateway if you modified config:
|
|
193
|
+
```bash
|
|
194
|
+
openclaw gateway restart
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Naming convention:** When scaffolding test teams, use a prefix like `qa-<ticketNum>-` (e.g., `qa-0017-social-team`) so cleanup is easier.
|
|
198
|
+
|
|
173
199
|
lead.tools: |
|
|
174
200
|
# TOOLS.md
|
|
175
201
|
|