@clawcipes/recipes 0.1.6 → 0.1.8

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 CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Clawcipes is an OpenClaw plugin that provides **CLI-first recipes** for scaffolding specialist agents and teams from Markdown.
10
10
 
11
- If you like durable workflows: Clawcipes is built around a **file-first team workspace** (inbox/backlog/in-progress/done) that plays nicely with git.
11
+ If you like durable workflows: Clawcipes is built around a **file-first team workspace** (inbox/backlog/in-progress/testing/done) that plays nicely with git.
12
12
 
13
13
  ## Quickstart
14
14
  ### 1) Install
@@ -57,6 +57,7 @@ openclaw recipes dispatch \
57
57
  - `openclaw recipes install <idOrSlug> [--yes] [--global|--agent-id <id>|--team-id <id>]` (skills: global or scoped)
58
58
  - `openclaw recipes bind|unbind|bindings` (multi-agent routing)
59
59
  - `openclaw recipes dispatch ...` (request → inbox + ticket + assignment)
60
+ - `openclaw recipes tickets|move-ticket|assign|take|complete` (file-first ticket workflow)
60
61
 
61
62
  For full details, see `docs/COMMANDS.md`.
62
63
 
@@ -127,4 +128,4 @@ Clawcipes is meant to be *installed* and then used to build **agents + teams**.
127
128
  Most users should focus on:
128
129
  - authoring recipes in their OpenClaw workspace (`<workspace>/recipes/*.md`)
129
130
  - scaffolding teams (`openclaw recipes scaffold-team ...`)
130
- - running the file-first workflow (dispatch → backlog → in-progress → done)
131
+ - running the file-first workflow (dispatch → backlog → in-progress → testing → done)
package/docs/COMMANDS.md CHANGED
@@ -66,7 +66,7 @@ Creates a shared team workspace root:
66
66
 
67
67
  Standard folders:
68
68
  - `inbox/`, `outbox/`, `shared/`, `notes/`
69
- - `work/{backlog,in-progress,done,assignments}`
69
+ - `work/{backlog,in-progress,testing,done,assignments}`
70
70
  - `roles/<role>/...` (role-specific recipe files)
71
71
 
72
72
  Also creates agent config entries under `agents.list[]` (when `--apply-config`), with agent ids:
@@ -150,6 +150,53 @@ Options:
150
150
  ## `dispatch`
151
151
  Convert a natural-language request into file-first execution artifacts.
152
152
 
153
+ ## `tickets`
154
+ List tickets for a team across the standard workflow stages.
155
+
156
+ ```bash
157
+ openclaw recipes tickets --team-id <teamId>
158
+ openclaw recipes tickets --team-id <teamId> --json
159
+ ```
160
+
161
+ ## `move-ticket`
162
+ Move a ticket file between workflow stages and update the ticket’s `Status:` field.
163
+
164
+ ```bash
165
+ openclaw recipes move-ticket --team-id <teamId> --ticket 0007 --to in-progress
166
+ openclaw recipes move-ticket --team-id <teamId> --ticket 0007 --to testing
167
+ openclaw recipes move-ticket --team-id <teamId> --ticket 0007 --to done --completed
168
+ ```
169
+
170
+ Stages:
171
+ - `backlog` → `Status: queued`
172
+ - `in-progress` → `Status: in-progress`
173
+ - `testing` → `Status: testing`
174
+ - `done` → `Status: done` (optional `Completed:` timestamp)
175
+
176
+ ## `assign`
177
+ Assign a ticket to an owner (updates `Owner:` and creates an assignment stub).
178
+
179
+ ```bash
180
+ openclaw recipes assign --team-id <teamId> --ticket 0007 --owner dev
181
+ openclaw recipes assign --team-id <teamId> --ticket 0007 --owner lead
182
+ ```
183
+
184
+ Owners (current): `dev|devops|lead`.
185
+
186
+ ## `take`
187
+ Shortcut: assign + move to in-progress.
188
+
189
+ ```bash
190
+ openclaw recipes take --team-id <teamId> --ticket 0007 --owner dev
191
+ ```
192
+
193
+ ## `complete`
194
+ Shortcut: move to done + ensure `Status: done` + add `Completed:` timestamp.
195
+
196
+ ```bash
197
+ openclaw recipes complete --team-id <teamId> --ticket 0007
198
+ ```
199
+
153
200
  ```bash
154
201
  openclaw recipes dispatch \
155
202
  --team-id development-team-team \
@@ -164,12 +211,12 @@ Options:
164
211
  - `--yes` (skip review prompt)
165
212
 
166
213
  Creates (createOnly):
167
- - `teams/<teamId>/inbox/<timestamp>-<slug>.md`
168
- - `teams/<teamId>/work/backlog/<NNNN>-<slug>.md`
169
- - `teams/<teamId>/work/assignments/<NNNN>-assigned-<owner>.md`
214
+ - `workspace-<teamId>/inbox/<timestamp>-<slug>.md`
215
+ - `workspace-<teamId>/work/backlog/<NNNN>-<slug>.md`
216
+ - `workspace-<teamId>/work/assignments/<NNNN>-assigned-<owner>.md`
170
217
 
171
218
  Ticket numbering:
172
- - Scans `work/backlog`, `work/in-progress`, `work/done` and uses max+1.
219
+ - Scans `work/backlog`, `work/in-progress`, `work/testing`, `work/done` and uses max+1.
173
220
 
174
221
  Review-before-write:
175
222
  - Prints a JSON plan and asks for confirmation unless `--yes`.
@@ -6,7 +6,7 @@ Clawcipes’ differentiator is the **shared team workspace** + a simple, durable
6
6
  When you scaffold a team:
7
7
 
8
8
  ```
