@clawcipes/recipes 0.2.4 → 0.2.6
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 +17 -3
- package/docs/AGENTS_AND_SKILLS.md +2 -1
- package/docs/CLAWCIPES_KITCHEN.md +1 -1
- package/docs/COMMANDS.md +39 -2
- package/docs/verify-built-in-team-recipes.md +65 -0
- package/index.ts +394 -184
- package/package.json +9 -3
- package/recipes/default/customer-support-team.md +26 -4
- package/recipes/default/development-team.md +14 -0
- package/recipes/default/product-team.md +32 -15
- package/recipes/default/research-team.md +21 -1
- package/recipes/default/social-team.md +89 -5
- package/recipes/default/writing-team.md +23 -2
- package/src/lib/bindings.ts +59 -0
- package/src/lib/cleanup-workspaces.ts +173 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/lanes.ts +63 -0
- package/src/lib/recipe-frontmatter.ts +59 -0
- package/src/lib/scaffold-templates.ts +7 -0
- package/src/lib/shared-context.ts +52 -0
- package/src/lib/ticket-finder.ts +60 -0
- package/src/lib/ticket-workflow.ts +94 -0
package/src/lib/lanes.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type TicketLane = 'backlog' | 'in-progress' | 'testing' | 'done' | 'assignments';
|
|
5
|
+
|
|
6
|
+
export class RecipesCliError extends Error {
|
|
7
|
+
code: string;
|
|
8
|
+
command?: string;
|
|
9
|
+
missingPath?: string;
|
|
10
|
+
suggestedFix?: string;
|
|
11
|
+
|
|
12
|
+
constructor(opts: { message: string; code: string; command?: string; missingPath?: string; suggestedFix?: string }) {
|
|
13
|
+
super(opts.message);
|
|
14
|
+
this.name = 'RecipesCliError';
|
|
15
|
+
this.code = opts.code;
|
|
16
|
+
this.command = opts.command;
|
|
17
|
+
this.missingPath = opts.missingPath;
|
|
18
|
+
this.suggestedFix = opts.suggestedFix;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fileExists(p: string) {
|
|
23
|
+
try {
|
|
24
|
+
await fs.stat(p);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensure a lane dir exists, with a one-line migration hint for older workspaces.
|
|
33
|
+
*
|
|
34
|
+
* If creation fails, throws a RecipesCliError with an actionable message.
|
|
35
|
+
*/
|
|
36
|
+
export async function ensureLaneDir(opts: { teamDir: string; lane: TicketLane; command?: string; quiet?: boolean }) {
|
|
37
|
+
const laneDir = path.join(opts.teamDir, 'work', opts.lane);
|
|
38
|
+
const existed = await fileExists(laneDir);
|
|
39
|
+
|
|
40
|
+
if (!existed) {
|
|
41
|
+
try {
|
|
42
|
+
await fs.mkdir(laneDir, { recursive: true });
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
throw new RecipesCliError({
|
|
45
|
+
code: 'LANE_DIR_CREATE_FAILED',
|
|
46
|
+
command: opts.command,
|
|
47
|
+
missingPath: laneDir,
|
|
48
|
+
suggestedFix: `mkdir -p ${path.join('work', opts.lane)}`,
|
|
49
|
+
message:
|
|
50
|
+
`Failed to create required lane directory: ${laneDir}` +
|
|
51
|
+
(opts.command ? ` (command: ${opts.command})` : '') +
|
|
52
|
+
(e?.message ? `\nUnderlying error: ${String(e.message)}` : ''),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!opts.quiet) {
|
|
57
|
+
const rel = path.join('work', opts.lane);
|
|
58
|
+
console.error(`[recipes] migration: created ${rel}/ (older workspace missing this lane)`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { path: laneDir, created: !existed };
|
|
63
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
|
|
3
|
+
export type CronJobSpec = {
|
|
4
|
+
id: string;
|
|
5
|
+
schedule: string;
|
|
6
|
+
message: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
timezone?: string;
|
|
10
|
+
channel?: string;
|
|
11
|
+
to?: string;
|
|
12
|
+
agentId?: string;
|
|
13
|
+
enabledByDefault?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RecipeFrontmatter = {
|
|
17
|
+
id: string;
|
|
18
|
+
kind?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
cronJobs?: CronJobSpec[];
|
|
21
|
+
[k: string]: any;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function parseFrontmatter(md: string): { frontmatter: RecipeFrontmatter; body: string } {
|
|
25
|
+
if (!md.startsWith('---\n')) throw new Error('Recipe markdown must start with YAML frontmatter (---)');
|
|
26
|
+
const end = md.indexOf('\n---\n', 4);
|
|
27
|
+
if (end === -1) throw new Error('Recipe frontmatter not terminated (---)');
|
|
28
|
+
const yamlText = md.slice(4, end);
|
|
29
|
+
const body = md.slice(end + 5);
|
|
30
|
+
const frontmatter = YAML.parse(yamlText) as RecipeFrontmatter;
|
|
31
|
+
if (!frontmatter?.id) throw new Error('Recipe frontmatter must include id');
|
|
32
|
+
return { frontmatter, body };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeCronJobs(frontmatter: { cronJobs?: any }): CronJobSpec[] {
|
|
36
|
+
const raw = (frontmatter as any).cronJobs;
|
|
37
|
+
if (!raw) return [];
|
|
38
|
+
if (!Array.isArray(raw)) throw new Error('frontmatter.cronJobs must be an array');
|
|
39
|
+
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
const out: CronJobSpec[] = [];
|
|
42
|
+
|
|
43
|
+
for (const j of raw) {
|
|
44
|
+
if (!j || typeof j !== 'object') throw new Error('cronJobs entries must be objects');
|
|
45
|
+
const id = String((j as any).id ?? '').trim();
|
|
46
|
+
if (!id) throw new Error('cronJobs[].id is required');
|
|
47
|
+
if (seen.has(id)) throw new Error(`Duplicate cronJobs[].id: ${id}`);
|
|
48
|
+
seen.add(id);
|
|
49
|
+
|
|
50
|
+
const schedule = String((j as any).schedule ?? '').trim();
|
|
51
|
+
const message = String((j as any).message ?? '').trim();
|
|
52
|
+
if (!schedule) throw new Error(`cronJobs[${id}].schedule is required`);
|
|
53
|
+
if (!message) throw new Error(`cronJobs[${id}].message is required`);
|
|
54
|
+
|
|
55
|
+
out.push({ ...j, id, schedule, message } as CronJobSpec);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function renderTeamMd(teamId: string) {
|
|
2
|
+
return `# ${teamId}\n\nShared workspace for this agent team.\n\n## Workflow\n- Stages: backlog → in-progress → testing → done\n- Backlog: work/backlog/\n- In progress: work/in-progress/\n- Testing / QA: work/testing/\n- Done: work/done/\n\n## QA verification\nBefore moving a ticket from work/testing/ → work/done/, record verification results.\n- Template: notes/QA_CHECKLIST.md\n- Preferred: create work/testing/<ticket>.testing-verified.md\n\n## Folders\n- inbox/ — requests\n- outbox/ — deliverables\n- shared-context/ — curated shared context + append-only agent outputs\n- shared/ — legacy shared artifacts (back-compat)\n- notes/ — plan + status + templates\n- work/ — working files\n`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function renderTicketsMd(teamId: string) {
|
|
6
|
+
return `# Tickets — ${teamId}\n\n## Workflow\n- Stages: backlog → in-progress → testing → done\n- Backlog tickets live in work/backlog/\n- In-progress tickets live in work/in-progress/\n- Testing / QA tickets live in work/testing/\n- Done tickets live in work/done/\n\n### QA handoff (dev → test)\nWhen development is complete:\n- Move the ticket file to work/testing/\n- Assign to test (set \`Owner: test\`)\n- Add clear test instructions / repro steps\n\n### QA verification (test → done)\nBefore moving a ticket to done, QA must record verification.\n- Template: notes/QA_CHECKLIST.md\n- Preferred: create work/testing/<ticket>.testing-verified.md\n\n## Naming\n- Filename ordering is the queue: 0001-..., 0002-...\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## How to test\n- ...\n\`\`\`\n`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
async function ensureDir(p: string) {
|
|
5
|
+
await fs.mkdir(p, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function exists(p: string) {
|
|
9
|
+
try {
|
|
10
|
+
await fs.stat(p);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function writeCreateOnlyOrOverwrite(filePath: string, content: string, mode: 'createOnly' | 'overwrite') {
|
|
18
|
+
if (mode === 'createOnly' && (await exists(filePath))) return { wrote: false as const };
|
|
19
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
20
|
+
return { wrote: true as const };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function ensureSharedContextScaffold(opts: { teamDir: string; teamId: string; overwrite: boolean }) {
|
|
24
|
+
const { teamDir, teamId, overwrite } = opts;
|
|
25
|
+
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
26
|
+
const outputsDir = path.join(sharedContextDir, 'agent-outputs');
|
|
27
|
+
const feedbackDir = path.join(sharedContextDir, 'feedback');
|
|
28
|
+
const kpisDir = path.join(sharedContextDir, 'kpis');
|
|
29
|
+
const calendarDir = path.join(sharedContextDir, 'calendar');
|
|
30
|
+
|
|
31
|
+
await Promise.all([
|
|
32
|
+
// Back-compat alias: keep shared/ folder.
|
|
33
|
+
ensureDir(path.join(teamDir, 'shared')),
|
|
34
|
+
ensureDir(sharedContextDir),
|
|
35
|
+
ensureDir(outputsDir),
|
|
36
|
+
ensureDir(feedbackDir),
|
|
37
|
+
ensureDir(kpisDir),
|
|
38
|
+
ensureDir(calendarDir),
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const prioritiesPath = path.join(sharedContextDir, 'priorities.md');
|
|
42
|
+
const prioritiesMd = `# Priorities — ${teamId}\n\n- (empty)\n\n## Notes\n- Lead curates this file.\n- Non-lead roles should append updates to shared-context/agent-outputs/ instead.\n`;
|
|
43
|
+
|
|
44
|
+
const mode = overwrite ? 'overwrite' : 'createOnly';
|
|
45
|
+
const wrote = await writeCreateOnlyOrOverwrite(prioritiesPath, prioritiesMd, mode);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
sharedContextDir,
|
|
49
|
+
prioritiesPath,
|
|
50
|
+
wrotePriorities: wrote.wrote,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
async function fileExists(p: string) {
|
|
5
|
+
try {
|
|
6
|
+
await fs.access(p);
|
|
7
|
+
return true;
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type TicketLane = 'backlog' | 'in-progress' | 'testing' | 'done';
|
|
14
|
+
|
|
15
|
+
export function laneDir(teamDir: string, lane: TicketLane) {
|
|
16
|
+
return path.join(teamDir, 'work', lane);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function allLaneDirs(teamDir: string) {
|
|
20
|
+
return [
|
|
21
|
+
laneDir(teamDir, 'backlog'),
|
|
22
|
+
laneDir(teamDir, 'in-progress'),
|
|
23
|
+
laneDir(teamDir, 'testing'),
|
|
24
|
+
laneDir(teamDir, 'done'),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseTicketArg(ticketArg: string) {
|
|
29
|
+
const ticketNum = ticketArg.match(/^\d{4}$/)
|
|
30
|
+
? ticketArg
|
|
31
|
+
: (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
32
|
+
return { ticketArg, ticketNum };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function findTicketFile(opts: {
|
|
36
|
+
teamDir: string;
|
|
37
|
+
ticket: string;
|
|
38
|
+
lanes?: TicketLane[];
|
|
39
|
+
}) {
|
|
40
|
+
const lanes = opts.lanes ?? ['backlog', 'in-progress', 'testing', 'done'];
|
|
41
|
+
const { ticketArg, ticketNum } = parseTicketArg(String(opts.ticket));
|
|
42
|
+
|
|
43
|
+
for (const lane of lanes) {
|
|
44
|
+
const dir = laneDir(opts.teamDir, lane);
|
|
45
|
+
if (!(await fileExists(dir))) continue;
|
|
46
|
+
const files = await fs.readdir(dir);
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
if (!f.endsWith('.md')) continue;
|
|
49
|
+
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
50
|
+
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseOwnerFromMd(md: string): string | null {
|
|
58
|
+
const m = md.match(/^Owner:\s*(.+)\s*$/m);
|
|
59
|
+
return m?.[1]?.trim() ?? null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ensureLaneDir } from './lanes';
|
|
5
|
+
|
|
6
|
+
async function fileExists(p: string) {
|
|
7
|
+
try {
|
|
8
|
+
await fs.stat(p);
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function ensureDir(p: string) {
|
|
16
|
+
await fs.mkdir(p, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function findTicketFile(teamDir: string, ticketArg: string) {
|
|
20
|
+
const stageDir = (stage: string) => path.join(teamDir, 'work', stage);
|
|
21
|
+
const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
|
|
22
|
+
|
|
23
|
+
const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
|
|
24
|
+
|
|
25
|
+
for (const dir of searchDirs) {
|
|
26
|
+
if (!(await fileExists(dir))) continue;
|
|
27
|
+
const files = await fs.readdir(dir);
|
|
28
|
+
for (const f of files) {
|
|
29
|
+
if (!f.endsWith('.md')) continue;
|
|
30
|
+
if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
|
|
31
|
+
if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handoffTicket(opts: { teamDir: string; ticket: string; tester?: string; overwriteAssignment: boolean }) {
|
|
38
|
+
const teamDir = opts.teamDir;
|
|
39
|
+
const tester = (opts.tester ?? 'test').trim() || 'test';
|
|
40
|
+
const testerSafe = tester.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/(^-|-$)/g, '') || 'test';
|
|
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 handoff a done ticket (already completed)');
|
|
45
|
+
|
|
46
|
+
const testingDir = (await ensureLaneDir({ teamDir, lane: 'testing', command: 'openclaw recipes handoff' })).path;
|
|
47
|
+
|
|
48
|
+
const filename = path.basename(srcPath);
|
|
49
|
+
const destPath = path.join(testingDir, 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-${testerSafe}.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: ${testerSafe}`);
|
|
63
|
+
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${testerSafe}\n`);
|
|
64
|
+
|
|
65
|
+
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, 'Status: testing');
|
|
66
|
+
else out = out.replace(/^(# .+\n)/, `$1\nStatus: testing\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 alreadyInTesting = 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 (!alreadyInTesting) {
|
|
81
|
+
await fs.rename(srcPath, destPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${testerSafe}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes handoff\n`;
|
|
85
|
+
|
|
86
|
+
const assignmentExists = await fileExists(assignmentPath);
|
|
87
|
+
if (assignmentExists && !opts.overwriteAssignment) {
|
|
88
|
+
// createOnly: leave as-is
|
|
89
|
+
} else {
|
|
90
|
+
await fs.writeFile(assignmentPath, assignmentMd, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { srcPath, destPath, moved: !alreadyInTesting, assignmentPath };
|
|
94
|
+
}
|