@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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawcipes/recipes",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Clawcipes recipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
|
|
3
|
+
"version": "0.2.8",
|
|
4
|
+
"description": "DEPRECATED: use @jiggai/clawrecipes. Clawcipes recipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"openclaw": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"keywords": [
|
|
31
31
|
"openclaw",
|
|
32
|
-
"
|
|
32
|
+
"clawrecipes",
|
|
33
33
|
"plugin",
|
|
34
34
|
"recipes"
|
|
35
35
|
],
|
|
@@ -384,6 +384,6 @@ tools:
|
|
|
384
384
|
Scaffolds a shared team workspace and four namespaced agents (lead/dev/devops/test).
|
|
385
385
|
|
|
386
386
|
## What you get
|
|
387
|
-
- Shared workspace at `~/.openclaw/workspace-development-team/`
|
|
387
|
+
- Shared workspace at `~/.openclaw/workspace-<teamId>/` (e.g. `~/.openclaw/workspace-development-team-team/`)
|
|
388
388
|
- File-first tickets: backlog → in-progress → testing → done
|
|
389
389
|
- Team lead acts as dispatcher; tester verifies before done
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type CronJob = {
|
|
5
|
+
id: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
schedule?: any;
|
|
9
|
+
payload?: { kind?: string; message?: string };
|
|
10
|
+
delivery?: any;
|
|
11
|
+
agentId?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CronStore = {
|
|
15
|
+
version: number;
|
|
16
|
+
jobs: CronJob[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RemoveTeamPlan = {
|
|
20
|
+
teamId: string;
|
|
21
|
+
workspaceDir: string;
|
|
22
|
+
openclawConfigPath: string;
|
|
23
|
+
cronJobsPath: string;
|
|
24
|
+
agentsToRemove: string[];
|
|
25
|
+
cronJobsExact: Array<{ id: string; name?: string }>;
|
|
26
|
+
cronJobsAmbiguous: Array<{ id: string; name?: string; reason: string }>;
|
|
27
|
+
notes: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type RemoveTeamResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
plan: RemoveTeamPlan;
|
|
33
|
+
removed: {
|
|
34
|
+
workspaceDir: "deleted" | "missing";
|
|
35
|
+
agentsRemoved: number;
|
|
36
|
+
cronJobsRemoved: number;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function stampTeamId(teamId: string) {
|
|
41
|
+
return `recipes.teamId=${teamId}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isProtectedTeamId(teamId: string) {
|
|
45
|
+
const t = teamId.trim().toLowerCase();
|
|
46
|
+
return t === "development-team" || t === "main";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function fileExists(p: string) {
|
|
50
|
+
try {
|
|
51
|
+
await fs.stat(p);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function loadCronStore(cronJobsPath: string): Promise<CronStore> {
|
|
59
|
+
const raw = await fs.readFile(cronJobsPath, "utf8");
|
|
60
|
+
const data = JSON.parse(raw) as CronStore;
|
|
61
|
+
if (!data || typeof data !== "object" || !Array.isArray((data as any).jobs)) {
|
|
62
|
+
throw new Error(`Invalid cron store: ${cronJobsPath}`);
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function saveCronStore(cronJobsPath: string, store: CronStore) {
|
|
68
|
+
await fs.writeFile(cronJobsPath, JSON.stringify(store, null, 2) + "\n", "utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function loadOpenClawConfig(openclawConfigPath: string): Promise<any> {
|
|
72
|
+
const raw = await fs.readFile(openclawConfigPath, "utf8");
|
|
73
|
+
// NOTE: openclaw.json is JSON5 in some deployments; but we avoid adding dependency here.
|
|
74
|
+
// The main plugin already depends on json5; callers may parse using that. For remove-team, keep strict JSON.
|
|
75
|
+
return JSON.parse(raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function saveOpenClawConfig(openclawConfigPath: string, cfg: any) {
|
|
79
|
+
await fs.writeFile(openclawConfigPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function findAgentsToRemove(cfgObj: any, teamId: string) {
|
|
83
|
+
const list = cfgObj?.agents?.list;
|
|
84
|
+
if (!Array.isArray(list)) return [] as string[];
|
|
85
|
+
const prefix = `${teamId}-`;
|
|
86
|
+
return list
|
|
87
|
+
.map((a: any) => String(a?.id ?? ""))
|
|
88
|
+
.filter((id: string) => id && id.startsWith(prefix));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function planCronJobRemovals(jobs: CronJob[], teamId: string) {
|
|
92
|
+
const stamp = stampTeamId(teamId);
|
|
93
|
+
const exact: Array<{ id: string; name?: string }> = [];
|
|
94
|
+
const ambiguous: Array<{ id: string; name?: string; reason: string }> = [];
|
|
95
|
+
|
|
96
|
+
for (const j of jobs) {
|
|
97
|
+
const msg = String(j?.payload?.message ?? "");
|
|
98
|
+
const name = String(j?.name ?? "");
|
|
99
|
+
|
|
100
|
+
// Exact: message contains the stamp.
|
|
101
|
+
if (msg.includes(stamp)) {
|
|
102
|
+
exact.push({ id: j.id, name: j.name });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Ambiguous: name mentions teamId (helpful for manual review).
|
|
107
|
+
if (name.includes(teamId) || msg.includes(teamId)) {
|
|
108
|
+
ambiguous.push({ id: j.id, name: j.name, reason: "mentions-teamId" });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { exact, ambiguous };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function buildRemoveTeamPlan(opts: {
|
|
116
|
+
teamId: string;
|
|
117
|
+
workspaceRoot: string; // e.g. ~/.openclaw/workspace
|
|
118
|
+
openclawConfigPath: string; // e.g. ~/.openclaw/openclaw.json
|
|
119
|
+
cronJobsPath: string; // e.g. ~/.openclaw/cron/jobs.json
|
|
120
|
+
cfgObj: any;
|
|
121
|
+
cronStore?: CronStore | null;
|
|
122
|
+
}) {
|
|
123
|
+
const teamId = opts.teamId.trim();
|
|
124
|
+
const workspaceDir = path.resolve(path.join(opts.workspaceRoot, "..", `workspace-${teamId}`));
|
|
125
|
+
|
|
126
|
+
const notes: string[] = [];
|
|
127
|
+
if (isProtectedTeamId(teamId)) notes.push(`protected-team:${teamId}`);
|
|
128
|
+
|
|
129
|
+
const agentsToRemove = findAgentsToRemove(opts.cfgObj, teamId);
|
|
130
|
+
|
|
131
|
+
const jobs = (opts.cronStore?.jobs ?? []) as CronJob[];
|
|
132
|
+
const cron = planCronJobRemovals(jobs, teamId);
|
|
133
|
+
|
|
134
|
+
const plan: RemoveTeamPlan = {
|
|
135
|
+
teamId,
|
|
136
|
+
workspaceDir,
|
|
137
|
+
openclawConfigPath: opts.openclawConfigPath,
|
|
138
|
+
cronJobsPath: opts.cronJobsPath,
|
|
139
|
+
agentsToRemove,
|
|
140
|
+
cronJobsExact: cron.exact,
|
|
141
|
+
cronJobsAmbiguous: cron.ambiguous,
|
|
142
|
+
notes,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return plan;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function executeRemoveTeamPlan(opts: {
|
|
149
|
+
plan: RemoveTeamPlan;
|
|
150
|
+
includeAmbiguous?: boolean;
|
|
151
|
+
cfgObj: any;
|
|
152
|
+
cronStore: CronStore;
|
|
153
|
+
}) {
|
|
154
|
+
const { plan } = opts;
|
|
155
|
+
const teamId = plan.teamId;
|
|
156
|
+
|
|
157
|
+
if (isProtectedTeamId(teamId)) {
|
|
158
|
+
throw new Error(`Refusing to remove protected team: ${teamId}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 1) Delete workspace dir
|
|
162
|
+
const workspaceExists = await fileExists(plan.workspaceDir);
|
|
163
|
+
if (workspaceExists) {
|
|
164
|
+
await fs.rm(plan.workspaceDir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2) Remove agents from config
|
|
168
|
+
const list = opts.cfgObj?.agents?.list;
|
|
169
|
+
const before = Array.isArray(list) ? list.length : 0;
|
|
170
|
+
if (Array.isArray(list)) {
|
|
171
|
+
const remove = new Set(plan.agentsToRemove);
|
|
172
|
+
opts.cfgObj.agents.list = list.filter((a: any) => !remove.has(String(a?.id ?? "")));
|
|
173
|
+
}
|
|
174
|
+
const after = Array.isArray(opts.cfgObj?.agents?.list) ? opts.cfgObj.agents.list.length : 0;
|
|
175
|
+
|
|
176
|
+
// 3) Remove cron jobs from store
|
|
177
|
+
const exactIds = new Set(plan.cronJobsExact.map((j) => j.id));
|
|
178
|
+
const ambiguousIds = new Set(plan.cronJobsAmbiguous.map((j) => j.id));
|
|
179
|
+
|
|
180
|
+
const removeIds = new Set<string>([...exactIds]);
|
|
181
|
+
if (opts.includeAmbiguous) {
|
|
182
|
+
for (const id of ambiguousIds) removeIds.add(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const beforeJobs = opts.cronStore.jobs.length;
|
|
186
|
+
opts.cronStore.jobs = opts.cronStore.jobs.filter((j) => !removeIds.has(j.id));
|
|
187
|
+
const afterJobs = opts.cronStore.jobs.length;
|
|
188
|
+
|
|
189
|
+
const result: RemoveTeamResult = {
|
|
190
|
+
ok: true,
|
|
191
|
+
plan,
|
|
192
|
+
removed: {
|
|
193
|
+
workspaceDir: workspaceExists ? "deleted" : "missing",
|
|
194
|
+
agentsRemoved: Math.max(0, before - after),
|
|
195
|
+
cronJobsRemoved: Math.max(0, beforeJobs - afterJobs),
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
@@ -34,6 +34,65 @@ export async function findTicketFile(teamDir: string, ticketArg: string) {
|
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export async function takeTicket(opts: { teamDir: string; ticket: string; owner?: string; overwriteAssignment: boolean }) {
|
|
38
|
+
const teamDir = opts.teamDir;
|
|
39
|
+
const owner = (opts.owner ?? 'dev').trim() || 'dev';
|
|
40
|
+
const ownerSafe = owner.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/(^-|-$)/g, '') || 'dev';
|
|
41
|
+
|
|
42
|
+
const srcPath = await findTicketFile(teamDir, opts.ticket);
|
|
43
|
+
if (!srcPath) throw new Error(`Ticket not found: ${opts.ticket}`);
|
|
44
|
+
if (srcPath.includes(`${path.sep}work${path.sep}done${path.sep}`)) throw new Error('Cannot take a done ticket (already completed)');
|
|
45
|
+
|
|
46
|
+
const inProgressDir = (await ensureLaneDir({ teamDir, lane: 'in-progress', command: 'openclaw recipes take' })).path;
|
|
47
|
+
|
|
48
|
+
const filename = path.basename(srcPath);
|
|
49
|
+
const destPath = path.join(inProgressDir, filename);
|
|
50
|
+
|
|
51
|
+
const m = filename.match(/^(\d{4})-(.+)\.md$/);
|
|
52
|
+
const ticketNumStr = m?.[1] ?? (opts.ticket.match(/^\d{4}$/) ? opts.ticket : '0000');
|
|
53
|
+
const slug = m?.[2] ?? 'ticket';
|
|
54
|
+
|
|
55
|
+
const assignmentsDir = path.join(teamDir, 'work', 'assignments');
|
|
56
|
+
await ensureDir(assignmentsDir);
|
|
57
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${ownerSafe}.md`);
|
|
58
|
+
const assignmentRel = path.relative(teamDir, assignmentPath);
|
|
59
|
+
|
|
60
|
+
const patch = (md: string) => {
|
|
61
|
+
let out = md;
|
|
62
|
+
if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${ownerSafe}`);
|
|
63
|
+
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${ownerSafe}\n`);
|
|
64
|
+
|
|
65
|
+
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, 'Status: in-progress');
|
|
66
|
+
else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
|
|
67
|
+
|
|
68
|
+
if (out.match(/^Assignment:\s.*$/m)) out = out.replace(/^Assignment:\s.*$/m, `Assignment: ${assignmentRel}`);
|
|
69
|
+
else out = out.replace(/^Owner:.*$/m, (line) => `${line}\nAssignment: ${assignmentRel}`);
|
|
70
|
+
|
|
71
|
+
return out;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const alreadyInProgress = srcPath === destPath;
|
|
75
|
+
|
|
76
|
+
const md = await fs.readFile(srcPath, 'utf8');
|
|
77
|
+
const nextMd = patch(md);
|
|
78
|
+
await fs.writeFile(srcPath, nextMd, 'utf8');
|
|
79
|
+
|
|
80
|
+
if (!alreadyInProgress) {
|
|
81
|
+
await fs.rename(srcPath, destPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${ownerSafe}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes take\n`;
|
|
85
|
+
|
|
86
|
+
const assignmentExists = await fileExists(assignmentPath);
|
|
87
|
+
if (assignmentExists && !opts.overwriteAssignment) {
|
|
88
|
+
// createOnly
|
|
89
|
+
} else {
|
|
90
|
+
await fs.writeFile(assignmentPath, assignmentMd, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { srcPath, destPath, moved: !alreadyInProgress, assignmentPath };
|
|
94
|
+
}
|
|
95
|
+
|
|
37
96
|
export async function handoffTicket(opts: { teamDir: string; ticket: string; tester?: string; overwriteAssignment: boolean }) {
|
|
38
97
|
const teamDir = opts.teamDir;
|
|
39
98
|
const tester = (opts.tester ?? 'test').trim() || 'test';
|