@jiggai/recipes 0.2.11
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 +142 -0
- package/clawcipes_cook.jpg +0 -0
- package/docs/AGENTS_AND_SKILLS.md +232 -0
- package/docs/BUNDLED_RECIPES.md +208 -0
- package/docs/CLAWCIPES_KITCHEN.md +27 -0
- package/docs/COMMANDS.md +266 -0
- package/docs/INSTALLATION.md +80 -0
- package/docs/RECIPE_FORMAT.md +127 -0
- package/docs/TEAM_WORKFLOW.md +62 -0
- package/docs/TUTORIAL_CREATE_RECIPE.md +151 -0
- package/docs/shared-context.md +47 -0
- package/docs/verify-built-in-team-recipes.md +65 -0
- package/index.ts +2244 -0
- package/openclaw.plugin.json +28 -0
- package/package.json +46 -0
- package/recipes/default/customer-support-team.md +246 -0
- package/recipes/default/developer.md +73 -0
- package/recipes/default/development-team.md +389 -0
- package/recipes/default/editor.md +74 -0
- package/recipes/default/product-team.md +298 -0
- package/recipes/default/project-manager.md +69 -0
- package/recipes/default/research-team.md +243 -0
- package/recipes/default/researcher.md +75 -0
- package/recipes/default/social-team.md +205 -0
- package/recipes/default/writing-team.md +228 -0
- 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/remove-team.ts +200 -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 +153 -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,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
|
+
}
|
|
@@ -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,153 @@
|
|
|
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 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
|
+
|
|
96
|
+
export async function handoffTicket(opts: { teamDir: string; ticket: string; tester?: string; overwriteAssignment: boolean }) {
|
|
97
|
+
const teamDir = opts.teamDir;
|
|
98
|
+
const tester = (opts.tester ?? 'test').trim() || 'test';
|
|
99
|
+
const testerSafe = tester.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/(^-|-$)/g, '') || 'test';
|
|
100
|
+
|
|
101
|
+
const srcPath = await findTicketFile(teamDir, opts.ticket);
|
|
102
|
+
if (!srcPath) throw new Error(`Ticket not found: ${opts.ticket}`);
|
|
103
|
+
if (srcPath.includes(`${path.sep}work${path.sep}done${path.sep}`)) throw new Error('Cannot handoff a done ticket (already completed)');
|
|
104
|
+
|
|
105
|
+
const testingDir = (await ensureLaneDir({ teamDir, lane: 'testing', command: 'openclaw recipes handoff' })).path;
|
|
106
|
+
|
|
107
|
+
const filename = path.basename(srcPath);
|
|
108
|
+
const destPath = path.join(testingDir, filename);
|
|
109
|
+
|
|
110
|
+
const m = filename.match(/^(\d{4})-(.+)\.md$/);
|
|
111
|
+
const ticketNumStr = m?.[1] ?? (opts.ticket.match(/^\d{4}$/) ? opts.ticket : '0000');
|
|
112
|
+
const slug = m?.[2] ?? 'ticket';
|
|
113
|
+
|
|
114
|
+
const assignmentsDir = path.join(teamDir, 'work', 'assignments');
|
|
115
|
+
await ensureDir(assignmentsDir);
|
|
116
|
+
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${testerSafe}.md`);
|
|
117
|
+
const assignmentRel = path.relative(teamDir, assignmentPath);
|
|
118
|
+
|
|
119
|
+
const patch = (md: string) => {
|
|
120
|
+
let out = md;
|
|
121
|
+
if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${testerSafe}`);
|
|
122
|
+
else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${testerSafe}\n`);
|
|
123
|
+
|
|
124
|
+
if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, 'Status: testing');
|
|
125
|
+
else out = out.replace(/^(# .+\n)/, `$1\nStatus: testing\n`);
|
|
126
|
+
|
|
127
|
+
if (out.match(/^Assignment:\s.*$/m)) out = out.replace(/^Assignment:\s.*$/m, `Assignment: ${assignmentRel}`);
|
|
128
|
+
else out = out.replace(/^Owner:.*$/m, (line) => `${line}\nAssignment: ${assignmentRel}`);
|
|
129
|
+
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const alreadyInTesting = srcPath === destPath;
|
|
134
|
+
|
|
135
|
+
const md = await fs.readFile(srcPath, 'utf8');
|
|
136
|
+
const nextMd = patch(md);
|
|
137
|
+
await fs.writeFile(srcPath, nextMd, 'utf8');
|
|
138
|
+
|
|
139
|
+
if (!alreadyInTesting) {
|
|
140
|
+
await fs.rename(srcPath, destPath);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
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`;
|
|
144
|
+
|
|
145
|
+
const assignmentExists = await fileExists(assignmentPath);
|
|
146
|
+
if (assignmentExists && !opts.overwriteAssignment) {
|
|
147
|
+
// createOnly: leave as-is
|
|
148
|
+
} else {
|
|
149
|
+
await fs.writeFile(assignmentPath, assignmentMd, 'utf8');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { srcPath, destPath, moved: !alreadyInTesting, assignmentPath };
|
|
153
|
+
}
|