@jiggai/recipes 0.3.10 → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -0,0 +1,10 @@
1
+ {
2
+ "laneByOwner": {
3
+ "dev": "in-progress",
4
+ "devops": "in-progress",
5
+ "lead": "backlog",
6
+ "test": "testing",
7
+ "qa": "testing"
8
+ },
9
+ "defaultLane": "in-progress"
10
+ }
@@ -9,14 +9,14 @@ cronJobs:
9
9
  name: "Lead triage loop"
10
10
  schedule: "*/30 7-23 * * 1-5"
11
11
  timezone: "America/New_York"
12
- message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md. Anti-stuck: if lowest in-progress is HARD BLOCKED, advance the next unblocked ticket (or pull from backlog). If in-progress is stale (>12h no dated update), comment or move it back. Guardrail: run ./scripts/ticket-hygiene.sh each loop; if it fails, fix assignment stubs before proceeding."
12
+ message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md. Anti-stuck: if lowest in-progress is HARD BLOCKED, advance the next unblocked ticket (or pull from backlog). If in-progress is stale (>12h no dated update), comment or move it back. Guardrail: run ./scripts/ticket-hygiene.sh each loop; if it fails, fix lane/status/owner mismatches before proceeding (assignment stubs are deprecated)."
13
13
  enabledByDefault: true
14
14
 
15
15
  - id: execution-loop
16
16
  name: "Execution loop"
17
17
  schedule: "*/30 7-23 * * 1-5"
18
18
  timezone: "America/New_York"
19
- message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md. Guardrail: run ./scripts/ticket-hygiene-dev.sh each loop; if it fails, fix stubs/ownership/stage issues before proceeding."
19
+ message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md. Guardrail: run ./scripts/ticket-hygiene-dev.sh each loop; if it fails, fix lane/status/owner mismatches before proceeding (assignment stubs are deprecated)."
20
20
  enabledByDefault: false
21
21
 
22
22
  - id: pr-watcher
@@ -70,78 +70,111 @@ agents:
70
70
  deny: []
71
71
 
72
72
  templates:
73
+ sharedContext.ticketFlow: |
74
+ {
75
+ "laneByOwner": {
76
+ "lead": "backlog",
77
+ "dev": "in-progress",
78
+ "devops": "in-progress",
79
+ "test": "testing",
80
+ "qa": "testing"
81
+ },
82
+ "defaultLane": "in-progress"
83
+ }
84
+
73
85
  lead.ticketHygiene: |
74
86
  #!/usr/bin/env bash
75
87
  set -euo pipefail
76
88
 
77
89
  # ticket-hygiene.sh
78
- # Guardrail script used by lead triage cron.
79
- # Fails if any assignment stub points at a non-existent or wrong-stage ticket path.
90
+ # Guardrail script used by lead triage + execution loops.
91
+ # Assignment stubs are deprecated.
92
+ #
93
+ # Checks (ACTIVE lanes only):
94
+ # - Ticket file location (lane) must match Status:
95
+ # - Ticket Owner should be in the expected lane per shared-context/ticket-flow.json (best-effort)
96
+ #
97
+ # Notes:
98
+ # - We intentionally do NOT enforce mapping for work/done/ because historical tickets may have old Owner/Status.
80
99
 
81
100
  cd "$(dirname "$0")/.."
82
101
 
83
- stages=(backlog in-progress testing done)
84
-
85
102
  fail=0