9
- teams/<teamId>/
9
+ ~/.openclaw/workspace-<teamId>/
10
10
  inbox/
11
11
  outbox/
12
12
  shared/
@@ -14,9 +14,9 @@ teams/<teamId>/
14
14
  work/
15
15
  backlog/
16
16
  in-progress/
17
+ testing/
17
18
  done/
18
19
  assignments/
19
- TICKETS.md
20
20
  TEAM.md
21
21
  ```
22
22
 
@@ -29,12 +29,16 @@ teams/<teamId>/
29
29
  - Filename ordering is the priority queue.
30
30
 
31
31
  3) **Execute**
32
- - Move ticket file to `work/in-progress/`.
32
+ - Move ticket file to `work/in-progress/` (or use `take`).
33
33
  - Do work; write artifacts into `shared/` or agent workspaces.
34
34
 
35
- 4) **Complete**
36
- - Move ticket to `work/done/`.
37
- - Add a `*.DONE.md` report next to it.
35
+ 4) **Test**
36
+ - Move ticket to `work/testing/`.
37
+ - Optionally assign to a tester/lead for verification.
38
+
39
+ 5) **Complete**
40
+ - Move ticket to `work/done/` (or use `complete`).
41
+ - Add `Completed:` timestamp (automated by `complete` or `move-ticket --completed`).
38
42
 
39
43
  ## Dispatcher command
40
44
  The lead can convert a natural-language request into artifacts with:
package/index.ts CHANGED
@@ -894,6 +894,7 @@ const recipesPlugin = {
894
894
  const dirs = [
895
895
  backlogDir,
896
896
  path.join(teamDir, "work", "in-progress"),
897
+ path.join(teamDir, "work", "testing"),
897
898
  path.join(teamDir, "work", "done"),
898
899
  ];
899
900
  let max = 0;
@@ -933,11 +934,13 @@ const recipesPlugin = {
933
934
  const ticketPath = path.join(backlogDir, `${ticketNumStr}-${baseSlug}.md`);
934
935
  const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
935
936
 
936
- const inboxMd = `# Inbox — ${teamId}\n\nReceived: ${new Date().toISOString()}\n\n## Request\n${requestText}\n\n## Proposed work\n- Ticket: ${ticketNumStr}-${baseSlug}\n- Owner: ${owner}\n`;
937
+ const receivedIso = new Date().toISOString();
937
938
 
938
- const ticketMd = `# ${ticketNumStr}-${baseSlug}\n\nOwner: ${owner}\nStatus: queued\n\n## Context\n${requestText}\n\n## Requirements\n- (fill in)\n\n## Acceptance criteria\n- (fill in)\n`;
939
+ 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`;
939
940
 
940
- const assignmentMd = `# Assignment — ${ticketNumStr}-${baseSlug}\n\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`;
941
+ 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`;
942
+
943
+ 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`;
941
944
 
