@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 +3 -2
- package/docs/COMMANDS.md +52 -5
- package/docs/TEAM_WORKFLOW.md +10 -6
- package/index.ts +396 -3
- package/package.json +1 -1
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
|
-
- `
|
|
168
|
-
- `
|
|
169
|
-
- `
|
|
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`.
|
package/docs/TEAM_WORKFLOW.md
CHANGED
|
@@ -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
|
-
|
|
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) **
|
|
36
|
-
- Move ticket to `work/
|
|
37
|
-
-
|
|
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
|
|
937
|
+
const receivedIso = new Date().toISOString();
|
|
937
938
|
|
|
938
|
-
const
|
|
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
|
|
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")
|