86
-
87
- for f in work/assignments/*-assigned-*.md; do
88
- [[ -f "$f" ]] || continue
89
-
90
- bn="$(basename "$f")"
91
- num="${bn%%-*}"
92
- if [[ ! "$num" =~ ^[0-9]{4}$ ]]; then
93
- continue
103
+ flow="shared-context/ticket-flow.json"
104
+
105
+ lane_from_rel() {
106
+ # expects work/<lane>/<file>.md
107
+ echo "$1" | sed -E 's#^work/([^/]+)/.*$##'
108
+ }
109
+
110
+ field_from_md() {
111
+ local file="$1"
112
+ local key="$2"
113
+ # Extract first matching header line like: Key: value
114
+ local line
115
+ line="$(grep -m1 -E "^${key}:[[:space:]]*" "$file" 2>/dev/null || true)"
116
+ echo "${line#${key}:}" | sed -E 's/^\s+//'
117
+ }
118
+
119
+ expected_lane_for_owner() {
120
+ local owner="$1"
121
+ local currentLane="$2"
122
+
123
+ # If jq or the mapping file isn't available, do not block progress.
124
+ if [[ ! -f "$flow" ]]; then
125
+ echo "$currentLane"
126
+ return 0
94
127
  fi
95
-
96
- canonical=""
97
-
98
- if [[ "$bn" == *"pr-body"* ]]; then
99
- for stage in "${stages[@]}"; do
100
- cand="work/$stage/${num}-pr-body.md"
101
- if [[ -f "$cand" ]]; then
102
- canonical="$cand"
103
- break
104
- fi
105
- done
128
+ if ! command -v jq >/dev/null 2>&1; then
129
+ echo "$currentLane"
130
+ return 0
106
131
  fi
107
132
 
108
- if [[ -z "$canonical" ]]; then
109
- for stage in "${stages[@]}"; do
110
- match=( work/"$stage"/"$num"-*.md )
111
- if [[ -e "${match[0]}" ]]; then
112
- canonical="${match[0]}"
113
- break
114
- fi
115
- done
133
+ local out
134
+ out="$(jq -r --arg o "$owner" '.laneByOwner[$o] // .defaultLane // empty' "$flow" 2>/dev/null || true)"
135
+ if [[ -n "$out" ]]; then
136
+ echo "$out"
137
+ else
138
+ echo "$currentLane"
116
139
  fi
140
+ }
117
141
 
118
- if [[ -z "$canonical" ]]; then
119
- echo "[FAIL] $f: no ticket file found for $num in work/{backlog,in-progress,testing,done}/" >&2
120
- fail=1
121
- continue
122
- fi
142
+ check_ticket() {
143
+ local file="$1"
144
+ local rel="$file"
145
+ rel="${rel#./}"
123
146
 
124
- ticket_line="$(awk 'BEGIN{flag=0} /^## Ticket$/{flag=1; next} /^## /{if(flag){exit}} {if(flag && $0!=""){print; exit}}' "$f" | tr -d '\r')"
125
- if [[ -z "$ticket_line" ]]; then
126
- echo "[FAIL] $f: missing Ticket path (expected: $canonical)" >&2
127
- fail=1
128
- continue
147
+ local lane
148
+ lane="$(lane_from_rel "$rel")"
149
+
150
+ # Ignore done lane for owner/status enforcement.
151
+ if [[ "$lane" == "done" ]]; then
152
+ return 0
129
153
  fi
130
154
 
131
- if [[ "$ticket_line" != "$canonical" ]]; then
132
- echo "[FAIL] $f: Ticket path mismatch" >&2
133
- echo " has: $ticket_line" >&2
134
- echo " want: $canonical" >&2
155
+ local owner status
156
+ owner="$(field_from_md "$file" "Owner")"
157
+ status="$(field_from_md "$file" "Status")"
158
+
159
+ if [[ -n "$status" && "$status" != "$lane" ]]; then
160
+ echo "[FAIL] $rel: Status mismatch (has: $status, lane: $lane)" >&2
135
161
  fail=1
136
- continue
137
162
  fi
138
163
 
139
- if [[ ! -f "$ticket_line" ]]; then
140
- echo "[FAIL] $f: Ticket path does not exist on disk: $ticket_line" >&2
141
- fail=1
142
- continue
164
+ if [[ -n "$owner" ]]; then
165
+ local expected
166
+ expected="$(expected_lane_for_owner "$owner" "$lane")"
167
+ if [[ -n "$expected" && "$expected" != "$lane" ]]; then
168
+ echo "[FAIL] $rel: Owner '$owner' expects lane '$expected' per $flow (currently in '$lane')" >&2
169
+ fail=1
170
+ fi
143
171
  fi
172
+ }
144
173
 
174
+ shopt -s nullglob
175
+ for file in work/backlog/*.md work/in-progress/*.md work/testing/*.md work/done/*.md; do
176
+ [[ -f "$file" ]] || continue
177
+ check_ticket "$file"
145
178
  done
146
179
 
147
180
  if [[ "$fail" -ne 0 ]]; then
@@ -186,73 +219,94 @@ templates:
186
219
  set -euo pipefail
187
220
 
188
221
  # ticket-hygiene.sh
189
- # Guardrail script used by lead triage cron.
190
- # Fails if any assignment stub points at a non-existent or wrong-stage ticket path.
222
+ # Guardrail script used by lead triage + execution loops.
223
+ # Assignment stubs are deprecated.
224
+ #
225
+ # Checks (ACTIVE lanes only):
226
+ # - Ticket file location (lane) must match Status:
227
+ # - Ticket Owner should be in the expected lane per shared-context/ticket-flow.json (best-effort)
228
+ #
229
+ # Notes:
230
+ # - We intentionally do NOT enforce mapping for work/done/ because historical tickets may have old Owner/Status.
191
231
 
192
232
  cd "$(dirname "$0")/.."
193
233
 
194
- stages=(backlog in-progress testing done)
195
-
196
234
  fail=0
197
-
198
- for f in work/assignments/*-assigned-*.md; do
199
- [[ -f "$f" ]] || continue
200
-
201
- bn="$(basename "$f")"
202
- num="${bn%%-*}"
203
- if [[ ! "$num" =~ ^[0-9]{4}$ ]]; then
204
- continue
235
+ flow="shared-context/ticket-flow.json"
236
+
237
+ lane_from_rel() {
238
+ # expects work/<lane>/<file>.md
239
+ echo "$1" | sed -E 's#^work/([^/]+)/.*$##'
240
+ }
241
+
242
+ field_from_md() {
243
+ local file="$1"
244
+ local key="$2"
245
+ # Extract first matching header line like: Key: value
246
+ local line
247
+ line="$(grep -m1 -E "^${key}:[[:space:]]*" "$file" 2>/dev/null || true)"
248
+ echo "${line#${key}:}" | sed -E 's/^\s+//'
249
+ }
250
+
251
+ expected_lane_for_owner() {
252
+ local owner="$1"
253
+ local currentLane="$2"
254
+
255
+ # If jq or the mapping file isn't available, do not block progress.
256
+ if [[ ! -f "$flow" ]]; then
257
+ echo "$currentLane"
258
+ return 0
205
259
  fi
206
-
207
- canonical=""
208
-
209
- if [[ "$bn" == *"pr-body"* ]]; then
210
- for stage in "${stages[@]}"; do
211
- cand="work/$stage/${num}-pr-body.md"
212
- if [[ -f "$cand" ]]; then
213
- canonical="$cand"
214
- break
215
- fi
216
- done
260
+ if ! command -v jq >/dev/null 2>&1; then
261
+ echo "$currentLane"
262
+ return 0
217
263
  fi
218
264
 
219
- if [[ -z "$canonical" ]]; then
220
- for stage in "${stages[@]}"; do
221
- match=( work/"$stage"/"$num"-*.md )
222
- if [[ -e "${match[0]}" ]]; then
223
- canonical="${match[0]}"
224
- break
225
- fi
226
- done
265
+ local out
266
+ out="$(jq -r --arg o "$owner" '.laneByOwner[$o] // .defaultLane // empty' "$flow" 2>/dev/null || true)"
267
+ if [[ -n "$out" ]]; then
268
+ echo "$out"
269
+ else
270
+ echo "$currentLane"
227
271
  fi
272
+ }
228
273
 
229
- if [[ -z "$canonical" ]]; then
230
- echo "[FAIL] $f: no ticket file found for $num in work/{backlog,in-progress,testing,done}/" >&2
231
- fail=1
232
- continue
233
- fi
274
+ check_ticket() {
275
+ local file="$1"
276
+ local rel="$file"
277
+ rel="${rel#./}"
234
278
 
235
- ticket_line="$(awk 'BEGIN{flag=0} /^## Ticket$/{flag=1; next} /^## /{if(flag){exit}} {if(flag && $0!=""){print; exit}}' "$f" | tr -d '\r')"
236
- if [[ -z "$ticket_line" ]]; then
237
- echo "[FAIL] $f: missing Ticket path (expected: $canonical)" >&2
238
- fail=1
239
- continue
279
+ local lane
280
+ lane="$(lane_from_rel "$rel")"
281
+
282
+ # Ignore done lane for owner/status enforcement.
283
+ if [[ "$lane" == "done" ]]; then
284
+ return 0
240
285
  fi
241
286
 
242
- if [[ "$ticket_line" != "$canonical" ]]; then
243
- echo "[FAIL] $f: Ticket path mismatch" >&2
244
- echo " has: $ticket_line" >&2
245
- echo " want: $canonical" >&2
287
+ local owner status
288
+ owner="$(field_from_md "$file" "Owner")"
289
+ status="$(field_from_md "$file" "Status")"
290
+
291
+ if [[ -n "$status" && "$status" != "$lane" ]]; then
292
+ echo "[FAIL] $rel: Status mismatch (has: $status, lane: $lane)" >&2
246
293
  fail=1
247
- continue
248
294
  fi
249
295
 
250
- if [[ ! -f "$ticket_line" ]]; then
251
- echo "[FAIL] $f: Ticket path does not exist on disk: $ticket_line" >&2
252
- fail=1
253
- continue
296
+ if [[ -n "$owner" ]]; then
297
+ local expected
298
+ expected="$(expected_lane_for_owner "$owner" "$lane")"
299
+ if [[ -n "$expected" && "$expected" != "$lane" ]]; then
300
+ echo "[FAIL] $rel: Owner '$owner' expects lane '$expected' per $flow (currently in '$lane')" >&2
301
+ fail=1
302
+ fi
254
303
  fi
304
+ }
255
305
 
306
+ shopt -s nullglob
307
+ for file in work/backlog/*.md work/in-progress/*.md work/testing/*.md work/done/*.md; do
308
+ [[ -f "$file" ]] || continue
309
+ check_ticket "$file"
256
310
  done
257
311
 
258
312
  if [[ "$fail" -ne 0 ]]; then
@@ -294,73 +348,94 @@ templates:
294
348
  set -euo pipefail
295
349
 
296
350
  # ticket-hygiene.sh
297
- # Guardrail script used by lead triage cron.
298
- # Fails if any assignment stub points at a non-existent or wrong-stage ticket path.
351
+ # Guardrail script used by lead triage + execution loops.
352
+ # Assignment stubs are deprecated.
353
+ #
354
+ # Checks (ACTIVE lanes only):
355
+ # - Ticket file location (lane) must match Status:
356
+ # - Ticket Owner should be in the expected lane per shared-context/ticket-flow.json (best-effort)
357
+ #
358
+ # Notes:
359
+ # - We intentionally do NOT enforce mapping for work/done/ because historical tickets may have old Owner/Status.
299
360
 
300
361
  cd "$(dirname "$0")/.."
301
362
 
302
- stages=(backlog in-progress testing done)
303
-
304
363
  fail=0
305
-
306
- for f in work/assignments/*-assigned-*.md; do
307
- [[ -f "$f" ]] || continue
308
-
309
- bn="$(basename "$f")"
310
- num="${bn%%-*}"
311
- if [[ ! "$num" =~ ^[0-9]{4}$ ]]; then
312
- continue
364
+ flow="shared-context/ticket-flow.json"
365
+
366
+ lane_from_rel() {
367
+ # expects work/<lane>/<file>.md
368
+ echo "$1" | sed -E 's#^work/([^/]+)/.*$##'
369
+ }
370
+
371
+ field_from_md() {
372
+ local file="$1"
373
+ local key="$2"
374
+ # Extract first matching header line like: Key: value
375
+ local line
376
+ line="$(grep -m1 -E "^${key}:[[:space:]]*" "$file" 2>/dev/null || true)"
377
+ echo "${line#${key}:}" | sed -E 's/^\s+//'
378
+ }
379
+
380
+ expected_lane_for_owner() {
381
+ local owner="$1"
382
+ local currentLane="$2"
383
+
384
+ # If jq or the mapping file isn't available, do not block progress.
385
+ if [[ ! -f "$flow" ]]; then
386
+ echo "$currentLane"
387
+ return 0
313
388
  fi
314
-
315
- canonical=""
316
-
317
- if [[ "$bn" == *"pr-body"* ]]; then
318
- for stage in "${stages[@]}"; do
319
- cand="work/$stage/${num}-pr-body.md"
320
- if [[ -f "$cand" ]]; then
321
- canonical="$cand"
322
- break
323
- fi
324
- done
389
+ if ! command -v jq >/dev/null 2>&1; then
390
+ echo "$currentLane"
391
+ return 0
325
392
  fi
326
393
 
327
- if [[ -z "$canonical" ]]; then
328
- for stage in "${stages[@]}"; do
329
- match=( work/"$stage"/"$num"-*.md )
330
- if [[ -e "${match[0]}" ]]; then
331
- canonical="${match[0]}"
332
- break
333
- fi
334
- done
394
+ local out
395
+ out="$(jq -r --arg o "$owner" '.laneByOwner[$o] // .defaultLane // empty' "$flow" 2>/dev/null || true)"
396
+ if [[ -n "$out" ]]; then
397
+ echo "$out"
398
+ else
399
+ echo "$currentLane"
335
400
  fi
401
+ }
336
402
 
337
- if [[ -z "$canonical" ]]; then
338
- echo "[FAIL] $f: no ticket file found for $num in work/{backlog,in-progress,testing,done}/" >&2
339
- fail=1
340
- continue
341
- fi
403
+ check_ticket() {
404
+ local file="$1"
405
+ local rel="$file"
406
+ rel="${rel#./}"
342
407
 
343
- ticket_line="$(awk 'BEGIN{flag=0} /^## Ticket$/{flag=1; next} /^## /{if(flag){exit}} {if(flag && $0!=""){print; exit}}' "$f" | tr -d '\r')"
344
- if [[ -z "$ticket_line" ]]; then
345
- echo "[FAIL] $f: missing Ticket path (expected: $canonical)" >&2
346
- fail=1
347
- continue
408
+ local lane
409
+ lane="$(lane_from_rel "$rel")"
410
+
411
+ # Ignore done lane for owner/status enforcement.
412
+ if [[ "$lane" == "done" ]]; then
413
+ return 0
348
414
  fi
349
415
 
350
- if [[ "$ticket_line" != "$canonical" ]]; then
351
- echo "[FAIL] $f: Ticket path mismatch" >&2
352
- echo " has: $ticket_line" >&2
353
- echo " want: $canonical" >&2
416
+ local owner status
417
+ owner="$(field_from_md "$file" "Owner")"
418
+ status="$(field_from_md "$file" "Status")"
419
+
420
+ if [[ -n "$status" && "$status" != "$lane" ]]; then
421
+ echo "[FAIL] $rel: Status mismatch (has: $status, lane: $lane)" >&2
354
422
  fail=1
355
- continue
356
423
  fi
357
424
 
358
- if [[ ! -f "$ticket_line" ]]; then
359
- echo "[FAIL] $f: Ticket path does not exist on disk: $ticket_line" >&2
360
- fail=1
361
- continue
425
+ if [[ -n "$owner" ]]; then
426
+ local expected
427
+ expected="$(expected_lane_for_owner "$owner" "$lane")"
428
+ if [[ -n "$expected" && "$expected" != "$lane" ]]; then
429
+ echo "[FAIL] $rel: Owner '$owner' expects lane '$expected' per $flow (currently in '$lane')" >&2
430
+ fail=1
431
+ fi
362
432
  fi
433
+ }
363
434
 
435
+ shopt -s nullglob
436
+ for file in work/backlog/*.md work/in-progress/*.md work/testing/*.md work/done/*.md; do
437
+ [[ -f "$file" ]] || continue
438
+ check_ticket "$file"
364
439
  done
365
440
 
366
441
  if [[ "$fail" -ne 0 ]]; then
@@ -402,73 +477,94 @@ templates:
402
477
  set -euo pipefail
403
478
 
404
479
  # ticket-hygiene.sh
405
- # Guardrail script used by lead triage cron.
406
- # Fails if any assignment stub points at a non-existent or wrong-stage ticket path.
480
+ # Guardrail script used by lead triage + execution loops.
481
+ # Assignment stubs are deprecated.
482
+ #
483
+ # Checks (ACTIVE lanes only):
484
+ # - Ticket file location (lane) must match Status:
485
+ # - Ticket Owner should be in the expected lane per shared-context/ticket-flow.json (best-effort)
486
+ #
487
+ # Notes:
488
+ # - We intentionally do NOT enforce mapping for work/done/ because historical tickets may have old Owner/Status.
407
489
 
408
490
  cd "$(dirname "$0")/.."
409
491
 
410
- stages=(backlog in-progress testing done)
411
-
412
492
  fail=0
413
-
414
- for f in work/assignments/*-assigned-*.md; do
415
- [[ -f "$f" ]] || continue
416
-
417
- bn="$(basename "$f")"
418
- num="${bn%%-*}"
419
- if [[ ! "$num" =~ ^[0-9]{4}$ ]]; then
420
- continue
493
+ flow="shared-context/ticket-flow.json"
494
+
495
+ lane_from_rel() {
496
+ # expects work/<lane>/<file>.md
497
+ echo "$1" | sed -E 's#^work/([^/]+)/.*$##'
498
+ }
499
+
500
+ field_from_md() {
501
+ local file="$1"
502
+ local key="$2"
503
+ # Extract first matching header line like: Key: value
504
+ local line
505
+ line="$(grep -m1 -E "^${key}:[[:space:]]*" "$file" 2>/dev/null || true)"
506
+ echo "${line#${key}:}" | sed -E 's/^\s+//'
507
+ }
508
+
509
+ expected_lane_for_owner() {
510
+ local owner="$1"
511
+ local currentLane="$2"
512
+
513
+ # If jq or the mapping file isn't available, do not block progress.
514
+ if [[ ! -f "$flow" ]]; then
515
+ echo "$currentLane"
516
+ return 0
421
517
  fi
422
-
423
- canonical=""
424
-
425
- if [[ "$bn" == *"pr-body"* ]]; then
426
- for stage in "${stages[@]}"; do
427
- cand="work/$stage/${num}-pr-body.md"
428
- if [[ -f "$cand" ]]; then
429
- canonical="$cand"
430
- break
431
- fi
432
- done
518
+ if ! command -v jq >/dev/null 2>&1; then
519
+ echo "$currentLane"
520
+ return 0
433
521
  fi
434
522
 
435
- if [[ -z "$canonical" ]]; then
436
- for stage in "${stages[@]}"; do
437
- match=( work/"$stage"/"$num"-*.md )
438
- if [[ -e "${match[0]}" ]]; then
439
- canonical="${match[0]}"
440
- break
441
- fi
442
- done
523
+ local out
524
+ out="$(jq -r --arg o "$owner" '.laneByOwner[$o] // .defaultLane // empty' "$flow" 2>/dev/null || true)"
525
+ if [[ -n "$out" ]]; then
526
+ echo "$out"
527
+ else
528
+ echo "$currentLane"
443
529
  fi
530
+ }
444
531
 
445
- if [[ -z "$canonical" ]]; then
446
- echo "[FAIL] $f: no ticket file found for $num in work/{backlog,in-progress,testing,done}/" >&2
447
- fail=1
448
- continue
449
- fi
532
+ check_ticket() {
533
+ local file="$1"
534
+ local rel="$file"
535
+ rel="${rel#./}"
450
536
 
451
- ticket_line="$(awk 'BEGIN{flag=0} /^## Ticket$/{flag=1; next} /^## /{if(flag){exit}} {if(flag && $0!=""){print; exit}}' "$f" | tr -d '\r')"
452
- if [[ -z "$ticket_line" ]]; then
453
- echo "[FAIL] $f: missing Ticket path (expected: $canonical)" >&2
454
- fail=1
455
- continue
537
+ local lane
538
+ lane="$(lane_from_rel "$rel")"
539
+
540
+ # Ignore done lane for owner/status enforcement.
541
+ if [[ "$lane" == "done" ]]; then
542
+ return 0
456
543
  fi
457
544
 
458
- if [[ "$ticket_line" != "$canonical" ]]; then
459
- echo "[FAIL] $f: Ticket path mismatch" >&2
460
- echo " has: $ticket_line" >&2
461
- echo " want: $canonical" >&2
545
+ local owner status
546
+ owner="$(field_from_md "$file" "Owner")"
547
+ status="$(field_from_md "$file" "Status")"
548
+
549
+ if [[ -n "$status" && "$status" != "$lane" ]]; then
550
+ echo "[FAIL] $rel: Status mismatch (has: $status, lane: $lane)" >&2
462
551
  fail=1
463
- continue
464
552
  fi
465
553
 
466
- if [[ ! -f "$ticket_line" ]]; then
467
- echo "[FAIL] $f: Ticket path does not exist on disk: $ticket_line" >&2
468
- fail=1
469
- continue
554
+ if [[ -n "$owner" ]]; then
555
+ local expected
556
+ expected="$(expected_lane_for_owner "$owner" "$lane")"
557
+ if [[ -n "$expected" && "$expected" != "$lane" ]]; then
558
+ echo "[FAIL] $rel: Owner '$owner' expects lane '$expected' per $flow (currently in '$lane')" >&2
559
+ fail=1
560
+ fi
470
561
  fi
562
+ }
471
563
 
564
+ shopt -s nullglob
565
+ for file in work/backlog/*.md work/in-progress/*.md work/testing/*.md work/done/*.md; do
566
+ [[ -f "$file" ]] || continue
567
+ check_ticket "$file"
472
568
  done
473
569
 
474
570
  if [[ "$fail" -ne 0 ]]; then
@@ -831,6 +927,10 @@ files:
831
927
  - path: NOTES.md
832
928
  template: notes
833
929
  mode: createOnly
930
+ - path: shared-context/ticket-flow.json
931
+ template: sharedContext.ticketFlow
932
+ mode: createOnly
933
+
834
934
 
835
935
  # Automation / hygiene scripts
836
936
  # NOTE: portable policy: we do NOT chmod automatically. After scaffold:
@@ -6,7 +6,7 @@ import { findTicketFile, handoffTicket, takeTicket } from "../lib/ticket-workflo
6
6
  import { ticketStageDir } from "../lib/lanes";
7
7
  import { computeNextTicketNumber, TICKET_FILENAME_REGEX } from "../lib/ticket-finder";
8
8
  import { resolveTeamContext } from "../lib/workspace";
9
- import { DEFAULT_TICKET_NUMBER, VALID_ROLES, VALID_STAGES } from "../lib/constants";
9
+ import { VALID_ROLES, VALID_STAGES } from "../lib/constants";
10
10
 
11
11
  export function patchTicketField(md: string, key: string, value: string): string {
12
12
  const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
@@ -108,40 +108,20 @@ export async function handleMoveTicket(
108
108
  await fs.writeFile(srcPath, patched, "utf8");
109
109
  if (srcPath !== destPath) await fs.rename(srcPath, destPath);
110
110
 
111
- // When a ticket is moved to done, archive any assignment stubs into work/assignments/archive/.
112
- if (dest === "done") {
113
- const filename = path.basename(destPath);
114
- const m = filename.match(TICKET_FILENAME_REGEX);
115
- const ticketNumStr = m?.[1] ?? null;
116
- if (ticketNumStr) {
117
- const assignmentsDir = ticketStageDir(teamDir, "assignments");
118
- if (await fileExists(assignmentsDir)) {
119
- const files = (await fs.readdir(assignmentsDir)).filter((f) => f.startsWith(`${ticketNumStr}-assigned-`) && f.endsWith(".md"));
120
- if (files.length) {
121
- const archiveDir = path.join(assignmentsDir, "archive");
122
- await ensureDir(archiveDir);
123
- for (const f of files) {
124
- const from = path.join(assignmentsDir, f);
125
- const to = path.join(archiveDir, f);
126
- await fs.rename(from, to);
127
- }
128
- }
129
- }
130
- }
131
- }
111
+ // Assignment stubs are deprecated; no archival behavior.
132
112
 
133
113
  return { ok: true, from: srcPath, to: destPath };
134
114
  }
135
115
 
136
116
  /**
137
- * Assign a ticket to an owner (writes assignment stub + updates Owner:).
117
+ * Assign a ticket to an owner (updates Owner: only; assignment stubs are deprecated).
138
118
  * @param api - OpenClaw plugin API
139
- * @param options - teamId, ticket, owner, optional overwrite, dryRun
119
+ * @param options - teamId, ticket, owner, optional dryRun
140
120
  * @returns Plan (dryRun) or ok with plan
141
121
  */
