@mgeri1993/claude-task-manager 1.0.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/engine/task.sh ADDED
@@ -0,0 +1,926 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # task.sh — token-efficient CLI for managing the task-manager tasks.json (and context.json).
4
+ #
5
+ # Goal: Claude Code should NOT re-read/re-write the whole JSON for every operation (expensive
6
+ # in tokens); instead it should use terse commands. Every mutation is atomic (temp file + mv),
7
+ # and timestamps + history/notes entries are maintained automatically.
8
+ #
9
+ # Usage: ./task.sh <command> [arguments]
10
+ # Help: ./task.sh help
11
+ #
12
+ # The store defaults to the project's .claude/task-manager/ directory; overridable via the
13
+ # TM_DIR environment variable (this is how the claude-task-manager wrapper scripts point each
14
+ # project at its own data directory).
15
+
16
+ set -euo pipefail
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Configuration / paths
20
+ # ---------------------------------------------------------------------------
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+
24
+ # Project root: two levels up from the skill directory (.claude/skills/task-manager -> project).
25
+ # Uses git if available, otherwise falls back to the relative path.
26
+ default_dir() {
27
+ local root
28
+ if root="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then
29
+ echo "$root/.claude/task-manager"
30
+ else
31
+ echo "$SCRIPT_DIR/../../task-manager"
32
+ fi
33
+ }
34
+
35
+ TM_DIR="${TM_DIR:-$(default_dir)}"
36
+ TASKS_FILE="$TM_DIR/tasks.json"
37
+ CONTEXT_FILE="$TM_DIR/context.json"
38
+ BACKUP_FILE="$TM_DIR/tasks.json.bak"
39
+ CTX_BACKUP_FILE="$TM_DIR/context.json.bak"
40
+ LOCK_DIR="$TM_DIR/.tm.lock"
41
+
42
+ # --- Agent-notification (inbox) storage ----------------------------------------
43
+ # events.jsonl: an append-only event feed (ALONGSIDE the per-task history, not replacing it),
44
+ # from which the `inbox <agent>` command returns only NEW events generated by OTHER agents,
45
+ # using a per-agent cursor. The PostToolUse hook injects this for the caller automatically.
46
+ # This is ADDITIVE: it does not change the existing tasks.json/history logic.
47
+ EVENTS_FILE="$TM_DIR/events.jsonl"
48
+ CURSORS_DIR="$TM_DIR/.cursors"
49
+
50
+ VALID_STATUSES="todo in_progress blocked review done"
51
+ VALID_PRIORITIES="low normal high urgent"
52
+
53
+ # The calling agent's name (--as <name>); main() fills this in from the command arguments.
54
+ # emit_event writes this as `by`; inbox uses it to filter out the caller's own echo.
55
+ ACTOR=""
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Helper functions
59
+ # ---------------------------------------------------------------------------
60
+
61
+ die() { echo "error: $*" >&2; exit 1; }
62
+
63
+ command -v jq >/dev/null 2>&1 || die "jq is not installed (required for this script)."
64
+
65
+ now_iso() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
66
+
67
+ # --- Concurrency lock (mkdir-based, portable) --------------------------------
68
+ # macOS has no built-in flock(1), so we use an atomic mkdir-lock (same pattern as the
69
+ # agent-message-bus skill). This means two concurrently-writing agents don't clobber each
70
+ # other (last-write-wins is eliminated). The lock is cleaned up by a trap if the process
71
+ # exits early.
72
+ LOCK_HELD=0
73
+
74
+ # Determine the lock's age in seconds (based on dir mtime), portably (BSD/macOS
75
+ # `stat -f %m`, GNU/Linux `stat -c %Y`). Empty if there's no lock.
76
+ lock_age() {
77
+ local m now
78
+ m="$(stat -f %m "$LOCK_DIR" 2>/dev/null || stat -c %Y "$LOCK_DIR" 2>/dev/null || echo "")"
79
+ [[ -z "$m" ]] && { echo ""; return; }
80
+ now="$(date +%s)"
81
+ echo $(( now - m ))
82
+ }
83
+
84
+ acquire_lock() {
85
+ local waited=0 timeout="${TM_LOCK_TIMEOUT:-15}" stale="${TM_LOCK_STALE:-60}"
86
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
87
+ # Remove an orphaned lock ONLY based on age (after a real crash). On the hot path
88
+ # (fast, healthy writers) this never runs, so an active lock is never removed, and two
89
+ # writers can never be in the critical section at once.
90
+ local age; age="$(lock_age)"
91
+ if [[ -n "$age" && "$age" -ge "$stale" ]]; then
92
+ rm -rf "$LOCK_DIR" 2>/dev/null || true
93
+ continue
94
+ fi
95
+ waited=$((waited + 1))
96
+ [[ $waited -ge $((timeout * 20)) ]] && die "could not acquire lock within ${timeout}s: $LOCK_DIR"
97
+ sleep 0.05
98
+ done
99
+ LOCK_HELD=1
100
+ trap release_lock EXIT INT TERM
101
+ }
102
+ release_lock() {
103
+ [[ "$LOCK_HELD" == "1" ]] && rm -rf "$LOCK_DIR" 2>/dev/null || true
104
+ LOCK_HELD=0
105
+ }
106
+
107
+ # --- Pre-write backup --------------------------------------------------------
108
+ # Before every mutation we save the previous state (one generation), so that `restore`
109
+ # can undo a broken write.
110
+ backup_tasks() { [[ -f "$TASKS_FILE" ]] && cp "$TASKS_FILE" "$BACKUP_FILE" 2>/dev/null || true; }
111
+ backup_context() { [[ -f "$CONTEXT_FILE" ]] && cp "$CONTEXT_FILE" "$CTX_BACKUP_FILE" 2>/dev/null || true; }
112
+
113
+ ensure_tasks_file() {
114
+ if [[ ! -f "$TASKS_FILE" ]]; then
115
+ mkdir -p "$TM_DIR"
116
+ jq -n --arg now "$(now_iso)" \
117
+ '{schemaVersion:1, updatedAt:$now, tasks:[]}' > "$TASKS_FILE"
118
+ fi
119
+ }
120
+
121
+ require_tasks_file() {
122
+ [[ -f "$TASKS_FILE" ]] || die "no tasks.json: $TASKS_FILE (run: task.sh init)"
123
+ }
124
+
125
+ is_valid_status() {
126
+ local s="$1"
127
+ for v in $VALID_STATUSES; do [[ "$s" == "$v" ]] && return 0; done
128
+ return 1
129
+ }
130
+
131
+ # Atomic write: applies a jq filter to TASKS_FILE via a temp file. The filter also updates
132
+ # the root updatedAt field.
133
+ apply_jq() {
134
+ acquire_lock
135
+ backup_tasks
136
+ local tmp
137
+ tmp="$(mktemp "${TASKS_FILE}.XXXXXX")"
138
+ if jq "$@" "$TASKS_FILE" > "$tmp"; then
139
+ mv "$tmp" "$TASKS_FILE"
140
+ else
141
+ rm -f "$tmp"
142
+ release_lock
143
+ die "jq operation failed."
144
+ fi
145
+ release_lock
146
+ }
147
+
148
+ task_exists() {
149
+ local id="$1"
150
+ [[ "$(jq --arg id "$id" '[.tasks[]|select(.id==$id)]|length' "$TASKS_FILE")" != "0" ]]
151
+ }
152
+
153
+ # --- Event feed (inbox) ----------------------------------------------------
154
+ # One line in events.jsonl: {seq, at, by, task, kind, text}. `seq` monotonically increases
155
+ # (the file's line count + 1, consistent under the lock). `by` is the calling agent (ACTOR).
156
+ # Only mutations call this; queries do not generate events. Swallows errors: notification is
157
+ # best-effort and should never fail a task operation.
158
+ emit_event() {
159
+ local task="$1" kind="$2" text="${3:-}"
160
+ [[ -n "$ACTOR" ]] || return 0
161
+ local now; now="$(now_iso)"
162
+ acquire_lock
163
+ mkdir -p "$TM_DIR" 2>/dev/null || true
164
+ touch "$EVENTS_FILE" 2>/dev/null || true
165
+ local seq; seq=$(( $(wc -l < "$EVENTS_FILE" 2>/dev/null || echo 0) + 1 ))
166
+ jq -cn --argjson seq "$seq" --arg at "$now" --arg by "$ACTOR" \
167
+ --arg task "$task" --arg kind "$kind" --arg text "$text" \
168
+ '{seq:$seq, at:$at, by:$by, task:$task, kind:$kind, text:$text}' \
169
+ >> "$EVENTS_FILE" 2>/dev/null || true
170
+ release_lock
171
+ }
172
+
173
+ # --- context.json helpers (same atomic pattern, separate file) ---------
174
+
175
+ ensure_context_file() {
176
+ if [[ ! -f "$CONTEXT_FILE" ]]; then
177
+ mkdir -p "$TM_DIR"
178
+ jq -n --arg now "$(now_iso)" '{
179
+ schemaVersion:1, updatedAt:$now, initPrompt:"", goal:"",
180
+ currentFocus:"", constraints:[], decisions:[], openQuestions:[], notes:""
181
+ }' > "$CONTEXT_FILE"
182
+ fi
183
+ }
184
+
185
+ require_context_file() {
186
+ [[ -f "$CONTEXT_FILE" ]] || die "no context.json: $CONTEXT_FILE (run: task.sh ctx-init)"
187
+ }
188
+
189
+ # Atomic write to CONTEXT_FILE (like apply_jq, just for the other file).
190
+ apply_ctx() {
191
+ acquire_lock
192
+ backup_context
193
+ local tmp
194
+ tmp="$(mktemp "${CONTEXT_FILE}.XXXXXX")"
195
+ if jq "$@" "$CONTEXT_FILE" > "$tmp"; then
196
+ mv "$tmp" "$CONTEXT_FILE"
197
+ else
198
+ rm -f "$tmp"
199
+ release_lock
200
+ die "jq operation failed (context)."
201
+ fi
202
+ release_lock
203
+ }
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Commands
207
+ # ---------------------------------------------------------------------------
208
+
209
+ cmd_help() {
210
+ cat <<'EOF'
211
+ task.sh — token-efficient tasks.json manager
212
+
213
+ STORAGE
214
+ Default: <project>/.claude/task-manager/tasks.json (override: TM_DIR env)
215
+
216
+ CALLER IDENTITY (--as) — REQUIRED for every non-meta command
217
+ Add to every call: --as <agent-name> (your name; the main agent: main).
218
+ This is how the PostToolUse hook knows who to inject fresh events (inbox) for, and it's
219
+ written as every event's `by`. Meta commands are exempt (no --as needed):
220
+ help, inbox, init, validate, restore, raw.
221
+ Example: task.sh status fix-login done --as backend-auth
222
+
223
+ NOTIFICATION (inbox — the hook calls this automatically, but you can too)
224
+ inbox <agent> Events generated by OTHER agents since <agent>'s cursor
225
+ (not its own), then advances the cursor.
226
+ SILENT if there's nothing new. The hook appends this as
227
+ additionalContext.
228
+
229
+ QUERIES (non-mutating, terse output)
230
+ list [status] [filters] Terse list: "<id> [status] (prio) @module title #tag".
231
+ Filters: --tag <t> --agent <a> --priority <p> --module <m>
232
+ --all (include archived) --json (machine output)
233
+ ids [status] Just the ids, one per line
234
+ get <id> ONE task's full JSON (not the whole file)
235
+ field <id> <field> The raw value of one field of one task
236
+ summary Count by status + total
237
+ find <text> Title/description search (case-insensitive), terse list
238
+ next Next recommended todo (no open dependency), by priority
239
+ deps <id> What this task waits on and what it blocks (dependency view)
240
+ history <id> One task's history entries, terse
241
+ validate Schema check (required fields, status, priority, broken
242
+ dependency, duplicate id)
243
+ raw The whole file (rarely needed — token-expensive)
244
+
245
+ MUTATIONS (atomic write under mkdir-lock; timestamp + history auto-maintained; pre-write backup)
246
+ init Create an empty tasks.json if missing
247
+ add <id> <title> [desc] New task (todo, priority=normal, tags=[], dependsOn=[])
248
+ status <id> <status> [note]
249
+ Status change + history entry (todo|in_progress|blocked|review|done)
250
+ status-many <status> <id...>
251
+ Set multiple tasks to one status, in ONE atomic write
252
+ reopen <id> [status] Reopen a closed/archived task (default: todo) with history
253
+ note <id> <text> Append a note to the notes array ({at,text})
254
+ priority <id> <low|normal|high|urgent>
255
+ Set priority
256
+ tag <id> <add|rm> <tag>
257
+ Add/remove a label (tags array, unique)
258
+ module <id> <module> Set the module/area label (assignedModule) — free text,
259
+ empty string clears it. Use `list --module <m>` to filter.
260
+ assign <id> <agent> Set assignedAgentId (more convenient than `set` for claiming)
261
+ dep <id> <add|rm> <other-id>
262
+ Dependency (dependsOn): 'id' waits on 'other-id'; cycle- and
263
+ existence-checked
264
+ set <id> <field> <json> Set an arbitrary field to a RAW JSON value
265
+ (e.g. set x assignedAgentId '"main"' or set x isInferred true)
266
+ archive <id> isArchived=true
267
+ unarchive <id> isArchived=false
268
+ restore Restore the latest pre-write backup (tasks.json.bak)
269
+ rm <id> Delete a task
270
+
271
+ CONTEXT (session continuity — write context.json ONLY through these commands too, never directly)
272
+ ctx Print the whole context.json (small, cheap)
273
+ ctx-init [init] [goal] Create context.json if missing (initPrompt, goal)
274
+ ctx-set <field> <json> Set a top-level field (goal|currentFocus|initPrompt|notes ...)
275
+ ctx-decision <topic> <decision> [rationale]
276
+ Append a decision to the decisions array (with timestamp)
277
+ ctx-constraint <text> Append a standing constraint to the constraints array
278
+ ctx-question <add|rm> <text>
279
+ Add / resolve an open question in the openQuestions array
280
+
281
+ STATUSES: todo, in_progress, blocked, review, done
282
+ PRIORITIES: low, normal, high, urgent
283
+
284
+ EXAMPLES (--as is required for every non-meta command)
285
+ task.sh list todo --priority high --as main
286
+ task.sh add fix-login "Login fix" "Login returns 500" --as main
287
+ task.sh priority fix-login urgent --as main
288
+ task.sh module fix-login auth --as main
289
+ task.sh list --module auth --as main
290
+ task.sh dep deploy add fix-login --as main # deploy waits on fix-login
291
+ task.sh next --as main # what should I do next?
292
+ task.sh status fix-login in_progress "started" --as backend-auth
293
+ task.sh status-many done fix-login deploy --as main
294
+ task.sh inbox backend-auth # (meta: no --as) fresh events
295
+ task.sh validate # (meta: no --as)
296
+ task.sh ctx-init "Port the legacy event editor" "Full port" --as main
297
+ task.sh ctx-constraint "Legacy: read-only, never click buttons" --as main
298
+ EOF
299
+ }
300
+
301
+ cmd_init() {
302
+ ensure_tasks_file
303
+ echo "done: $TASKS_FILE"
304
+ }
305
+
306
+ cmd_list() {
307
+ require_tasks_file
308
+ local status="" tag="" agent="" prio="" module="" show_all=0 as_json=0
309
+ while [[ $# -gt 0 ]]; do
310
+ case "$1" in
311
+ --tag) tag="${2:-}"; shift 2 ;;
312
+ --agent) agent="${2:-}"; shift 2 ;;
313
+ --priority|--prio) prio="${2:-}"; shift 2 ;;
314
+ --module|--mod) module="${2:-}"; shift 2 ;;
315
+ --all|--include-archived) show_all=1; shift ;;
316
+ --json) as_json=1; shift ;;
317
+ --*) die "unknown flag: $1" ;;
318
+ *) status="$1"; shift ;;
319
+ esac
320
+ done
321
+ [[ -n "$status" ]] && { is_valid_status "$status" || die "invalid status: $status (valid: $VALID_STATUSES)"; }
322
+ local flt='.tasks[]
323
+ | select(($all==1) or ((.isArchived // false)|not))
324
+ | select($status=="" or (.status==$status))
325
+ | select($tag=="" or (((.tags//[]) | index($tag)) != null))
326
+ | select($agent=="" or (.assignedAgentId==$agent))
327
+ | select($prio=="" or ((.priority//"normal")==$prio))
328
+ | select($module=="" or ((.module//"")==$module))'
329
+ if [[ $as_json == 1 ]]; then
330
+ jq --arg status "$status" --arg tag "$tag" --arg agent "$agent" --arg prio "$prio" --arg module "$module" --argjson all "$show_all" \
331
+ "[ $flt | {id, status, title, priority:(.priority//\"normal\"), module:(.module//\"\"), tags:(.tags//[]), assignedAgentId, dependsOn:(.dependsOn//[])} ]" \
332
+ "$TASKS_FILE"
333
+ else
334
+ jq -r --arg status "$status" --arg tag "$tag" --arg agent "$agent" --arg prio "$prio" --arg module "$module" --argjson all "$show_all" \
335
+ "$flt | \"\(.id)\t[\(.status)]\t\((.priority//\"normal\") | if .==\"normal\" then \"\" else \"(\"+.+\")\" end)\t\((.module//\"\") | if .==\"\" then \"\" else \"@\"+. end)\t\(.title)\t\((.tags//[]) | if length>0 then \"#\"+join(\" #\") else \"\" end)\"" \
336
+ "$TASKS_FILE" | column -t -s $'\t'
337
+ fi
338
+ }
339
+
340
+ cmd_ids() {
341
+ require_tasks_file
342
+ if [[ $# -ge 1 ]]; then
343
+ jq -r --arg s "$1" '.tasks[]|select(.status==$s)|.id' "$TASKS_FILE"
344
+ else
345
+ jq -r '.tasks[].id' "$TASKS_FILE"
346
+ fi
347
+ }
348
+
349
+ cmd_get() {
350
+ require_tasks_file
351
+ [[ $# -ge 1 ]] || die "usage: get <id>"
352
+ local id="$1"
353
+ task_exists "$id" || die "no such task: $id"
354
+ jq --arg id "$id" '.tasks[]|select(.id==$id)' "$TASKS_FILE"
355
+ }
356
+
357
+ cmd_field() {
358
+ require_tasks_file
359
+ [[ $# -ge 2 ]] || die "usage: field <id> <field>"
360
+ local id="$1" f="$2"
361
+ task_exists "$id" || die "no such task: $id"
362
+ jq -r --arg id "$id" --arg f "$f" '.tasks[]|select(.id==$id)|.[$f]' "$TASKS_FILE"
363
+ }
364
+
365
+ cmd_summary() {
366
+ require_tasks_file
367
+ jq -r '
368
+ (.tasks|group_by(.status)|map("\(.[0].status)\t\(length)")|.[]),
369
+ "─\t─",
370
+ "total\t\(.tasks|length)"
371
+ ' "$TASKS_FILE" | column -t -s $'\t'
372
+ }
373
+
374
+ cmd_find() {
375
+ require_tasks_file
376
+ [[ $# -ge 1 ]] || die "usage: find <text>"
377
+ local q="$1"
378
+ jq -r --arg q "$q" '
379
+ .tasks[]
380
+ | select((.title|ascii_downcase|contains($q|ascii_downcase))
381
+ or ((.description//"")|ascii_downcase|contains($q|ascii_downcase)))
382
+ | "\(.id)\t[\(.status)]\t\(.title)"
383
+ ' "$TASKS_FILE" | column -t -s $'\t'
384
+ }
385
+
386
+ cmd_history() {
387
+ require_tasks_file
388
+ [[ $# -ge 1 ]] || die "usage: history <id>"
389
+ local id="$1"
390
+ task_exists "$id" || die "no such task: $id"
391
+ jq -r --arg id "$id" '
392
+ .tasks[]|select(.id==$id)|.history[]
393
+ | "\(.at)\t\(.type)\t\(.fromStatus // "-") -> \(.toStatus // "-")\t\(.note // "")"
394
+ ' "$TASKS_FILE" | column -t -s $'\t'
395
+ }
396
+
397
+ cmd_raw() {
398
+ require_tasks_file
399
+ cat "$TASKS_FILE"
400
+ }
401
+
402
+ cmd_add() {
403
+ ensure_tasks_file
404
+ [[ $# -ge 2 ]] || die "usage: add <id> <title> [description]"
405
+ local id="$1" title="$2" desc="${3:-}"
406
+ task_exists "$id" && die "a task with this id already exists: $id"
407
+ local now; now="$(now_iso)"
408
+ apply_jq --arg id "$id" --arg title "$title" --arg desc "$desc" --arg now "$now" '
409
+ .updatedAt = $now
410
+ | .tasks += [{
411
+ id: $id,
412
+ title: $title,
413
+ description: $desc,
414
+ status: "todo",
415
+ priority: "normal",
416
+ module: "",
417
+ tags: [],
418
+ dependsOn: [],
419
+ source: "claude_code",
420
+ sourceEventId: null,
421
+ assignedAgentId: "main",
422
+ createdAt: $now,
423
+ updatedAt: $now,
424
+ playbookJobId: null,
425
+ runId: null,
426
+ channel: null,
427
+ externalThreadId: null,
428
+ lastActivityAt: $now,
429
+ notes: [],
430
+ isArchived: false,
431
+ isInferred: false,
432
+ history: [ { at: $now, type: "created", note: "Task created.", fromStatus: null, toStatus: "todo" } ]
433
+ }]'
434
+ emit_event "$id" "created" "$title"
435
+ echo "added: $id [todo]"
436
+ }
437
+
438
+ cmd_status() {
439
+ require_tasks_file
440
+ [[ $# -ge 2 ]] || die "usage: status <id> <status> [note]"
441
+ local id="$1" new="$2" note="${3:-}"
442
+ task_exists "$id" || die "no such task: $id"
443
+ is_valid_status "$new" || die "invalid status: $new (valid: $VALID_STATUSES)"
444
+ local now; now="$(now_iso)"
445
+ local notval="null"; [[ -n "$note" ]] && notval="\$note"
446
+ apply_jq --arg id "$id" --arg new "$new" --arg now "$now" --arg note "$note" "
447
+ .updatedAt = \$now
448
+ | (.tasks[] | select(.id==\$id)) |= (
449
+ .history += [ { at: \$now, type: \"status_changed\", note: $notval, fromStatus: .status, toStatus: \$new } ]
450
+ | .status = \$new
451
+ | .updatedAt = \$now
452
+ | .lastActivityAt = \$now
453
+ )"
454
+ emit_event "$id" "status:$new" "$note"
455
+ echo "status: $id -> $new"
456
+ }
457
+
458
+ cmd_note() {
459
+ require_tasks_file
460
+ [[ $# -ge 2 ]] || die "usage: note <id> <text>"
461
+ local id="$1" text="$2"
462
+ task_exists "$id" || die "no such task: $id"
463
+ local now; now="$(now_iso)"
464
+ apply_jq --arg id "$id" --arg text "$text" --arg now "$now" '
465
+ .updatedAt = $now
466
+ | (.tasks[] | select(.id==$id)) |= (
467
+ .notes += [ { at: $now, text: $text } ]
468
+ | .updatedAt = $now
469
+ | .lastActivityAt = $now
470
+ )'
471
+ emit_event "$id" "note" "$text"
472
+ echo "note appended: $id"
473
+ }
474
+
475
+ cmd_set() {
476
+ require_tasks_file
477
+ [[ $# -ge 3 ]] || die "usage: set <id> <field> <json-value>"
478
+ local id="$1" field="$2" value="$3"
479
+ task_exists "$id" || die "no such task: $id"
480
+ # Is the value valid JSON?
481
+ echo "$value" | jq -e . >/dev/null 2>&1 || die "value is not valid JSON: $value (quote strings: '\"text\"')"
482
+ local now; now="$(now_iso)"
483
+ apply_jq --arg id "$id" --arg field "$field" --argjson value "$value" --arg now "$now" '
484
+ .updatedAt = $now
485
+ | (.tasks[] | select(.id==$id)) |= (
486
+ .[$field] = $value
487
+ | .updatedAt = $now
488
+ | .lastActivityAt = $now
489
+ )'
490
+ emit_event "$id" "set:$field" "$value"
491
+ echo "set: $id.$field"
492
+ }
493
+
494
+ cmd_archive() { _set_archived "$1" true; }
495
+ cmd_unarchive() { _set_archived "$1" false; }
496
+ _set_archived() {
497
+ require_tasks_file
498
+ local id="$1" val="$2"
499
+ task_exists "$id" || die "no such task: $id"
500
+ local now; now="$(now_iso)"
501
+ apply_jq --arg id "$id" --argjson val "$val" --arg now "$now" '
502
+ .updatedAt = $now
503
+ | (.tasks[] | select(.id==$id)) |= (.isArchived = $val | .updatedAt = $now)'
504
+ emit_event "$id" "archived:$val" ""
505
+ echo "isArchived=$val: $id"
506
+ }
507
+
508
+ cmd_rm() {
509
+ require_tasks_file
510
+ [[ $# -ge 1 ]] || die "usage: rm <id>"
511
+ local id="$1"
512
+ task_exists "$id" || die "no such task: $id"
513
+ local now; now="$(now_iso)"
514
+ apply_jq --arg id "$id" --arg now "$now" '
515
+ .updatedAt = $now | .tasks |= map(select(.id != $id))'
516
+ emit_event "$id" "removed" ""
517
+ echo "removed: $id"
518
+ }
519
+
520
+ # --- Structured fields: priority / tag / module / assign / dep ------------------------
521
+
522
+ cmd_priority() {
523
+ require_tasks_file
524
+ [[ $# -ge 2 ]] || die "usage: priority <id> <low|normal|high|urgent>"
525
+ local id="$1" p="$2"
526
+ task_exists "$id" || die "no such task: $id"
527
+ local ok=0; for v in $VALID_PRIORITIES; do [[ "$p" == "$v" ]] && ok=1; done
528
+ [[ $ok == 1 ]] || die "invalid priority: $p (valid: $VALID_PRIORITIES)"
529
+ local now; now="$(now_iso)"
530
+ apply_jq --arg id "$id" --arg p "$p" --arg now "$now" '
531
+ .updatedAt=$now
532
+ | (.tasks[]|select(.id==$id)) |= (.priority=$p | .updatedAt=$now | .lastActivityAt=$now)'
533
+ emit_event "$id" "priority:$p" ""
534
+ echo "priority: $id -> $p"
535
+ }
536
+
537
+ cmd_module() {
538
+ require_tasks_file
539
+ [[ $# -ge 2 ]] || die "usage: module <id> <module-name> (empty string clears it)"
540
+ local id="$1" m="$2"
541
+ task_exists "$id" || die "no such task: $id"
542
+ local now; now="$(now_iso)"
543
+ apply_jq --arg id "$id" --arg m "$m" --arg now "$now" '
544
+ .updatedAt=$now
545
+ | (.tasks[]|select(.id==$id)) |= (.module=$m | .updatedAt=$now | .lastActivityAt=$now)'
546
+ emit_event "$id" "module:$m" ""
547
+ echo "module: $id -> ${m:-<cleared>}"
548
+ }
549
+
550
+ cmd_tag() {
551
+ require_tasks_file
552
+ [[ $# -ge 3 ]] || die "usage: tag <id> <add|rm> <label>"
553
+ local id="$1" op="$2" t="$3"
554
+ task_exists "$id" || die "no such task: $id"
555
+ local now; now="$(now_iso)"
556
+ case "$op" in
557
+ add) apply_jq --arg id "$id" --arg t "$t" --arg now "$now" '
558
+ .updatedAt=$now
559
+ | (.tasks[]|select(.id==$id)) |= (.tags = (((.tags//[]) + [$t]) | unique) | .updatedAt=$now | .lastActivityAt=$now)'
560
+ echo "label added: $id +$t" ;;
561
+ rm) apply_jq --arg id "$id" --arg t "$t" --arg now "$now" '
562
+ .updatedAt=$now
563
+ | (.tasks[]|select(.id==$id)) |= (.tags = ((.tags//[]) - [$t]) | .updatedAt=$now | .lastActivityAt=$now)'
564
+ echo "label removed: $id -$t" ;;
565
+ *) die "unknown tag operation: $op (add|rm)" ;;
566
+ esac
567
+ emit_event "$id" "tag:$op" "$t"
568
+ }
569
+
570
+ cmd_assign() {
571
+ require_tasks_file
572
+ [[ $# -ge 2 ]] || die "usage: assign <id> <agent>"
573
+ local id="$1" agent="$2"
574
+ task_exists "$id" || die "no such task: $id"
575
+ local now; now="$(now_iso)"
576
+ apply_jq --arg id "$id" --arg a "$agent" --arg now "$now" '
577
+ .updatedAt=$now
578
+ | (.tasks[]|select(.id==$id)) |= (.assignedAgentId=$a | .updatedAt=$now | .lastActivityAt=$now)'
579
+ emit_event "$id" "assign" "$agent"
580
+ echo "assigned: $id -> $agent"
581
+ }
582
+
583
+ cmd_dep() {
584
+ require_tasks_file
585
+ [[ $# -ge 3 ]] || die "usage: dep <id> <add|rm> <other-id>"
586
+ local id="$1" op="$2" other="$3"
587
+ task_exists "$id" || die "no such task: $id"
588
+ local now; now="$(now_iso)"
589
+ case "$op" in
590
+ add)
591
+ [[ "$id" == "$other" ]] && die "a task cannot depend on itself: $id"
592
+ task_exists "$other" || die "no such task (dependency): $other"
593
+ # Forbid a direct cycle: if 'other' already depends on 'id'.
594
+ local rev; rev="$(jq -r --arg o "$other" --arg id "$id" \
595
+ '[.tasks[]|select(.id==$o)|(.dependsOn//[])[]]|index($id)' "$TASKS_FILE")"
596
+ [[ "$rev" != "null" ]] && die "cyclic dependency: $other already depends on: $id"
597
+ apply_jq --arg id "$id" --arg o "$other" --arg now "$now" '
598
+ .updatedAt=$now
599
+ | (.tasks[]|select(.id==$id)) |= (.dependsOn = (((.dependsOn//[]) + [$o]) | unique) | .updatedAt=$now | .lastActivityAt=$now)'
600
+ emit_event "$id" "dep:add" "$other"
601
+ echo "dependency: $id ⛔ waits on: $other" ;;
602
+ rm)
603
+ apply_jq --arg id "$id" --arg o "$other" --arg now "$now" '
604
+ .updatedAt=$now
605
+ | (.tasks[]|select(.id==$id)) |= (.dependsOn = ((.dependsOn//[]) - [$o]) | .updatedAt=$now | .lastActivityAt=$now)'
606
+ emit_event "$id" "dep:rm" "$other"
607
+ echo "dependency removed: $id -$other" ;;
608
+ *) die "unknown dep operation: $op (add|rm)" ;;
609
+ esac
610
+ }
611
+
612
+ cmd_deps() {
613
+ require_tasks_file
614
+ [[ $# -ge 1 ]] || die "usage: deps <id>"
615
+ local id="$1"
616
+ task_exists "$id" || die "no such task: $id"
617
+ jq -r --arg id "$id" '
618
+ . as $root
619
+ | def stat($i): ($root.tasks[]|select(.id==$i)|.status) // "?";
620
+ ($root.tasks[]|select(.id==$id)) as $t
621
+ | "Task: \($id) [\($t.status)]",
622
+ "",
623
+ "Waiting on (dependsOn):",
624
+ ( if (($t.dependsOn//[])|length)==0 then " – none"
625
+ else ($t.dependsOn[] | " \(.) [\(stat(.))]") end ),
626
+ "",
627
+ "Blocks (referenced by):",
628
+ ( [ $root.tasks[] | select(((.dependsOn//[])|index($id)) != null) | " \(.id) [\(.status)]" ]
629
+ | if length==0 then " – none" else .[] end )
630
+ ' "$TASKS_FILE"
631
+ }
632
+
633
+ cmd_next() {
634
+ require_tasks_file
635
+ jq -r '
636
+ . as $root
637
+ | def doneish($i): ($root.tasks[]|select(.id==$i)) as $d
638
+ | ($d==null) or ($d.status=="done") or ($d.isArchived==true);
639
+ def prio_rank: {"urgent":0,"high":1,"normal":2,"low":3}[(.priority//"normal")] // 2;
640
+ [ $root.tasks[]
641
+ | select((.isArchived//false)|not)
642
+ | select(.status=="todo")
643
+ | select( ((.dependsOn//[]) | map(doneish(.)) | all) ) ]
644
+ | sort_by(prio_rank, .createdAt)
645
+ | if length==0 then "no available todo (all blocked, done, or none exist)"
646
+ else (.[] | "\(.id)\t[\(.priority//"normal")]\t\(.title)") end
647
+ ' "$TASKS_FILE" | column -t -s $'\t'
648
+ }
649
+
650
+ cmd_reopen() {
651
+ require_tasks_file
652
+ [[ $# -ge 1 ]] || die "usage: reopen <id> [status=todo]"
653
+ local id="$1" new="${2:-todo}"
654
+ task_exists "$id" || die "no such task: $id"
655
+ is_valid_status "$new" || die "invalid status: $new (valid: $VALID_STATUSES)"
656
+ local now; now="$(now_iso)"
657
+ apply_jq --arg id "$id" --arg new "$new" --arg now "$now" '
658
+ .updatedAt=$now
659
+ | (.tasks[]|select(.id==$id)) |= (
660
+ .history += [{at:$now, type:"reopened", note:"Task reopened.", fromStatus:.status, toStatus:$new}]
661
+ | .status=$new | .isArchived=false | .updatedAt=$now | .lastActivityAt=$now)'
662
+ emit_event "$id" "status:$new" "reopened"
663
+ echo "reopened: $id -> $new"
664
+ }
665
+
666
+ cmd_status_many() {
667
+ require_tasks_file
668
+ [[ $# -ge 2 ]] || die "usage: status-many <status> <id> [id...]"
669
+ local new="$1"; shift
670
+ is_valid_status "$new" || die "invalid status: $new (valid: $VALID_STATUSES)"
671
+ local id
672
+ for id in "$@"; do task_exists "$id" || die "no such task: $id"; done
673
+ local ids_json; ids_json="$(printf '%s\n' "$@" | jq -R . | jq -s .)"
674
+ local now; now="$(now_iso)"
675
+ apply_jq --arg new "$new" --arg now "$now" --argjson ids "$ids_json" '
676
+ .updatedAt=$now
677
+ | (.tasks[] | select((.id as $i | $ids | index($i)) != null)) |= (
678
+ .history += [{at:$now, type:"status_changed", note:null, fromStatus:.status, toStatus:$new}]
679
+ | .status=$new | .updatedAt=$now | .lastActivityAt=$now)'
680
+ for id in "$@"; do emit_event "$id" "status:$new" ""; done
681
+ echo "status (bulk): $* -> $new"
682
+ }
683
+
684
+ # --- Robustness: validate / restore ----------------------------------------
685
+
686
+ cmd_validate() {
687
+ require_tasks_file
688
+ jq -e . "$TASKS_FILE" >/dev/null 2>&1 || { echo "VALIDATION: FAILED – invalid JSON"; return 1; }
689
+ local report
690
+ report="$(jq -r --arg statuses "$VALID_STATUSES" --arg prios "$VALID_PRIORITIES" '
691
+ ($statuses|split(" ")) as $vs
692
+ | ($prios|split(" ")) as $vp
693
+ | ([.tasks[].id]) as $ids
694
+ | ( [ .tasks[]
695
+ | . as $t
696
+ | ( if ($t.id//"")=="" then "a task has an empty/missing id" else empty end ),
697
+ ( if ($t.title//"")=="" then "\($t.id): missing title" else empty end ),
698
+ ( if ($vs|index($t.status)) then empty else "\($t.id): invalid status: \($t.status)" end ),
699
+ ( if (($t.priority//"normal") as $p | $vp|index($p)) then empty else "\($t.id): invalid priority: \($t.priority)" end ),
700
+ ( ($t.dependsOn//[])[] as $d | select(($ids|index($d))==null) | "\($t.id): broken dependency -> \($d)" )
701
+ ] )
702
+ + ( [.tasks[].id] | group_by(.) | map(select(length>1)|.[0]) | map("duplicate id: \(.)") )
703
+ | .[]
704
+ ' "$TASKS_FILE")"
705
+ if [[ -n "$report" ]]; then
706
+ echo "$report"
707
+ echo "─"
708
+ echo "VALIDATION: FAILED ($(printf '%s\n' "$report" | grep -c . ) issue(s))"
709
+ return 1
710
+ fi
711
+ echo "VALIDATION: OK ($(jq '.tasks|length' "$TASKS_FILE") task(s))"
712
+ }
713
+
714
+ cmd_restore() {
715
+ [[ -f "$BACKUP_FILE" ]] || die "no backup: $BACKUP_FILE"
716
+ acquire_lock
717
+ [[ -f "$TASKS_FILE" ]] && cp "$TASKS_FILE" "$TASKS_FILE.prerestore" 2>/dev/null || true
718
+ cp "$BACKUP_FILE" "$TASKS_FILE"
719
+ release_lock
720
+ echo "restored from backup: $BACKUP_FILE -> $TASKS_FILE (previous: $TASKS_FILE.prerestore)"
721
+ }
722
+
723
+ # --- Inbox: per-agent, deduplicated event notification ---------------------------
724
+ # `inbox <agent>`: returns events generated by OTHER agents since <agent>'s cursor (not its
725
+ # own), then advances the cursor. SILENT if there's nothing new (empty output) — so the hook
726
+ # only injects when there's something to inject. On first connect it does NOT dump the whole
727
+ # past: the cursor is set to the current end, and only events from then on are returned. The
728
+ # caller agent is supplied by the PostToolUse hook (from the --as flag); this command itself
729
+ # does NOT require --as.
730
+ cmd_inbox() {
731
+ [[ $# -ge 1 ]] || die "usage: inbox <agent>"
732
+ local agent="$1"
733
+ [[ -f "$EVENTS_FILE" ]] || return 0
734
+ local maxseq; maxseq="$(wc -l < "$EVENTS_FILE" 2>/dev/null | tr -d ' ')"
735
+ [[ -z "$maxseq" ]] && maxseq=0
736
+ mkdir -p "$CURSORS_DIR" 2>/dev/null || true
737
+ # Filename-safe cursor key (agent names are typically [A-Za-z0-9_-], but be defensive).
738
+ local safe; safe="$(printf '%s' "$agent" | tr -c 'A-Za-z0-9_.-' '_')"
739
+ local cursor_file="$CURSORS_DIR/$safe"
740
+ if [[ ! -f "$cursor_file" ]]; then
741
+ printf '%s\n' "$maxseq" > "$cursor_file" 2>/dev/null || true
742
+ return 0
743
+ fi
744
+ local seen; seen="$(cat "$cursor_file" 2>/dev/null || echo 0)"
745
+ [[ "$seen" =~ ^[0-9]+$ ]] || seen=0
746
+ # Advance the cursor first (so we don't loop on our own events either), then filter.
747
+ printf '%s\n' "$maxseq" > "$cursor_file" 2>/dev/null || true
748
+ jq -r --argjson seen "$seen" --arg me "$agent" '
749
+ select(.seq > $seen and .by != $me)
750
+ | "📬 [\(.task)] \(.by) → \(.kind)" + (if ((.text//"")|length)==0 then "" else ": \(.text)" end)
751
+ ' "$EVENTS_FILE" 2>/dev/null || true
752
+ }
753
+
754
+ # ---------------------------------------------------------------------------
755
+ # Commands — context.json (session continuity)
756
+ # ---------------------------------------------------------------------------
757
+ # context.json must ALSO only be written through these commands (never direct
758
+ # Read/Write/Edit), so there's a single atomic writer for both stores.
759
+
760
+ cmd_ctx() { # the whole context.json (small, cheap to read)
761
+ require_context_file
762
+ cat "$CONTEXT_FILE"
763
+ }
764
+
765
+ cmd_ctx_init() {
766
+ local init="${1:-}" goal="${2:-}"
767
+ if [[ -f "$CONTEXT_FILE" ]]; then
768
+ echo "already exists: $CONTEXT_FILE"
769
+ return 0
770
+ fi
771
+ ensure_context_file
772
+ local now; now="$(now_iso)"
773
+ apply_ctx --arg init "$init" --arg goal "$goal" --arg now "$now" \
774
+ '.updatedAt=$now | .initPrompt=$init | .goal=$goal'
775
+ echo "done: $CONTEXT_FILE"
776
+ }
777
+
778
+ cmd_ctx_set() {
779
+ ensure_context_file
780
+ [[ $# -ge 2 ]] || die "usage: ctx-set <field> <json-value>"
781
+ local field="$1" value="$2"
782
+ echo "$value" | jq -e . >/dev/null 2>&1 \
783
+ || die "value is not valid JSON: $value (quote strings: '\"text\"')"
784
+ local now; now="$(now_iso)"
785
+ apply_ctx --arg field "$field" --argjson value "$value" --arg now "$now" \
786
+ '.updatedAt=$now | .[$field]=$value'
787
+ echo "context set: $field"
788
+ }
789
+
790
+ cmd_ctx_decision() {
791
+ ensure_context_file
792
+ [[ $# -ge 2 ]] || die "usage: ctx-decision <topic> <decision> [rationale]"
793
+ local topic="$1" decision="$2" rationale="${3:-}"
794
+ local now; now="$(now_iso)"
795
+ apply_ctx --arg topic "$topic" --arg decision "$decision" \
796
+ --arg rationale "$rationale" --arg now "$now" \
797
+ '.updatedAt=$now
798
+ | .decisions += [{at:$now, topic:$topic, decision:$decision, rationale:$rationale}]'
799
+ echo "decision recorded: $topic"
800
+ }
801
+
802
+ cmd_ctx_constraint() {
803
+ ensure_context_file
804
+ [[ $# -ge 1 ]] || die "usage: ctx-constraint <text>"
805
+ local text="$1"
806
+ local now; now="$(now_iso)"
807
+ apply_ctx --arg text "$text" --arg now "$now" \
808
+ '.updatedAt=$now | .constraints += [$text]'
809
+ echo "constraint added"
810
+ }
811
+
812
+ cmd_ctx_question() {
813
+ ensure_context_file
814
+ [[ $# -ge 2 ]] || die "usage: ctx-question <add|rm> <text>"
815
+ local op="$1" text="$2"
816
+ local now; now="$(now_iso)"
817
+ case "$op" in
818
+ add)
819
+ apply_ctx --arg text "$text" --arg now "$now" \
820
+ '.updatedAt=$now | .openQuestions += [$text]'
821
+ echo "open question added" ;;
822
+ rm|resolve)
823
+ apply_ctx --arg text "$text" --arg now "$now" \
824
+ '.updatedAt=$now | .openQuestions |= map(select(. != $text))'
825
+ echo "question resolved" ;;
826
+ *) die "unknown ctx-question operation: $op (add|rm)" ;;
827
+ esac
828
+ }
829
+
830
+ # ---------------------------------------------------------------------------
831
+ # Entry point
832
+ # ---------------------------------------------------------------------------
833
+
834
+ # Commands for which --as is NOT required (meta / diagnostic / called by the hook itself).
835
+ # Every other command REQUIRES --as <agent> (identifies the caller, so the hook can inject
836
+ # the right inbox, and emit_event records it as `by`).
837
+ actor_optional() {
838
+ case "$1" in
839
+ help|-h|--help|inbox|init|validate|check|restore|raw) return 0 ;;
840
+ *) return 1 ;;
841
+ esac
842
+ }
843
+
844
+ # Prints a reminder (stderr) of the project's preferred language, set from the board's
845
+ # current UI language (api/index.php writes it to $TM_DIR/.board-lang on every write — see
846
+ # ApiClient.js). Deliberately NOT stored in any task/note: this is board-level state, read
847
+ # fresh on every task.sh invocation, so whichever agent runs the command next sees it.
848
+ # Skipped for help/inbox to avoid noise on the hook's own (very frequent) inbox lookups.
849
+ print_lang_hint() {
850
+ case "$1" in
851
+ help|-h|--help|inbox) return 0 ;;
852
+ esac
853
+ local f="$TM_DIR/.board-lang" lang
854
+ [[ -f "$f" ]] || return 0
855
+ lang="$(tr -d '[:space:]' < "$f" 2>/dev/null)"
856
+ case "$lang" in
857
+ hu) echo "[task-manager] Preferred language for this project: Hungarian — please reply and do the work in Hungarian." >&2 ;;
858
+ esac
859
+ }
860
+
861
+ main() {
862
+ local cmd="${1:-help}"
863
+ shift || true
864
+ print_lang_hint "$cmd"
865
+
866
+ # Extract --as <agent> from ANYWHERE in the arguments; the rest goes into array A.
867
+ local -a A=()
868
+ while [[ $# -gt 0 ]]; do
869
+ case "$1" in
870
+ --as) ACTOR="${2:-}"; shift 2 || shift ;;
871
+ --as=*) ACTOR="${1#--as=}"; shift ;;
872
+ *) A+=("$1"); shift ;;
873
+ esac
874
+ done
875
+
876
+ # Require --as (except for meta commands). This way the hook always knows the caller,
877
+ # and every event is written with a `by`.
878
+ if ! actor_optional "$cmd"; then
879
+ [[ -n "$ACTOR" ]] || die "--as <agent> is required (e.g. --as main). Command: $cmd"
880
+ fi
881
+
882
+ # "${A[@]}" would throw 'unbound variable' under bash 3.2 (macOS) + set -u when empty;
883
+ # the ${A[@]+...} guard avoids that.
884
+ set -- ${A[@]+"${A[@]}"}
885
+
886
+ case "$cmd" in
887
+ help|-h|--help) cmd_help ;;
888
+ inbox) cmd_inbox "$@" ;;
889
+ init) cmd_init "$@" ;;
890
+ list|ls) cmd_list "$@" ;;
891
+ ids) cmd_ids "$@" ;;
892
+ get) cmd_get "$@" ;;
893
+ field) cmd_field "$@" ;;
894
+ summary) cmd_summary "$@" ;;
895
+ find) cmd_find "$@" ;;
896
+ history) cmd_history "$@" ;;
897
+ raw) cmd_raw "$@" ;;
898
+ next) cmd_next "$@" ;;
899
+ deps) cmd_deps "$@" ;;
900
+ validate|check) cmd_validate "$@" ;;
901
+ add) cmd_add "$@" ;;
902
+ status) cmd_status "$@" ;;
903
+ status-many|status-multi) cmd_status_many "$@" ;;
904
+ reopen) cmd_reopen "$@" ;;
905
+ note) cmd_note "$@" ;;
906
+ set) cmd_set "$@" ;;
907
+ priority|prio) cmd_priority "$@" ;;
908
+ module|mod) cmd_module "$@" ;;
909
+ tag) cmd_tag "$@" ;;
910
+ assign) cmd_assign "$@" ;;
911
+ dep) cmd_dep "$@" ;;
912
+ archive) cmd_archive "$@" ;;
913
+ unarchive) cmd_unarchive "$@" ;;
914
+ restore) cmd_restore "$@" ;;
915
+ rm|remove) cmd_rm "$@" ;;
916
+ ctx|context) cmd_ctx "$@" ;;
917
+ ctx-init) cmd_ctx_init "$@" ;;
918
+ ctx-set) cmd_ctx_set "$@" ;;
919
+ ctx-decision) cmd_ctx_decision "$@" ;;
920
+ ctx-constraint) cmd_ctx_constraint "$@" ;;
921
+ ctx-question) cmd_ctx_question "$@" ;;
922
+ *) die "unknown command: $cmd (help: task.sh help)" ;;
923
+ esac
924
+ }
925
+
926
+ main "$@"