942
945
  const plan = {
943
946
  teamId,
@@ -991,6 +994,396 @@ const recipesPlugin = {
991
994
  console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
992
995
  });
993
996
 
997
+ cmd
998
+ .command("tickets")
999
+ .description("List tickets for a team (backlog / in-progress / testing / done)")
1000
+ .requiredOption("--team-id <teamId>", "Team id")
1001
+ .option("--json", "Output JSON")
1002
+ .action(async (options: any) => {
1003
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1004
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1005
+ const teamId = String(options.teamId);
1006
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1007
+
1008
+ const dirs = {
1009
+ backlog: path.join(teamDir, "work", "backlog"),
1010
+ inProgress: path.join(teamDir, "work", "in-progress"),
1011
+ testing: path.join(teamDir, "work", "testing"),
1012
+ done: path.join(teamDir, "work", "done"),
1013
+ } as const;
1014
+
1015
+ const readTickets = async (dir: string, stage: "backlog" | "in-progress" | "testing" | "done") => {
1016
+ if (!(await fileExists(dir))) return [] as any[];
1017
+ const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
1018
+ return files.map((f) => {
1019
+ const m = f.match(/^(\d{4})-(.+)\.md$/);
1020
+ return {
1021
+ stage,
1022
+ number: m ? Number(m[1]) : null,
1023
+ id: m ? `${m[1]}-${m[2]}` : f.replace(/\.md$/, ""),
1024
+ file: path.join(dir, f),
1025
+ };
1026
+ });
1027
+ };
1028
+
1029
+ const out = {
1030
+ teamId,
1031
+ backlog: await readTickets(dirs.backlog, "backlog"),
1032
+ inProgress: await readTickets(dirs.inProgress, "in-progress"),
1033
+ testing: await readTickets(dirs.testing, "testing"),
1034
+ done: await readTickets(dirs.done, "done"),
1035
+ };
1036
+
1037
+ if (options.json) {
1038
+ console.log(JSON.stringify(out, null, 2));
1039
+ return;
1040
+ }
1041
+
1042
+ const print = (label: string, items: any[]) => {
1043
+ console.log(`\n${label} (${items.length})`);
1044
+ for (const t of items) console.log(`- ${t.id}`);
1045
+ };
1046
+ console.log(`Team: ${teamId}`);
1047
+ print("Backlog", out.backlog);
1048
+ print("In progress", out.inProgress);
1049
+ print("Testing", out.testing);
1050
+ print("Done", out.done);
1051
+ });
1052
+
1053
+ cmd
1054
+ .command("move-ticket")
1055
+ .description("Move a ticket between backlog/in-progress/testing/done (updates Status: line)")
1056
+ .requiredOption("--team-id <teamId>", "Team id")
1057
+ .requiredOption("--ticket <ticket>", "Ticket id or number (e.g. 0007 or 0007-some-slug)")
1058
+ .requiredOption("--to <stage>", "Destination stage: backlog|in-progress|testing|done")
1059
+ .option("--completed", "When moving to done, add Completed: timestamp")
1060
+ .option("--yes", "Skip confirmation")
1061
+ .action(async (options: any) => {
1062
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1063
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1064
+ const teamId = String(options.teamId);
1065
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1066
+
1067
+ const dest = String(options.to);
1068
+ if (!['backlog','in-progress','testing','done'].includes(dest)) {
1069
+ throw new Error("--to must be one of: backlog, in-progress, testing, done");
1070
+ }
1071
+
1072
+ const ticketArg = String(options.ticket);
1073
+ const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1074
+
1075
+ const stageDir = (stage: string) => {
1076
+ if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1077
+ if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1078
+ if (stage === 'testing') return path.join(teamDir, 'work', 'testing');
1079
+ if (stage === 'done') return path.join(teamDir, 'work', 'done');
1080
+ throw new Error(`Unknown stage: ${stage}`);
1081
+ };
1082
+
1083
+ const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('testing'), stageDir('done')];
1084
+
1085
+ const findTicketFile = async () => {
1086
+ for (const dir of searchDirs) {
1087
+ if (!(await fileExists(dir))) continue;
1088
+ const files = await fs.readdir(dir);
1089
+ for (const f of files) {
1090
+ if (!f.endsWith('.md')) continue;
1091
+ if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1092
+ if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1093
+ }
1094
+ }
1095
+ return null;
1096
+ };
1097
+
1098
+ const srcPath = await findTicketFile();
1099
+ if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1100
+
1101
+ const destDir = stageDir(dest);
1102
+ await ensureDir(destDir);
1103
+ const filename = path.basename(srcPath);
1104
+ const destPath = path.join(destDir, filename);
1105
+
1106
+ const patchStatus = (md: string) => {
1107
+ const nextStatus =
1108
+ dest === 'backlog'
1109
+ ? 'queued'
1110
+ : dest === 'in-progress'
1111
+ ? 'in-progress'
1112
+ : dest === 'testing'
1113
+ ? 'testing'
1114
+ : 'done';
1115
+
1116
+ let out = md;
1117
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${nextStatus}`);
1118
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${nextStatus}\n`);
1119
+
1120
+ if (dest === 'done' && options.completed) {
1121
+ const completed = new Date().toISOString();
1122
+ if (out.match(/^Completed:\s.*$/m)) out = out.replace(/^Completed:\s.*$/m, `Completed: ${completed}`);
1123
+ else out = out.replace(/^Status:.*$/m, (m) => `${m}\nCompleted: ${completed}`);
1124
+ }
1125
+
1126
+ return out;
1127
+ };
1128
+
1129
+ const plan = { from: srcPath, to: destPath };
1130
+
1131
+ if (!options.yes && process.stdin.isTTY) {
1132
+ console.log(JSON.stringify({ plan }, null, 2));
1133
+ const readline = await import('node:readline/promises');
1134
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1135
+ try {
1136
+ const ans = await rl.question(`Move ticket to ${dest}? (y/N) `);
1137
+ const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1138
+ if (!ok) {
1139
+ console.error('Aborted; no changes made.');
1140
+ return;
1141
+ }
1142
+ } finally {
1143
+ rl.close();
1144
+ }
1145
+ } else if (!options.yes && !process.stdin.isTTY) {
1146
+ console.error('Refusing to move without confirmation in non-interactive mode. Re-run with --yes.');
1147
+ process.exitCode = 2;
1148
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
1149
+ return;
1150
+ }
1151
+
1152
+ const md = await fs.readFile(srcPath, 'utf8');
1153
+ const nextMd = patchStatus(md);
1154
+ await fs.writeFile(srcPath, nextMd, 'utf8');
1155
+
1156
+ if (srcPath !== destPath) {
1157
+ await fs.rename(srcPath, destPath);
1158
+ }
1159
+
1160
+ console.log(JSON.stringify({ ok: true, moved: plan }, null, 2));
1161
+ });
1162
+
1163
+ cmd
1164
+ .command("assign")
1165
+ .description("Assign a ticket to an owner (writes assignment stub + updates Owner: in ticket)")
1166
+ .requiredOption("--team-id <teamId>", "Team id")
1167
+ .requiredOption("--ticket <ticket>", "Ticket id or number (e.g. 0007 or 0007-some-slug)")
1168
+ .requiredOption("--owner <owner>", "Owner: dev|devops|lead")
1169
+ .option("--overwrite", "Overwrite existing assignment file")
1170
+ .option("--yes", "Skip confirmation")
1171
+ .action(async (options: any) => {
1172
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1173
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1174
+ const teamId = String(options.teamId);
1175
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1176
+
1177
+ const owner = String(options.owner);
1178
+ if (!['dev','devops','lead'].includes(owner)) {
1179
+ throw new Error("--owner must be one of: dev, devops, lead");
1180
+ }
1181
+
1182
+ const stageDir = (stage: string) => {
1183
+ if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1184
+ if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1185
+ if (stage === 'done') return path.join(teamDir, 'work', 'done');
1186
+ throw new Error(`Unknown stage: ${stage}`);
1187
+ };
1188
+ const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('done')];
1189
+
1190
+ const ticketArg = String(options.ticket);
1191
+ const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1192
+
1193
+ const findTicketFile = async () => {
1194
+ for (const dir of searchDirs) {
1195
+ if (!(await fileExists(dir))) continue;
1196
+ const files = await fs.readdir(dir);
1197
+ for (const f of files) {
1198
+ if (!f.endsWith('.md')) continue;
1199
+ if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1200
+ if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1201
+ }
1202
+ }
1203
+ return null;
1204
+ };
1205
+
1206
+ const ticketPath = await findTicketFile();
1207
+ if (!ticketPath) throw new Error(`Ticket not found: ${ticketArg}`);
1208
+
1209
+ const filename = path.basename(ticketPath);
1210
+ const m = filename.match(/^(\d{4})-(.+)\.md$/);
1211
+ const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1212
+ const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1213
+
1214
+ const assignmentsDir = path.join(teamDir, 'work', 'assignments');
1215
+ await ensureDir(assignmentsDir);
1216
+ const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
1217
+
1218
+ const patchOwner = (md: string) => {
1219
+ if (md.match(/^Owner:\s.*$/m)) return md.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
1220
+ return md.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
1221
+ };
1222
+
1223
+ const plan = { ticketPath, assignmentPath, owner };
1224
+
1225
+ if (!options.yes && process.stdin.isTTY) {
1226
+ console.log(JSON.stringify({ plan }, null, 2));
1227
+ const readline = await import('node:readline/promises');
1228
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1229
+ try {
1230
+ const ans = await rl.question(`Assign ticket to ${owner}? (y/N) `);
1231
+ const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1232
+ if (!ok) {
1233
+ console.error('Aborted; no changes made.');
1234
+ return;
1235
+ }
1236
+ } finally {
1237
+ rl.close();
1238
+ }
1239
+ } else if (!options.yes && !process.stdin.isTTY) {
1240
+ console.error('Refusing to assign without confirmation in non-interactive mode. Re-run with --yes.');
1241
+ process.exitCode = 2;
1242
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
1243
+ return;
1244
+ }
1245
+
1246
+ const md = await fs.readFile(ticketPath, 'utf8');
1247
+ const nextMd = patchOwner(md);
1248
+ await fs.writeFile(ticketPath, nextMd, 'utf8');
1249
+
1250
+ 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`;
1251
+ await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? 'overwrite' : 'createOnly');
1252
+
1253
+ console.log(JSON.stringify({ ok: true, plan }, null, 2));
1254
+ });
1255
+
1256
+ cmd
1257
+ .command("take")
1258
+ .description("Shortcut: assign ticket to owner + move to in-progress")
1259
+ .requiredOption("--team-id <teamId>", "Team id")
1260
+ .requiredOption("--ticket <ticket>", "Ticket id or number")
1261
+ .option("--owner <owner>", "Owner: dev|devops|lead", "dev")
1262
+ .option("--yes", "Skip confirmation")
1263
+ .action(async (options: any) => {
1264
+ const workspaceRoot = api.config.agents?.defaults?.workspace;
1265
+ if (!workspaceRoot) throw new Error("agents.defaults.workspace is not set in config");
1266
+ const teamId = String(options.teamId);
1267
+ const teamDir = path.resolve(workspaceRoot, "..", `workspace-${teamId}`);
1268
+
1269
+ const owner = String(options.owner ?? 'dev');
1270
+ if (!['dev','devops','lead'].includes(owner)) {
1271
+ throw new Error("--owner must be one of: dev, devops, lead");
1272
+ }
1273
+
1274
+ const stageDir = (stage: string) => {
1275
+ if (stage === 'backlog') return path.join(teamDir, 'work', 'backlog');
1276
+ if (stage === 'in-progress') return path.join(teamDir, 'work', 'in-progress');
1277
+ if (stage === 'done') return path.join(teamDir, 'work', 'done');
1278
+ throw new Error(`Unknown stage: ${stage}`);
1279
+ };
1280
+ const searchDirs = [stageDir('backlog'), stageDir('in-progress'), stageDir('done')];
1281
+
1282
+ const ticketArg = String(options.ticket);
1283
+ const ticketNum = ticketArg.match(/^\d{4}$/) ? ticketArg : (ticketArg.match(/^(\d{4})-/)?.[1] ?? null);
1284
+
1285
+ const findTicketFile = async () => {
1286
+ for (const dir of searchDirs) {
1287
+ if (!(await fileExists(dir))) continue;
1288
+ const files = await fs.readdir(dir);
1289
+ for (const f of files) {
1290
+ if (!f.endsWith('.md')) continue;
1291
+ if (ticketNum && f.startsWith(`${ticketNum}-`)) return path.join(dir, f);
1292
+ if (!ticketNum && f.replace(/\.md$/, '') === ticketArg) return path.join(dir, f);
1293
+ }
1294
+ }
1295
+ return null;
1296
+ };
1297
+
1298
+ const srcPath = await findTicketFile();
1299
+ if (!srcPath) throw new Error(`Ticket not found: ${ticketArg}`);
1300
+
1301
+ const destDir = stageDir('in-progress');
1302
+ await ensureDir(destDir);
1303
+ const filename = path.basename(srcPath);
1304
+ const destPath = path.join(destDir, filename);
1305
+
1306
+ const patch = (md: string) => {
1307
+ let out = md;
1308
+ if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
1309
+ else out = out.replace(/^(# .+\n)/, `$1\nOwner: ${owner}\n`);
1310
+
1311
+ if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: in-progress`);
1312
+ else out = out.replace(/^(# .+\n)/, `$1\nStatus: in-progress\n`);
1313
+
1314
+ return out;
1315
+ };
1316
+
1317
+ const plan = { from: srcPath, to: destPath, owner };
1318
+
1319
+ if (!options.yes && process.stdin.isTTY) {
1320
+ console.log(JSON.stringify({ plan }, null, 2));
1321
+ const readline = await import('node:readline/promises');
1322
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1323
+ try {
1324
+ const ans = await rl.question(`Assign to ${owner} and move to in-progress? (y/N) `);
1325
+ const ok = ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes';
1326
+ if (!ok) {
1327
+ console.error('Aborted; no changes made.');
1328
+ return;
1329
+ }
1330
+ } finally {
1331
+ rl.close();
1332
+ }
1333
+ } else if (!options.yes && !process.stdin.isTTY) {
1334
+ console.error('Refusing to take without confirmation in non-interactive mode. Re-run with --yes.');
1335
+ process.exitCode = 2;
1336
+ console.log(JSON.stringify({ ok: false, plan }, null, 2));
1337
+ return;
1338
+ }
1339
+
1340
+ const md = await fs.readFile(srcPath, 'utf8');
1341
+ const nextMd = patch(md);
1342
+ await fs.writeFile(srcPath, nextMd, 'utf8');
1343
+
1344
+ if (srcPath !== destPath) {
1345
+ await fs.rename(srcPath, destPath);
1346
+ }
1347
+
1348
+ const m = filename.match(/^(\d{4})-(.+)\.md$/);
1349
+ const ticketNumStr = m?.[1] ?? (ticketNum ?? '0000');
1350
+ const slug = m?.[2] ?? (ticketArg.replace(/^\d{4}-?/, '') || 'ticket');
1351
+ const assignmentsDir = path.join(teamDir, 'work', 'assignments');
1352
+ await ensureDir(assignmentsDir);
1353
+ const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
1354
+ const assignmentMd = `# Assignment — ${ticketNumStr}-${slug}\n\nAssigned: ${owner}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes take\n`;
1355
+ await writeFileSafely(assignmentPath, assignmentMd, 'createOnly');
1356
+
1357
+ console.log(JSON.stringify({ ok: true, plan, assignmentPath }, null, 2));
1358
+ });
1359
+
1360
+ cmd
1361
+ .command("complete")
1362
+ .description("Complete a ticket (move to done, set Status: done, and add Completed: timestamp)")
1363
+ .requiredOption("--team-id <teamId>", "Team id")
1364
+ .requiredOption("--ticket <ticket>", "Ticket id or number")
1365
+ .option("--yes", "Skip confirmation")
1366
+ .action(async (options: any) => {
1367
+ const args = [
1368
+ 'recipes',
1369
+ 'move-ticket',
1370
+ '--team-id',
1371
+ String(options.teamId),
1372
+ '--ticket',
1373
+ String(options.ticket),
1374
+ '--to',
1375
+ 'done',
1376
+ '--completed',
1377
+ ];
1378
+ if (options.yes) args.push('--yes');
1379
+
1380
+ const { spawnSync } = await import('node:child_process');
1381
+ const res = spawnSync('openclaw', args, { stdio: 'inherit' });
1382
+ if (res.status !== 0) {
1383
+ process.exitCode = res.status ?? 1;
1384
+ }
1385
+ });
1386
+
994
1387
  cmd
995
1388
  .command("scaffold")
996
1389
  .description("Scaffold an agent from a recipe")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawcipes/recipes",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Clawcipes recipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",