@clawcipes/recipes 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -3
- package/docs/AGENTS_AND_SKILLS.md +2 -1
- package/docs/CLAWCIPES_KITCHEN.md +1 -1
- package/docs/COMMANDS.md +39 -2
- package/docs/verify-built-in-team-recipes.md +65 -0
- package/index.ts +252 -91
- package/package.json +9 -3
- package/recipes/default/customer-support-team.md +26 -4
- package/recipes/default/development-team.md +14 -0
- package/recipes/default/product-team.md +32 -15
- package/recipes/default/research-team.md +21 -1
- package/recipes/default/social-team.md +89 -5
- package/recipes/default/writing-team.md +23 -2
- package/src/lib/bindings.ts +59 -0
- package/src/lib/cleanup-workspaces.ts +173 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/lanes.ts +63 -0
- package/src/lib/recipe-frontmatter.ts +59 -0
- package/src/lib/scaffold-templates.ts +7 -0
- package/src/lib/shared-context.ts +52 -0
- package/src/lib/ticket-finder.ts +60 -0
- package/src/lib/ticket-workflow.ts +94 -0
|
@@ -4,6 +4,20 @@ name: Product Team
|
|
|
4
4
|
version: 0.1.0
|
|
5
5
|
description: A product delivery team (pm, designer, engineer, qa) that turns ideas into shipped features.
|
|
6
6
|
kind: team
|
|
7
|
+
cronJobs:
|
|
8
|
+
- id: lead-triage-loop
|
|
9
|
+
name: "Lead triage loop"
|
|
10
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
11
|
+
timezone: "America/New_York"
|
|
12
|
+
message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md."
|
|
13
|
+
enabledByDefault: false
|
|
14
|
+
- id: execution-loop
|
|
15
|
+
name: "Execution loop"
|
|
16
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
17
|
+
timezone: "America/New_York"
|
|
18
|
+
message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md."
|
|
19
|
+
enabledByDefault: false
|
|
20
|
+
# pr-watcher omitted (enable only when a real PR integration exists)
|
|
7
21
|
requiredSkills: []
|
|
8
22
|
team:
|
|
9
23
|
teamId: product-team
|
|
@@ -32,8 +46,8 @@ agents:
|
|
|
32
46
|
profile: "coding"
|
|
33
47
|
allow: ["group:fs", "group:web", "group:runtime"]
|
|
34
48
|
deny: []
|
|
35
|
-
- role:
|
|
36
|
-
name: QA /
|
|
49
|
+
- role: test
|
|
50
|
+
name: QA / Tester
|
|
37
51
|
tools:
|
|
38
52
|
profile: "coding"
|
|
39
53
|
allow: ["group:fs", "group:web"]
|
|
@@ -134,24 +148,27 @@ templates:
|
|
|
134
148
|
- Implement
|
|
135
149
|
- Write a DONE note with how to test
|
|
136
150
|
|
|
137
|
-
|
|
151
|
+
test.soul: |
|
|
138
152
|
# SOUL.md
|
|
139
153
|
|
|
140
|
-
You are QA /
|
|
154
|
+
You are QA / Testing on {{teamId}}.
|
|
141
155
|
|
|
142
|
-
You
|
|
156
|
+
You verify acceptance criteria, catch edge cases, and document verification results.
|
|
143
157
|
|
|
144
|
-
|
|
158
|
+
test.agents: |
|
|
145
159
|
# AGENTS.md
|
|
146
160
|
|
|
147
161
|
Team directory: {{teamDir}}
|
|
148
162
|
|
|
163
|
+
How you work:
|
|
164
|
+
1) Look in work/testing/ for tickets assigned to you (Owner: test).
|
|
165
|
+
2) Follow the ticket's "How to test" steps and validate acceptance criteria.
|
|
166
|
+
3) Record verification using notes/QA_CHECKLIST.md (preferred: a sibling *.testing-verified.md note).
|
|
167
|
+
4) If PASS: move ticket to work/done/.
|
|
168
|
+
5) If FAIL: move ticket back to work/in-progress/ with clear repro steps.
|
|
169
|
+
|
|
149
170
|
Output conventions:
|
|
150
|
-
- Test plans go in work/test-plans/
|
|
151
|
-
- Include:
|
|
152
|
-
- happy path
|
|
153
|
-
- edge cases
|
|
154
|
-
- regression checklist
|
|
171
|
+
- Test plans (optional) go in work/test-plans/
|
|
155
172
|
|
|
156
173
|
lead.tools: |
|
|
157
174
|
# TOOLS.md
|
|
@@ -213,17 +230,17 @@ templates:
|
|
|
213
230
|
|
|
214
231
|
- (empty)
|
|
215
232
|
|
|
216
|
-
|
|
233
|
+
test.tools: |
|
|
217
234
|
# TOOLS.md
|
|
218
235
|
|
|
219
|
-
# Agent-local notes for
|
|
236
|
+
# Agent-local notes for test (paths, conventions, env quirks).
|
|
220
237
|
|
|
221
|
-
|
|
238
|
+
test.status: |
|
|
222
239
|
# STATUS.md
|
|
223
240
|
|
|
224
241
|
- (empty)
|
|
225
242
|
|
|
226
|
-
|
|
243
|
+
test.notes: |
|
|
227
244
|
# NOTES.md
|
|
228
245
|
|
|
229
246
|
- (empty)
|
|
@@ -4,6 +4,20 @@ name: Research Team
|
|
|
4
4
|
version: 0.1.0
|
|
5
5
|
description: A research team (lead, researcher, fact-checker, summarizer) that produces sourced briefs and notes.
|
|
6
6
|
kind: team
|
|
7
|
+
cronJobs:
|
|
8
|
+
- id: lead-triage-loop
|
|
9
|
+
name: "Lead triage loop"
|
|
10
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
11
|
+
timezone: "America/New_York"
|
|
12
|
+
message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md."
|
|
13
|
+
enabledByDefault: false
|
|
14
|
+
- id: execution-loop
|
|
15
|
+
name: "Execution loop"
|
|
16
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
17
|
+
timezone: "America/New_York"
|
|
18
|
+
message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md."
|
|
19
|
+
enabledByDefault: false
|
|
20
|
+
# pr-watcher omitted (enable only when a real PR integration exists)
|
|
7
21
|
requiredSkills: []
|
|
8
22
|
team:
|
|
9
23
|
teamId: research-team
|
|
@@ -55,6 +69,7 @@ templates:
|
|
|
55
69
|
- inbox/ — intake requests
|
|
56
70
|
- work/backlog/ — tickets (filename ordered: 0001-...)
|
|
57
71
|
- work/in-progress/ — active tickets
|
|
72
|
+
- work/testing/ — verification / fact-check / review
|
|
58
73
|
- work/done/ — completed tickets + DONE notes
|
|
59
74
|
- work/sources/ — source links + captured quotes
|
|
60
75
|
- work/notes/ — working notes
|
|
@@ -70,7 +85,8 @@ templates:
|
|
|
70
85
|
1) Read new items in inbox/
|
|
71
86
|
2) Create a normalized ticket in work/backlog/
|
|
72
87
|
3) Assign an owner (researcher/fact-checker/summarizer)
|
|
73
|
-
4) When
|
|
88
|
+
4) When ready for verification, move the ticket to work/testing/
|
|
89
|
+
5) After verification, move to work/done/ and consolidate into outbox/
|
|
74
90
|
|
|
75
91
|
researcher.soul: |
|
|
76
92
|
# SOUL.md
|
|
@@ -117,6 +133,10 @@ templates:
|
|
|
117
133
|
|
|
118
134
|
- Write results in work/notes/fact-check-<ticket>.md.
|
|
119
135
|
|
|
136
|
+
## QA verification
|
|
137
|
+
- When verification is complete, record results using notes/QA_CHECKLIST.md.
|
|
138
|
+
- Preferred: create work/testing/<ticket>.testing-verified.md.
|
|
139
|
+
|
|
120
140
|
summarizer.soul: |
|
|
121
141
|
# SOUL.md
|
|
122
142
|
|
|
@@ -4,6 +4,20 @@ name: Social Media Team
|
|
|
4
4
|
version: 0.1.0
|
|
5
5
|
description: A small social media team with a shared workspace (lead, research, writer, editor).
|
|
6
6
|
kind: team
|
|
7
|
+
cronJobs:
|
|
8
|
+
- id: lead-triage-loop
|
|
9
|
+
name: "Lead triage loop"
|
|
10
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
11
|
+
timezone: "America/New_York"
|
|
12
|
+
message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md."
|
|
13
|
+
enabledByDefault: false
|
|
14
|
+
- id: execution-loop
|
|
15
|
+
name: "Execution loop"
|
|
16
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
17
|
+
timezone: "America/New_York"
|
|
18
|
+
message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md."
|
|
19
|
+
enabledByDefault: false
|
|
20
|
+
# pr-watcher omitted (enable only when a real PR integration exists)
|
|
7
21
|
requiredSkills: []
|
|
8
22
|
team:
|
|
9
23
|
teamId: social-team
|
|
@@ -42,11 +56,16 @@ templates:
|
|
|
42
56
|
Team: {{teamId}}
|
|
43
57
|
Team directory: {{teamDir}}
|
|
44
58
|
|
|
45
|
-
Workflow:
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
59
|
+
Workflow (mapped to canonical lanes):
|
|
60
|
+
- backlog → in-progress → testing → done
|
|
61
|
+
- Intake: check `inbox/` and write tickets into work/backlog/
|
|
62
|
+
- Drafting: use work/in-progress/ for active drafting
|
|
63
|
+
- Approval/review: use work/testing/ for review + final checks
|
|
64
|
+
- Done: move to work/done/ and publish/schedule into outbox/
|
|
65
|
+
|
|
66
|
+
QA verification:
|
|
67
|
+
- Use notes/QA_CHECKLIST.md
|
|
68
|
+
- Preferred record: work/testing/<ticket>.testing-verified.md
|
|
50
69
|
|
|
51
70
|
research.soul: |
|
|
52
71
|
# SOUL.md
|
|
@@ -93,6 +112,71 @@ templates:
|
|
|
93
112
|
- Edited drafts go in `work/edited/`.
|
|
94
113
|
- Provide a short changelog at the top.
|
|
95
114
|
|
|
115
|
+
## QA verification (approval)
|
|
116
|
+
Before moving a deliverable to done/scheduled:
|
|
117
|
+
- Record verification using notes/QA_CHECKLIST.md.
|
|
118
|
+
- Preferred: create work/testing/<ticket>.testing-verified.md.
|
|
119
|
+
|
|
120
|
+
lead.tools: |
|
|
121
|
+
# TOOLS.md
|
|
122
|
+
|
|
123
|
+
(empty)
|
|
124
|
+
|
|
125
|
+
lead.status: |
|
|
126
|
+
# STATUS.md
|
|
127
|
+
|
|
128
|
+
- (empty)
|
|
129
|
+
|
|
130
|
+
lead.notes: |
|
|
131
|
+
# NOTES.md
|
|
132
|
+
|
|
133
|
+
- (empty)
|
|
134
|
+
|
|
135
|
+
research.tools: |
|
|
136
|
+
# TOOLS.md
|
|
137
|
+
|
|
138
|
+
(empty)
|
|
139
|
+
|
|
140
|
+
research.status: |
|
|
141
|
+
# STATUS.md
|
|
142
|
+
|
|
143
|
+
- (empty)
|
|
144
|
+
|
|
145
|
+
research.notes: |
|
|
146
|
+
# NOTES.md
|
|
147
|
+
|
|
148
|
+
- (empty)
|
|
149
|
+
|
|
150
|
+
writer.tools: |
|
|
151
|
+
# TOOLS.md
|
|
152
|
+
|
|
153
|
+
(empty)
|
|
154
|
+
|
|
155
|
+
writer.status: |
|
|
156
|
+
# STATUS.md
|
|
157
|
+
|
|
158
|
+
- (empty)
|
|
159
|
+
|
|
160
|
+
writer.notes: |
|
|
161
|
+
# NOTES.md
|
|
162
|
+
|
|
163
|
+
- (empty)
|
|
164
|
+
|
|
165
|
+
editor.tools: |
|
|
166
|
+
# TOOLS.md
|
|
167
|
+
|
|
168
|
+
(empty)
|
|
169
|
+
|
|
170
|
+
editor.status: |
|
|
171
|
+
# STATUS.md
|
|
172
|
+
|
|
173
|
+
- (empty)
|
|
174
|
+
|
|
175
|
+
editor.notes: |
|
|
176
|
+
# NOTES.md
|
|
177
|
+
|
|
178
|
+
- (empty)
|
|
179
|
+
|
|
96
180
|
|
|
97
181
|
files:
|
|
98
182
|
- path: SOUL.md
|
|
@@ -4,6 +4,20 @@ name: Writing Team
|
|
|
4
4
|
version: 0.1.0
|
|
5
5
|
description: A writing pipeline (lead, outliner, writer, editor) that produces drafts and polished deliverables.
|
|
6
6
|
kind: team
|
|
7
|
+
cronJobs:
|
|
8
|
+
- id: lead-triage-loop
|
|
9
|
+
name: "Lead triage loop"
|
|
10
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
11
|
+
timezone: "America/New_York"
|
|
12
|
+
message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md."
|
|
13
|
+
enabledByDefault: false
|
|
14
|
+
- id: execution-loop
|
|
15
|
+
name: "Execution loop"
|
|
16
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
17
|
+
timezone: "America/New_York"
|
|
18
|
+
message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md."
|
|
19
|
+
enabledByDefault: false
|
|
20
|
+
# pr-watcher omitted (enable only when a real PR integration exists)
|
|
7
21
|
requiredSkills: []
|
|
8
22
|
team:
|
|
9
23
|
teamId: writing-team
|
|
@@ -54,6 +68,7 @@ templates:
|
|
|
54
68
|
- inbox/ — requests
|
|
55
69
|
- work/backlog/ — tickets (0001-...)
|
|
56
70
|
- work/in-progress/ — active tickets
|
|
71
|
+
- work/testing/ — review/edit/QA (verification before publishing)
|
|
57
72
|
- work/done/ — completed tickets + DONE notes
|
|
58
73
|
- work/briefs/ — writing briefs
|
|
59
74
|
- work/outlines/ — outlines
|
|
@@ -64,8 +79,9 @@ templates:
|
|
|
64
79
|
## Dispatch loop
|
|
65
80
|
1) Intake in inbox/
|
|
66
81
|
2) Brief in work/briefs/
|
|
67
|
-
3) Assign outline → draft
|
|
68
|
-
4)
|
|
82
|
+
3) Assign outline → draft
|
|
83
|
+
4) Move to work/testing/ for edit/review
|
|
84
|
+
5) After verification (see notes/QA_CHECKLIST.md), move to work/done/ and finalize to outbox/
|
|
69
85
|
|
|
70
86
|
outliner.soul: |
|
|
71
87
|
# SOUL.md
|
|
@@ -120,6 +136,11 @@ templates:
|
|
|
120
136
|
- Provide a short changelog at the top.
|
|
121
137
|
- Flag any factual claims that need citations.
|
|
122
138
|
|
|
139
|
+
## QA verification
|
|
140
|
+
Before a deliverable is marked done/published:
|
|
141
|
+
- Record verification using notes/QA_CHECKLIST.md.
|
|
142
|
+
- Preferred: create work/testing/<ticket>.testing-verified.md.
|
|
143
|
+
|
|
123
144
|
lead.tools: |
|
|
124
145
|
# TOOLS.md
|
|
125
146
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Minimal extracted binding helper so we can test precedence without running the CLI.
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export type BindingMatch = {
|
|
6
|
+
channel?: string;
|
|
7
|
+
peer?: string;
|
|
8
|
+
[k: string]: any;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type BindingSnippet = {
|
|
12
|
+
agentId: string;
|
|
13
|
+
match: BindingMatch;
|
|
14
|
+
to: any;
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function stableStringify(obj: any) {
|
|
19
|
+
const seen = new WeakSet();
|
|
20
|
+
const walk = (x: any): any => {
|
|
21
|
+
if (x && typeof x === 'object') {
|
|
22
|
+
if (seen.has(x)) return '[Circular]';
|
|
23
|
+
seen.add(x);
|
|
24
|
+
if (Array.isArray(x)) return x.map(walk);
|
|
25
|
+
const keys = Object.keys(x).sort();
|
|
26
|
+
const out: any = {};
|
|
27
|
+
for (const k of keys) out[k] = walk(x[k]);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
return x;
|
|
31
|
+
};
|
|
32
|
+
return JSON.stringify(walk(obj));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
|
|
36
|
+
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
37
|
+
const list: any[] = cfgObj.bindings;
|
|
38
|
+
|
|
39
|
+
const sigPayload = stableStringify({ agentId: binding.agentId, match: binding.match });
|
|
40
|
+
const sig = crypto.createHash('sha256').update(sigPayload).digest('hex');
|
|
41
|
+
|
|
42
|
+
const idx = list.findIndex((b: any) => {
|
|
43
|
+
const payload = stableStringify({ agentId: b.agentId, match: b.match });
|
|
44
|
+
const bsig = crypto.createHash('sha256').update(payload).digest('hex');
|
|
45
|
+
return bsig === sig;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (idx >= 0) {
|
|
49
|
+
// Update in place (preserve ordering)
|
|
50
|
+
list[idx] = { ...list[idx], ...binding };
|
|
51
|
+
return { changed: false as const, note: 'already-present' as const };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Most-specific-first: if a peer match is specified, insert at front so it wins.
|
|
55
|
+
if (binding.match?.peer) list.unshift(binding);
|
|
56
|
+
else list.push(binding);
|
|
57
|
+
|
|
58
|
+
return { changed: true as const, note: 'added' as const };
|
|
59
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type CleanupDecision =
|
|
5
|
+
| { kind: 'candidate'; teamId: string; dirName: string; absPath: string }
|
|
6
|
+
| { kind: 'skip'; teamId?: string; dirName: string; absPath: string; reason: string };
|
|
7
|
+
|
|
8
|
+
export type CleanupPlan = {
|
|
9
|
+
rootDir: string;
|
|
10
|
+
prefixes: string[];
|
|
11
|
+
protectedTeamIds: string[];
|
|
12
|
+
decisions: CleanupDecision[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_ALLOWED_PREFIXES = ['smoke-', 'qa-', 'tmp-', 'test-'] as const;
|
|
16
|
+
export const DEFAULT_PROTECTED_TEAM_IDS = ['development-team'] as const;
|
|
17
|
+
|
|
18
|
+
async function isDir(p: string) {
|
|
19
|
+
try {
|
|
20
|
+
const st = await fs.stat(p);
|
|
21
|
+
return st.isDirectory();
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Refuse to operate on symlinks (for safety). */
|
|
28
|
+
async function isSymlink(p: string) {
|
|
29
|
+
try {
|
|
30
|
+
const st = await fs.lstat(p);
|
|
31
|
+
return st.isSymbolicLink();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseTeamIdFromWorkspaceDirName(dirName: string) {
|
|
38
|
+
if (!dirName.startsWith('workspace-')) return null;
|
|
39
|
+
const teamId = dirName.slice('workspace-'.length);
|
|
40
|
+
return teamId || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isEligibleTeamId(opts: { teamId: string; prefixes: string[]; protectedTeamIds: string[] }) {
|
|
44
|
+
const { teamId, prefixes, protectedTeamIds } = opts;
|
|
45
|
+
|
|
46
|
+
if (!teamId.endsWith('-team')) return { ok: false, reason: 'teamId does not end with -team' } as const;
|
|
47
|
+
|
|
48
|
+
if (protectedTeamIds.includes(teamId.replace(/-team$/, ''))) {
|
|
49
|
+
// Back-compat: protect by base id too if someone passes development-team-team etc.
|
|
50
|
+
return { ok: false, reason: 'protected teamId' } as const;
|
|
51
|
+
}
|
|
52
|
+
if (protectedTeamIds.includes(teamId)) return { ok: false, reason: 'protected teamId' } as const;
|
|
53
|
+
|
|
54
|
+
const okPrefix = prefixes.some((p) => teamId.startsWith(p));
|
|
55
|
+
if (!okPrefix) return { ok: false, reason: `teamId does not start with an allowed prefix (${prefixes.join(', ')})` } as const;
|
|
56
|
+
|
|
57
|
+
return { ok: true } as const;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a safe cleanup plan for workspace-<teamId> directories under rootDir.
|
|
62
|
+
*
|
|
63
|
+
* Safety rails:
|
|
64
|
+
* - only directories named workspace-<teamId>
|
|
65
|
+
* - teamId must end with -team and start with an allowed prefix
|
|
66
|
+
* - protected teamIds are always skipped
|
|
67
|
+
* - refuses symlinks
|
|
68
|
+
* - resolved path must remain within rootDir
|
|
69
|
+
*/
|
|
70
|
+
export async function planWorkspaceCleanup(opts: {
|
|
71
|
+
rootDir: string;
|
|
72
|
+
prefixes?: string[];
|
|
73
|
+
protectedTeamIds?: string[];
|
|
74
|
+
}) {
|
|
75
|
+
const rootDir = path.resolve(opts.rootDir);
|
|
76
|
+
const prefixes = (opts.prefixes?.length ? opts.prefixes : [...DEFAULT_ALLOWED_PREFIXES]) as string[];
|
|
77
|
+
const protectedTeamIds = (opts.protectedTeamIds?.length ? opts.protectedTeamIds : [...DEFAULT_PROTECTED_TEAM_IDS]) as string[];
|
|
78
|
+
|
|
79
|
+
const decisions: CleanupDecision[] = [];
|
|
80
|
+
|
|
81
|
+
let entries: string[] = [];
|
|
82
|
+
try {
|
|
83
|
+
entries = await fs.readdir(rootDir);
|
|
84
|
+
} catch (e: any) {
|
|
85
|
+
return {
|
|
86
|
+
rootDir,
|
|
87
|
+
prefixes,
|
|
88
|
+
protectedTeamIds,
|
|
89
|
+
decisions: [
|
|
90
|
+
{
|
|
91
|
+
kind: 'skip',
|
|
92
|
+
dirName: rootDir,
|
|
93
|
+
absPath: rootDir,
|
|
94
|
+
reason: `failed to read rootDir: ${e?.message ? String(e.message) : String(e)}`,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
} satisfies CleanupPlan;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const dirName of entries) {
|
|
101
|
+
if (!dirName.startsWith('workspace-')) continue;
|
|
102
|
+
|
|
103
|
+
const absPath = path.join(rootDir, dirName);
|
|
104
|
+
|
|
105
|
+
if (!(await isDir(absPath))) continue;
|
|
106
|
+
|
|
107
|
+
const teamId = parseTeamIdFromWorkspaceDirName(dirName);
|
|
108
|
+
if (!teamId) {
|
|
109
|
+
decisions.push({ kind: 'skip', dirName, absPath, reason: 'could not parse teamId' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (await isSymlink(absPath)) {
|
|
114
|
+
decisions.push({ kind: 'skip', teamId, dirName, absPath, reason: 'refusing to operate on symlink' });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const real = await fs.realpath(absPath);
|
|
119
|
+
const rootReal = await fs.realpath(rootDir);
|
|
120
|
+
if (!real.startsWith(rootReal + path.sep) && real !== rootReal) {
|
|
121
|
+
decisions.push({ kind: 'skip', teamId, dirName, absPath, reason: 'resolved path escapes rootDir' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const elig = isEligibleTeamId({ teamId, prefixes, protectedTeamIds });
|
|
126
|
+
if (!elig.ok) {
|
|
127
|
+
decisions.push({ kind: 'skip', teamId, dirName, absPath, reason: elig.reason });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
decisions.push({ kind: 'candidate', teamId, dirName, absPath });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { rootDir, prefixes, protectedTeamIds, decisions } satisfies CleanupPlan;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function executeWorkspaceCleanup(plan: CleanupPlan, opts: { yes: boolean }) {
|
|
138
|
+
const candidates = plan.decisions.filter((d): d is Extract<CleanupDecision, { kind: 'candidate' }> => d.kind === 'candidate');
|
|
139
|
+
const skipped = plan.decisions.filter((d): d is Extract<CleanupDecision, { kind: 'skip' }> => d.kind === 'skip');
|
|
140
|
+
|
|
141
|
+
if (!opts.yes) {
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
dryRun: true,
|
|
145
|
+
rootDir: plan.rootDir,
|
|
146
|
+
candidates,
|
|
147
|
+
skipped,
|
|
148
|
+
deleted: [] as string[],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const deleted: string[] = [];
|
|
153
|
+
const deleteErrors: Array<{ path: string; error: string }> = [];
|
|
154
|
+
|
|
155
|
+
for (const c of candidates) {
|
|
156
|
+
try {
|
|
157
|
+
await fs.rm(c.absPath, { recursive: true, force: true });
|
|
158
|
+
deleted.push(c.absPath);
|
|
159
|
+
} catch (e: any) {
|
|
160
|
+
deleteErrors.push({ path: c.absPath, error: e?.message ? String(e.message) : String(e) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
ok: deleteErrors.length === 0,
|
|
166
|
+
dryRun: false,
|
|
167
|
+
rootDir: plan.rootDir,
|
|
168
|
+
candidates,
|
|
169
|
+
skipped,
|
|
170
|
+
deleted,
|
|
171
|
+
deleteErrors,
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/lib/index.ts
ADDED
package/src/lib/lanes.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type TicketLane = 'backlog' | 'in-progress' | 'testing' | 'done' | 'assignments';
|
|
5
|
+
|
|
6
|
+
export class RecipesCliError extends Error {
|
|
7
|
+
code: string;
|
|
8
|
+
command?: string;
|
|
9
|
+
missingPath?: string;
|
|
10
|
+
suggestedFix?: string;
|
|
11
|
+
|
|
12
|
+
constructor(opts: { message: string; code: string; command?: string; missingPath?: string; suggestedFix?: string }) {
|
|
13
|
+
super(opts.message);
|
|
14
|
+
this.name = 'RecipesCliError';
|
|
15
|
+
this.code = opts.code;
|
|
16
|
+
this.command = opts.command;
|
|
17
|
+
this.missingPath = opts.missingPath;
|
|
18
|
+
this.suggestedFix = opts.suggestedFix;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fileExists(p: string) {
|
|
23
|
+
try {
|
|
24
|
+
await fs.stat(p);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensure a lane dir exists, with a one-line migration hint for older workspaces.
|
|
33
|
+
*
|
|
34
|
+
* If creation fails, throws a RecipesCliError with an actionable message.
|
|
35
|
+
*/
|
|
36
|
+
export async function ensureLaneDir(opts: { teamDir: string; lane: TicketLane; command?: string; quiet?: boolean }) {
|
|
37
|
+
const laneDir = path.join(opts.teamDir, 'work', opts.lane);
|
|
38
|
+
const existed = await fileExists(laneDir);
|
|
39
|
+
|
|
40
|
+
if (!existed) {
|
|
41
|
+
try {
|
|
42
|
+
await fs.mkdir(laneDir, { recursive: true });
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
throw new RecipesCliError({
|
|
45
|
+
code: 'LANE_DIR_CREATE_FAILED',
|
|
46
|
+
command: opts.command,
|
|
47
|
+
missingPath: laneDir,
|
|
48
|
+
suggestedFix: `mkdir -p ${path.join('work', opts.lane)}`,
|
|
49
|
+
message:
|
|
50
|
+
`Failed to create required lane directory: ${laneDir}` +
|
|
51
|
+
(opts.command ? ` (command: ${opts.command})` : '') +
|
|
52
|
+
(e?.message ? `\nUnderlying error: ${String(e.message)}` : ''),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!opts.quiet) {
|
|
57
|
+
const rel = path.join('work', opts.lane);
|
|
58
|
+
console.error(`[recipes] migration: created ${rel}/ (older workspace missing this lane)`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { path: laneDir, created: !existed };
|
|
63
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
|
|
3
|
+
export type CronJobSpec = {
|
|
4
|
+
id: string;
|
|
5
|
+
schedule: string;
|
|
6
|
+
message: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
timezone?: string;
|
|
10
|
+
channel?: string;
|
|
11
|
+
to?: string;
|
|
12
|
+
agentId?: string;
|
|
13
|
+
enabledByDefault?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RecipeFrontmatter = {
|
|
17
|
+
id: string;
|
|
18
|
+
kind?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
cronJobs?: CronJobSpec[];
|
|
21
|
+
[k: string]: any;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function parseFrontmatter(md: string): { frontmatter: RecipeFrontmatter; body: string } {
|
|
25
|
+
if (!md.startsWith('---\n')) throw new Error('Recipe markdown must start with YAML frontmatter (---)');
|
|
26
|
+
const end = md.indexOf('\n---\n', 4);
|
|
27
|
+
if (end === -1) throw new Error('Recipe frontmatter not terminated (---)');
|
|
28
|
+
const yamlText = md.slice(4, end);
|
|
29
|
+
const body = md.slice(end + 5);
|
|
30
|
+
const frontmatter = YAML.parse(yamlText) as RecipeFrontmatter;
|
|
31
|
+
if (!frontmatter?.id) throw new Error('Recipe frontmatter must include id');
|
|
32
|
+
return { frontmatter, body };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeCronJobs(frontmatter: { cronJobs?: any }): CronJobSpec[] {
|
|
36
|
+
const raw = (frontmatter as any).cronJobs;
|
|
37
|
+
if (!raw) return [];
|
|
38
|
+
if (!Array.isArray(raw)) throw new Error('frontmatter.cronJobs must be an array');
|
|
39
|
+
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
const out: CronJobSpec[] = [];
|
|
42
|
+
|
|
43
|
+
for (const j of raw) {
|
|
44
|
+
if (!j || typeof j !== 'object') throw new Error('cronJobs entries must be objects');
|
|
45
|
+
const id = String((j as any).id ?? '').trim();
|
|
46
|
+
if (!id) throw new Error('cronJobs[].id is required');
|
|
47
|
+
if (seen.has(id)) throw new Error(`Duplicate cronJobs[].id: ${id}`);
|
|
48
|
+
seen.add(id);
|
|
49
|
+
|
|
50
|
+
const schedule = String((j as any).schedule ?? '').trim();
|
|
51
|
+
const message = String((j as any).message ?? '').trim();
|
|
52
|
+
if (!schedule) throw new Error(`cronJobs[${id}].schedule is required`);
|
|
53
|
+
if (!message) throw new Error(`cronJobs[${id}].message is required`);
|
|
54
|
+
|
|
55
|
+
out.push({ ...j, id, schedule, message } as CronJobSpec);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function renderTeamMd(teamId: string) {
|
|
2
|
+
return `# ${teamId}\n\nShared workspace for this agent team.\n\n## Workflow\n- Stages: backlog → in-progress → testing → done\n- Backlog: work/backlog/\n- In progress: work/in-progress/\n- Testing / QA: work/testing/\n- Done: work/done/\n\n## QA verification\nBefore moving a ticket from work/testing/ → work/done/, record verification results.\n- Template: notes/QA_CHECKLIST.md\n- Preferred: create work/testing/<ticket>.testing-verified.md\n\n## Folders\n- inbox/ — requests\n- outbox/ — deliverables\n- shared-context/ — curated shared context + append-only agent outputs\n- shared/ — legacy shared artifacts (back-compat)\n- notes/ — plan + status + templates\n- work/ — working files\n`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function renderTicketsMd(teamId: string) {
|
|
6
|
+
return `# Tickets — ${teamId}\n\n## Workflow\n- Stages: backlog → in-progress → testing → done\n- Backlog tickets live in work/backlog/\n- In-progress tickets live in work/in-progress/\n- Testing / QA tickets live in work/testing/\n- Done tickets live in work/done/\n\n### QA handoff (dev → test)\nWhen development is complete:\n- Move the ticket file to work/testing/\n- Assign to test (set \`Owner: test\`)\n- Add clear test instructions / repro steps\n\n### QA verification (test → done)\nBefore moving a ticket to done, QA must record verification.\n- Template: notes/QA_CHECKLIST.md\n- Preferred: create work/testing/<ticket>.testing-verified.md\n\n## Naming\n- Filename ordering is the queue: 0001-..., 0002-...\n\n## Required fields\nEach ticket should include:\n- Title\n- Context\n- Requirements\n- Acceptance criteria\n- Owner (dev/devops/lead/test)\n- Status (queued/in-progress/testing/done)\n\n## Example\n\n\`\`\`md\n# 0001-example-ticket\n\nOwner: dev\nStatus: queued\n\n## Context\n...\n\n## Requirements\n- ...\n\n## Acceptance criteria\n- ...\n\n## How to test\n- ...\n\`\`\`\n`;
|
|
7
|
+
}
|