@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.
@@ -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
+ }
@@ -1,59 +1,9 @@
1
- // Minimal extracted binding helper so we can test precedence without running the CLI.
2
-
3
- import crypto from 'node:crypto';
4
-
5
- export type BindingMatch = {
6
- channel?: string;
7
- peer?: string;
8
- [k: string]: any;
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: any) {
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?.message ? String(e.message) : String(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: any) {
160
- deleteErrors.push({ path: c.absPath, error: e?.message ? String(e.message) : String(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 'node:fs/promises';
2
- import path from 'node:path';
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
3
 
4
- export type TicketLane = 'backlog' | 'in-progress' | 'testing' | 'done' | 'assignments';
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
- async function fileExists(p: string) {
23
- try {
24
- await fs.stat(p);
25
- return true;
26
- } catch {
27
- return false;
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: any) {
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?.message ? `\nUnderlying error: ${String(e.message)}` : ''),
54
+ (e instanceof Error ? `\nUnderlying error: ${e.message}` : ''),
53
55
  });
54
56
  }
55
57