@clawcipes/recipes 0.1.5 → 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 +4 -3
- package/docs/COMMANDS.md +65 -5
- package/docs/TEAM_WORKFLOW.md +10 -6
- package/index.ts +473 -29
- 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
|
|
@@ -55,8 +55,9 @@ openclaw recipes dispatch \
|
|
|
55
55
|
- `openclaw recipes scaffold` (agent → `workspace-<agentId>`)
|
|
56
56
|
- `openclaw recipes scaffold-team` (team → `workspace-<teamId>` + `roles/<role>/`)
|
|
57
57
|
- `openclaw recipes install <idOrSlug> [--yes] [--global|--agent-id <id>|--team-id <id>]` (skills: global or scoped)
|
|
58
|
-
- `openclaw recipes bind|bindings` (multi-agent routing)
|
|
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:
|
|
@@ -114,6 +114,19 @@ Notes:
|
|
|
114
114
|
- `peer.kind` must be one of: `dm|group|channel`.
|
|
115
115
|
- Peer-specific bindings are inserted first (more specific wins).
|
|
116
116
|
|
|
117
|
+
## `unbind`
|
|
118
|
+
Remove routing binding(s) from OpenClaw config (`bindings[]`).
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Remove a specific DM binding for an agent
|
|
124
|
+
openclaw recipes unbind --agent-id dev --channel telegram --peer-kind dm --peer-id 6477250615
|
|
125
|
+
|
|
126
|
+
# Remove ALL bindings that match this peer (any agent)
|
|
127
|
+
openclaw recipes unbind --channel telegram --peer-kind dm --peer-id 6477250615
|
|
128
|
+
```
|
|
129
|
+
|
|
117
130
|
## `bindings`
|
|
118
131
|
Print the current `bindings[]` from OpenClaw config.
|
|
119
132
|
|
|
@@ -137,6 +150,53 @@ Options:
|
|
|
137
150
|
## `dispatch`
|
|
138
151
|
Convert a natural-language request into file-first execution artifacts.
|
|
139
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
|
+
|
|
140
200
|
```bash
|
|
141
201
|
openclaw recipes dispatch \
|
|
142
202
|
--team-id development-team-team \
|
|
@@ -151,12 +211,12 @@ Options:
|
|
|
151
211
|
- `--yes` (skip review prompt)
|
|
152
212
|
|
|
153
213
|
Creates (createOnly):
|
|
154
|
-
- `
|
|
155
|
-
- `
|
|
156
|
-
- `
|
|
214
|
+
- `workspace-<teamId>/inbox/<timestamp>-<slug>.md`
|
|
215
|
+
- `workspace-<teamId>/work/backlog/<NNNN>-<slug>.md`
|
|
216
|
+
- `workspace-<teamId>/work/assignments/<NNNN>-assigned-<owner>.md`
|
|
157
217
|
|
|
158
218
|
Ticket numbering:
|
|
159
|
-
- 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.
|
|
160
220
|
|
|
161
221
|
Review-before-write:
|
|
162
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
|
@@ -293,6 +293,27 @@ function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
|
|
|
293
293
|
return { changed: true, note: "added" as const };
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
function removeBindingsInConfig(cfgObj: any, opts: { agentId?: string; match: BindingMatch }) {
|
|
297
|
+
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
298
|
+
const list: any[] = cfgObj.bindings;
|
|
299
|
+
|
|
300
|
+
const targetMatchSig = stableStringify(opts.match);
|
|
301
|
+
|
|
302
|
+
const before = list.length;
|
|
303
|
+
const kept: any[] = [];
|
|
304
|
+
const removed: any[] = [];
|
|
305
|
+
|
|
306
|
+
for (const b of list) {
|
|
307
|
+
const sameAgent = opts.agentId ? String(b?.agentId ?? "") === opts.agentId : true;
|
|
308
|
+
const sameMatch = stableStringify(b?.match ?? {}) === targetMatchSig;
|
|
309
|
+
if (sameAgent && sameMatch) removed.push(b);
|
|
310
|
+
else kept.push(b);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
cfgObj.bindings = kept;
|
|
314
|
+
return { removedCount: before - kept.length, removed };
|
|
315
|
+
}
|
|
316
|
+
|
|
296
317
|
async function applyAgentSnippetsToOpenClawConfig(api: OpenClawPluginApi, snippets: AgentConfigSnippet[]) {
|
|
297
318
|
// Load the latest config from disk (not the snapshot in api.config).
|
|
298
319
|
const current = (api.runtime as any).config?.loadConfig?.();
|
|
@@ -469,6 +490,34 @@ const recipesPlugin = {
|
|
|
469
490
|
console.log(JSON.stringify(out, null, 2));
|
|
470
491
|
});
|
|
471
492
|
|
|
493
|
+
const parseMatchFromOptions = (options: any): BindingMatch => {
|
|
494
|
+
if (options.match) {
|
|
495
|
+
return JSON5.parse(String(options.match)) as BindingMatch;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const match: BindingMatch = {
|
|
499
|
+
channel: String(options.channel),
|
|
500
|
+
};
|
|
501
|
+
if (options.accountId) match.accountId = String(options.accountId);
|
|
502
|
+
if (options.guildId) match.guildId = String(options.guildId);
|
|
503
|
+
if (options.teamId) match.teamId = String(options.teamId);
|
|
504
|
+
|
|
505
|
+
if (options.peerKind || options.peerId) {
|
|
506
|
+
if (!options.peerKind || !options.peerId) {
|
|
507
|
+
throw new Error("--peer-kind and --peer-id must be provided together");
|
|
508
|
+
}
|
|
509
|
+
let kind = String(options.peerKind);
|
|
510
|
+
// Back-compat alias
|
|
511
|
+
if (kind === "direct") kind = "dm";
|
|
512
|
+
if (kind !== "dm" && kind !== "group" && kind !== "channel") {
|
|
513
|
+
throw new Error("--peer-kind must be dm|group|channel (or direct as alias for dm)");
|
|
514
|
+
}
|
|
515
|
+
match.peer = { kind, id: String(options.peerId) };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return match;
|
|
519
|
+
};
|
|
520
|
+
|
|
472
521
|
cmd
|
|
473
522
|
.command("bind")
|
|
474
523
|
.description("Add/update a multi-agent routing binding (writes openclaw.json bindings[])")
|
|
@@ -482,32 +531,7 @@ const recipesPlugin = {
|
|
|
482
531
|
.option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
|
|
483
532
|
.action(async (options: any) => {
|
|
484
533
|
const agentId = String(options.agentId);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (options.match) {
|
|
488
|
-
match = JSON5.parse(String(options.match)) as BindingMatch;
|
|
489
|
-
} else {
|
|
490
|
-
match = {
|
|
491
|
-
channel: String(options.channel),
|
|
492
|
-
};
|
|
493
|
-
if (options.accountId) match.accountId = String(options.accountId);
|
|
494
|
-
if (options.guildId) match.guildId = String(options.guildId);
|
|
495
|
-
if (options.teamId) match.teamId = String(options.teamId);
|
|
496
|
-
|
|
497
|
-
if (options.peerKind || options.peerId) {
|
|
498
|
-
if (!options.peerKind || !options.peerId) {
|
|
499
|
-
throw new Error("--peer-kind and --peer-id must be provided together");
|
|
500
|
-
}
|
|
501
|
-
let kind = String(options.peerKind);
|
|
502
|
-
// Back-compat alias
|
|
503
|
-
if (kind === "direct") kind = "dm";
|
|
504
|
-
if (kind !== "dm" && kind !== "group" && kind !== "channel") {
|
|
505
|
-
throw new Error("--peer-kind must be dm|group|channel (or direct as alias for dm)");
|
|
506
|
-
}
|
|
507
|
-
match.peer = { kind, id: String(options.peerId) };
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
534
|
+
const match = parseMatchFromOptions(options);
|
|
511
535
|
if (!match?.channel) throw new Error("match.channel is required");
|
|
512
536
|
|
|
513
537
|
const res = await applyBindingSnippetsToOpenClawConfig(api, [{ agentId, match }]);
|
|
@@ -515,6 +539,33 @@ const recipesPlugin = {
|
|
|
515
539
|
console.error("Binding written. Restart gateway if required for changes to take effect.");
|
|
516
540
|
});
|
|
517
541
|
|
|
542
|
+
cmd
|
|
543
|
+
.command("unbind")
|
|
544
|
+
.description("Remove routing binding(s) from openclaw.json bindings[]")
|
|
545
|
+
.requiredOption("--channel <channel>", "Channel name")
|
|
546
|
+
.option("--agent-id <agentId>", "Optional agent id; when set, removes only bindings for this agent")
|
|
547
|
+
.option("--account-id <accountId>", "Channel accountId")
|
|
548
|
+
.option("--peer-kind <kind>", "Peer kind (dm|group|channel)")
|
|
549
|
+
.option("--peer-id <id>", "Peer id")
|
|
550
|
+
.option("--guild-id <guildId>", "Discord guildId")
|
|
551
|
+
.option("--team-id <teamId>", "Slack teamId")
|
|
552
|
+
.option("--match <json>", "Full match object as JSON/JSON5 (overrides flags)")
|
|
553
|
+
.action(async (options: any) => {
|
|
554
|
+
const agentId = typeof options.agentId === "string" ? String(options.agentId) : undefined;
|
|
555
|
+
const match = parseMatchFromOptions(options);
|
|
556
|
+
if (!match?.channel) throw new Error("match.channel is required");
|
|
557
|
+
|
|
558
|
+
const current = (api.runtime as any).config?.loadConfig?.();
|
|
559
|
+
if (!current) throw new Error("Failed to load config via api.runtime.config.loadConfig()");
|
|
560
|
+
const cfgObj = (current.cfg ?? current) as any;
|
|
561
|
+
|
|
562
|
+
const res = removeBindingsInConfig(cfgObj, { agentId, match });
|
|
563
|
+
await (api.runtime as any).config?.writeConfigFile?.(cfgObj);
|
|
564
|
+
|
|
565
|
+
console.log(JSON.stringify({ ok: true, ...res }, null, 2));
|
|
566
|
+
console.error("Binding(s) removed. Restart gateway if required for changes to take effect.");
|
|
567
|
+
});
|
|
568
|
+
|
|
518
569
|
cmd
|
|
519
570
|
.command("bindings")
|
|
520
571
|
.description("Show current bindings from openclaw config")
|
|
@@ -843,6 +894,7 @@ const recipesPlugin = {
|
|
|
843
894
|
const dirs = [
|
|
844
895
|
backlogDir,
|
|
845
896
|
path.join(teamDir, "work", "in-progress"),
|
|
897
|
+
path.join(teamDir, "work", "testing"),
|
|
846
898
|
path.join(teamDir, "work", "done"),
|
|
847
899
|
];
|
|
848
900
|
let max = 0;
|
|
@@ -882,11 +934,13 @@ const recipesPlugin = {
|
|
|
882
934
|
const ticketPath = path.join(backlogDir, `${ticketNumStr}-${baseSlug}.md`);
|
|
883
935
|
const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
|
|
884
936
|
|
|
885
|
-
const
|
|
937
|
+
const receivedIso = new Date().toISOString();
|
|
938
|
+
|
|
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`;
|
|
886
940
|
|
|
887
|
-
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`;
|
|
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`;
|
|
888
942
|
|
|
889
|
-
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`;
|
|
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`;
|
|
890
944
|
|
|
891
945
|
const plan = {
|
|
892
946
|
teamId,
|
|
@@ -940,6 +994,396 @@ const recipesPlugin = {
|
|
|
940
994
|
console.log(JSON.stringify({ ok: true, wrote: plan.files.map((f) => f.path) }, null, 2));
|
|
941
995
|
});
|
|
942
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
|
+
|
|
943
1387
|
cmd
|
|
944
1388
|
.command("scaffold")
|
|
945
1389
|
.description("Scaffold an agent from a recipe")
|