@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clawcipes/recipes",
3
- "version": "0.2.7",
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
- "clawcipes",
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';