@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/.env.example +2 -0
- package/Dockerfile +11 -0
- package/LICENSE +21 -0
- package/README.hu.md +255 -0
- package/README.md +253 -0
- package/api/index.php +173 -0
- package/bin/add-agent.sh +77 -0
- package/bin/ctm +160 -0
- package/docker-compose.yml +14 -0
- package/engine/check-update.sh +48 -0
- package/engine/projects.sh +164 -0
- package/engine/task.sh +926 -0
- package/favicon.ico +6 -0
- package/favicon.svg +6 -0
- package/index.html +141 -0
- package/install.sh +231 -0
- package/js/ApiClient.js +63 -0
- package/js/App.js +547 -0
- package/js/BoardView.js +205 -0
- package/js/ContextPanel.js +47 -0
- package/js/ContextStore.js +39 -0
- package/js/ProjectStore.js +27 -0
- package/js/TaskModal.js +112 -0
- package/js/TaskStore.js +73 -0
- package/js/UrlState.js +53 -0
- package/js/Utils.js +202 -0
- package/js/i18n.js +338 -0
- package/js/main.js +3 -0
- package/package.json +85 -0
- package/style.css +396 -0
- package/templates/SKILL.md.tmpl +175 -0
- package/templates/agents/ctm-backend-developer.md.tmpl +53 -0
- package/templates/agents/ctm-code-investigator.md.tmpl +49 -0
- package/templates/agents/ctm-frontend-developer.md.tmpl +53 -0
- package/templates/hooks/allow-task-sh.sh.tmpl +59 -0
- package/templates/hooks/notify-inbox.sh.tmpl +53 -0
- package/templates/tm-custom.md.tmpl +54 -0
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 "$@"
|