@jiggai/recipes 0.3.0 → 0.3.2

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.
@@ -39,14 +39,16 @@ async function ensureTeamDirectoryStructure(
39
39
  ]);
40
40
  }
41
41
 
42
- async function writeTeamBootstrapFiles(
43
- teamId: string,
44
- teamDir: string,
45
- sharedContextDir: string,
46
- notesDir: string,
47
- goalsDir: string,
48
- overwrite: boolean
49
- ) {
42
+ async function writeTeamBootstrapFiles(opts: {
43
+ teamId: string;
44
+ teamDir: string;
45
+ sharedContextDir: string;
46
+ notesDir: string;
47
+ goalsDir: string;
48
+ overwrite: boolean;
49
+ qaChecklist?: boolean;
50
+ }) {
51
+ const { teamId, teamDir, sharedContextDir, notesDir, goalsDir, overwrite, qaChecklist } = opts;
50
52
  const mode = overwrite ? "overwrite" : "createOnly";
51
53
  await ensureDir(goalsDir);
52
54
  await writeFileSafely(
@@ -66,6 +68,14 @@ async function writeTeamBootstrapFiles(
66
68
  `# Goals folder — ${teamId}\n\nCreate one markdown file per goal in this directory.\n\nRecommended file naming:\n- short, kebab-case, no leading numbers (e.g. \`reduce-support-backlog.md\`)\n\nLink goals from:\n- notes/GOALS.md\n`,
67
69
  mode
68
70
  );
71
+
72
+ if (qaChecklist) {
73
+ await writeFileSafely(
74
+ path.join(notesDir, "QA_CHECKLIST.md"),
75
+ `# QA Checklist — ${teamId}\n\nUse this when verifying a ticket before moving it from work/testing/ → work/done/.\n\n## Checklist\n- [ ] Repro steps verified\n- [ ] Acceptance criteria met\n- [ ] No regressions in adjacent flows\n- [ ] Notes/screenshots attached (if relevant)\n\n## Verified by\n- QA: (name)\n- Date: (YYYY-MM-DD)\n\n## Links\n- Ticket: (path or URL)\n- PR/Commit: (optional)\n`,
76
+ mode,
77
+ );
78
+ }
69
79
  const ticketsMd = `# Tickets — ${teamId}\n\n## Naming\n- Backlog tickets live in work/backlog/\n- In-progress tickets live in work/in-progress/\n- Testing tickets live in work/testing/\n- Done tickets live in work/done/\n- Filename ordering is the queue: 0001-..., 0002-...\n\n## Stages\n- backlog → in-progress → testing → done\n\n## QA handoff\n- When work is ready for QA: move the ticket to \`work/testing/\` and assign to test.\n\n## Required fields\nEach ticket should include:\n- Title\n- Context\n- Requirements\n- Acceptance criteria\n- Owner (dev/devops/lead/test)\n- Status (queued/in-progress/testing/done)\n\n## Example\n\n\`\`\`md\n# 0001-example-ticket\n\nOwner: dev\nStatus: queued\n\n## Context\n...\n\n## Requirements\n- ...\n\n## Acceptance criteria\n- ...\n\`\`\`\n`;
70
80
  await writeFileSafely(path.join(teamDir, "TICKETS.md"), ticketsMd, mode);
71
81
  }
@@ -175,7 +185,6 @@ export async function handleScaffoldTeam(
175
185
  await ensureDir(recipesDir);
176
186
  const overwriteRecipe = !!options.overwriteRecipe;
177
187
  const autoIncrement = !!options.autoIncrement;
178
-
179
188
  const explicitRecipeId = typeof options.recipeIdExplicit === "string" ? String(options.recipeIdExplicit).trim() : "";
180
189
  const baseRecipeId = explicitRecipeId || teamId;
181
190
  const workspaceRecipeId = await pickRecipeId({
@@ -192,7 +201,6 @@ export async function handleScaffoldTeam(
192
201
  `Workspace recipe already exists: recipes/${id}.md. Choose --recipe-id (e.g. ${suggestions.join(", ")}) or --auto-increment or --overwrite-recipe.`,
193
202
  });
194
203
  await writeWorkspaceRecipeFile(loaded, recipesDir, workspaceRecipeId, overwriteRecipe);
195
-
196
204
  const rolesDir = path.join(teamDir, "roles");
197
205
  const notesDir = path.join(teamDir, "notes");
198
206
  const workDir = path.join(teamDir, "work");
@@ -200,9 +208,17 @@ export async function handleScaffoldTeam(
200
208
  const sharedContextDir = path.join(teamDir, "shared-context");
201
209
  const goalsDir = path.join(notesDir, "goals");
202
210
 
211
+ const qaChecklist = Boolean(recipe.qaChecklist ?? false) || (recipe.agents ?? []).some((a) => String(a.role ?? "").toLowerCase() === "test");
203
212
  await ensureTeamDirectoryStructure(teamDir, sharedContextDir, notesDir, workDir);
204
- await writeTeamBootstrapFiles(teamId, teamDir, sharedContextDir, notesDir, goalsDir, overwrite);
205
-
213
+ await writeTeamBootstrapFiles({
214
+ teamId,
215
+ teamDir,
216
+ sharedContextDir,
217
+ notesDir,
218
+ goalsDir,
219
+ overwrite,
220
+ qaChecklist,
221
+ });
206
222
  const results = await scaffoldTeamAgents(api, recipe, teamId, teamDir, rolesDir, overwrite);
207
223
  await writeTeamMetadataAndConfig({ api, teamId, teamDir, recipe, results, applyConfig: !!options.applyConfig, overwrite });
208
224
 
@@ -365,6 +381,23 @@ export async function handleRemoveTeam(
365
381
  const cronJobsPath = path.resolve(workspaceRoot, "..", "cron", "jobs.json");
366
382
  const cfgObj = await loadOpenClawConfig(api);
367
383
  const cronStore = await loadCronStore(cronJobsPath);
384
+
385
+ // IMPORTANT: read cron provenance BEFORE deleting workspace.
386
+ // Teams/recipes track installed cron jobs via notes/cron-jobs.json.
387
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
388
+ const cronMappingPath = path.join(teamDir, "notes", "cron-jobs.json");
389
+ let installedCronIds: string[] = [];
390
+ try {
391
+ const raw = await fs.readFile(cronMappingPath, "utf8");
392
+ const json = JSON.parse(raw) as { entries?: Record<string, { installedCronId?: unknown; orphaned?: unknown }> };
393
+ installedCronIds = Object.values(json.entries ?? {})
394
+ .filter((e) => e && !e.orphaned)
395
+ .map((e) => String(e.installedCronId ?? "").trim())
396
+ .filter(Boolean);
397
+ } catch {
398
+ installedCronIds = [];
399
+ }
400
+
368
401
  const plan = await buildRemoveTeamPlan({
369
402
  teamId,
370
403
  workspaceRoot,
@@ -372,6 +405,7 @@ export async function handleRemoveTeam(
372
405
  cronJobsPath,
373
406
  cfgObj,
374
407
  cronStore,
408
+ installedCronIds,
375
409
  });
376
410
  if (options.plan) return { ok: true as const, plan };
377
411
  if (!options.yes && !process.stdin.isTTY) {
@@ -16,7 +16,17 @@ export function upsertAgentInConfig(cfgObj: AgentsConfigMutable, snippet: AgentC
16
16
  const list = cfgObj.agents.list;
17
17
  const idx = list.findIndex((a) => a?.id === snippet.id);
18
18
  const prev = idx >= 0 ? list[idx] : {};
19
- const prevTools = (prev as any)?.tools as undefined | { profile?: string; allow?: string[]; deny?: string[] };
19
+
20
+ const prevTools = (() => {
21
+ const v = (prev as { tools?: unknown } | null | undefined)?.tools;
22
+ if (!v || typeof v !== "object") return undefined;
23
+ const t = v as { profile?: unknown; allow?: unknown; deny?: unknown };
24
+ return {
25
+ profile: typeof t.profile === "string" ? t.profile : undefined,
26
+ allow: Array.isArray(t.allow) ? (t.allow as string[]) : undefined,
27
+ deny: Array.isArray(t.deny) ? (t.deny as string[]) : undefined,
28
+ } as { profile?: string; allow?: string[]; deny?: string[] };
29
+ })();
20
30
  const nextTools =
21
31
  snippet.tools === undefined
22
32
  ? prevTools
@@ -34,6 +34,13 @@ export type RecipeFrontmatter = {
34
34
  kind?: string;
35
35
  name?: string;
36
36
  cronJobs?: CronJobSpec[];
37
+
38
+ /**
39
+ * If true, scaffold `notes/QA_CHECKLIST.md` even if the team has no `test` role.
40
+ * If false/omitted, QA checklist is scaffolded only when the team recipe includes a `test` role.
41
+ */
42
+ qaChecklist?: boolean;
43
+
37
44
  [k: string]: unknown;
38
45
  };
39
46
 
@@ -81,22 +81,34 @@ export function findAgentsToRemove(cfgObj: Record<string, unknown>, teamId: stri
81
81
  .filter((id: string) => id && id.startsWith(prefix));
82
82
  }
83
83
 
84
- export function planCronJobRemovals(jobs: CronJob[], teamId: string) {
84
+ export function planCronJobRemovals(
85
+ jobs: CronJob[],
86
+ teamId: string,
87
+ opts?: { installedCronIds?: string[] | null }
88
+ ) {
85
89
  const stamp = stampTeamId(teamId);
86
90
  const exact: Array<{ id: string; name?: string }> = [];
87
91
  const ambiguous: Array<{ id: string; name?: string; reason: string }> = [];
88
92
 
93
+ const installed = new Set((opts?.installedCronIds ?? []).map((s) => String(s).trim()).filter(Boolean));
94
+
89
95
  for (const j of jobs) {
90
96
  const msg = String(j?.payload?.message ?? "");
91
97
  const name = String(j?.name ?? "");
92
98
 
93
- // Exact: message contains the stamp.
99
+ // Exact (preferred): installedCronIds from the team provenance file.
100
+ if (installed.has(String(j.id))) {
101
+ exact.push({ id: j.id, name: j.name });
102
+ continue;
103
+ }
104
+
105
+ // Fallback exact: message contains the stamp.
94
106
  if (msg.includes(stamp)) {
95
107
  exact.push({ id: j.id, name: j.name });
96
108
  continue;
97
109
  }
98
110
 
99
- // Ambiguous: name mentions teamId (helpful for manual review).
111
+ // Ambiguous: name/message mentions teamId (helpful for manual review).
100
112
  if (name.includes(teamId) || msg.includes(teamId)) {
101
113
  ambiguous.push({ id: j.id, name: j.name, reason: "mentions-teamId" });
102
114
  }
@@ -112,6 +124,7 @@ export async function buildRemoveTeamPlan(opts: {
112
124
  cronJobsPath: string; // e.g. ~/.openclaw/cron/jobs.json
113
125
  cfgObj: Record<string, unknown>;
114
126
  cronStore?: CronStore | null;
127
+ installedCronIds?: string[] | null;
115
128
  }) {
116
129
  const teamId = opts.teamId.trim();
117
130
  const workspaceDir = path.resolve(path.join(opts.workspaceRoot, "..", `workspace-${teamId}`));
@@ -122,7 +135,7 @@ export async function buildRemoveTeamPlan(opts: {
122
135
  const agentsToRemove = findAgentsToRemove(opts.cfgObj, teamId);
123
136
 
124
137
  const jobs = (opts.cronStore?.jobs ?? []) as CronJob[];
125
- const cron = planCronJobRemovals(jobs, teamId);
138
+ const cron = planCronJobRemovals(jobs, teamId, { installedCronIds: opts.installedCronIds });
126
139
 
127
140
  const plan: RemoveTeamPlan = {
128
141
  teamId,
@@ -1,12 +1,25 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
4
  import { ensureDir } from "./fs-utils";
4
5
  import { ticketStageDir } from "./lanes";
5
6
 
7
+ /**
8
+ * Resolve the OpenClaw workspace root.
9
+ *
10
+ * Priority:
11
+ * 1) config: agents.defaults.workspace
12
+ * 2) env: OPENCLAW_WORKSPACE
13
+ * 3) default: ~/.openclaw/workspace
14
+ */
6
15
  export function resolveWorkspaceRoot(api: OpenClawPluginApi): string {
7
16
  const root = api.config.agents?.defaults?.workspace;
8
- if (!root) throw new Error("agents.defaults.workspace is not set in config");
9
- return root;
17
+ if (root) return root;
18
+
19
+ const envRoot = process.env.OPENCLAW_WORKSPACE;
20
+ if (envRoot) return envRoot;
21
+
22
+ return path.join(os.homedir(), ".openclaw", "workspace");
10
23
  }
11
24
 
12
25
  export function resolveTeamDir(api: OpenClawPluginApi, teamId: string): string {