@jiggai/recipes 0.2.23 → 0.2.25
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 +1 -1
- package/docs/CLEANUP_TODO.md +31 -0
- package/docs/CODE_SMELLS_TRACKER.md +42 -0
- package/docs/SMELLS_TODO.md +23 -0
- package/docs/TEST_COVERAGE_PROGRESS.md +37 -0
- package/index.ts +385 -2168
- package/openclaw.plugin.json +1 -1
- package/package.json +20 -2
- package/src/handlers/cron.ts +309 -0
- package/src/handlers/install.ts +160 -0
- package/src/handlers/recipes.ts +119 -0
- package/src/handlers/scaffold.ts +141 -0
- package/src/handlers/team.ts +395 -0
- package/src/handlers/tickets.ts +304 -0
- package/src/lib/agent-config.ts +48 -0
- package/src/lib/bindings.ts +9 -59
- package/src/lib/cleanup-workspaces.ts +4 -4
- package/src/lib/config.ts +47 -0
- package/src/lib/constants.ts +11 -0
- package/src/lib/cron-utils.ts +54 -0
- package/src/lib/fs-utils.ts +33 -0
- package/src/lib/json-utils.ts +17 -0
- package/src/lib/lanes.ts +14 -12
- package/src/lib/prompt.ts +47 -0
- package/src/lib/recipe-frontmatter.ts +65 -21
- package/src/lib/recipe-id.ts +49 -0
- package/src/lib/recipes-config.ts +166 -0
- package/src/lib/recipes.ts +57 -0
- package/src/lib/remove-team.ts +17 -23
- package/src/lib/scaffold-utils.ts +95 -0
- package/src/lib/skill-install.ts +22 -0
- package/src/lib/stable-stringify.ts +21 -0
- package/src/lib/template.ts +10 -0
- package/src/lib/ticket-finder.ts +40 -23
- package/src/lib/ticket-workflow.ts +32 -65
- package/src/lib/workspace.ts +33 -0
- package/src/marketplaceFetch.ts +1 -1
- package/src/toolsInvoke.ts +41 -32
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import { ensureDir, fileExists, writeFileSafely } from "../lib/fs-utils";
|
|
5
|
+
import { findTicketFile, handoffTicket, takeTicket } from "../lib/ticket-workflow";
|
|
6
|
+
import { ticketStageDir } from "../lib/lanes";
|
|
7
|
+
import { computeNextTicketNumber, TICKET_FILENAME_REGEX } from "../lib/ticket-finder";
|
|
8
|
+
import { resolveTeamContext } from "../lib/workspace";
|
|
9
|
+
import { DEFAULT_TICKET_NUMBER, VALID_ROLES, VALID_STAGES } from "../lib/constants";
|
|
10
|
+
|
|
11
|
+
export function patchTicketField(md: string, key: string, value: string): string {
|
|
12
|
+
const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
|
|
13
|
+
if (md.match(lineRe)) return md.replace(lineRe, `${key}: ${value}`);
|
|
14
|
+
return md.replace(/^(# .+\n)/, `$1\n${key}: ${value}\n`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function patchTicketOwner(md: string, owner: string): string {
|
|
18
|
+
return patchTicketField(md, "Owner", owner);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function patchTicketStatus(md: string, status: string): string {
|
|
22
|
+
return patchTicketField(md, "Status", status);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type TicketRow = { stage: "backlog" | "in-progress" | "testing" | "done"; number: number | null; id: string; file: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* List tickets for a team (backlog, in-progress, testing, done).
|
|
29
|
+
* @param api - OpenClaw plugin API
|
|
30
|
+
* @param options - teamId
|
|
31
|
+
* @returns Grouped tickets by stage
|
|
32
|
+
*/
|
|
33
|
+
export async function handleTickets(api: OpenClawPluginApi, options: { teamId: string }) {
|
|
34
|
+
const teamId = String(options.teamId);
|
|
35
|
+
const { teamDir } = await resolveTeamContext(api, teamId);
|
|
36
|
+
const dirs = {
|
|
37
|
+
backlog: ticketStageDir(teamDir, "backlog"),
|
|
38
|
+
inProgress: ticketStageDir(teamDir, "in-progress"),
|
|
39
|
+
testing: ticketStageDir(teamDir, "testing"),
|
|
40
|
+
done: ticketStageDir(teamDir, "done"),
|
|
41
|
+
} as const;
|
|
42
|
+
const readTickets = async (dir: string, stage: "backlog" | "in-progress" | "testing" | "done") => {
|
|
43
|
+
if (!(await fileExists(dir))) return [] as TicketRow[];
|
|
44
|
+
const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
45
|
+
return files.map((f) => {
|
|
46
|
+
const m = f.match(TICKET_FILENAME_REGEX);
|
|
47
|
+
return {
|
|
48
|
+
stage,
|
|
49
|
+
number: m ? Number(m[1]) : null,
|
|
50
|
+
id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
|
|
51
|
+
file: path.join(dir, f),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const backlog = await readTickets(dirs.backlog, "backlog");
|
|
56
|
+
const inProgress = await readTickets(dirs.inProgress, "in-progress");
|
|
57
|
+
const testing = await readTickets(dirs.testing, "testing");
|
|
58
|
+
const done = await readTickets(dirs.done, "done");
|
|
59
|
+
return {
|
|
60
|
+
teamId,
|
|
61
|
+
tickets: [...backlog, ...inProgress, ...testing, ...done],
|
|
62
|
+
backlog,
|
|
63
|
+
inProgress,
|
|
64
|
+
testing,
|
|
65
|
+
done,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Move a ticket between backlog/in-progress/testing/done.
|
|
71
|
+
* @param api - OpenClaw plugin API
|
|
72
|
+
* @param options - teamId, ticket, to stage, optional completed, dryRun
|
|
73
|
+
* @returns Plan (dryRun) or result with from/to paths
|
|
74
|
+
*/
|
|
75
|
+
export async function handleMoveTicket(
|
|
76
|
+
api: OpenClawPluginApi,
|
|
77
|
+
options: { teamId: string; ticket: string; to: string; completed?: boolean; dryRun?: boolean },
|
|
78
|
+
) {
|
|
79
|
+
const teamId = String(options.teamId);
|
|
80
|
+
const { teamDir } = await resolveTeamContext(api, teamId);
|
|
81
|
+
const dest = String(options.to);
|
|
82
|
+
if (!(VALID_STAGES as readonly string[]).includes(dest)) {
|
|
83
|
+
throw new Error("--to must be one of: backlog, in-progress, testing, done");
|
|
84
|
+
}
|
|
85
|
+
const srcPath = await findTicketFile(teamDir, options.ticket);
|
|
86
|
+
if (!srcPath) throw new Error(`Ticket not found: ${options.ticket}`);
|
|
87
|
+
const destDir = ticketStageDir(teamDir, dest as "backlog" | "in-progress" | "testing" | "done");
|
|
88
|
+
await ensureDir(destDir);
|
|
89
|
+
const filename = path.basename(srcPath);
|
|
90
|
+
const destPath = path.join(destDir, filename);
|
|
91
|
+
const plan = { from: srcPath, to: destPath };
|
|
92
|
+
if (options.dryRun) return { ok: true as const, plan };
|
|
93
|
+
const patchStatus = (md: string) => {
|
|
94
|
+
const nextStatus =
|
|
95
|
+
dest === "backlog" ? "queued" : dest === "in-progress" ? "in-progress" : dest === "testing" ? "testing" : "done";
|
|
96
|
+
let out = md;
|
|
97
|
+
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
|
|
98
|
+
else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
|
|
99
|
+
if (dest === "done" && options.completed) {
|
|
100
|
+
const completed = new Date().toISOString();
|
|
101
|
+
if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
|
|
102
|
+
else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
};
|
|
106
|
+
const md = await fs.readFile(srcPath, "utf8");
|
|
107
|
+
const patched = patchStatus(md);
|
|
108
|
+
await fs.writeFile(srcPath, patched, "utf8");
|
|
109
|
+
if (srcPath !== destPath) await fs.rename(srcPath, destPath);
|
|
110
|
+
return { ok: true, from: srcPath, to: destPath };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Assign a ticket to an owner (writes assignment stub + updates Owner:).
|
|
115
|
+
* @param api - OpenClaw plugin API
|
|
116
|
+
* @param options - teamId, ticket, owner, optional overwrite, dryRun
|
|
117
|
+
* @returns Plan (dryRun) or ok with plan
|
|
118
|
+
*/
|
|
119
|
+
export async function handleAssign(
|
|
120
|
+
api: OpenClawPluginApi,
|
|
121
|
+
options: { teamId: string; ticket: string; owner: string; overwrite?: boolean; dryRun?: boolean },
|
|
122
|
+
) {
|
|
123
|
+
const teamId = String(options.teamId);
|
|
124
|
+
const { teamDir } = await resolveTeamContext(api, teamId);
|
|
125
|
+
const owner = String(options.owner);
|
|
126
|
+
if (!(VALID_ROLES as readonly string[]).includes(owner)) {
|
|
127
|
+
throw new Error("--owner must be one of: dev, devops, lead, test");
|
|
128
|
+
}
|
|
129
|
+
const ticketPath = await findTicketFile(teamDir, options.ticket);
|
|
130
|
+
if (!ticketPath) throw new Error(`Ticket not found: ${options.ticket}`);
|
|
131
|
+
const filename = path.basename(ticketPath);
|
|
132
|
+
const m = filename.match(TICKET_FILENAME_REGEX);
|
|
133
|
+
const ticketNumStr = m?.[1] ?? DEFAULT_TICKET_NUMBER;
|
|
134
|
+
const slug = m?.[2] ?? (options.ticket.replace(/^\d{4}-?/, "") || "ticket");
|
|
135
|
+
const assignmentsDir = ticketStageDir(teamDir, "assignments");
|
|
136
|
+
await ensureDir(assignmentsDir);
|
|
137
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
138
|
+
const plan = { ticketPath, assignmentPath, owner };
|
|
139
|
+
if (options.dryRun) return { ok: true, plan };
|
|
140
|
+
const patchOwner = (md: string) => {
|
|
141
|
+
if (md.match(/^Owner:\s.*$/m)) return md.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
|
|
142
|
+
return md.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
|
|
143
|
+
};
|
|
144
|
+
const md = await fs.readFile(ticketPath, "utf8");
|
|
145
|
+
const nextMd = patchOwner(md);
|
|
146
|
+
await fs.writeFile(ticketPath, nextMd, "utf8");
|
|
147
|
+
const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${owner}\n\n## Ticket\n${path.relative(teamDir, ticketPath)}\n\n## Notes\n- Created by: openclaw recipes assign\n`;
|
|
148
|
+
await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? "overwrite" : "createOnly");
|
|
149
|
+
return { ok: true, plan };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function dryRunTicketMove(
|
|
153
|
+
teamDir: string,
|
|
154
|
+
ticket: string,
|
|
155
|
+
lane: "in-progress" | "testing"
|
|
156
|
+
): Promise<{ from: string; to: string }> {
|
|
157
|
+
const srcPath = await findTicketFile(teamDir, ticket);
|
|
158
|
+
if (!srcPath) throw new Error(`Ticket not found: ${ticket}`);
|
|
159
|
+
const filename = path.basename(srcPath);
|
|
160
|
+
const destPath = path.join(ticketStageDir(teamDir, lane), filename);
|
|
161
|
+
return { from: srcPath, to: destPath };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function resolveTeamAndValidateRole(
|
|
165
|
+
api: OpenClawPluginApi,
|
|
166
|
+
teamId: string,
|
|
167
|
+
role: string,
|
|
168
|
+
optionName: string
|
|
169
|
+
): Promise<{ teamDir: string }> {
|
|
170
|
+
const { teamDir } = await resolveTeamContext(api, teamId);
|
|
171
|
+
if (!(VALID_ROLES as readonly string[]).includes(role)) {
|
|
172
|
+
throw new Error(`--${optionName} must be one of: dev, devops, lead, test`);
|
|
173
|
+
}
|
|
174
|
+
return { teamDir };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Assign ticket to owner and move to in-progress.
|
|
179
|
+
* @param api - OpenClaw plugin API
|
|
180
|
+
* @param options - teamId, ticket, optional owner, overwrite, dryRun
|
|
181
|
+
* @returns Plan (dryRun) or result with paths and assignment
|
|
182
|
+
*/
|
|
183
|
+
export async function handleTake(
|
|
184
|
+
api: OpenClawPluginApi,
|
|
185
|
+
options: { teamId: string; ticket: string; owner?: string; overwrite?: boolean; dryRun?: boolean },
|
|
186
|
+
) {
|
|
187
|
+
const teamId = String(options.teamId);
|
|
188
|
+
const owner = String(options.owner ?? "dev");
|
|
189
|
+
const { teamDir } = await resolveTeamAndValidateRole(api, teamId, owner, "owner");
|
|
190
|
+
if (options.dryRun) {
|
|
191
|
+
const plan = await dryRunTicketMove(teamDir, options.ticket, "in-progress");
|
|
192
|
+
return { plan: { ...plan, owner } };
|
|
193
|
+
}
|
|
194
|
+
return takeTicket({
|
|
195
|
+
teamDir,
|
|
196
|
+
ticket: options.ticket,
|
|
197
|
+
owner,
|
|
198
|
+
overwriteAssignment: !!options.overwrite,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Move ticket to testing and assign to tester (QA handoff).
|
|
204
|
+
* @param api - OpenClaw plugin API
|
|
205
|
+
* @param options - teamId, ticket, optional tester, overwrite, dryRun
|
|
206
|
+
* @returns Plan (dryRun) or result with paths and assignment
|
|
207
|
+
*/
|
|
208
|
+
export async function handleHandoff(
|
|
209
|
+
api: OpenClawPluginApi,
|
|
210
|
+
options: { teamId: string; ticket: string; tester?: string; overwrite?: boolean; dryRun?: boolean },
|
|
211
|
+
) {
|
|
212
|
+
const teamId = String(options.teamId);
|
|
213
|
+
const tester = String(options.tester ?? "test");
|
|
214
|
+
const { teamDir } = await resolveTeamAndValidateRole(api, teamId, tester, "tester");
|
|
215
|
+
if (options.dryRun) {
|
|
216
|
+
const plan = await dryRunTicketMove(teamDir, options.ticket, "testing");
|
|
217
|
+
return {
|
|
218
|
+
plan: { ...plan, tester, note: plan.from.includes("testing") ? "already-in-testing" : "move-to-testing" },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return handoffTicket({
|
|
222
|
+
teamDir,
|
|
223
|
+
ticket: options.ticket,
|
|
224
|
+
tester,
|
|
225
|
+
overwriteAssignment: !!options.overwrite,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Turn a request into inbox + backlog ticket(s) + assignment stubs.
|
|
231
|
+
* @param api - OpenClaw plugin API
|
|
232
|
+
* @param options - teamId, requestText, optional owner, dryRun
|
|
233
|
+
* @returns Plan (dryRun) or result with wrote paths and nudgeQueued
|
|
234
|
+
*/
|
|
235
|
+
export async function handleDispatch(
|
|
236
|
+
api: OpenClawPluginApi,
|
|
237
|
+
options: { teamId: string; requestText: string; owner?: string; dryRun?: boolean },
|
|
238
|
+
) {
|
|
239
|
+
const teamId = String(options.teamId);
|
|
240
|
+
const { teamDir } = await resolveTeamContext(api, teamId);
|
|
241
|
+
const owner = String(options.owner ?? "dev");
|
|
242
|
+
if (!(VALID_ROLES as readonly string[]).includes(owner)) {
|
|
243
|
+
throw new Error("--owner must be one of: dev, devops, lead, test");
|
|
244
|
+
}
|
|
245
|
+
const requestText = options.requestText.trim();
|
|
246
|
+
if (!requestText) throw new Error("Request cannot be empty");
|
|
247
|
+
const inboxDir = path.join(teamDir, "inbox");
|
|
248
|
+
const backlogDir = ticketStageDir(teamDir, "backlog");
|
|
249
|
+
const assignmentsDir = ticketStageDir(teamDir, "assignments");
|
|
250
|
+
const slugify = (s: string) =>
|
|
251
|
+
s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 60) || "request";
|
|
252
|
+
const nowKey = () => {
|
|
253
|
+
const d = new Date();
|
|
254
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
255
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
256
|
+
};
|
|
257
|
+
const ticketNum = await computeNextTicketNumber(teamDir);
|
|
258
|
+
const ticketNumStr = String(ticketNum).padStart(4, "0");
|
|
259
|
+
const title = requestText.length > 80 ? requestText.slice(0, 77) + "…" : requestText;
|
|
260
|
+
const baseSlug = slugify(title);
|
|
261
|
+
const inboxPath = path.join(inboxDir, `${nowKey()}-${baseSlug}.md`);
|
|
262
|
+
const ticketPath = path.join(backlogDir, `${ticketNumStr}-${baseSlug}.md`);
|
|
263
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
264
|
+
const receivedIso = new Date().toISOString();
|
|
265
|
+
const inboxMd = `# Inbox — ${teamId}\n\nReceived: ${receivedIso}\n\n## Request\n${requestText}\n\n## Proposed work\n- Ticket: ${ticketNumStr}-${baseSlug}\n- Owner: ${owner}\n\n## Links\n- Ticket: ${path.relative(teamDir, ticketPath)}\n- Assignment: ${path.relative(teamDir, assignmentPath)}\n`;
|
|
266
|
+
const ticketMd = `# ${ticketNumStr}-${baseSlug}\n\nCreated: ${receivedIso}\nOwner: ${owner}\nStatus: queued\nInbox: ${path.relative(teamDir, inboxPath)}\nAssignment: ${path.relative(teamDir, assignmentPath)}\n\n## Context\n${requestText}\n\n## Requirements\n- (fill in)\n\n## Acceptance criteria\n- (fill in)\n\n## Tasks\n- [ ] (fill in)\n\n## Comments\n- (use this section for @mentions, questions, decisions, and dated replies)\n`;
|
|
267
|
+
const assignmentMd = `# Assignment — ${ticketNumStr}-${baseSlug}\n\nCreated: ${receivedIso}\nAssigned: ${owner}\n\n## Goal\n${title}\n\n## Ticket\n${path.relative(teamDir, ticketPath)}\n\n## Notes\n- Created by: openclaw recipes dispatch\n`;
|
|
268
|
+
const plan = {
|
|
269
|
+
teamId,
|
|
270
|
+
request: requestText,
|
|
271
|
+
files: [
|
|
272
|
+
{ path: inboxPath, kind: "inbox", summary: title },
|
|
273
|
+
{ path: ticketPath, kind: "backlog-ticket", summary: title },
|
|
274
|
+
{ path: assignmentPath, kind: "assignment", summary: owner },
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
if (options.dryRun) return { ok: true as const, plan };
|
|
278
|
+
await ensureDir(inboxDir);
|
|
279
|
+
await ensureDir(backlogDir);
|
|
280
|
+
await ensureDir(assignmentsDir);
|
|
281
|
+
await writeFileSafely(inboxPath, inboxMd, "createOnly");
|
|
282
|
+
await writeFileSafely(ticketPath, ticketMd, "createOnly");
|
|
283
|
+
await writeFileSafely(assignmentPath, assignmentMd, "createOnly");
|
|
284
|
+
let nudgeQueued = false;
|
|
285
|
+
try {
|
|
286
|
+
const leadAgentId = `${teamId}-lead`;
|
|
287
|
+
api.runtime.system.enqueueSystemEvent(
|
|
288
|
+
[
|
|
289
|
+
`Dispatch created new intake for team: ${teamId}`,
|
|
290
|
+
`- Inbox: ${path.relative(teamDir, inboxPath)}`,
|
|
291
|
+
`- Backlog: ${path.relative(teamDir, ticketPath)}`,
|
|
292
|
+
`- Assignment: ${path.relative(teamDir, assignmentPath)}`,
|
|
293
|
+
`Action: please triage/normalize the ticket (fill Requirements/AC/tasks) and move it through the workflow.`,
|
|
294
|
+
].join("\n"),
|
|
295
|
+
{ sessionKey: `agent:${leadAgentId}:main` },
|
|
296
|
+
);
|
|
297
|
+
nudgeQueued = true;
|
|
298
|
+
} catch {
|
|
299
|
+
// Non-critical: enqueueSystemEvent may be unavailable or fail (e.g. in tests, headless).
|
|
300
|
+
// Dispatch still succeeds; nudgeQueued stays false so caller knows the nudge was skipped.
|
|
301
|
+
nudgeQueued = false;
|
|
302
|
+
}
|
|
303
|
+
return { ok: true as const, wrote: plan.files.map((f) => f.path), nudgeQueued };
|
|
304
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type AgentConfigSnippet = {
|
|
2
|
+
id: string;
|
|
3
|
+
workspace: string;
|
|
4
|
+
identity?: { name?: string };
|
|
5
|
+
tools?: { profile?: string; allow?: string[]; deny?: string[] };
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type AgentsConfigMutable = Record<string, unknown> & {
|
|
9
|
+
agents?: { list?: Array<{ id?: string; workspace?: string; identity?: Record<string, unknown>; tools?: unknown }> };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function upsertAgentInConfig(cfgObj: AgentsConfigMutable, snippet: AgentConfigSnippet) {
|
|
13
|
+
if (!cfgObj.agents) cfgObj.agents = {};
|
|
14
|
+
if (!Array.isArray(cfgObj.agents.list)) cfgObj.agents.list = [];
|
|
15
|
+
|
|
16
|
+
const list = cfgObj.agents.list;
|
|
17
|
+
const idx = list.findIndex((a) => a?.id === snippet.id);
|
|
18
|
+
const prev = idx >= 0 ? list[idx] : {};
|
|
19
|
+
const prevTools = (prev as any)?.tools as undefined | { profile?: string; allow?: string[]; deny?: string[] };
|
|
20
|
+
const nextTools =
|
|
21
|
+
snippet.tools === undefined
|
|
22
|
+
? prevTools
|
|
23
|
+
: {
|
|
24
|
+
...(prevTools ?? {}),
|
|
25
|
+
...(snippet.tools ?? {}),
|
|
26
|
+
...(Object.prototype.hasOwnProperty.call(snippet.tools, "profile") ? { profile: snippet.tools.profile } : {}),
|
|
27
|
+
...(Object.prototype.hasOwnProperty.call(snippet.tools, "allow") ? { allow: snippet.tools.allow } : {}),
|
|
28
|
+
...(Object.prototype.hasOwnProperty.call(snippet.tools, "deny") ? { deny: snippet.tools.deny } : {}),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const nextAgent = {
|
|
32
|
+
...prev,
|
|
33
|
+
id: snippet.id,
|
|
34
|
+
workspace: snippet.workspace,
|
|
35
|
+
identity: {
|
|
36
|
+
...(prev?.identity ?? {}),
|
|
37
|
+
...(snippet.identity ?? {}),
|
|
38
|
+
},
|
|
39
|
+
tools: nextTools,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (idx >= 0) {
|
|
43
|
+
list[idx] = nextAgent;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
list.push(nextAgent);
|
|
48
|
+
}
|
package/src/lib/bindings.ts
CHANGED
|
@@ -1,59 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export type BindingSnippet = {
|
|
12
|
-
agentId: string;
|
|
13
|
-
match: BindingMatch;
|
|
14
|
-
to: any;
|
|
15
|
-
enabled?: boolean;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
function stableStringify(obj: any) {
|
|
19
|
-
const seen = new WeakSet();
|
|
20
|
-
const walk = (x: any): any => {
|
|
21
|
-
if (x && typeof x === 'object') {
|
|
22
|
-
if (seen.has(x)) return '[Circular]';
|
|
23
|
-
seen.add(x);
|
|
24
|
-
if (Array.isArray(x)) return x.map(walk);
|
|
25
|
-
const keys = Object.keys(x).sort();
|
|
26
|
-
const out: any = {};
|
|
27
|
-
for (const k of keys) out[k] = walk(x[k]);
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
return x;
|
|
31
|
-
};
|
|
32
|
-
return JSON.stringify(walk(obj));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
|
|
36
|
-
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
37
|
-
const list: any[] = cfgObj.bindings;
|
|
38
|
-
|
|
39
|
-
const sigPayload = stableStringify({ agentId: binding.agentId, match: binding.match });
|
|
40
|
-
const sig = crypto.createHash('sha256').update(sigPayload).digest('hex');
|
|
41
|
-
|
|
42
|
-
const idx = list.findIndex((b: any) => {
|
|
43
|
-
const payload = stableStringify({ agentId: b.agentId, match: b.match });
|
|
44
|
-
const bsig = crypto.createHash('sha256').update(payload).digest('hex');
|
|
45
|
-
return bsig === sig;
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
if (idx >= 0) {
|
|
49
|
-
// Update in place (preserve ordering)
|
|
50
|
-
list[idx] = { ...list[idx], ...binding };
|
|
51
|
-
return { changed: false as const, note: 'already-present' as const };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Most-specific-first: if a peer match is specified, insert at front so it wins.
|
|
55
|
-
if (binding.match?.peer) list.unshift(binding);
|
|
56
|
-
else list.push(binding);
|
|
57
|
-
|
|
58
|
-
return { changed: true as const, note: 'added' as const };
|
|
59
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Use recipes-config for BindingMatch, BindingSnippet, upsertBindingInConfig.
|
|
3
|
+
* This module re-exports from recipes-config for backwards compatibility.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
type BindingMatch,
|
|
7
|
+
type BindingSnippet,
|
|
8
|
+
upsertBindingInConfig,
|
|
9
|
+
} from "./recipes-config";
|
|
@@ -81,7 +81,7 @@ export async function planWorkspaceCleanup(opts: {
|
|
|
81
81
|
let entries: string[] = [];
|
|
82
82
|
try {
|
|
83
83
|
entries = await fs.readdir(rootDir);
|
|
84
|
-
} catch (e:
|
|
84
|
+
} catch (e: unknown) {
|
|
85
85
|
return {
|
|
86
86
|
rootDir,
|
|
87
87
|
prefixes,
|
|
@@ -91,7 +91,7 @@ export async function planWorkspaceCleanup(opts: {
|
|
|
91
91
|
kind: 'skip',
|
|
92
92
|
dirName: rootDir,
|
|
93
93
|
absPath: rootDir,
|
|
94
|
-
reason: `failed to read rootDir: ${e
|
|
94
|
+
reason: `failed to read rootDir: ${e instanceof Error ? e.message : String(e)}`,
|
|
95
95
|
},
|
|
96
96
|
],
|
|
97
97
|
} satisfies CleanupPlan;
|
|
@@ -156,8 +156,8 @@ export async function executeWorkspaceCleanup(plan: CleanupPlan, opts: { yes: bo
|
|
|
156
156
|
try {
|
|
157
157
|
await fs.rm(c.absPath, { recursive: true, force: true });
|
|
158
158
|
deleted.push(c.absPath);
|
|
159
|
-
} catch (e:
|
|
160
|
-
deleteErrors.push({ path: c.absPath, error: e
|
|
159
|
+
} catch (e: unknown) {
|
|
160
|
+
deleteErrors.push({ path: c.absPath, error: e instanceof Error ? e.message : String(e) });
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type RecipesConfig = {
|
|
2
|
+
workspaceRecipesDir?: string;
|
|
3
|
+
workspaceAgentsDir?: string;
|
|
4
|
+
workspaceSkillsDir?: string;
|
|
5
|
+
workspaceTeamsDir?: string;
|
|
6
|
+
autoInstallMissingSkills?: boolean;
|
|
7
|
+
confirmAutoInstall?: boolean;
|
|
8
|
+
cronInstallation?: "off" | "prompt" | "on";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type RequiredRecipesConfig = Required<RecipesConfig>;
|
|
12
|
+
|
|
13
|
+
function extractPluginRecipesConfig(config: {
|
|
14
|
+
plugins?: { entries?: { recipes?: { config?: RecipesConfig }; [k: string]: unknown } };
|
|
15
|
+
}): RecipesConfig {
|
|
16
|
+
return (config?.plugins?.entries?.["recipes"]?.config ?? config?.plugins?.entries?.recipes?.config ?? {}) as RecipesConfig;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_RECIPES_CONFIG: RequiredRecipesConfig = {
|
|
20
|
+
workspaceRecipesDir: "recipes",
|
|
21
|
+
workspaceAgentsDir: "agents",
|
|
22
|
+
workspaceSkillsDir: "skills",
|
|
23
|
+
workspaceTeamsDir: "teams",
|
|
24
|
+
autoInstallMissingSkills: false,
|
|
25
|
+
confirmAutoInstall: true,
|
|
26
|
+
cronInstallation: "prompt",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get recipes plugin config with defaults.
|
|
31
|
+
* @param config - OpenClaw config (plugins.entries.recipes.config)
|
|
32
|
+
* @returns RequiredRecipesConfig with all keys defined
|
|
33
|
+
*/
|
|
34
|
+
export function getRecipesConfig(config: {
|
|
35
|
+
plugins?: { entries?: { recipes?: { config?: RecipesConfig }; [k: string]: unknown } };
|
|
36
|
+
}): RequiredRecipesConfig {
|
|
37
|
+
const cfg = extractPluginRecipesConfig(config);
|
|
38
|
+
return {
|
|
39
|
+
workspaceRecipesDir: cfg.workspaceRecipesDir ?? DEFAULT_RECIPES_CONFIG.workspaceRecipesDir,
|
|
40
|
+
workspaceAgentsDir: cfg.workspaceAgentsDir ?? DEFAULT_RECIPES_CONFIG.workspaceAgentsDir,
|
|
41
|
+
workspaceSkillsDir: cfg.workspaceSkillsDir ?? DEFAULT_RECIPES_CONFIG.workspaceSkillsDir,
|
|
42
|
+
workspaceTeamsDir: cfg.workspaceTeamsDir ?? DEFAULT_RECIPES_CONFIG.workspaceTeamsDir,
|
|
43
|
+
autoInstallMissingSkills: cfg.autoInstallMissingSkills ?? DEFAULT_RECIPES_CONFIG.autoInstallMissingSkills,
|
|
44
|
+
confirmAutoInstall: cfg.confirmAutoInstall ?? DEFAULT_RECIPES_CONFIG.confirmAutoInstall,
|
|
45
|
+
cronInstallation: cfg.cronInstallation ?? DEFAULT_RECIPES_CONFIG.cronInstallation,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const VALID_ROLES = ["dev", "devops", "lead", "test"] as const;
|
|
2
|
+
export type ValidRole = (typeof VALID_ROLES)[number];
|
|
3
|
+
|
|
4
|
+
export const VALID_STAGES = ["backlog", "in-progress", "testing", "done"] as const;
|
|
5
|
+
export type ValidStage = (typeof VALID_STAGES)[number];
|
|
6
|
+
|
|
7
|
+
/** Max auto-increment attempts for pickRecipeId (e.g. id-2, id-3, ... id-999). */
|
|
8
|
+
export const MAX_RECIPE_ID_AUTO_INCREMENT = 1000;
|
|
9
|
+
|
|
10
|
+
/** Fallback ticket number when parsing fails. */
|
|
11
|
+
export const DEFAULT_TICKET_NUMBER = "0000";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { readJsonFile } from "./json-utils";
|
|
3
|
+
import { stableStringify } from "./stable-stringify";
|
|
4
|
+
|
|
5
|
+
export type CronMappingStateV1 = {
|
|
6
|
+
version: 1;
|
|
7
|
+
entries: Record<
|
|
8
|
+
string,
|
|
9
|
+
{
|
|
10
|
+
installedCronId: string;
|
|
11
|
+
specHash: string;
|
|
12
|
+
orphaned?: boolean;
|
|
13
|
+
updatedAtMs: number;
|
|
14
|
+
}
|
|
15
|
+
>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function loadCronMappingState(statePath: string): Promise<CronMappingStateV1> {
|
|
19
|
+
const existing = await readJsonFile<CronMappingStateV1>(statePath);
|
|
20
|
+
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === "object") return existing;
|
|
21
|
+
return { version: 1, entries: {} };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function cronKey(
|
|
25
|
+
scope:
|
|
26
|
+
| { kind: "team"; teamId: string; recipeId: string }
|
|
27
|
+
| { kind: "agent"; agentId: string; recipeId: string },
|
|
28
|
+
cronJobId: string
|
|
29
|
+
): string {
|
|
30
|
+
return scope.kind === "team"
|
|
31
|
+
? `team:${scope.teamId}:recipe:${scope.recipeId}:cron:${cronJobId}`
|
|
32
|
+
: `agent:${scope.agentId}:recipe:${scope.recipeId}:cron:${cronJobId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function hashSpec(spec: unknown): string {
|
|
36
|
+
const json = stableStringify(spec);
|
|
37
|
+
return crypto.createHash("sha256").update(json, "utf8").digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse JSON from tool text output. Throws with label and cause on parse error.
|
|
42
|
+
*/
|
|
43
|
+
export function parseToolTextJson<T = unknown>(text: string, label: string): T | null {
|
|
44
|
+
const trimmed = String(text ?? "").trim();
|
|
45
|
+
if (!trimmed) return null;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(trimmed) as T;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
const err = new Error(`Failed parsing JSON from tool text (${label})`);
|
|
50
|
+
(err as Error & { text?: string; cause?: unknown }).text = text;
|
|
51
|
+
(err as Error & { text?: string; cause?: unknown }).cause = e;
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if a path exists (file or directory).
|
|
6
|
+
* Uses fs.stat for consistency across the codebase.
|
|
7
|
+
*/
|
|
8
|
+
export async function fileExists(p: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
await fs.stat(p);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function ensureDir(p: string): Promise<void> {
|
|
18
|
+
await fs.mkdir(p, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Write content to a file, with optional createOnly mode.
|
|
23
|
+
*/
|
|
24
|
+
export async function writeFileSafely(
|
|
25
|
+
p: string,
|
|
26
|
+
content: string,
|
|
27
|
+
mode: "createOnly" | "overwrite"
|
|
28
|
+
): Promise<{ wrote: boolean; reason: "exists" | "ok" }> {
|
|
29
|
+
if (mode === "createOnly" && (await fileExists(p))) return { wrote: false, reason: "exists" };
|
|
30
|
+
await ensureDir(path.dirname(p));
|
|
31
|
+
await fs.writeFile(p, content, "utf8");
|
|
32
|
+
return { wrote: true, reason: "ok" };
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { ensureDir } from "./fs-utils";
|
|
4
|
+
|
|
5
|
+
export async function readJsonFile<T>(p: string): Promise<T | null> {
|
|
6
|
+
try {
|
|
7
|
+
const raw = await fs.readFile(p, "utf8");
|
|
8
|
+
return JSON.parse(raw) as T;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function writeJsonFile(p: string, data: unknown): Promise<void> {
|
|
15
|
+
await ensureDir(path.dirname(p));
|
|
16
|
+
await fs.writeFile(p, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
17
|
+
}
|
package/src/lib/lanes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { fileExists } from "./fs-utils";
|
|
5
5
|
|
|
6
6
|
export class RecipesCliError extends Error {
|
|
7
7
|
code: string;
|
|
@@ -19,13 +19,15 @@ export class RecipesCliError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
export type TicketStage = "backlog" | "in-progress" | "testing" | "done" | "assignments";
|
|
23
|
+
|
|
24
|
+
/** Subset of TicketStage excluding assignments (used for lane search order). */
|
|
25
|
+
export type TicketLane = Exclude<TicketStage, "assignments">;
|
|
26
|
+
|
|
27
|
+
export function ticketStageDir(teamDir: string, stage: TicketStage): string {
|
|
28
|
+
return stage === "assignments"
|
|
29
|
+
? path.join(teamDir, "work", "assignments")
|
|
30
|
+
: path.join(teamDir, "work", stage);
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -40,7 +42,7 @@ export async function ensureLaneDir(opts: { teamDir: string; lane: TicketLane; c
|
|
|
40
42
|
if (!existed) {
|
|
41
43
|
try {
|
|
42
44
|
await fs.mkdir(laneDir, { recursive: true });
|
|
43
|
-
} catch (e:
|
|
45
|
+
} catch (e: unknown) {
|
|
44
46
|
throw new RecipesCliError({
|
|
45
47
|
code: 'LANE_DIR_CREATE_FAILED',
|
|
46
48
|
command: opts.command,
|
|
@@ -49,7 +51,7 @@ export async function ensureLaneDir(opts: { teamDir: string; lane: TicketLane; c
|
|
|
49
51
|
message:
|
|
50
52
|
`Failed to create required lane directory: ${laneDir}` +
|
|
51
53
|
(opts.command ? ` (command: ${opts.command})` : '') +
|
|
52
|
-
(e
|
|
54
|
+
(e instanceof Error ? `\nUnderlying error: ${e.message}` : ''),
|
|
53
55
|
});
|
|
54
56
|
}
|
|
55
57
|
|