monomind 1.10.41 → 1.10.43

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.
@@ -0,0 +1,1284 @@
1
+ ---
2
+ name: mastermind-monitor
3
+ description: Mastermind monitor — a forever-running task executor that watches Linear, GitHub Issues/PRs, Monotask boards, and filesystem folders for new tasks, claims them, executes them with the right agent, posts progress comments, and advances status at every stage. Supports per-user/per-state filtering, 3-retry failure handling, and a single concurrent task at a time (safe default). Persists state across cycles via ScheduleWakeup.
4
+ type: domain-skill
5
+ default_mode: confirm
6
+ ---
7
+
8
+ # Mastermind Monitor
9
+
10
+ Invoked via `mastermind:monitor` or `/mastermind:monitor`.
11
+
12
+ A monitor is a named, forever-running task executor. It polls one or more task sources on a configurable interval, claims matching tasks, hands them off to a Claude agent for execution, posts back results as comments/status updates, and self-reschedules via `ScheduleWakeup`.
13
+
14
+ ---
15
+
16
+ ## CLI Flags
17
+
18
+ ```
19
+ --action start | stop | pause | resume | status | list | add-source | tick
20
+ --name monitor name (slug, e.g. "dev-agent")
21
+ --source linear | github | monotask | filesystem
22
+ --interval poll interval in seconds (default: 120)
23
+ --user filter by assignee username/email (can be repeated)
24
+ --state filter by task state/status (can be repeated, default: open/todo)
25
+ --max-concurrent max tasks in flight (default: 1)
26
+ --agent agent type to execute tasks (default: coder)
27
+ --project monotask project/board name, or gh repo (org/repo)
28
+ --team Linear team ID or slug
29
+ --folder filesystem folder path to watch (for source=filesystem)
30
+ --label filter by label (can be repeated)
31
+ --caller command | master (internal — skip brain load if "command")
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Step 0 — Brain Load (standalone only)
37
+
38
+ If `caller` is not "command", load brain context following `_protocol.md` Brain Load Procedure with namespace: `ops`.
39
+
40
+ ---
41
+
42
+ ## Step 1 — Resolve Monitor Config Directory
43
+
44
+ ```bash
45
+ MONITOR_DIR=".monomind/monitor"
46
+ mkdir -p "$MONITOR_DIR"
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Step 2 — Dispatch by Action
52
+
53
+ ### `list` (default when no --action)
54
+
55
+ ```bash
56
+ echo "MONITORS"
57
+ echo "────────────────────────────────────────"
58
+ for f in "$MONITOR_DIR"/*.json; do
59
+ [ -f "$f" ] || continue
60
+ jq -r '
61
+ "[\(.name)] status=\(.status // "active") interval=\(.poll_interval)s agent=\(.agent_type)
62
+ sources: \([.sources[].type] | join(", "))
63
+ last_tick: \(.last_tick // "never") tasks_done: \(.stats.done // 0) tasks_failed: \(.stats.failed // 0)"
64
+ ' "$f"
65
+ echo ""
66
+ done
67
+ ```
68
+
69
+ ---
70
+
71
+ ### `start`
72
+
73
+ Creates a new monitor config and triggers the first tick.
74
+
75
+ **Required:** `--name`
76
+
77
+ ```bash
78
+ cfg="$MONITOR_DIR/${name}.json"
79
+ if [ -f "$cfg" ]; then
80
+ echo "Monitor '$name' already exists. Use --action resume to restart it."
81
+ exit 0
82
+ fi
83
+
84
+ cat > "$cfg" <<EOF
85
+ {
86
+ "name": "${name}",
87
+ "status": "active",
88
+ "poll_interval": ${interval:-120},
89
+ "agent_type": "${agent:-coder}",
90
+ "max_concurrent": ${max_concurrent:-1},
91
+ "sources": [],
92
+ "users": [],
93
+ "states": [],
94
+ "labels": [],
95
+ "stats": { "done": 0, "failed": 0, "retried": 0, "total_claimed": 0 },
96
+ "created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
97
+ "last_tick": null
98
+ }
99
+ EOF
100
+
101
+ echo "Monitor '$name' created."
102
+ echo "Add sources with: /mastermind:monitor --action add-source --name $name --source linear ..."
103
+ echo "Then start the tick loop."
104
+ ```
105
+
106
+ After creating the config, if at least one source was provided (via `--source` + related flags), also run the `add-source` logic for each provided source before the loop starts.
107
+
108
+ Then **immediately call `ScheduleWakeup`** with:
109
+ - `delaySeconds`: 10 (first tick almost immediately)
110
+ - `prompt`: `/mastermind:monitor --action tick --name <name>`
111
+ - `reason`: `First tick for monitor: <name>`
112
+
113
+ ---
114
+
115
+ ### `stop`
116
+
117
+ ```bash
118
+ cfg="$MONITOR_DIR/${name}.json"
119
+ [ ! -f "$cfg" ] && echo "Monitor '$name' not found." && exit 1
120
+ tmp="${cfg}.tmp"
121
+ jq '.status = "stopped"' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
122
+ echo "Monitor '$name' stopped. It will not reschedule after the current tick finishes."
123
+ ```
124
+
125
+ ---
126
+
127
+ ### `pause` / `resume`
128
+
129
+ ```bash
130
+ cfg="$MONITOR_DIR/${name}.json"
131
+ [ ! -f "$cfg" ] && echo "Monitor '$name' not found." && exit 1
132
+ tmp="${cfg}.tmp"
133
+ new_status=$([ "$action" = "pause" ] && echo "paused" || echo "active")
134
+ jq --arg s "$new_status" '.status = $s' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
135
+ echo "Monitor '$name' is now: $new_status"
136
+ [ "$action" = "resume" ] && echo "Scheduling next tick..." # then call ScheduleWakeup
137
+ ```
138
+
139
+ If `action=resume`, call `ScheduleWakeup`:
140
+ - `delaySeconds`: 10
141
+ - `prompt`: `/mastermind:monitor --action tick --name <name>`
142
+ - `reason`: `Resuming monitor: <name>`
143
+
144
+ ---
145
+
146
+ ### `status`
147
+
148
+ ```bash
149
+ cfg="$MONITOR_DIR/${name}.json"
150
+ state_file="$MONITOR_DIR/${name}-state.json"
151
+ [ ! -f "$cfg" ] && echo "Monitor '$name' not found." && exit 1
152
+
153
+ jq -r '
154
+ "MONITOR: \(.name)",
155
+ "Status: \(.status)",
156
+ "Agent: \(.agent_type) | Interval: \(.poll_interval)s | Max concurrent: \(.max_concurrent)",
157
+ "Last tick: \(.last_tick // "never")",
158
+ "",
159
+ "Stats:",
160
+ " Claimed: \(.stats.total_claimed) Done: \(.stats.done) Failed: \(.stats.failed) Retried: \(.stats.retried)",
161
+ "",
162
+ "Sources (\(.sources | length)):"
163
+ ' "$cfg"
164
+
165
+ jq -r '.sources[] | " [\(.type)] \(.filter | to_entries | map("\(.key)=\(.value)") | join(" "))"' "$cfg"
166
+
167
+ if [ -f "$state_file" ]; then
168
+ in_flight=$(jq '.in_flight // [] | length' "$state_file" 2>/dev/null || echo 0)
169
+ echo ""
170
+ echo "In-flight tasks: $in_flight"
171
+ jq -r '.in_flight[]? |
172
+ " [\(.source_type):\(.external_id)] claimed_at=\(.claimed_at) retry=\(.retry_count) last_failure=\(.last_failure // "n/a")"' \
173
+ "$state_file" 2>/dev/null
174
+ fi
175
+ ```
176
+
177
+ ---
178
+
179
+ ### `add-source`
180
+
181
+ Appends a new source adapter config to an existing monitor.
182
+
183
+ **Required:** `--name`, `--source`
184
+
185
+ ```bash
186
+ cfg="$MONITOR_DIR/${name}.json"
187
+ [ ! -f "$cfg" ] && echo "Monitor '$name' not found. Run --action start first." && exit 1
188
+ ```
189
+
190
+ Build the source object based on `--source`:
191
+
192
+ **linear:**
193
+ ```json
194
+ {
195
+ "type": "linear",
196
+ "filter": {
197
+ "team": "<--team value>",
198
+ "assignees": ["<--user values>"],
199
+ "states": ["<--state values, default: Todo>"],
200
+ "labels": ["<--label values>"],
201
+ "project": "<--project value if given>"
202
+ }
203
+ }
204
+ ```
205
+
206
+ **github:**
207
+ ```json
208
+ {
209
+ "type": "github",
210
+ "filter": {
211
+ "repo": "<--project value, e.g. org/repo>",
212
+ "assignee": "<--user value>",
213
+ "labels": ["<--label values>"],
214
+ "state": "<--state value, default: open>",
215
+ "type": "issue"
216
+ }
217
+ }
218
+ ```
219
+
220
+ **monotask:**
221
+ ```json
222
+ {
223
+ "type": "monotask",
224
+ "filter": {
225
+ "board": "<--project value>",
226
+ "column": "<--state value, default: Todo>",
227
+ "label": "<--label value, default: role:ai-agent>"
228
+ }
229
+ }
230
+ ```
231
+
232
+ **filesystem:**
233
+ ```json
234
+ {
235
+ "type": "filesystem",
236
+ "filter": {
237
+ "folder": "<--folder value>",
238
+ "glob": "*.task",
239
+ "user": "<--user value>"
240
+ }
241
+ }
242
+ ```
243
+
244
+ **Repeated-flag parsing rule:** When `--user`, `--state`, or `--label` is specified multiple times, collect the values into space-separated shell variables `$users`, `$states`, `$labels` (e.g. `users="alice bob"` from `--user alice --user bob`). Single-occurrence flags (`--team`, `--project`, `--folder`) map to `$team`, `$project`, `$folder` directly.
245
+
246
+ > **Linear multi-assignee:** The Linear MCP tool (`mcp__claude_ai_Linear__list_issues`) does not support multiple assignees in a single query. When `$users` contains more than one value, loop over each user and merge results client-side before dedup filtering. For simplicity in v1, only `filter.assignees[0]` is sent per query cycle — document this limitation to users.
247
+
248
+ > **Before calling any `mcp__claude_ai_Linear__*` tool**, confirm availability with `ToolSearch` (`select:mcp__claude_ai_Linear__list_issues,mcp__claude_ai_Linear__save_issue,mcp__claude_ai_Linear__save_comment`) and load the schema. If the Linear MCP server is not registered, skip the Linear source for this tick with a warning.
249
+
250
+ Build and append the source object using `jq -n` — construct the JSON from flags, then append:
251
+
252
+ ```bash
253
+ # Build src_json based on --source type:
254
+ # Repeated flags collected as space-separated: $users, $states, $labels
255
+ # Derive singular $user / $state / $label from first value (adapters that take one value)
256
+ user="${users%% *}"
257
+ state="${states%% *}"
258
+ label="${labels%% *}"
259
+
260
+ case "$source" in
261
+ linear)
262
+ assignees_json=$([ -n "$users" ] && printf '%s\n' $users | jq -R . | jq -sc '.' || echo '[]')
263
+ states_json=$([ -n "$states" ] && printf '%s\n' $states | jq -R . | jq -sc '.' || echo '["Todo"]')
264
+ labels_json=$([ -n "$labels" ] && printf '%s\n' $labels | jq -R . | jq -sc '.' || echo '[]')
265
+ src_json=$(jq -cn \
266
+ --arg team "${team:-}" \
267
+ --arg project "${project:-}" \
268
+ --argjson assignees "$assignees_json" \
269
+ --argjson states "$states_json" \
270
+ --argjson labels "$labels_json" \
271
+ '{"type":"linear","filter":{"team":$team,"assignees":$assignees,"states":$states,"labels":$labels,"project":$project}}')
272
+ ;;
273
+ github)
274
+ labels_json=$([ -n "$labels" ] && printf '%s\n' $labels | jq -R . | jq -sc '.' || echo '[]')
275
+ src_json=$(jq -cn \
276
+ --arg repo "${project:-}" \
277
+ --arg assignee "${user:-}" \
278
+ --argjson labels "$labels_json" \
279
+ --arg state "${state:-open}" \
280
+ '{"type":"github","filter":{"repo":$repo,"assignee":$assignee,"labels":$labels,"state":$state,"type":"issue"}}')
281
+ ;;
282
+ monotask)
283
+ src_json=$(jq -cn \
284
+ --arg board "${project:-}" \
285
+ --arg column "${state:-Todo}" \
286
+ --arg label "${label:-role:ai-agent}" \
287
+ '{"type":"monotask","filter":{"board":$board,"column":$column,"label":$label}}')
288
+ ;;
289
+ filesystem)
290
+ src_json=$(jq -cn \
291
+ --arg folder "${folder:-./tasks}" \
292
+ --arg user "${user:-}" \
293
+ '{"type":"filesystem","filter":{"folder":$folder,"glob":"*.task","user":$user}}')
294
+ ;;
295
+ *)
296
+ echo "Unknown source type: $source. Supported: linear | github | monotask | filesystem"
297
+ exit 1
298
+ ;;
299
+ esac
300
+
301
+ tmp="${cfg}.tmp"
302
+ jq --argjson src "$src_json" '.sources += [$src]' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
303
+ echo "Source added to monitor '$name'."
304
+ ```
305
+
306
+ ---
307
+
308
+ ### `tick` — The Main Execution Loop
309
+
310
+ This is the heart of the monitor. Called by `ScheduleWakeup` every `poll_interval` seconds.
311
+
312
+ > **Execution model:** bash code blocks in this section are executable fragments. Prose instructions between blocks complete the control flow (ScheduleWakeup calls, conditional branches, `fi` closures). Follow both the code and the prose — neither is complete without the other.
313
+
314
+ **Required:** `--name`
315
+
316
+ ```bash
317
+ cfg="$MONITOR_DIR/${name}.json"
318
+ state_file="$MONITOR_DIR/${name}-state.json"
319
+
320
+ # Load config
321
+ [ ! -f "$cfg" ] && echo "Monitor '$name' not found — loop terminated." && exit 0
322
+
323
+ monitor_status=$(jq -r '.status' "$cfg")
324
+ [ "$monitor_status" = "stopped" ] && echo "Monitor '$name' stopped — loop terminated." && exit 0
325
+ [ "$monitor_status" = "paused" ] && echo "Monitor '$name' paused — will not reschedule." && exit 0
326
+
327
+ # Init state file if missing
328
+ [ ! -f "$state_file" ] && echo '{"processed_ids":{},"in_flight":[]}' > "$state_file"
329
+
330
+ max_concurrent=$(jq -r '.max_concurrent // 1' "$cfg")
331
+ agent_type=$(jq -r '.agent_type // "coder"' "$cfg")
332
+ poll_interval=$(jq -r '.poll_interval // 120' "$cfg")
333
+ ```
334
+
335
+ **In-flight guard** — if at capacity, reschedule and stop this tick:
336
+ ```bash
337
+ in_flight_count=$(jq '.in_flight // [] | length' "$state_file")
338
+ in_flight_count=${in_flight_count:-0}
339
+ if [ "$in_flight_count" -ge "$max_concurrent" ]; then
340
+ echo "[$name] In-flight ($in_flight_count) >= max_concurrent ($max_concurrent) — skipping claim this tick."
341
+ # Reschedule next tick — do this BEFORE exit so the loop survives
342
+ # ScheduleWakeup: delaySeconds=poll_interval, prompt="/mastermind:monitor --action tick --name <name>",
343
+ # reason="Monitor <name> in-flight throttle — will retry next tick"
344
+ exit 0
345
+ fi
346
+ ```
347
+
348
+ > **Note:** The ScheduleWakeup call above is pseudocode inside the comment. Claude must call the actual `ScheduleWakeup` tool at this point, then `exit 0`. The `exit 0` here is a bash signal that the tick logic description treats as "stop further processing in this tick"; it does NOT skip the ScheduleWakeup — that fires first.
349
+
350
+ **Check for retry-pending tasks (before polling sources):**
351
+
352
+ If a previous task failed but has retry_count < 3, it remains in `in_flight` with `status = "retry_pending"`. Re-execute it instead of polling for a new task:
353
+
354
+ ```bash
355
+ retry_task=$(jq -c '
356
+ .in_flight[] as $t |
357
+ (.processed_ids[($t.source_type + ":" + $t.external_id)].status // "") |
358
+ if . == "retry_pending" then $t else empty end' "$state_file" | head -1)
359
+ ```
360
+
361
+ If `retry_task` is non-empty, bind it as the active task and jump to Step 3:
362
+
363
+ ```bash
364
+ if [ -n "$retry_task" ]; then
365
+ task_json="$retry_task"
366
+ task_source_type=$(echo "$task_json" | jq -r '.source_type')
367
+ task_external_id=$(echo "$task_json" | jq -r '.external_id')
368
+ task_title=$(echo "$task_json" | jq -r '.title')
369
+ echo "[$name] Retrying task: $task_title (source=$task_source_type id=$task_external_id)"
370
+ # Proceed directly to Step 3 — skip source polling and the "After claim" section
371
+ fi
372
+ ```
373
+
374
+ When `retry_task` is non-empty, the source loop below is skipped entirely (guarded by `if [ -z "$retry_task" ]`). `task_claimed` remains `false`, so the "After claim" registration block is also bypassed — the task is already registered in `in_flight` from its original claim.
375
+
376
+ **Update last_tick:**
377
+ ```bash
378
+ tmp="${cfg}.tmp"
379
+ jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.last_tick = $ts' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
380
+ ```
381
+
382
+ **Emit dashboard event:**
383
+ ```bash
384
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
385
+ CTRL_URL=$(jq -r '.url // "http://localhost:4242"' "$REPO_ROOT/.monomind/control.json" 2>/dev/null || echo "http://localhost:4242")
386
+ SESSION_ID="monitor-${name}-$(date -u +%Y%m%dT%H%M%S)"
387
+ curl -s -o /dev/null -X POST "${CTRL_URL}/api/mastermind/event" \
388
+ -H "Content-Type: application/json" \
389
+ -d "$(jq -cn --arg sid "$SESSION_ID" --arg name "$name" \
390
+ '{type:"monitor:tick",session:$sid,monitor:$name,ts:(now*1000|floor)}')" || true
391
+ ```
392
+
393
+ **Poll each source (in order, stop after first claimable task found):**
394
+
395
+ Source polling is skipped when a retry task is available (guarded below). The loop, the `case` dispatch, and each adapter are all inside the `if [ -z "$retry_task" ]` block:
396
+
397
+ ```bash
398
+ task_claimed=false
399
+ if [ -z "$retry_task" ]; then
400
+ task_json=""
401
+ while IFS= read -r src; do
402
+ src_type=$(echo "$src" | jq -r '.type')
403
+ case "$src_type" in
404
+ linear)
405
+ # === Linear adapter (below) ===
406
+ ```
407
+
408
+ > The `case` block continues through all adapters. After the Filesystem adapter closes its inner loop and `fi`, add `esac` → `done < <(jq -c '.sources[]' "$cfg")` → `fi` (closing the `if [ -z "$retry_task" ]` guard). After the outer `fi`, check `$task_claimed` to decide whether to run the "After claim" section.
409
+
410
+ ---
411
+
412
+ #### Source Adapter: Linear
413
+
414
+ > **Before calling any `mcp__claude_ai_Linear__*` tool**, confirm availability with `ToolSearch` (`select:mcp__claude_ai_Linear__list_issues,mcp__claude_ai_Linear__save_issue,mcp__claude_ai_Linear__save_comment`). If the Linear MCP server is unavailable, skip this source for the current tick with a warning.
415
+
416
+ ```bash
417
+ # Extract filter fields from $src
418
+ _lin_team=$(echo "$src" | jq -r '.filter.team // ""')
419
+ _lin_assignee=$(echo "$src" | jq -r '.filter.assignees[0] // ""')
420
+ _lin_states=$(echo "$src" | jq -r '.filter.states // ["Todo"] | join(",")')
421
+ _lin_labels=$(echo "$src" | jq -c '.filter.labels // []')
422
+ ```
423
+
424
+ Use `mcp__claude_ai_Linear__list_issues` with:
425
+ - `teamId`: `$_lin_team`
426
+ - `assigneeId` / `assigneeEmail`: `$_lin_assignee` (if non-empty)
427
+ - State filter: resolved from `$_lin_states`
428
+ - Label filter applied client-side after fetch
429
+
430
+ The MCP call returns a JSON array. Bind it to a shell variable immediately after the call:
431
+
432
+ ```bash
433
+ # issues = JSON array returned by mcp__claude_ai_Linear__list_issues
434
+ # Assign the raw JSON response to $issues before the loop below:
435
+ issues='[]' # Claude MUST overwrite this with the real array returned by mcp__claude_ai_Linear__list_issues before the loop runs
436
+ ```
437
+
438
+ Then iterate with an explicit bash loop (same pattern as Monotask/Filesystem adapters):
439
+
440
+ ```bash
441
+ while IFS= read -r issue_json; do
442
+ [ -z "$issue_json" ] && continue
443
+
444
+ issue_id=$(echo "$issue_json" | jq -r '.id')
445
+ issue_title=$(echo "$issue_json" | jq -r '.title')
446
+ issue_desc=$(echo "$issue_json" | jq -r '.description // ""')
447
+ issue_url=$(echo "$issue_json" | jq -r '.url // ""')
448
+ issue_labels=$(echo "$issue_json" | jq -c '[(.labels // [])[] | .name]')
449
+ ```
450
+
451
+ 1. Check dedup — skip if already processed:
452
+ ```bash
453
+ already_processed=$(jq -r --arg id "linear:${issue_id}" '.processed_ids[$id] // empty' "$state_file")
454
+ [ -n "$already_processed" ] && continue
455
+ ```
456
+
457
+ 2. Client-side label filter — skip if issue doesn't match configured labels:
458
+ ```bash
459
+ if [ "$(echo "$_lin_labels" | jq 'length')" -gt 0 ]; then
460
+ has_label=$(jq -n --argjson want "$_lin_labels" --argjson got "$issue_labels" \
461
+ '($want - ($want - $got)) | length > 0')
462
+ [ "$has_label" != "true" ] && continue
463
+ fi
464
+ ```
465
+
466
+ 3. Build task object:
467
+ ```bash
468
+ task_json=$(jq -cn \
469
+ --arg eid "$issue_id" \
470
+ --arg title "$issue_title" \
471
+ --arg desc "$issue_desc" \
472
+ --arg url "$issue_url" \
473
+ --argjson labels "$issue_labels" \
474
+ '{
475
+ "source_type": "linear",
476
+ "external_id": $eid,
477
+ "title": $title,
478
+ "description": $desc,
479
+ "url": $url,
480
+ "labels": $labels
481
+ }')
482
+ ```
483
+
484
+ **Claim** (MCP — not bash): Call `mcp__claude_ai_Linear__save_issue` with:
485
+ - `issueId`: `$issue_id`
486
+ - Set state to "In Progress"
487
+ - Add label: `monitor:claimed`
488
+
489
+ **Progress comment** (MCP): Call `mcp__claude_ai_Linear__save_comment` with:
490
+ - `issueId`: `$issue_id`
491
+ - `body`: `"[Monitor: ${name}] Claimed by AI agent. Starting execution with agent type \`${agent_type}\`. Started: $(date -u +%Y-%m-%dT%H:%M:%SZ)"`
492
+
493
+ ```bash
494
+ task_claimed=true
495
+ break 2 # break 2: exit per-issue loop AND outer source loop
496
+ done < <(echo "$issues" | jq -c '.[]') # close per-issue loop
497
+ ;; # end linear case branch
498
+ ```
499
+
500
+ ---
501
+
502
+ #### Source Adapter: GitHub Issues/PRs
503
+
504
+ ```bash
505
+ # Extract filter fields from $src
506
+ _gh_repo=$(echo "$src" | jq -r '.filter.repo // ""')
507
+ _gh_assignee=$(echo "$src" | jq -r '.filter.assignee // ""')
508
+ _gh_state=$(echo "$src" | jq -r '.filter.state // "open"')
509
+
510
+ # Build --label flags as an array (gh issue list takes one --label per flag, not comma-separated)
511
+ gh_label_args=()
512
+ while IFS= read -r _lbl; do
513
+ [ -n "$_lbl" ] && gh_label_args+=(--label "$_lbl")
514
+ done < <(echo "$src" | jq -r '(.filter.labels // [])[]')
515
+
516
+ # Poll via gh CLI — capture into $issues
517
+ issues=$(gh issue list \
518
+ --repo "$_gh_repo" \
519
+ ${_gh_assignee:+--assignee "$_gh_assignee"} \
520
+ "${gh_label_args[@]}" \
521
+ --state "$_gh_state" \
522
+ --json number,title,body,url,labels,assignees \
523
+ --limit 20)
524
+ ```
525
+
526
+ Iterate with an explicit bash loop:
527
+
528
+ ```bash
529
+ while IFS= read -r issue_json; do
530
+ [ -z "$issue_json" ] && continue
531
+
532
+ number=$(echo "$issue_json" | jq -r '.number')
533
+ ```
534
+
535
+ 1. Check dedup — TWO checks required:
536
+ ```bash
537
+ # Local dedup: skip if already in processed_ids
538
+ already_processed=$(jq -r --arg id "github:${number}" '.processed_ids[$id] // empty' "$state_file")
539
+ [ -n "$already_processed" ] && continue
540
+
541
+ # Remote dedup: skip if issue already has label monitor:claimed (externally labeled or other instance)
542
+ already_claimed=$(echo "$issue_json" | jq -r '[(.labels // [])[] | .name] | index("monitor:claimed")')
543
+ [ "$already_claimed" != "null" ] && continue
544
+ ```
545
+
546
+ 2. Build task object:
547
+ ```bash
548
+ task_json=$(jq -cn \
549
+ --argjson issue "$issue_json" \
550
+ --arg repo "$_gh_repo" \
551
+ '{
552
+ "source_type": "github",
553
+ "external_id": ($issue.number | tostring),
554
+ "title": $issue.title,
555
+ "description": ($issue.body // ""),
556
+ "url": $issue.url,
557
+ "repo": $repo,
558
+ "labels": [($issue.labels // [])[] | .name]
559
+ }')
560
+ ```
561
+
562
+ **Claim:** Run the GitHub Label Bootstrap (see end of file) using `$_gh_repo` before the first label operation — it is idempotent and only creates labels if missing. Then add labels:
563
+ ```bash
564
+ # Run GitHub Label Bootstrap here (see "GitHub Label Bootstrap" section) — idempotent, guarded
565
+ gh issue edit "$number" --repo "$_gh_repo" --add-label "monitor:claimed,monitor:in-progress"
566
+ ```
567
+
568
+ **Progress comment:**
569
+ ```bash
570
+ gh issue comment "$number" --repo "$_gh_repo" --body \
571
+ "[Monitor: ${name}] Claimed by AI agent. Executing with \`${agent_type}\`. Started: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
572
+ ```
573
+
574
+ ```bash
575
+ task_claimed=true
576
+ break 2 # break 2: exit per-issue loop AND outer source loop
577
+ done < <(echo "$issues" | jq -c '.[]') # close per-issue loop
578
+ ;; # end github case branch
579
+ ```
580
+
581
+ **Stage labels** (applied progressively throughout execution):
582
+ - `monitor:claimed` → task picked up
583
+ - `monitor:in-progress` → agent is executing
584
+ - `monitor:review` → agent completed, awaiting human review (optional)
585
+ - `monitor:done` → fully complete
586
+ - `monitor:failed` → all retries exhausted
587
+
588
+ ---
589
+
590
+ #### Source Adapter: Monotask
591
+
592
+ ```bash
593
+ # Extract filter fields from $src
594
+ _mt_board=$(echo "$src" | jq -r '.filter.board // ""')
595
+ _mt_col=$(echo "$src" | jq -r '.filter.column // "Todo"')
596
+ _mt_label=$(echo "$src" | jq -r '.filter.label // "role:ai-agent"')
597
+
598
+ # Resolve board_id from board title
599
+ board_id=$(monotask board list --json | jq -r --arg t "$_mt_board" '.[] | select(.title==$t) | .id' | head -1)
600
+ if [ -z "$board_id" ]; then
601
+ echo "[monotask] Board '$_mt_board' not found — skipping source."
602
+ # skip to next source adapter
603
+ else
604
+
605
+ # Resolve column ids
606
+ cols=$(monotask column list "$board_id" --json)
607
+ todo_col=$(echo "$cols" | jq -r --arg t "$_mt_col" '.[] | select(.title==$t) | .id' | head -1)
608
+ doing_col=$(echo "$cols" | jq -r '.[] | select(.title=="Doing" or .title=="In Progress") | .id' | head -1)
609
+ done_col=$(echo "$cols" | jq -r '.[] | select(.title=="Done") | .id' | head -1)
610
+
611
+ # Poll: unclaimed cards in todo column with matching label
612
+ cards=$(monotask card list "$board_id" --col "$todo_col" --label "$_mt_label" --json \
613
+ | jq '[.[] | select((.labels // []) | index("claimed") | not)]')
614
+ ```
615
+
616
+ Iterate over cards with an explicit bash loop (same pattern as the filesystem adapter):
617
+
618
+ ```bash
619
+ while IFS= read -r card; do
620
+ [ -z "$card" ] && continue
621
+
622
+ card_id=$(echo "$card" | jq -r '.id')
623
+ card_title=$(echo "$card" | jq -r '.title')
624
+ ```
625
+
626
+ 1. Check dedup:
627
+ ```bash
628
+ already_processed=$(jq -r --arg id "monotask:${card_id}" '.processed_ids[$id] // empty' "$state_file")
629
+ [ -n "$already_processed" ] && continue
630
+ ```
631
+
632
+ **Build task object** (includes board/column IDs so Step 4 can re-extract them from `task_json` without relying on polling-scope shell vars):
633
+ ```bash
634
+ task_json=$(jq -cn \
635
+ --arg eid "$card_id" \
636
+ --arg title "$card_title" \
637
+ --arg board "$board_id" \
638
+ --arg doing "$doing_col" \
639
+ --arg done "$done_col" \
640
+ '{
641
+ "source_type": "monotask",
642
+ "external_id": $eid,
643
+ "title": $title,
644
+ "description": "",
645
+ "board_id": $board,
646
+ "doing_col": $doing,
647
+ "done_col": $done
648
+ }')
649
+ ```
650
+
651
+ **Claim:**
652
+ ```bash
653
+ monotask card move "$board_id" "$card_id" "$doing_col" --json
654
+ monotask card label add "$board_id" "$card_id" "claimed" --json
655
+ monotask card label add "$board_id" "$card_id" "monitor:in-progress" --json
656
+ ```
657
+
658
+ **Progress comment:**
659
+ ```bash
660
+ monotask card comment add "$board_id" "$card_id" \
661
+ "[Monitor: ${name}] Claimed by AI agent. Executing with \`${agent_type}\`. Started: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
662
+
663
+ task_claimed=true
664
+ break 2 # break 2: exit per-card loop AND outer source loop
665
+ done < <(echo "$cards" | jq -c '.[]') # close per-card loop
666
+ fi # close 'if [ -z "$board_id" ]' else block
667
+ ;; # end monotask case branch
668
+ ```
669
+
670
+ ---
671
+
672
+ #### Source Adapter: Filesystem
673
+
674
+ ```bash
675
+ # Extract filter fields from $src
676
+ folder=$(echo "$src" | jq -r '.filter.folder // "./tasks"')
677
+
678
+ if [ ! -d "$folder" ]; then
679
+ echo "[filesystem] Folder '$folder' not found — skipping source."
680
+ # skip to next source adapter
681
+ else
682
+
683
+ # Poll: find unclaimed .task files
684
+ tasks=$(find "$folder" -name "*.task" -not -name "*.claimed" -not -name "*.done" -not -name "*.failed" 2>/dev/null)
685
+
686
+ # Iterate — stop after first claimable file
687
+ while IFS= read -r task_file; do
688
+ [ -z "$task_file" ] && continue
689
+ ```
690
+
691
+ (All per-file steps are inside this while loop; close with `done <<< "$tasks"` after the claim. Then close the `else` block with `fi`.)
692
+
693
+ For each `.task` file (`task_file` = absolute path):
694
+ 1. Check dedup:
695
+ ```bash
696
+ already_processed=$(jq -r --arg id "filesystem:${task_file}" '.processed_ids[$id] // empty' "$state_file")
697
+ [ -n "$already_processed" ] && continue
698
+ ```
699
+ 2. Read task content:
700
+ ```bash
701
+ task_title=$(head -1 "$task_file")
702
+ task_description=$(tail -n +2 "$task_file")
703
+ task_base="${task_file%.task}"
704
+ task_json=$(jq -cn \
705
+ --arg title "$task_title" \
706
+ --arg description "$task_description" \
707
+ --arg eid "$task_file" \
708
+ --arg base_path "$task_base" \
709
+ '{
710
+ "source_type": "filesystem",
711
+ "external_id": $eid,
712
+ "title": $title,
713
+ "description": $description,
714
+ "base_path": $base_path
715
+ }')
716
+ ```
717
+
718
+ **Claim:**
719
+ ```bash
720
+ mv "${task_file}" "${task_base}.task.claimed"
721
+ ```
722
+
723
+ **Progress log:**
724
+ ```bash
725
+ echo "[Monitor: ${name}] $(date -u +%Y-%m-%dT%H:%M:%SZ) — Claimed. Executing with ${agent_type}." \
726
+ >> "${task_base}.task.log"
727
+ ```
728
+
729
+ ```bash
730
+ task_claimed=true
731
+ break 2 # break 2: exit per-file loop AND outer source loop
732
+ done <<< "$tasks" # close per-file while loop (reached only if no file was claimed)
733
+ fi # close 'if [ ! -d "$folder" ]' else block
734
+ ;; # end filesystem case branch
735
+ esac
736
+ done < <(jq -c '.sources[]' "$cfg")
737
+ fi # close 'if [ -z "$retry_task" ]' guard
738
+ # After: if $task_claimed=true, proceed to "After claim"; otherwise skip to Step 5
739
+ ```
740
+
741
+ **Stage files:** `.task` → `.task.claimed` → `.task.done` or `.task.failed`
742
+
743
+ ---
744
+
745
+ #### After a task is claimed (all sources — common code)
746
+
747
+ This section runs ONLY when `task_claimed = true` (a task was found and claimed in the source loop above). The retry path sets `task_json=$retry_task` and jumps directly to Step 3, bypassing this section entirely.
748
+
749
+ ```bash
750
+ if [ "$task_claimed" = "true" ]; then
751
+ ```
752
+
753
+ Each source adapter MUST have set `task_json` to a complete JSON object before `task_claimed=true`. The full task object includes all fields used by Step 3 and Step 4 for that source type.
754
+
755
+ **Extract task metadata (must run first, before all blocks below):**
756
+ ```bash
757
+ task_source_type=$(echo "$task_json" | jq -r '.source_type')
758
+ task_external_id=$(echo "$task_json" | jq -r '.external_id')
759
+ task_title=$(echo "$task_json" | jq -r '.title')
760
+ ```
761
+
762
+ **Register in state as in-flight** (stores full task object for retry replay):
763
+ ```bash
764
+ tmp="${state_file}.tmp"
765
+ jq --argjson task "$task_json" \
766
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
767
+ '.in_flight += [$task + {"claimed_at": $ts, "retry_count": 0}]' \
768
+ "$state_file" > "$tmp" && mv "$tmp" "$state_file"
769
+ ```
770
+
771
+ **Mark as processed in dedup index:**
772
+ ```bash
773
+ tmp="${state_file}.tmp"
774
+ jq --arg key "${task_source_type}:${task_external_id}" \
775
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
776
+ '.processed_ids[$key] = {status: "in_flight", claimed_at: $ts, retry_count: 0}' \
777
+ "$state_file" > "$tmp" && mv "$tmp" "$state_file"
778
+ ```
779
+
780
+ **Increment stats:**
781
+ ```bash
782
+ tmp="${cfg}.tmp"
783
+ jq '.stats.total_claimed += 1' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
784
+ fi # end 'if [ "$task_claimed" = "true" ]'
785
+ ```
786
+
787
+ ---
788
+
789
+ ## Step 3 — Execute the Task
790
+
791
+ **Guard:** Only execute Step 3 and Step 4 when a task was actually claimed or retried. On idle ticks (no task available, no retry pending), skip directly to Step 5.
792
+
793
+ ```bash
794
+ if [ -n "$task_json" ]; then
795
+ ```
796
+
797
+ Spawn a Task agent to do the actual work. This runs **synchronously** (`run_in_background: false`) so we can capture the result and update status.
798
+
799
+ The return value of the `Task` tool call is the agent's full text output. Capture it into `agent_output`:
800
+
801
+ ```javascript
802
+ agent_output = Task({
803
+ subagent_type: agent_type, // from monitor config
804
+ description: `Monitor "${name}" executing: ${task.title}`,
805
+ run_in_background: false,
806
+ prompt: `You are an AI agent executing a task claimed by the Mastermind Monitor "${name}".
807
+
808
+ TASK: ${task.title}
809
+
810
+ DESCRIPTION:
811
+ ${task.description || "(no description provided)"}
812
+
813
+ SOURCE: ${task.source_type} | ID: ${task.external_id}
814
+ URL: ${task.url || "n/a"}
815
+
816
+ INSTRUCTIONS:
817
+ 1. Understand the task from the title and description above.
818
+ 2. Execute the task fully and completely using available tools.
819
+ 3. For code tasks: read relevant files, make changes, run tests if possible.
820
+ 4. For research tasks: gather information, synthesize findings.
821
+ 5. Produce a clear result summary at the end.
822
+
823
+ OUTPUT FORMAT:
824
+ At the end of your work, output a JSON block EXACTLY like this:
825
+ \`\`\`json
826
+ {
827
+ "status": "done|failed",
828
+ "summary": "one paragraph summary of what was accomplished",
829
+ "artifacts": ["list of files changed or created"],
830
+ "next_actions": ["any follow-up suggestions"]
831
+ }
832
+ \`\`\`
833
+
834
+ If you encounter an error you cannot recover from, set status to "failed" and explain in summary.`
835
+ })
836
+ ```
837
+
838
+ `agent_output` is the full text returned by the Task call. Parse the JSON block and extract the task metadata for use in Step 4. The metadata vars (`task_source_type`, `task_external_id`, `task_title`) are already set by the "After claim" common code for new tasks, and by the retry branch for retried tasks — this block re-extracts them from `$task_json` as a safety guard:
839
+
840
+ ```bash
841
+ # Task metadata (safe re-extract — already set by claim/retry path)
842
+ task_source_type=$(echo "$task_json" | jq -r '.source_type')
843
+ task_external_id=$(echo "$task_json" | jq -r '.external_id')
844
+ task_title=$(echo "$task_json" | jq -r '.title')
845
+
846
+ # Parse result JSON block from agent_output (all three fields in one python pass)
847
+ _result_json=$(echo "$agent_output" | python3 -c "
848
+ import sys, re, json
849
+ m = re.search(r'\`\`\`json\s*(\{.*?\})\s*\`\`\`', sys.stdin.read(), re.DOTALL)
850
+ print(m.group(1) if m else '{}')" 2>/dev/null || echo '{}')
851
+
852
+ result_status=$(echo "$_result_json" | jq -r '.status // "failed"')
853
+ result_summary=$(echo "$_result_json" | jq -r '.summary // "(no summary)"')
854
+ result_artifacts=$(echo "$_result_json" | jq -r '(.artifacts // []) | join(", ")')
855
+ result_next_actions=$(echo "$_result_json" | jq -r '(.next_actions // []) | join(", ")')
856
+ ```
857
+
858
+ ---
859
+
860
+ ## Step 4 — Handle Result
861
+
862
+ Branch on `result_status`:
863
+
864
+ ```bash
865
+ if [ "$result_status" = "done" ]; then
866
+ ```
867
+
868
+ ### On success (`status: "done"`)
869
+
870
+ **Post completion comment/update per source** — dispatch on `$task_source_type`:
871
+
872
+ **Linear** (MCP tool calls — not bash; load Linear MCP schema via ToolSearch first): for `task_source_type=linear`:
873
+
874
+ Call `mcp__claude_ai_Linear__save_comment` with:
875
+ - `issueId`: `$task_external_id`
876
+ - `body`: pass the following as a real multi-line string (use actual newline characters — do NOT use `\n` escape sequences per the Linear MCP server requirement):
877
+
878
+ ```
879
+ [Monitor: ${name}] ✓ Task complete.
880
+
881
+ Summary: ${result_summary}
882
+
883
+ Artifacts: ${result_artifacts}
884
+ Next actions: ${result_next_actions}
885
+
886
+ Completed: $(date -u +%Y-%m-%dT%H:%M:%SZ) | Agent: ${agent_type}
887
+ ```
888
+
889
+ Then call `mcp__claude_ai_Linear__save_issue` with:
890
+ - `issueId`: `$task_external_id`
891
+ - `stateId`: resolved ID for state name "Done" in this team
892
+
893
+ **GitHub, Monotask, Filesystem** — dispatched via `case`:
894
+ ```bash
895
+ case "$task_source_type" in
896
+ github)
897
+ task_repo=$(echo "$task_json" | jq -r '.repo')
898
+ gh issue comment "$task_external_id" --repo "$task_repo" --body \
899
+ "[Monitor: ${name}] Task complete.
900
+
901
+ Summary: ${result_summary}
902
+
903
+ Artifacts: ${result_artifacts}
904
+ Next actions: ${result_next_actions}
905
+ Completed: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
906
+
907
+ gh issue edit "$task_external_id" --repo "$task_repo" \
908
+ --remove-label "monitor:in-progress" \
909
+ --add-label "monitor:done"
910
+
911
+ # If it was a "complete this task" issue, close it:
912
+ # gh issue close "$task_external_id" --repo "$task_repo" --comment "Closed by monitor after completion."
913
+ ;;
914
+ monotask)
915
+ # Re-extract from task_json — polling-scope vars are not reliable on retry path
916
+ board_id=$(echo "$task_json" | jq -r '.board_id')
917
+ card_id=$(echo "$task_json" | jq -r '.external_id')
918
+ done_col=$(echo "$task_json" | jq -r '.done_col')
919
+
920
+ monotask card comment add "$board_id" "$card_id" \
921
+ "[Monitor: ${name}] Task complete.
922
+
923
+ Summary: ${result_summary}
924
+
925
+ Artifacts: ${result_artifacts}
926
+ Next actions: ${result_next_actions}
927
+ Completed: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
928
+
929
+ monotask card move "$board_id" "$card_id" "$done_col" --json
930
+ monotask card label remove "$board_id" "$card_id" "monitor:in-progress" --json
931
+ monotask card label add "$board_id" "$card_id" "monitor:done" --json
932
+ ;;
933
+ filesystem)
934
+ task_base=$(echo "$task_json" | jq -r '.base_path')
935
+ echo "[Monitor: ${name}] $(date -u +%Y-%m-%dT%H:%M:%SZ) — DONE." >> "${task_base}.task.log"
936
+ echo "Summary: ${result_summary}" >> "${task_base}.task.log"
937
+ echo "Artifacts: ${result_artifacts}" >> "${task_base}.task.log"
938
+ echo "Next actions: ${result_next_actions}" >> "${task_base}.task.log"
939
+ mv "${task_base}.task.claimed" "${task_base}.task.done"
940
+ ;;
941
+ esac # end task_source_type dispatch (success path)
942
+ ```
943
+
944
+ **Update state — remove from in-flight, mark done:**
945
+ ```bash
946
+ tmp="${state_file}.tmp"
947
+ jq --arg key "${task_source_type}:${task_external_id}" \
948
+ --arg eid "$task_external_id" \
949
+ --arg stype "$task_source_type" \
950
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
951
+ '(.processed_ids[$key].status = "done") |
952
+ (.processed_ids[$key].done_at = $ts) |
953
+ (.in_flight = [.in_flight[] | select(.external_id != $eid or .source_type != $stype)])' \
954
+ "$state_file" > "$tmp" && mv "$tmp" "$state_file"
955
+
956
+ tmp="${cfg}.tmp"
957
+ jq '.stats.done += 1' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
958
+ ```
959
+
960
+ **Emit dashboard event:**
961
+ ```bash
962
+ curl -s -o /dev/null -X POST "${CTRL_URL}/api/mastermind/event" \
963
+ -H "Content-Type: application/json" \
964
+ -d "$(jq -cn --arg sid "$SESSION_ID" --arg name "$name" --arg title "$task_title" \
965
+ '{type:"monitor:task:done",session:$sid,monitor:$name,task:$title,ts:(now*1000|floor)}')" || true
966
+ ```
967
+
968
+ ```bash
969
+ else # result_status != "done"
970
+ ```
971
+
972
+ ### On failure
973
+
974
+ **Retry logic (max 3 attempts):**
975
+
976
+ ```bash
977
+ retry_count=$(jq -r --arg key "${task_source_type}:${task_external_id}" \
978
+ '.processed_ids[$key].retry_count // 0' "$state_file")
979
+
980
+ if [ "$retry_count" -lt 3 ]; then
981
+ new_retry=$((retry_count + 1))
982
+ echo "[$name] Task failed (attempt $new_retry/3). Will retry next tick."
983
+
984
+ # Update retry count in state
985
+ tmp="${state_file}.tmp"
986
+ jq --arg key "${task_source_type}:${task_external_id}" \
987
+ --arg eid "$task_external_id" \
988
+ --arg stype "$task_source_type" \
989
+ --argjson r "$new_retry" \
990
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
991
+ '(.processed_ids[$key].retry_count = $r) |
992
+ (.processed_ids[$key].last_failure = $ts) |
993
+ (.processed_ids[$key].status = "retry_pending") |
994
+ (.in_flight = [.in_flight[] |
995
+ if (.external_id == $eid and .source_type == $stype)
996
+ then .retry_count = $r | .last_failure = $ts
997
+ else . end])' \
998
+ "$state_file" > "$tmp" && mv "$tmp" "$state_file"
999
+
1000
+ tmp="${cfg}.tmp"
1001
+ jq '.stats.retried += 1' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
1002
+
1003
+ # Post retry comment per source
1004
+ _retry_msg="[Monitor: ${name}] Task failed (attempt ${new_retry}/3). Will retry next tick. Error: ${result_summary}"
1005
+ # Linear (MCP — not bash): call mcp__claude_ai_Linear__save_comment with
1006
+ # issueId=$task_external_id body="$_retry_msg"
1007
+ # Then call mcp__claude_ai_Linear__save_issue to set state back to "In Progress".
1008
+
1009
+ case "$task_source_type" in
1010
+ github)
1011
+ task_repo=$(echo "$task_json" | jq -r '.repo')
1012
+ gh issue comment "$task_external_id" --repo "$task_repo" --body "$_retry_msg" 2>/dev/null || true
1013
+ ;;
1014
+ monotask)
1015
+ _board=$(echo "$task_json" | jq -r '.board_id')
1016
+ monotask card comment add "$_board" "$task_external_id" "$_retry_msg" 2>/dev/null || true
1017
+ ;;
1018
+ filesystem)
1019
+ _base=$(echo "$task_json" | jq -r '.base_path')
1020
+ echo "[Monitor: ${name}] $(date -u +%Y-%m-%dT%H:%M:%SZ) — RETRY ${new_retry}/3. ${result_summary}" >> "${_base}.task.log"
1021
+ ;;
1022
+ esac
1023
+
1024
+ else
1025
+ echo "[$name] Task failed after 3 attempts. Marking as failed."
1026
+
1027
+ # Post final failure comment and set external status per source
1028
+ _fail_msg="[Monitor: ${name}] Task failed after 3 attempts. Manual intervention required.
1029
+ Error: ${result_summary}
1030
+ Last attempt: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
1031
+
1032
+ # Linear (MCP — not bash): call mcp__claude_ai_Linear__save_comment with
1033
+ # issueId=$task_external_id body="$_fail_msg"
1034
+ # Then call mcp__claude_ai_Linear__save_issue to:
1035
+ # - remove labels "monitor:claimed" and "monitor:in-progress"
1036
+ # - add label "monitor:failed"
1037
+ # - set state to "Cancelled" or "Blocked" as appropriate for the team.
1038
+
1039
+ case "$task_source_type" in
1040
+ github)
1041
+ task_repo=$(echo "$task_json" | jq -r '.repo')
1042
+ gh issue comment "$task_external_id" --repo "$task_repo" --body "$_fail_msg" 2>/dev/null || true
1043
+ gh issue edit "$task_external_id" --repo "$task_repo" \
1044
+ --add-label "monitor:failed" --remove-label "monitor:in-progress" 2>/dev/null || true
1045
+ ;;
1046
+ monotask)
1047
+ _board=$(echo "$task_json" | jq -r '.board_id')
1048
+ monotask card comment add "$_board" "$task_external_id" "$_fail_msg" 2>/dev/null || true
1049
+ monotask card label remove "$_board" "$task_external_id" "monitor:in-progress" 2>/dev/null || true
1050
+ monotask card label add "$_board" "$task_external_id" "monitor:failed" 2>/dev/null || true
1051
+ ;;
1052
+ filesystem)
1053
+ _base=$(echo "$task_json" | jq -r '.base_path')
1054
+ echo "[Monitor: ${name}] $(date -u +%Y-%m-%dT%H:%M:%SZ) — FAILED after 3 attempts. ${result_summary}" >> "${_base}.task.log"
1055
+ mv "${_base}.task.claimed" "${_base}.task.failed" 2>/dev/null || true
1056
+ ;;
1057
+ esac
1058
+
1059
+ # Update state
1060
+ tmp="${state_file}.tmp"
1061
+ jq --arg key "${task_source_type}:${task_external_id}" \
1062
+ --arg eid "$task_external_id" \
1063
+ --arg stype "$task_source_type" \
1064
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
1065
+ '(.processed_ids[$key].status = "failed") |
1066
+ (.processed_ids[$key].failed_at = $ts) |
1067
+ (.in_flight = [.in_flight[] | select(.external_id != $eid or .source_type != $stype)])' \
1068
+ "$state_file" > "$tmp" && mv "$tmp" "$state_file"
1069
+
1070
+ tmp="${cfg}.tmp"
1071
+ jq '.stats.failed += 1' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
1072
+ fi # end permanent-fail branch (if retry_count -lt 3 ... else ... fi)
1073
+ fi # end result_status branch (if result_status = "done" ... else ... fi)
1074
+
1075
+ fi # end task guard (if [ -n "$task_json" ]; only runs Steps 3-4 when a task was claimed or retried)
1076
+ ```
1077
+
1078
+ ---
1079
+
1080
+ ## Step 5 — Reschedule Next Tick
1081
+
1082
+ Always the last step, regardless of whether a task was found/executed.
1083
+
1084
+ Call `ScheduleWakeup` with:
1085
+ - `delaySeconds`: value of `poll_interval` from config (default 120)
1086
+ - `prompt`: `/mastermind:monitor --action tick --name <name>`
1087
+ - `reason`: `Monitor <name> polling every <poll_interval>s (<N sources>)`
1088
+
1089
+ This is what makes the monitor run forever. The only way to stop it is to set `status=stopped` or `status=paused` — the tick checks this at the top of Step 2.
1090
+
1091
+ ---
1092
+
1093
+ ## Step 6 — Return Output
1094
+
1095
+ ```yaml
1096
+ domain: ops
1097
+ status: complete
1098
+ action: <action>
1099
+ monitor: <name>
1100
+ tasks_this_tick: <0 or 1>
1101
+ task_result: <done|failed|retry_pending|none>
1102
+ next_tick_in: <poll_interval>s
1103
+ state_file: .monomind/monitor/<name>-state.json
1104
+ run_id: <SESSION_ID>
1105
+ ```
1106
+
1107
+ ---
1108
+
1109
+ ## Step 7 — Brain Write (standalone only)
1110
+
1111
+ If `caller` is not "command", follow `_protocol.md` Brain Write Procedure for domain `ops`.
1112
+
1113
+ ---
1114
+
1115
+ ## Configuration File Schemas
1116
+
1117
+ ### `.monomind/monitor/<name>.json`
1118
+
1119
+ ```json
1120
+ {
1121
+ "name": "dev-agent",
1122
+ "status": "active",
1123
+ "poll_interval": 120,
1124
+ "agent_type": "coder",
1125
+ "max_concurrent": 1,
1126
+ "sources": [
1127
+ {
1128
+ "type": "linear",
1129
+ "filter": {
1130
+ "team": "ENG",
1131
+ "assignees": ["morteza@agent-f.com"],
1132
+ "states": ["Todo"],
1133
+ "labels": ["ai-agent"],
1134
+ "project": ""
1135
+ }
1136
+ },
1137
+ {
1138
+ "type": "github",
1139
+ "filter": {
1140
+ "repo": "nokhodian/monomind",
1141
+ "assignee": "nokhodian",
1142
+ "labels": ["ai-agent"],
1143
+ "state": "open",
1144
+ "type": "issue"
1145
+ }
1146
+ },
1147
+ {
1148
+ "type": "monotask",
1149
+ "filter": {
1150
+ "board": "monomind-tasks-dev",
1151
+ "column": "Todo",
1152
+ "label": "role:ai-agent"
1153
+ }
1154
+ },
1155
+ {
1156
+ "type": "filesystem",
1157
+ "filter": {
1158
+ "folder": "./tasks",
1159
+ "glob": "*.task",
1160
+ "user": ""
1161
+ }
1162
+ }
1163
+ ],
1164
+ "stats": {
1165
+ "done": 0,
1166
+ "failed": 0,
1167
+ "retried": 0,
1168
+ "total_claimed": 0
1169
+ },
1170
+ "created_at": "2026-05-18T00:00:00Z",
1171
+ "last_tick": null
1172
+ }
1173
+ ```
1174
+
1175
+ ### `.monomind/monitor/<name>-state.json`
1176
+
1177
+ ```json
1178
+ {
1179
+ "processed_ids": {
1180
+ "linear:LIN-123": {
1181
+ "status": "done",
1182
+ "claimed_at": "2026-05-18T10:00:00Z",
1183
+ "done_at": "2026-05-18T10:05:00Z",
1184
+ "retry_count": 0
1185
+ },
1186
+ "github:456": {
1187
+ "status": "failed",
1188
+ "claimed_at": "2026-05-18T09:00:00Z",
1189
+ "failed_at": "2026-05-18T09:20:00Z",
1190
+ "retry_count": 3
1191
+ }
1192
+ },
1193
+ "in_flight": [
1194
+ {
1195
+ "source_type": "monotask",
1196
+ "external_id": "abc-uuid",
1197
+ "title": "Fix auth bug",
1198
+ "description": "",
1199
+ "board_id": "board-uuid-here",
1200
+ "doing_col": "doing-col-uuid",
1201
+ "done_col": "done-col-uuid",
1202
+ "claimed_at": "2026-05-18T10:10:00Z",
1203
+ "retry_count": 1,
1204
+ "last_failure": "2026-05-18T10:12:00Z"
1205
+ }
1206
+ ]
1207
+ }
1208
+ ```
1209
+
1210
+ ---
1211
+
1212
+ ## Quick Reference — Common Commands
1213
+
1214
+ ```bash
1215
+ # Create a monitor watching GitHub issues assigned to you
1216
+ /mastermind:monitor --action start --name dev-watcher \
1217
+ --source github --project nokhodian/monomind --user nokhodian \
1218
+ --label ai-agent --agent coder --interval 120
1219
+
1220
+ # Add a Linear source to an existing monitor
1221
+ /mastermind:monitor --action add-source --name dev-watcher \
1222
+ --source linear --team ENG --user morteza@agent-f.com \
1223
+ --state Todo --label ai-agent
1224
+
1225
+ # Add a monotask board source
1226
+ /mastermind:monitor --action add-source --name dev-watcher \
1227
+ --source monotask --project monomind-tasks-dev \
1228
+ --state Todo --label role:ai-agent
1229
+
1230
+ # Add a filesystem folder source
1231
+ /mastermind:monitor --action add-source --name dev-watcher \
1232
+ --source filesystem --folder ./tasks
1233
+
1234
+ # Check status
1235
+ /mastermind:monitor --action status --name dev-watcher
1236
+
1237
+ # List all monitors
1238
+ /mastermind:monitor
1239
+
1240
+ # Pause (stops auto-rescheduling after current tick)
1241
+ /mastermind:monitor --action pause --name dev-watcher
1242
+
1243
+ # Resume (re-enters the tick loop)
1244
+ /mastermind:monitor --action resume --name dev-watcher
1245
+
1246
+ # Stop permanently
1247
+ /mastermind:monitor --action stop --name dev-watcher
1248
+ ```
1249
+
1250
+ ---
1251
+
1252
+ ## GitHub Label Bootstrap
1253
+
1254
+ Run this once, inline in the GitHub claim path, immediately before the first `gh issue edit` call. At claim time, `$_gh_repo` is in scope (extracted from `$src` at the top of the GitHub adapter). Guard with a check so it only runs when the `monitor:claimed` label is absent:
1255
+
1256
+ ```bash
1257
+ # Bootstrap monitor labels on every claim — --force makes each call idempotent (no-ops if label already exists)
1258
+ # Running unconditionally is safe: 5 lightweight API calls, prevents gaps if any label was deleted externally
1259
+ for label in "monitor:claimed" "monitor:in-progress" "monitor:review" "monitor:done" "monitor:failed"; do
1260
+ # shasum is available on both macOS and Linux; md5sum is Linux-only
1261
+ color=$(printf '%s' "$label" | shasum | cut -c1-6)
1262
+ gh label create "$label" --repo "$_gh_repo" --color "$color" --force 2>/dev/null || true
1263
+ done
1264
+ ```
1265
+
1266
+ ---
1267
+
1268
+ ## Important Behavioral Notes
1269
+
1270
+ **`poll_interval` is idle gap, not heartbeat.** Because `tick` waits synchronously for the agent to finish before rescheduling (Step 3 uses `run_in_background: false`), `poll_interval` is the *minimum idle time between task claims* — not a wall-clock polling frequency. A user who sets `--interval 60` and has tasks that take 10 minutes will get one task claimed every ~10 minutes, not every 60 seconds.
1271
+
1272
+ **`max_concurrent` is a per-tick claim limit.** Since the default is 1 and the tick blocks on the agent, this means at most one task runs per cycle. Increasing it allows the monitor to claim and spawn multiple tasks per tick — but all still block the tick sequentially unless the Task calls use `run_in_background: true` (which requires manual result tracking).
1273
+
1274
+ **Retry-pending tasks are prioritized over new claims.** On each tick, the monitor first checks `in_flight` for any `retry_pending` entries and re-executes them before polling sources for new work. This ensures stuck tasks don't accumulate.
1275
+
1276
+ ---
1277
+
1278
+ ## Notes on Filter Precedence
1279
+
1280
+ When multiple users or states are specified (via repeated `--user` or `--state` flags), the monitor ORs them:
1281
+ - `--user alice --user bob` → fetch tasks assigned to alice OR bob
1282
+ - `--state Todo --state "In Progress"` → fetch tasks in either state
1283
+
1284
+ The `--label` flag is ANDed with user/state: only tasks matching the label AND the user/state filter are claimed.