142
122
  export async function handleAssign(
143
123
  api: OpenClawPluginApi,
144
- options: { teamId: string; ticket: string; owner: string; overwrite?: boolean; dryRun?: boolean },
124
+ options: { teamId: string; ticket: string; owner: string; dryRun?: boolean },
145
125
  ) {
146
126
  const teamId = String(options.teamId);
147
127
  const { teamDir } = await resolveTeamContext(api, teamId);
@@ -151,14 +131,8 @@ export async function handleAssign(
151
131
  }
152
132
  const ticketPath = await findTicketFile(teamDir, options.ticket);
153
133
  if (!ticketPath) throw new Error(`Ticket not found: ${options.ticket}`);
154
- const filename = path.basename(ticketPath);
155
- const m = filename.match(TICKET_FILENAME_REGEX);
156
- const ticketNumStr = m?.[1] ?? DEFAULT_TICKET_NUMBER;
157
- const slug = m?.[2] ?? (options.ticket.replace(/^\d{4}-?/, "") || "ticket");
158
- const assignmentsDir = ticketStageDir(teamDir, "assignments");
159
- await ensureDir(assignmentsDir);
160
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
161
- const plan = { ticketPath, assignmentPath, owner };
134
+ // Previously parsed for assignment-stub ids; assignment stubs are deprecated.
135
+ const plan = { ticketPath, owner };
162
136
  if (options.dryRun) return { ok: true, plan };
163
137
  const patchOwner = (md: string) => {
164
138
  if (md.match(/^Owner:\s.*$/m)) return md.replace(/^Owner:\s.*$/m, `Owner: ${owner}`);
@@ -167,8 +141,7 @@ export async function handleAssign(
167
141
  const md = await fs.readFile(ticketPath, "utf8");
168
142
  const nextMd = patchOwner(md);
169
143
  await fs.writeFile(ticketPath, nextMd, "utf8");
170
- 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`;
171
- await writeFileSafely(assignmentPath, assignmentMd, options.overwrite ? "overwrite" : "createOnly");
144
+ // Assignment stubs are deprecated; do not create/update work/assignments/*.md.
172
145
  return { ok: true, plan };
173
146
  }
174
147
 
@@ -269,7 +242,8 @@ export async function handleDispatch(
269
242
  if (!requestText) throw new Error("Request cannot be empty");
270
243
  const inboxDir = path.join(teamDir, "inbox");
271
244
  const backlogDir = ticketStageDir(teamDir, "backlog");
272
- const assignmentsDir = ticketStageDir(teamDir, "assignments");
245
+ // Assignment stubs are deprecated; do not create work/assignments/*.
246
+ // Keep old stubs if they exist (migration will move them to work/assignments.__deprecated__/).
273
247
  const slugify = (s: string) =>
274
248
  s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 60) || "request";
275
249
  const nowKey = () => {
@@ -283,27 +257,22 @@ export async function handleDispatch(
283
257
  const baseSlug = slugify(title);
284
258
  const inboxPath = path.join(inboxDir, `${nowKey()}-${baseSlug}.md`);
285
259
  const ticketPath = path.join(backlogDir, `${ticketNumStr}-${baseSlug}.md`);
286
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${owner}.md`);
287
260
  const receivedIso = new Date().toISOString();
288
- 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`;
289
- 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\n## Comments\n- (use this section for @mentions, questions, decisions, and dated replies)\n`;
290
- 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`;
261
+ 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`;
262
+ const ticketMd = `# ${ticketNumStr}-${baseSlug}\n\nCreated: ${receivedIso}\nOwner: ${owner}\nStatus: queued\nInbox: ${path.relative(teamDir, inboxPath)}\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\n## Comments\n- (use this section for @mentions, questions, decisions, and dated replies)\n`;
291
263
  const plan = {
292
264
  teamId,
293
265
  request: requestText,
294
266
  files: [
295
267
  { path: inboxPath, kind: "inbox", summary: title },
296
268
  { path: ticketPath, kind: "backlog-ticket", summary: title },
297
- { path: assignmentPath, kind: "assignment", summary: owner },
298
269
  ],
299
270
  };
300
271
  if (options.dryRun) return { ok: true as const, plan };
301
272
  await ensureDir(inboxDir);
302
273
  await ensureDir(backlogDir);
303
- await ensureDir(assignmentsDir);
304
274
  await writeFileSafely(inboxPath, inboxMd, "createOnly");
305
275
  await writeFileSafely(ticketPath, ticketMd, "createOnly");
306
- await writeFileSafely(assignmentPath, assignmentMd, "createOnly");
307
276
  let nudgeQueued = false;
308
277
  try {
309
278
  const leadAgentId = `${teamId}-lead`;
@@ -312,7 +281,8 @@ export async function handleDispatch(
312
281
  `Dispatch created new intake for team: ${teamId}`,
313
282
  `- Inbox: ${path.relative(teamDir, inboxPath)}`,
314
283
  `- Backlog: ${path.relative(teamDir, ticketPath)}`,
315
- `- Assignment: ${path.relative(teamDir, assignmentPath)}`,
284
+ // Assignment stubs are deprecated; no assignment artifact is created.
285
+
316
286
  `Action: please triage/normalize the ticket (fill Requirements/AC/tasks) and move it through the workflow.`,
317
287
  ].join("\n"),
318
288
  { sessionKey: `agent:${leadAgentId}:main` },
@@ -326,58 +296,6 @@ export async function handleDispatch(
326
296
  return { ok: true as const, wrote: plan.files.map((f) => f.path), nudgeQueued };
327
297
  }
328
298
 
329
- /**
330
- * Cleanup assignment stubs for tickets that are already closed (in work/done).
331
- *
332
- * Why: some automation/board views treat assignment stubs as active work signals.
333
- * If a ticket is manually moved to done (outside `openclaw recipes move-ticket`),
334
- * its `work/assignments/<num>-assigned-*.md` stubs may linger and resurface the ticket.
335
- *
336
- * This command archives any matching assignment stubs into `work/assignments/archive/`.
337
- */
338
- export async function handleCleanupClosedAssignments(
339
- api: OpenClawPluginApi,
340
- options: { teamId: string; ticketNums?: string[] }
341
- ): Promise<{ ok: true; teamId: string; archived: Array<{ from: string; to: string }> }> {
342
- const teamId = String(options.teamId);
343
- const { teamDir } = await resolveTeamContext(api, teamId);
344
-
345
- const assignmentsDir = ticketStageDir(teamDir, "assignments");
346
- const archiveDir = path.join(assignmentsDir, "archive");
347
- const doneDir = ticketStageDir(teamDir, "done");
348
-
349
- const archived: Array<{ from: string; to: string }> = [];
350
- if (!(await fileExists(assignmentsDir))) return { ok: true, teamId, archived };
351
- await ensureDir(archiveDir);
352
-
353
- const ticketNumsFilter = Array.isArray(options.ticketNums) && options.ticketNums.length
354
- ? new Set(options.ticketNums.map((n) => String(n).padStart(4, "0")))
355
- : null;
356
-
357
- const doneFiles = (await fileExists(doneDir)) ? await fs.readdir(doneDir) : [];
358
- const doneNums = new Set(
359
- doneFiles
360
- .map((f) => f.match(/^([0-9]{4})-/)?.[1])
361
- .filter((x): x is string => !!x)
362
- );
363
-
364
- const files = (await fs.readdir(assignmentsDir)).filter((f) => f.endsWith(".md"));
365
- for (const f of files) {
366
- if (f === "archive") continue;
367
- if (f.startsWith("archive" + path.sep)) continue;
368
- const m = f.match(/^([0-9]{4})-assigned-.*\.md$/);
369
- if (!m) continue;
370
- const num = m[1];
371
- if (ticketNumsFilter && !ticketNumsFilter.has(num)) continue;
372
-
373
- // If the ticket number is present in done/, this assignment is considered closed.
374
- if (!doneNums.has(num)) continue;
375
-
376
- const from = path.join(assignmentsDir, f);
377
- const to = path.join(archiveDir, f);
378
- await fs.rename(from, to);
379
- archived.push({ from, to });
380
- }
381
-
382
- return { ok: true, teamId, archived };
383
- }
299
+ // Assignment stubs are deprecated and preserved only via migration to work/assignments.__deprecated__/.
300
+ // This handler is intentionally removed to avoid continuing stub semantics.
301
+ // (If you need to clean up legacy stubs, do it via a one-time migration script.)
@@ -1,19 +1,12 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { fileExists } from "./fs-utils";
5
4
  import { ensureLaneDir } from "./lanes";
6
- import { DEFAULT_TICKET_NUMBER } from "./constants";
7
5
  import { findTicketFile as findTicketFileFromFinder } from "./ticket-finder";
8
- import { parseTicketFilename } from "./ticket-finder";
9
-
10
- async function ensureDir(p: string) {
11
- await fs.mkdir(p, { recursive: true });
12
- }
13
6
 
14
7
  function patchTicketFields(
15
8
  md: string,
16
- opts: { ownerSafe: string; status: string; assignmentRel: string }
9
+ opts: { ownerSafe: string; status: string }
17
10
  ): string {
18
11
  let out = md;
19
12
  if (out.match(/^Owner:\s.*$/m)) out = out.replace(/^Owner:\s.*$/m, `Owner: ${opts.ownerSafe}`);
@@ -22,9 +15,7 @@ function patchTicketFields(
22
15
  if (out.match(/^Status:\s.*$/m)) out = out.replace(/^Status:\s.*$/m, `Status: ${opts.status}`);
23
16
  else out = out.replace(/^(# .+\n)/, `$1\nStatus: ${opts.status}\n`);
24
17
 
25
- if (out.match(/^Assignment:\s.*$/m)) out = out.replace(/^Assignment:\s.*$/m, `Assignment: ${opts.assignmentRel}`);
26
- else out = out.replace(/^Owner:.*$/m, (line) => `${line}\nAssignment: ${opts.assignmentRel}`);
27
-
18
+ // Assignment stubs are deprecated; do not write/update Assignment: here.
28
19
  return out;
29
20
  }
30
21
 
@@ -47,34 +38,20 @@ export async function takeTicket(opts: { teamDir: string; ticket: string; owner?
47
38
  const filename = path.basename(srcPath);
48
39
  const destPath = path.join(inProgressDir, filename);
49
40
 
50
- const parsed = parseTicketFilename(filename) ?? { ticketNumStr: opts.ticket.match(/^\d{4}$/) ? opts.ticket : DEFAULT_TICKET_NUMBER, slug: "ticket" };
51
- const { ticketNumStr, slug } = parsed;
52
-
53
- const assignmentsDir = path.join(teamDir, 'work', 'assignments');
54
- await ensureDir(assignmentsDir);
55
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${ownerSafe}.md`);
56
- const assignmentRel = path.relative(teamDir, assignmentPath);
41
+ // Previously parsed filename for assignment-stub ids; no longer needed.
57
42
 
58
43
  const alreadyInProgress = srcPath === destPath;
59
44
 
60
45
  const md = await fs.readFile(srcPath, 'utf8');
61
- const nextMd = patchTicketFields(md, { ownerSafe, status: 'in-progress', assignmentRel });
46
+ const nextMd = patchTicketFields(md, { ownerSafe, status: 'in-progress' });
62
47
  await fs.writeFile(srcPath, nextMd, 'utf8');
63
48
 
64
49
  if (!alreadyInProgress) {
65
50
  await fs.rename(srcPath, destPath);
66
51
  }
67
52
 
68
- const assignmentMd = `# Assignment ${ticketNumStr}-${slug}\n\nAssigned: ${ownerSafe}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes take\n`;
69
-
70
- const assignmentExists = await fileExists(assignmentPath);
71
- if (assignmentExists && !opts.overwriteAssignment) {
72
- // createOnly
73
- } else {
74
- await fs.writeFile(assignmentPath, assignmentMd, 'utf8');
75
- }
76
-
77
- return { srcPath, destPath, moved: !alreadyInProgress, assignmentPath };
53
+ // Assignment stubs are deprecated; do not create/update work/assignments/*.md.
54
+ return { srcPath, destPath, moved: !alreadyInProgress };
78
55
  }
79
56
 
80
57
  export async function handoffTicket(opts: { teamDir: string; ticket: string; tester?: string; overwriteAssignment: boolean }) {
@@ -91,32 +68,18 @@ export async function handoffTicket(opts: { teamDir: string; ticket: string; tes
91
68
  const filename = path.basename(srcPath);
92
69
  const destPath = path.join(testingDir, filename);
93
70
 
94
- const parsed = parseTicketFilename(filename) ?? { ticketNumStr: opts.ticket.match(/^\d{4}$/) ? opts.ticket : DEFAULT_TICKET_NUMBER, slug: "ticket" };
95
- const { ticketNumStr, slug } = parsed;
96
-
97
- const assignmentsDir = path.join(teamDir, 'work', 'assignments');
98
- await ensureDir(assignmentsDir);
99
- const assignmentPath = path.join(assignmentsDir, `${ticketNumStr}-assigned-${testerSafe}.md`);
100
- const assignmentRel = path.relative(teamDir, assignmentPath);
71
+ // Previously parsed filename for assignment-stub ids; no longer needed.
101
72
 
102
73
  const alreadyInTesting = srcPath === destPath;
103
74
 
104
75
  const md = await fs.readFile(srcPath, 'utf8');
105
- const nextMd = patchTicketFields(md, { ownerSafe: testerSafe, status: 'testing', assignmentRel });
76
+ const nextMd = patchTicketFields(md, { ownerSafe: testerSafe, status: 'testing' });
106
77
  await fs.writeFile(srcPath, nextMd, 'utf8');
107
78
 
108
79
  if (!alreadyInTesting) {
109
80
  await fs.rename(srcPath, destPath);
110
81
  }
111
82
 
112
- const assignmentMd = `# Assignment ${ticketNumStr}-${slug}\n\nAssigned: ${testerSafe}\n\n## Ticket\n${path.relative(teamDir, destPath)}\n\n## Notes\n- Created by: openclaw recipes handoff\n`;
113
-
114
- const assignmentExists = await fileExists(assignmentPath);
115
- if (assignmentExists && !opts.overwriteAssignment) {
116
- // createOnly: leave as-is
117
- } else {
118
- await fs.writeFile(assignmentPath, assignmentMd, 'utf8');
119
- }
120
-
121
- return { srcPath, destPath, moved: !alreadyInTesting, assignmentPath };
83
+ // Assignment stubs are deprecated; do not create/update work/assignments/*.md.
84
+ return { srcPath, destPath, moved: !alreadyInTesting };
122
85
  }