tasks-toolkit 0.1.3__py3-none-any.whl

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.
Files changed (65) hide show
  1. scripts/__init__.py +0 -0
  2. scripts/agents_sync.py +61 -0
  3. scripts/apply-plan.sh +51 -0
  4. scripts/build.sh +7 -0
  5. scripts/codex-task.sh +198 -0
  6. scripts/install-hooks.sh +9 -0
  7. scripts/junie-status.sh +52 -0
  8. scripts/junie-task.sh +183 -0
  9. scripts/kilo-task.sh +226 -0
  10. scripts/needs-dotnet-checks.sh +24 -0
  11. scripts/plan-sprint.sh +92 -0
  12. scripts/run-task.sh +79 -0
  13. scripts/run.sh +7 -0
  14. scripts/run_stats.py +141 -0
  15. scripts/run_summary.py +94 -0
  16. scripts/spec-lint.sh +139 -0
  17. scripts/sprint-auto.sh +79 -0
  18. scripts/sprint_auto_execute.py +160 -0
  19. scripts/sprint_auto_preflight.py +309 -0
  20. scripts/sprint_auto_prepare.py +271 -0
  21. scripts/sprint_auto_resume.py +153 -0
  22. scripts/sprint_auto_state.py +191 -0
  23. scripts/sprint_plan.py +238 -0
  24. scripts/task-report.sh +45 -0
  25. scripts/tasks.py +1813 -0
  26. scripts/tasks_cli.py +14 -0
  27. scripts/tasks_commands.py +11 -0
  28. scripts/tasks_init.py +140 -0
  29. scripts/tasks_mcp.py +309 -0
  30. scripts/tasks_model.py +173 -0
  31. scripts/tasks_plan_import.py +269 -0
  32. scripts/tasks_render.py +148 -0
  33. scripts/tasks_storage.py +373 -0
  34. scripts/tasks_watch.py +159 -0
  35. scripts/test.sh +43 -0
  36. scripts/test_agent_config.py +97 -0
  37. scripts/test_plan_sprint.sh +172 -0
  38. scripts/test_python_fast.sh +26 -0
  39. scripts/test_python_lint.sh +22 -0
  40. scripts/test_python_slow.sh +14 -0
  41. scripts/test_run_stats.py +64 -0
  42. scripts/test_runners.sh +588 -0
  43. scripts/test_spec_lint.sh +438 -0
  44. scripts/test_sprint_auto.py +50 -0
  45. scripts/test_sprint_auto_execute.py +112 -0
  46. scripts/test_sprint_auto_preflight.py +143 -0
  47. scripts/test_sprint_auto_prepare.py +150 -0
  48. scripts/test_sprint_auto_resume.py +104 -0
  49. scripts/test_sprint_auto_state.py +157 -0
  50. scripts/test_tasks.py +1678 -0
  51. scripts/test_tasks_mcp.py +233 -0
  52. scripts/test_tasks_plan_import.py +129 -0
  53. scripts/test_tasks_storage.py +67 -0
  54. scripts/test_tasks_watch.py +239 -0
  55. scripts/test_worktree.sh +2112 -0
  56. scripts/worktree.sh +992 -0
  57. scripts/worktree_mr.py +133 -0
  58. scripts/worktree_path.py +70 -0
  59. scripts/worktree_sprint.py +70 -0
  60. tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
  61. tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
  62. tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
  63. tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
  64. tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
  65. tasks_toolkit-0.1.3.dist-info/top_level.txt +1 -0
scripts/worktree.sh ADDED
@@ -0,0 +1,992 @@
1
+ #!/usr/bin/env bash
2
+ # Isolated git worktrees for delegated agent work, kept in the git-ignored tmp/ folder.
3
+ #
4
+ # Sprint branch workflow (branch-per-task MR flow):
5
+ # sprint start <n> <slug> [--from <branch>]
6
+ # create sprint/<n>-<slug> from main (or --from) and check it out
7
+ # sprint status print sprint branch info, open task branches/worktrees
8
+ # sprint finish [--merge] run Rules checks; with --merge, merge sprint into main (--no-ff)
9
+ # new <name> [--task-id <id>] create task/<name> branch + worktree from sprint HEAD
10
+ # path <name> print the worktree path
11
+ # list list worktrees
12
+ # patch <name> export diff since baseline to tmp/patches/<name>.patch
13
+ # commit <name> [--runner R --model M --task-id ID --title T]
14
+ # guard paths and commit the worktree on task/<name> (exit 4 forbidden, 1 no changes)
15
+ # mr <name> [--remote] merge sprint into task branch, run checks, write MR record
16
+ # merge <name> [--accept-flags] merge task branch into sprint (--no-ff) with trailers
17
+ # accept <name> [--links-only --task-id ID]
18
+ # merge, then record commit/patch/runner/model links (exit 13: links failed;
19
+ # --links-only retries just the links after the merge)
20
+ # remove <name> delete the worktree, its branch and its metadata
21
+ # abandon <name> remove worktree + branch + meta (same as remove)
22
+ set -euo pipefail
23
+
24
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ cd "$root"
26
+
27
+ # ── generated files ──────────────────────────────────────────────────────
28
+ # These files are rewritten by the tracker on every change, so the main tree
29
+ # is always "dirty" with respect to them. They are ignored by clean-tree
30
+ # checks but forbidden in task branches (agents must never commit them).
31
+ GENERATED_PATHS="tasks/tasks.snapshot.json
32
+ tasks/README.md
33
+ tasks/sprint-*.md
34
+ TASKS.md"
35
+
36
+ # Check if a path is a generated file (ignored in clean-tree checks). Entries may be glob patterns.
37
+ is_generated_path() {
38
+ local p="$1"
39
+ while IFS= read -r gp; do
40
+ [[ "$p" == $gp ]] && return 0
41
+ done <<< "$GENERATED_PATHS"
42
+ return 1
43
+ }
44
+
45
+ # ── helpers ──────────────────────────────────────────────────────────────
46
+
47
+ name_ok() { [[ "${1:-}" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "invalid name '${1:-}' (use a-z, 0-9, -)" >&2; exit 2; }; }
48
+ wt_path() { echo "$root/tmp/worktrees/$1"; }
49
+ base_file() { echo "$root/tmp/meta/$1.base"; }
50
+ sprint_file() { echo "$root/tmp/meta/$1.sprint"; }
51
+ task_file() { echo "$root/tmp/meta/$1.task"; }
52
+ retry_file() { echo "$root/tmp/meta/$1.retries"; }
53
+ fixes_file() { echo "$root/tmp/meta/$1.fixes.md"; }
54
+
55
+ # tree_clean — check if the working tree is clean, ignoring GENERATED_PATHS.
56
+ # Returns 0 if clean, 1 if dirty. Prints dirty files to stderr.
57
+ tree_clean() {
58
+ local dirty=0
59
+ # Check staged changes
60
+ while IFS= read -r line; do
61
+ local fpath="${line#* }"
62
+ if ! is_generated_path "$fpath"; then
63
+ echo " staged: $fpath" >&2
64
+ dirty=1
65
+ fi
66
+ done < <(git diff --cached --name-only 2>/dev/null || true)
67
+ # Check unstaged changes
68
+ while IFS= read -r line; do
69
+ local fpath="${line#* }"
70
+ if ! is_generated_path "$fpath"; then
71
+ echo " modified: $fpath" >&2
72
+ dirty=1
73
+ fi
74
+ done < <(git diff --name-only 2>/dev/null || true)
75
+ # Check untracked files (excluding gitignore'd dirs)
76
+ while IFS= read -r fpath; do
77
+ [ -z "$fpath" ] && continue
78
+ if ! is_generated_path "$fpath"; then
79
+ echo " untracked: $fpath" >&2
80
+ dirty=1
81
+ fi
82
+ done < <(git ls-files -o --exclude-standard 2>/dev/null || true)
83
+ return "$dirty"
84
+ }
85
+
86
+ # current_branch — print the current branch name (short).
87
+ current_branch() {
88
+ git symbolic-ref --short HEAD 2>/dev/null || true
89
+ }
90
+
91
+ # on_sprint_branch — check if current branch matches sprint/*
92
+ # Returns 0 if on a sprint branch, 1 otherwise.
93
+ on_sprint_branch() {
94
+ local branch
95
+ branch="$(current_branch)"
96
+ [[ "$branch" == sprint/* ]]
97
+ }
98
+
99
+ # ── path validation ──────────────────────────────────────────────────────
100
+ # path_ok <path>
101
+ #
102
+ # Returns 0 if the path is safe for an agent patch, 1 otherwise.
103
+ # On failure prints the reason to stderr.
104
+ #
105
+ # Rules (the contract):
106
+ # - Only characters [A-Za-z0-9._/-] are allowed; no spaces, quotes, backslashes,
107
+ # newlines, control or non-ASCII characters.
108
+ # - No leading '/', no '..' segment, no empty segment ('//'), no segment starting with '-'.
109
+ # - Must not be inside .git/, .githooks/, .claude/, .junie/, .kilo/ or tmp/
110
+ # and must not equal kilo.json or .mcp.json.
111
+ # - Must not be tasks/tasks.db or tasks/.lock.
112
+ # - Symlinks and executable mode changes are allowed only under scripts/.
113
+ #
114
+ # The function is pure bash (regex + case) so it can be unit-tested.
115
+ path_ok() {
116
+ local p="$1"
117
+
118
+ # Reject empty paths
119
+ [[ -n "$p" ]] || { echo "empty path" >&2; return 1; }
120
+
121
+ # Reject paths with characters outside [A-Za-z0-9._/-]
122
+ if [[ "$p" =~ [^A-Za-z0-9._/\-] ]]; then
123
+ echo "path '$p' contains forbidden characters (only A-Za-z0-9._/- allowed)" >&2
124
+ return 1
125
+ fi
126
+
127
+ # Reject leading '/'
128
+ [[ "${p:0:1}" != "/" ]] || { echo "path '$p' must not start with /" >&2; return 1; }
129
+
130
+ # Split into segments and validate each
131
+ local IFS='/'
132
+ local -a segments
133
+ read -ra segments <<< "$p"
134
+
135
+ for seg in "${segments[@]}"; do
136
+ # Reject empty segments (from '//')
137
+ [[ -n "$seg" ]] || { echo "path '$p' contains empty segment (//)" >&2; return 1; }
138
+ # Reject '..'
139
+ [[ "$seg" != ".." ]] || { echo "path '$p' contains '..' segment" >&2; return 1; }
140
+ # Reject segments starting with '-'
141
+ [[ "${seg:0:1}" != "-" ]] || { echo "path '$p' has segment starting with '-': $seg" >&2; return 1; }
142
+ done
143
+
144
+ # Reject protected directories and files
145
+ # Check if path is inside a protected directory
146
+ for dir in .git .githooks .claude .junie .kilo tmp; do
147
+ # Path equals the directory itself or is inside it
148
+ if [[ "$p" == "$dir" || "$p" == "$dir/"* || "$p" == */"$dir" || "$p" == */"$dir/"* ]]; then
149
+ echo "path '$p' is inside protected directory $dir/" >&2
150
+ return 1
151
+ fi
152
+ done
153
+
154
+ # Reject specific protected files
155
+ case "$p" in
156
+ kilo.json|.mcp.json|tasks/tasks.db|tasks/.lock)
157
+ echo "path '$p' is a protected file" >&2
158
+ return 1
159
+ ;;
160
+ esac
161
+
162
+ # Reject generated paths (agents must never commit them)
163
+ if is_generated_path "$p"; then
164
+ echo "path '$p' is a generated file (agents must not commit it)" >&2
165
+ return 1
166
+ fi
167
+
168
+ return 0
169
+ }
170
+
171
+ # Collect all paths touched by a patch (NUL-safe, handles renames and binary files).
172
+ # Sets the global array `patch_files`.
173
+ # Falls back to parsing "diff --git" lines when git apply --numstat cannot parse
174
+ # the patch (e.g. paths with // that git rejects), so validation still catches them.
175
+ collect_patch_files() {
176
+ local patch_file="$1"
177
+ patch_files=()
178
+
179
+ # Use git apply --numstat -z for NUL-separated paths (handles binary files too)
180
+ # Format: added\tdeleted\tname\0
181
+ while IFS= read -r -d '' line; do
182
+ # Extract the filename (after the last tab)
183
+ local fpath="${line##*$'\t'}"
184
+ [[ -n "$fpath" ]] && patch_files+=("$fpath")
185
+ done < <(git apply --numstat -z "$patch_file" 2>/dev/null || true)
186
+
187
+ # Also capture rename source paths via --summary
188
+ # Output lines like: "rename from src/path" / "rename to dst/path"
189
+ while IFS= read -r line; do
190
+ if [[ "$line" == rename\ from\ * ]]; then
191
+ local src="${line#rename from }"
192
+ # Check if already in the list
193
+ local found=0
194
+ for existing in "${patch_files[@]+"${patch_files[@]}"}"; do
195
+ [[ "$existing" == "$src" ]] && { found=1; break; }
196
+ done
197
+ [[ "$found" -eq 0 ]] && patch_files+=("$src")
198
+ fi
199
+ done < <(git apply --summary "$patch_file" 2>/dev/null || true)
200
+
201
+ # Fallback: if git apply --numstat returned nothing (e.g. malformed paths like a//b.txt
202
+ # that git rejects), parse "diff --git" lines directly so we can still validate them.
203
+ if [ "${#patch_files[@]}" -eq 0 ]; then
204
+ while IFS= read -r line; do
205
+ if [[ "$line" == diff\ --git\ a/* ]]; then
206
+ local fpath="${line#diff --git a/}"
207
+ fpath="${fpath%% b/*}"
208
+ [[ -n "$fpath" ]] && patch_files+=("$fpath")
209
+ fi
210
+ done < "$patch_file"
211
+ fi
212
+ }
213
+
214
+ # Check whether the patch contains symlink additions or mode changes to symlinks.
215
+ # Returns 0 if symlinks found, 1 if none.
216
+ patch_has_symlinks() {
217
+ local patch_file="$1"
218
+ grep -q 'new file mode 120000\|old mode.*120000\|new mode.*120000' "$patch_file" 2>/dev/null
219
+ }
220
+
221
+ # Validate that symlink changes only affect scripts/ paths.
222
+ # Returns 0 if OK, 1 if forbidden.
223
+ validate_symlinks() {
224
+ local patch_file="$1"
225
+
226
+ if ! patch_has_symlinks "$patch_file"; then
227
+ return 0
228
+ fi
229
+
230
+ # Symlinks exist — check they are only under scripts/
231
+ local -a symlink_paths=()
232
+ while IFS= read -r -d '' line; do
233
+ local fpath="${line##*$'\t'}"
234
+ [[ -n "$fpath" ]] && symlink_paths+=("$fpath")
235
+ done < <(git apply --numstat -z "$patch_file" 2>/dev/null || true)
236
+
237
+ # Also check rename summary for symlinks
238
+ while IFS= read -r line; do
239
+ if [[ "$line" == copy\ from\ * ]]; then
240
+ local src="${line#copy from }"
241
+ symlink_paths+=("$src")
242
+ elif [[ "$line" == copy\ to\ * ]]; then
243
+ local dst="${line#copy to }"
244
+ symlink_paths+=("$dst")
245
+ fi
246
+ done < <(git apply --summary "$patch_file" 2>/dev/null || true)
247
+
248
+ for sp in "${symlink_paths[@]+"${symlink_paths[@]}"}"; do
249
+ if [[ "$sp" != scripts/ && "$sp" != scripts ]]; then
250
+ echo "symlink change on non-scripts path '$sp'" >&2
251
+ return 1
252
+ fi
253
+ done
254
+
255
+ return 0
256
+ }
257
+
258
+ # ── review sheet hook ────────────────────────────────────────────────────
259
+ # Prints a markdown review sheet for the diff $sprint...$task and returns 1 when it finds red flags
260
+ # (used by `mr` to record them and by `merge` to require --accept-flags). Task 3.13.
261
+ review_sheet() {
262
+ local sprint="$1" task="$2"
263
+ local diff added changed forbidden n=0 flags=""
264
+ diff="$(git -C "$wt" diff "${sprint}...${task}" 2>/dev/null || true)"
265
+ added="$(printf '%s\n' "$diff" | grep -E '^\+' | grep -vE '^\+\+\+' || true)"
266
+ if printf '%s\n' "$added" | grep -q '#pragma'; then
267
+ flags+=$'\n- ⚠️ StyleCop suppression (#pragma) added'
268
+ n=$((n + 1))
269
+ fi
270
+ if printf '%s\n' "$added" | grep -q 'System\.Security\.Cryptography'; then
271
+ flags+=$'\n- ⚠️ System.Security.Cryptography referenced in the patch (forbidden in production)'
272
+ n=$((n + 1))
273
+ fi
274
+ if printf '%s\n' "$added" | grep -Eq '\b(TODO|FIXME)\b'; then
275
+ flags+=$'\n- ⚠️ TODO/FIXME left in the patch'
276
+ n=$((n + 1))
277
+ fi
278
+ changed="$(git -C "$wt" diff --numstat "${sprint}...${task}" 2>/dev/null | awk '{a+=$1; d+=$2} END {print a+d+0}')"
279
+ if [ "${changed:-0}" -gt 300 ]; then
280
+ flags+=$'\n- ⚠️ large change: '"${changed}"' lines (spec target <= 300)'
281
+ n=$((n + 1))
282
+ fi
283
+ forbidden="$(git -C "$wt" diff --name-only "${sprint}...${task}" 2>/dev/null \
284
+ | grep -E '^(tasks/tasks\.snapshot\.json|tasks/README\.md|tasks/sprint-[^/]*\.md|TASKS\.md)$' || true)"
285
+ if [ -n "$forbidden" ]; then
286
+ flags+=$'\n- ⚠️ generated tracker file changed: '"$(printf '%s' "$forbidden" | tr '\n' ' ')"
287
+ n=$((n + 1))
288
+ fi
289
+
290
+ echo "## Review sheet"
291
+ echo ""
292
+ echo "Red flags: $n"
293
+ [ -n "$flags" ] && printf '%s\n' "$flags"
294
+ echo ""
295
+ echo "Checklist:"
296
+ echo "- [ ] Diff matches the task spec (files and behaviour)"
297
+ echo "- [ ] No #pragma suppressions; XML docs on new production code"
298
+ echo "- [ ] Results to stdout, logs to stderr"
299
+ echo "- [ ] No new dependency outside Directory.Packages.props"
300
+ echo "- [ ] format, build and tests green"
301
+ [ "$n" -gt 0 ] && return 1
302
+ return 0
303
+ }
304
+
305
+ # ── run checks (format, build, test) ─────────────────────────────────────
306
+ # Uses ACCEPT_CHECKS_CMD env var override when set (for worktree context).
307
+ # Returns 0 on success, 1 on failure.
308
+ run_checks() {
309
+ local context="${1:-main}" base="${2:-}" head="${3:-HEAD}"
310
+ if [ -n "${ACCEPT_CHECKS_CMD:-}" ]; then
311
+ eval "$ACCEPT_CHECKS_CMD"
312
+ return $?
313
+ fi
314
+ if [ -n "$base" ] && ! bash "$root/scripts/needs-dotnet-checks.sh" . "$base" "$head"; then
315
+ # Conditional verification (6.6): docs/scripts-only diff, so the .NET checks add nothing.
316
+ echo "== conditional verification (no .NET files changed): script tests =="
317
+ if [ -f scripts/test_spec_lint.sh ]; then
318
+ bash scripts/test_spec_lint.sh 2>&1 || return 1
319
+ fi
320
+ bash scripts/test_worktree.sh 2>&1 || return 1
321
+ bash scripts/test_runners.sh 2>&1 || return 1
322
+ python3 -m unittest discover -s scripts -p "test_*.py" 2>&1 || return 1
323
+ return 0
324
+ fi
325
+ if [ -f VibeEmulator.slnx ]; then
326
+ echo "== format =="
327
+ dotnet format VibeEmulator.slnx --verify-no-changes 2>&1 || return 1
328
+ echo "== build =="
329
+ dotnet build VibeEmulator.slnx --no-incremental -warnaserror 2>&1 || return 1
330
+ echo "== test =="
331
+ dotnet test VibeEmulator.slnx --no-build 2>&1 || return 1
332
+ else
333
+ echo "(skipping dotnet checks — no VibeEmulator.slnx)"
334
+ fi
335
+ return 0
336
+ }
337
+
338
+ cmd="${1:-}"
339
+ name="${2:-}"
340
+
341
+ case "$cmd" in
342
+
343
+ # ── sprint lifecycle ───────────────────────────────────────────────────
344
+ sprint)
345
+ subcmd="${2:-}"
346
+ case "$subcmd" in
347
+ start)
348
+ sprint_n="${3:-}"
349
+ sprint_slug="${4:-}"
350
+ sprint_from="main"
351
+ [ "${5:-}" != "--from" ] || sprint_from="${6:?--from needs a branch}"
352
+ [ -n "$sprint_n" ] && [ -n "$sprint_slug" ] \
353
+ || { echo "usage: $0 sprint start <n> <slug> [--from <branch>]" >&2; exit 1; }
354
+ [[ "$sprint_n" =~ ^[0-9]+$ ]] || { echo "error: sprint number must be an integer, got '$sprint_n'" >&2; exit 1; }
355
+ git rev-parse --verify -q "$sprint_from^{commit}" >/dev/null \
356
+ || { echo "error: base branch '$sprint_from' does not exist" >&2; exit 1; }
357
+ sprint_branch="sprint/${sprint_n}-${sprint_slug}"
358
+ # Must have a clean tree (ignoring generated paths)
359
+ if ! tree_clean; then
360
+ echo "error: working tree is not clean (ignoring generated paths)" >&2
361
+ exit 1
362
+ fi
363
+ # Refuse if branch already exists
364
+ if git rev-parse --verify "$sprint_branch" >/dev/null 2>&1; then
365
+ echo "error: sprint branch '$sprint_branch' already exists" >&2
366
+ exit 1
367
+ fi
368
+ # One sprint = one branch; a sprint starts from main, or from an explicit --from branch when
369
+ # it builds on a previous sprint that is not merged yet (cascade). Never from whatever is checked out.
370
+ git branch "$sprint_branch" "$sprint_from"
371
+ git checkout -q "$sprint_branch"
372
+ echo "sprint $sprint_branch (from $sprint_from)"
373
+ ;;
374
+ run)
375
+ # Batch driver: plan-batch -> worktree.sh run per task -> list MRs awaiting review.
376
+ # Never merges and never pushes (human merge gate).
377
+ shift 2 # drop 'sprint' and 'run'
378
+ sprint_max="" sprint_dry=0
379
+ while [ $# -gt 0 ]; do
380
+ case "$1" in
381
+ --max) sprint_max="${2:?--max needs a value}"; shift 2 ;;
382
+ --dry-run) sprint_dry=1; shift ;;
383
+ *) echo "unknown option '$1'" >&2; exit 1 ;;
384
+ esac
385
+ done
386
+ batch="$(python3 scripts/tasks.py plan-batch ${sprint_max:+--max "$sprint_max"})" \
387
+ || { echo "error: plan-batch failed" >&2; exit 1; }
388
+ if [ "$sprint_dry" -eq 1 ]; then
389
+ printf '%s\n' "$batch"
390
+ exit 0
391
+ fi
392
+ run_results="" run_mrs="" run_failed=0
393
+ while IFS= read -r line; do
394
+ tid="${line%% *}" # first token of "<id> <title>"
395
+ [[ "$tid" =~ ^[0-9]+(\.[0-9]+)+$ ]] || continue # skips "no disjoint ready tasks"
396
+ task_spec="$(python3 scripts/tasks.py show "$tid" \
397
+ | python3 -c 'import json,sys; d=json.load(sys.stdin); s=[l["value"] for l in d.get("links", []) if l["kind"]=="spec"]; print(s[-1] if s else "")')" \
398
+ || task_spec=""
399
+ if [ -z "$task_spec" ] || [ ! -f "$task_spec" ]; then
400
+ echo "skip $tid (no spec link)" >&2
401
+ run_results+="$tid skipped (no spec link)"$'\n'
402
+ continue
403
+ fi
404
+ task_name="$(basename "$task_spec" .md)"
405
+ echo "== running $tid as $task_name =="
406
+ run_rc=0
407
+ bash "$root/scripts/worktree.sh" run "$task_name" --file "$task_spec" --task-id "$tid" || run_rc=$?
408
+ run_results+="$tid $task_name exit $run_rc"$'\n'
409
+ [ "$run_rc" -eq 0 ] || run_failed=$((run_failed + 1))
410
+ if [ "$run_rc" -eq 0 ] && [ -f "$root/tmp/mr/$task_name.md" ]; then run_mrs+="$task_name"$'\n'; fi
411
+ done <<< "$batch"
412
+ echo ""
413
+ echo "Results:"
414
+ if [ -n "$run_results" ]; then printf '%s' "$run_results" | sed 's/^/ /'; else echo " (nothing to run)"; fi
415
+ echo "MRs awaiting review:"
416
+ if [ -n "$run_mrs" ]; then printf '%s' "$run_mrs" | sed 's/^/ /'; else echo " (none)"; fi
417
+ if [ "$run_failed" -gt 0 ]; then
418
+ echo "error: $run_failed task(s) failed (see Results)" >&2
419
+ exit 1
420
+ fi
421
+ ;;
422
+
423
+ status)
424
+ branch="$(current_branch)"
425
+ echo "branch: $branch"
426
+ if [[ "$branch" == sprint/* ]]; then
427
+ # Compare sprint branch against main — ahead = commits on sprint not on main
428
+ main_sha="$(git rev-parse --verify main 2>/dev/null || true)"
429
+ if [ -n "$main_sha" ]; then
430
+ ahead="$(git rev-list --count "main..${branch}" 2>/dev/null || echo 0)"
431
+ behind="$(git rev-list --count "${branch}..main" 2>/dev/null || echo 0)"
432
+ echo "ahead: $ahead behind: $behind"
433
+ fi
434
+ # List open task branches
435
+ echo "task branches:"
436
+ git for-each-ref --format=' %(refname:short)' refs/heads/task/ 2>/dev/null || true
437
+ # List worktrees for this sprint
438
+ echo "worktrees:"
439
+ if [ -d "$root/tmp/worktrees" ]; then
440
+ for wt_dir in "$root/tmp/worktrees"/*/; do
441
+ [ -d "$wt_dir" ] || continue
442
+ wt_name="$(basename "$wt_dir")"
443
+ sf="$(sprint_file "$wt_name")"
444
+ if [ -f "$sf" ] && [ "$(cat "$sf")" = "$branch" ]; then
445
+ echo " $wt_name"
446
+ fi
447
+ done
448
+ fi
449
+ else
450
+ echo "(not on a sprint branch)"
451
+ fi
452
+ ;;
453
+ finish)
454
+ branch="$(current_branch)"
455
+ [[ "$branch" == sprint/* ]] \
456
+ || { echo "error: not on a sprint branch (on '$branch')" >&2; exit 1; }
457
+ merge_flag=0
458
+ if [ "${3:-}" = "--merge" ]; then
459
+ merge_flag=1
460
+ fi
461
+ # Run the three Rules checks (skip if solution file is absent, e.g. in tests)
462
+ if [ -f VibeEmulator.slnx ]; then
463
+ echo "== format =="
464
+ dotnet format VibeEmulator.slnx --verify-no-changes 2>&1 || { echo "format check failed" >&2; exit 1; }
465
+ echo "== build =="
466
+ dotnet build VibeEmulator.slnx --no-incremental -warnaserror 2>&1 || { echo "build failed" >&2; exit 1; }
467
+ echo "== test =="
468
+ dotnet test VibeEmulator.slnx --no-build 2>&1 || { echo "tests failed" >&2; exit 1; }
469
+ else
470
+ echo "(skipping dotnet checks — no VibeEmulator.slnx)"
471
+ fi
472
+ echo "sprint $branch — checks passed"
473
+ if [ "$merge_flag" -eq 1 ]; then
474
+ # Merge into main with --no-ff
475
+ git checkout -q main
476
+ git merge --no-ff "$branch" -m "Merge sprint $branch"
477
+ echo "merged $branch into main"
478
+ else
479
+ echo "(no merge — use --merge to merge into main)"
480
+ fi
481
+ ;;
482
+ *)
483
+ echo "usage: $0 sprint {start|status|finish}" >&2
484
+ exit 1
485
+ ;;
486
+ esac
487
+ ;;
488
+
489
+ # ── new — create task worktree from sprint HEAD ────────────────────────
490
+ new)
491
+ name_ok "$name"
492
+ wt="$(wt_path "$name")"
493
+ [ ! -e "$wt" ] || { echo "worktree '$name' already exists: $wt" >&2; exit 1; }
494
+
495
+ # Must be on a sprint branch
496
+ if ! on_sprint_branch; then
497
+ echo "error: not on a sprint branch (on '$(current_branch)')" >&2
498
+ exit 1
499
+ fi
500
+
501
+ # Tree must be clean (ignoring generated paths)
502
+ if ! tree_clean; then
503
+ echo "error: working tree is not clean (ignoring generated paths)" >&2
504
+ exit 1
505
+ fi
506
+
507
+ sprint_branch="$(current_branch)"
508
+ sprint_head="$(git rev-parse HEAD)"
509
+
510
+ # Parse optional --task-id first: a tracker task N.M may only run on sprint/N-*.
511
+ task_id=""
512
+ shift 2 # skip "new" and <name>
513
+ while [ "${1:-}" = "--task-id" ]; do
514
+ task_id="${2:?--task-id needs a value}"
515
+ shift 2
516
+ done
517
+ if [ -n "$task_id" ]; then
518
+ want_sprint="${task_id%%.*}"
519
+ if [[ "$sprint_branch" != "sprint/${want_sprint}-"* ]]; then
520
+ echo "error: task $task_id belongs to sprint $want_sprint but the checked-out branch is '$sprint_branch'." >&2
521
+ echo " Another agent may own this branch. Do not merge across sprints: start/switch to sprint/${want_sprint}-* first." >&2
522
+ exit 11
523
+ fi
524
+ fi
525
+
526
+ mkdir -p "$root/tmp/worktrees" "$root/tmp/meta"
527
+
528
+ # Create branch task/<name> from sprint HEAD — no uncommitted changes carried
529
+ git worktree add -q -b "task/$name" "$wt" "$sprint_head"
530
+
531
+ # Write meta files
532
+ echo "$sprint_head" > "$(base_file "$name")"
533
+ echo "$sprint_branch" > "$(sprint_file "$name")"
534
+
535
+ if [ -n "$task_id" ]; then
536
+ echo "$task_id" > "$(task_file "$name")"
537
+ fi
538
+
539
+ echo "$wt"
540
+ ;;
541
+
542
+ # ── path ───────────────────────────────────────────────────────────────
543
+ path)
544
+ name_ok "$name"
545
+ wt_path "$name"
546
+ ;;
547
+
548
+ # ── list ───────────────────────────────────────────────────────────────
549
+ list)
550
+ git worktree list
551
+ ;;
552
+
553
+ # ── patch ──────────────────────────────────────────────────────────────
554
+ patch)
555
+ name_ok "$name"
556
+ wt="$(wt_path "$name")"
557
+ [ -d "$wt" ] || { echo "no worktree '$name'" >&2; exit 1; }
558
+ mkdir -p "$root/tmp/patches"
559
+ git -C "$wt" add -A
560
+ git -C "$wt" diff --cached --binary "$(cat "$(base_file "$name")")" > "$root/tmp/patches/$name.patch"
561
+ echo "$root/tmp/patches/$name.patch"
562
+ ;;
563
+
564
+ # ── commit — runners commit the agent's work on task/<name> ────────────
565
+ # commit <name> [--runner R] [--model M] [--task-id ID] [--title T]
566
+ # Exit: 0 committed, 1 no changes / usage, 4 forbidden path (nothing committed).
567
+ commit)
568
+ name_ok "$name"
569
+ wt="$(wt_path "$name")"
570
+ [ -d "$wt" ] || { echo "no worktree '$name'" >&2; exit 1; }
571
+ shift 2
572
+ c_runner="agent" c_model="" c_task="" c_title="$name"
573
+ while [ $# -gt 0 ]; do
574
+ case "$1" in
575
+ --runner) c_runner="${2:?--runner needs a value}"; shift 2 ;;
576
+ --model) c_model="${2:?--model needs a value}"; shift 2 ;;
577
+ --task-id) c_task="${2:?--task-id needs a value}"; shift 2 ;;
578
+ --title) c_title="${2:?--title needs a value}"; shift 2 ;;
579
+ *) echo "unknown option '$1'" >&2; exit 1 ;;
580
+ esac
581
+ done
582
+ # Guard every changed path (NUL-safe, relative to the worktree: modified, deleted,
583
+ # untracked and already staged files; renames show up as delete + add).
584
+ bad=0
585
+ while IFS= read -r -d '' path; do
586
+ path_ok "$path" || bad=1
587
+ done < <({ git -C "$wt" ls-files -z --modified --deleted --others --exclude-standard
588
+ git -C "$wt" diff --cached --name-only --relative -z; })
589
+ if [ "$bad" -ne 0 ]; then
590
+ echo "forbidden path(s) in the worktree - nothing committed" >&2
591
+ exit 4
592
+ fi
593
+ git -C "$wt" add -A
594
+ if git -C "$wt" diff --cached --quiet; then
595
+ echo "no changes to commit in '$name'" >&2
596
+ exit 1
597
+ fi
598
+ c_msg="${c_task:+$c_task }$c_title"$'\n\n'"runner: $c_runner"
599
+ [ -z "$c_model" ] || c_msg+=$'\n'"model: $c_model"
600
+ git -C "$wt" -c user.name="agent:$c_runner" -c user.email="agent@localhost" \
601
+ commit -q -m "$c_msg"
602
+ git -C "$wt" rev-parse HEAD
603
+ ;;
604
+
605
+ # ── run — the whole pipeline: new -> runner -> mr, stopping before merge ─
606
+ run)
607
+ name_ok "$name"
608
+ shift 2 # drop 'run' and <name>
609
+ run_spec="" run_task_id="" run_runner="codex" run_fallback="none"
610
+ while [ $# -gt 0 ]; do
611
+ case "$1" in
612
+ --file) run_spec="${2:?--file needs a path}"; shift 2 ;;
613
+ --task-id) run_task_id="${2:?--task-id needs a value}"; shift 2 ;;
614
+ --runner) run_runner="${2:?--runner needs a value}"; shift 2 ;;
615
+ --fallback) run_fallback="${2:?--fallback needs a value}"; shift 2 ;;
616
+ *) echo "unknown option '$1'" >&2; exit 1 ;;
617
+ esac
618
+ done
619
+ if [ -z "$run_spec" ] || [ ! -f "$run_spec" ]; then
620
+ echo "usage: $0 run <name> --file <spec> [--task-id <id>] [--runner kilo|junie|codex] [--fallback none]" >&2
621
+ exit 1
622
+ fi
623
+ # 1) run the task (creates the worktree, runs the runner, commits and verifies)
624
+ bash scripts/run-task.sh "$name" --file "$run_spec" ${run_task_id:+--task-id "$run_task_id"} \
625
+ --runner "$run_runner" --fallback "$run_fallback" || exit $?
626
+ # 2) build the MR (checks + review sheet); stop before merge (human gate)
627
+ bash scripts/worktree.sh mr "$name"
628
+ ;;
629
+
630
+ # ── retry — re-run a task in the SAME worktree with a fix list (max 2) ──
631
+ retry)
632
+ name_ok "$name"
633
+ shift 2 # drop 'retry' and <name>
634
+ retry_fixes="" retry_runner="codex" retry_task_id=""
635
+ while [ $# -gt 0 ]; do
636
+ case "$1" in
637
+ --fixes) retry_fixes="${2:?--fixes needs a path}"; shift 2 ;;
638
+ --runner) retry_runner="${2:?--runner needs a value}"; shift 2 ;;
639
+ --task-id) retry_task_id="${2:?--task-id needs a value}"; shift 2 ;;
640
+ *) echo "unknown option '$1'" >&2; exit 1 ;;
641
+ esac
642
+ done
643
+ [ -n "$retry_fixes" ] && [ -f "$retry_fixes" ] \
644
+ || { echo "usage: $0 retry <name> --fixes <file> [--runner kilo|junie|codex] [--task-id <id>]" >&2; exit 1; }
645
+ case "$retry_runner" in junie|kilo|codex) ;; *) echo "unknown runner '$retry_runner'" >&2; exit 1 ;; esac
646
+ [ -d "$(wt_path "$name")" ] \
647
+ || { echo "error: no worktree for '$name'; start it with 'run' or 'new'" >&2; exit 1; }
648
+ [ -n "$retry_task_id" ] || retry_task_id="$(cat "$(task_file "$name")" 2>/dev/null || true)"
649
+ retry_count="$(cat "$(retry_file "$name")" 2>/dev/null || echo 0)"
650
+ retry_count=$((retry_count + 1))
651
+ if [ "$retry_count" -gt 2 ]; then
652
+ echo "error: '$name' reached the retry limit (2); setting the task blocked" >&2
653
+ if [ -n "$retry_task_id" ]; then
654
+ python3 scripts/tasks.py block "$retry_task_id" --reason "retry limit (2) reached" >/dev/null 2>&1 || true
655
+ fi
656
+ exit 1
657
+ fi
658
+ echo "$retry_count" > "$(retry_file "$name")"
659
+ cp "$retry_fixes" "$(fixes_file "$name")"
660
+ echo "retry $retry_count/2 for '$name' (fixes: $retry_fixes)" >&2
661
+ bash "scripts/$retry_runner-task.sh" "$name" --file "$retry_fixes" ${retry_task_id:+--task-id "$retry_task_id"}
662
+ ;;
663
+
664
+ # ── mr — merge sprint into task branch, checks, path guard, MR record ─
665
+ mr)
666
+ name_ok "$name"
667
+ wt="$(wt_path "$name")"
668
+ [ -d "$wt" ] || { echo "no worktree '$name'" >&2; exit 1; }
669
+
670
+ # Read sprint branch from meta
671
+ sf="$(sprint_file "$name")"
672
+ [ -f "$sf" ] || { echo "error: sprint meta missing for '$name'" >&2; exit 1; }
673
+ sprint_branch="$(cat "$sf")"
674
+
675
+ # Parse optional --remote flag
676
+ remote_flag=0
677
+ shift 2 # skip "mr" and <name>
678
+ while [ "${1:-}" = "--remote" ]; do
679
+ remote_flag=1
680
+ shift
681
+ done
682
+
683
+ task_branch="task/$name"
684
+
685
+ # Merge sprint into task branch inside the worktree (no rebase)
686
+ if ! git -C "$wt" merge "$sprint_branch" -m "Merge $sprint_branch into $task_branch" 2>/dev/null; then
687
+ # Conflict — abort and exit 9
688
+ git -C "$wt" merge --abort 2>/dev/null || true
689
+ echo "error: merge conflict when merging $sprint_branch into $task_branch" >&2
690
+ exit 9
691
+ fi
692
+
693
+ # Review sheet: red flags are recorded in the MR document and printed for the reviewer.
694
+ review_out="$(review_sheet "$sprint_branch" "$task_branch" || true)"
695
+ printf '%s\n' "$review_out" >&2
696
+
697
+ # Run checks in the worktree (ACCEPT_CHECKS_CMD override supported; conditional on the diff)
698
+ if ! ( cd "$wt" && run_checks worktree "$sprint_branch" "$task_branch" ); then
699
+ echo "error: checks failed in worktree '$name'" >&2
700
+ exit 8
701
+ fi
702
+
703
+ # Path guard: check all files changed between sprint and task branch
704
+ local_violations=0
705
+ # --no-renames lists a rename as delete + add, so both the old and the new path are checked.
706
+ while IFS= read -r -d '' local_fpath; do
707
+ if ! path_ok "$local_fpath"; then
708
+ local_violations=1
709
+ fi
710
+ done < <(git -C "$wt" diff --no-renames --name-only -z "${sprint_branch}...${task_branch}" 2>/dev/null || true)
711
+
712
+ if [ "$local_violations" -eq 1 ]; then
713
+ echo "error: forbidden path(s) in diff $sprint_branch...$task_branch" >&2
714
+ exit 4
715
+ fi
716
+
717
+ # Build MR record
718
+ mkdir -p "$root/tmp/mr"
719
+ mr_file="$root/tmp/mr/$name.md"
720
+ task_id=""
721
+ tf="$(task_file "$name")"
722
+ [ -f "$tf" ] && task_id="$(cat "$tf")"
723
+
724
+ sprint_sha="$(git -C "$wt" rev-parse "$sprint_branch")"
725
+ task_sha="$(git -C "$wt" rev-parse "$task_branch")"
726
+ base_sha="$(git -C "$wt" merge-base "$sprint_branch" "$task_branch")"
727
+
728
+ diffstat="$(git -C "$wt" diff --stat "${sprint_branch}...${task_branch}" 2>/dev/null || true)"
729
+
730
+ {
731
+ echo "# MR: $name"
732
+ echo ""
733
+ echo "- **Task ID:** ${task_id:-unknown}"
734
+ echo "- **Sprint branch:** $sprint_branch ($sprint_sha)"
735
+ echo "- **Task branch:** $task_branch ($task_sha)"
736
+ echo "- **Merge base:** $base_sha"
737
+ echo "- **Checks:** passed"
738
+ echo "- **Path guard:** passed"
739
+ if [ "$remote_flag" -eq 1 ]; then
740
+ echo "- **Remote hint:** Consider creating a remote MR from $task_branch to $sprint_branch"
741
+ echo ""
742
+ echo " git push origin $task_branch"
743
+ fi
744
+ echo ""
745
+ printf '%s\n' "$review_out"
746
+ echo ""
747
+ echo "## Diffstat"
748
+ echo ""
749
+ echo '```'
750
+ echo "$diffstat"
751
+ echo '```'
752
+ } > "$mr_file"
753
+
754
+ if [ -n "$task_id" ]; then
755
+ # The task is ready for review (awaiting merge); `merge` moves it to done, `abandon` to none.
756
+ python3 scripts/tasks.py status "$task_id" review >/dev/null 2>&1 || true
757
+ fi
758
+ echo "mr record: $mr_file"
759
+ ;;
760
+
761
+ # ── merge — merge task branch into sprint (--no-ff) with trailers ──────
762
+ merge)
763
+ name_ok "$name"
764
+ wt="$(wt_path "$name")"
765
+ [ -d "$wt" ] || { echo "no worktree '$name'" >&2; exit 1; }
766
+
767
+ # Read sprint branch from meta
768
+ sf="$(sprint_file "$name")"
769
+ [ -f "$sf" ] || { echo "error: sprint meta missing for '$name'" >&2; exit 1; }
770
+ sprint_branch="$(cat "$sf")"
771
+
772
+ # The main tree may be checked out on another sprint (another agent's); never switch it silently.
773
+ if git rev-parse --verify -q "$sprint_branch" >/dev/null && [ "$(current_branch)" != "$sprint_branch" ]; then
774
+ echo "error: '$name' belongs to $sprint_branch but the main tree is on '$(current_branch)'; not merging across sprints." >&2
775
+ exit 11
776
+ fi
777
+
778
+ # Parse optional --accept-flags / --auto
779
+ accept_flags=""
780
+ auto_flag=0
781
+ shift 2 # skip "merge" and <name>
782
+ while [ $# -gt 0 ]; do
783
+ [ "$1" = "--auto" ] && auto_flag=1
784
+ accept_flags="$accept_flags $1"
785
+ shift
786
+ done
787
+
788
+ task_branch="task/$name"
789
+
790
+ # Preconditions: sprint branch must exist
791
+ if ! git rev-parse --verify "$sprint_branch" >/dev/null 2>&1; then
792
+ echo "error: sprint branch '$sprint_branch' does not exist" >&2
793
+ exit 6
794
+ fi
795
+
796
+ # The merge must be exactly what `mr` tested: the MR record has to exist and both branch heads
797
+ # must still be the ones it was built against (otherwise run `mr` again).
798
+ mrf="$root/tmp/mr/$name.md"
799
+ if [ ! -f "$mrf" ]; then
800
+ if [ "$auto_flag" -eq 1 ]; then
801
+ # --auto: build the MR (checks + review sheet) in one step, then require green below.
802
+ echo "merge --auto: building the MR (checks + review sheet) first" >&2
803
+ if ! bash "$root/scripts/worktree.sh" mr "$name" >&2; then
804
+ echo "error: --auto could not build a green MR for '$name'" >&2
805
+ exit 8
806
+ fi
807
+ else
808
+ echo "error: no MR record for '$name' - run: scripts/worktree.sh mr $name" >&2
809
+ exit 6
810
+ fi
811
+ fi
812
+ rec_sprint="$(sed -n 's/^- \*\*Sprint branch:\*\* .*(\([0-9a-f]\{40\}\))$/\1/p' "$mrf" | head -n1)"
813
+ rec_task="$(sed -n 's/^- \*\*Task branch:\*\* .*(\([0-9a-f]\{40\}\))$/\1/p' "$mrf" | head -n1)"
814
+ sprint_head="$(git rev-parse "$sprint_branch")"
815
+ task_head="$(git rev-parse "$task_branch")"
816
+ if [ -z "$rec_sprint" ] || [ -z "$rec_task" ] || [ "$rec_sprint" != "$sprint_head" ] || [ "$rec_task" != "$task_head" ]; then
817
+ echo "error: MR for '$name' is stale (a branch moved since it was built) - run: scripts/worktree.sh mr $name" >&2
818
+ exit 6
819
+ fi
820
+ if ! tree_clean; then
821
+ echo "error: the main working tree is not clean; commit or stash first" >&2
822
+ exit 1
823
+ fi
824
+
825
+ # Review gate: red flags require an explicit --accept-flags (recorded in the merge commit).
826
+ if review_out="$(review_sheet "$sprint_branch" "$task_branch")"; then
827
+ review_rc=0
828
+ else
829
+ review_rc=$?
830
+ fi
831
+ printf '%s\n' "$review_out" >&2
832
+ if [ "$review_rc" -ne 0 ] && [[ "$accept_flags" != *--accept-flags* ]]; then
833
+ echo "error: red flags found in '$name'; review the diff, then re-run merge with --accept-flags" >&2
834
+ exit 12
835
+ fi
836
+
837
+ # Merge task branch into sprint (--no-ff) with trailers
838
+ task_id=""
839
+ tf="$(task_file "$name")"
840
+ [ -f "$tf" ] && task_id="$(cat "$tf")"
841
+
842
+ # Real git trailers (blank line, then Key: value): task id from the meta file, runner and model
843
+ # from the trailers the runner put on the task branch tip.
844
+ tip_msg="$(git log -1 --format=%B "$task_branch")"
845
+ tip_runner="$(printf '%s\n' "$tip_msg" | sed -n 's/^runner: //p' | head -n1)"
846
+ tip_model="$(printf '%s\n' "$tip_msg" | sed -n 's/^model: //p' | head -n1)"
847
+ tip_title="$(git log -1 --format=%s "$task_branch")"
848
+ tip_title="${tip_title#"$task_id" }"
849
+ merge_msg="Merge ${task_id:+$task_id: }${tip_title:-$name}"$'\n'
850
+ merge_msg+=$'\n'"Task: ${task_id:-none}"
851
+ [ -z "$tip_runner" ] || merge_msg+=$'\n'"Runner: $tip_runner"
852
+ [ -z "$tip_model" ] || merge_msg+=$'\n'"Model: $tip_model"
853
+ merge_msg+=$'\n'"Branch: $task_branch"
854
+ case "$accept_flags" in *--accept-flags*) merge_msg+=$'\n'"Accepted-Flags: yes" ;; esac
855
+
856
+ if ! git merge --no-ff "$task_branch" -m "$merge_msg" 2>/dev/null; then
857
+ # Merge failure — abort to leave clean tree, exit 10
858
+ git merge --abort 2>/dev/null || true
859
+ echo "error: merge failed — conflicts between $sprint_branch and $task_branch" >&2
860
+ exit 10
861
+ fi
862
+
863
+ # Mark task as done if task id is known. Errors are reported, not silently ignored.
864
+ # A merge succeeding but tracker update failing is partial success — the merge stays,
865
+ # but cleanup is deferred so evidence is preserved for later link recording.
866
+ tracker_ok=1
867
+ if [ -n "$task_id" ]; then
868
+ tracker_out="$(python3 scripts/tasks.py done "$task_id" 2>&1)" || tracker_ok=0
869
+ if [ "$tracker_ok" -eq 0 ]; then
870
+ echo "warning: tracker update failed for $task_id: $tracker_out" >&2
871
+ elif [ -n "$tracker_out" ]; then
872
+ echo "$tracker_out" >&2
873
+ fi
874
+ fi
875
+
876
+ # Safe cleanup: always remove worktree, branch, and meta. Tracker errors are
877
+ # reported above but do not block cleanup (the merge already succeeded).
878
+ # On tracker failure, the MR record and patch are preserved for later link
879
+ # recording (accept --links-only); meta files are still removed.
880
+ git worktree remove --force "$wt" 2>/dev/null || true
881
+ git branch -d "$task_branch" >/dev/null 2>&1 || echo "warning: could not delete $task_branch (not fully merged?)" >&2
882
+ if [ "$tracker_ok" -eq 1 ]; then
883
+ rm -f "$(base_file "$name")" "$(sprint_file "$name")" "$(task_file "$name")" "$(retry_file "$name")" "$(fixes_file "$name")" "$mrf"
884
+ else
885
+ # Preserve MR record and patch for retry; remove meta files
886
+ rm -f "$(base_file "$name")" "$(sprint_file "$name")" "$(task_file "$name")" "$(retry_file "$name")" "$(fixes_file "$name")"
887
+ echo "warning: tracker update failed — MR record and patch preserved for retry" >&2
888
+ fi
889
+ git worktree prune
890
+
891
+ if [ "$tracker_ok" -eq 0 ]; then
892
+ echo "merged $task_branch into $sprint_branch (partial: tracker update failed)" >&2
893
+ else
894
+ echo "merged $task_branch into $sprint_branch"
895
+ fi
896
+ ;;
897
+
898
+ # ── accept — earn 'done': merge, then record the links (commit, patch, runner, model) ─
899
+ accept)
900
+ name_ok "$name"
901
+ shift 2 # drop 'accept' and <name>
902
+ accept_links_only=0 accept_given_id=""
903
+ while [ $# -gt 0 ]; do
904
+ case "$1" in
905
+ --links-only) accept_links_only=1; shift ;;
906
+ --task-id) accept_given_id="${2:?--task-id needs a value}"; shift 2 ;;
907
+ *) echo "unknown option '$1'" >&2; exit 1 ;;
908
+ esac
909
+ done
910
+ if [ "$accept_links_only" -eq 1 ]; then
911
+ # Retry after exit 13: the merge already happened (and removed the task meta), so only the links are recorded.
912
+ [ -n "$accept_given_id" ] || { echo "error: --links-only needs --task-id <id>" >&2; exit 1; }
913
+ accept_id="$accept_given_id"
914
+ # Recover the original merge commit SHA from git history (not current HEAD, which may have moved).
915
+ # The merge commit message contains "Branch: task/<name>".
916
+ accept_sha="$(git log --all --grep="Branch: task/$name" --format=%H -1 2>/dev/null || true)"
917
+ else
918
+ [ -f "$(task_file "$name")" ] || { echo "error: no task id for '$name'" >&2; exit 1; }
919
+ accept_id="$(cat "$(task_file "$name")")"
920
+ [ -f "$root/tmp/mr/$name.md" ] \
921
+ || { echo "error: no review marker for '$name' (run: $0 mr $name first)" >&2; exit 6; }
922
+ bash "$root/scripts/worktree.sh" merge "$name" || exit $?
923
+ fi
924
+ accept_failed=""
925
+ accept_link() { # <kind> <value>: record a tracker link; remember the kind when it fails
926
+ [ -n "$2" ] || return 0
927
+ python3 scripts/tasks.py link "$accept_id" "$1" "$2" >/dev/null || accept_failed="$accept_failed $1"
928
+ }
929
+ if [ "$accept_links_only" -eq 0 ]; then
930
+ accept_branch="$(git branch --show-current)"
931
+ accept_sha="$(git rev-parse "$accept_branch" 2>/dev/null || true)"
932
+ fi
933
+ if [ -n "$accept_sha" ]; then accept_link commit "$accept_sha"; else accept_failed="$accept_failed commit"; fi
934
+ [ ! -f "$root/tmp/patches/$name.patch" ] || accept_link patch "tmp/patches/$name.patch"
935
+ if [ -f "$root/tmp/runs/$name.summary.json" ]; then
936
+ for accept_key in runner model; do
937
+ accept_link "$accept_key" "$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2],""))' \
938
+ "$root/tmp/runs/$name.summary.json" "$accept_key" 2>/dev/null || true)"
939
+ done
940
+ fi
941
+ if [ -n "$accept_failed" ]; then
942
+ echo "error: merged $name (task $accept_id) but could not record links:$accept_failed" >&2
943
+ echo "retry: $0 accept $name --links-only --task-id $accept_id" >&2
944
+ exit 13
945
+ fi
946
+ echo "accepted $name (task $accept_id)"
947
+ ;;
948
+
949
+ # ── remove — delete worktree, branch and metadata ──────────────────────
950
+ remove)
951
+ name_ok "$name"
952
+ git worktree remove --force "$(wt_path "$name")" 2>/dev/null || true
953
+ # Try both branch naming conventions
954
+ git branch -D "agent/$name" >/dev/null 2>&1 || true
955
+ git branch -D "task/$name" >/dev/null 2>&1 || true
956
+ rm -f "$(base_file "$name")" "$(sprint_file "$name")" "$(task_file "$name")" "$(retry_file "$name")" "$(fixes_file "$name")"
957
+ git worktree prune
958
+ echo "removed $name"
959
+ ;;
960
+
961
+ # ── abandon — remove worktree + branch + meta (same as remove) ─────────
962
+ abandon)
963
+ name_ok "$name"
964
+ if [ -f "$(task_file "$name")" ]; then
965
+ abandoned_id="$(cat "$(task_file "$name")")"
966
+ [ -n "$abandoned_id" ] && python3 scripts/tasks.py status "$abandoned_id" none >/dev/null 2>&1 || true
967
+ fi
968
+ git worktree remove --force "$(wt_path "$name")" 2>/dev/null || true
969
+ # Try both branch naming conventions
970
+ git branch -D "agent/$name" >/dev/null 2>&1 || true
971
+ git branch -D "task/$name" >/dev/null 2>&1 || true
972
+ rm -f "$(base_file "$name")" "$(sprint_file "$name")" "$(task_file "$name")" "$(retry_file "$name")" "$(fixes_file "$name")"
973
+ git worktree prune
974
+ echo "abandoned $name"
975
+ ;;
976
+
977
+ # ── usage ──────────────────────────────────────────────────────────────
978
+ *)
979
+ sed -n '2,15p' "$0" >&2
980
+ echo "" >&2
981
+ echo "Exit codes:" >&2
982
+ echo " 0 success" >&2
983
+ echo " 1 precondition/usage error" >&2
984
+ echo " 4 forbidden path in diff (nothing changed)" >&2
985
+ echo " 6 precondition/staleness error (nothing changed)" >&2
986
+ echo " 8 checks failed in worktree" >&2
987
+ echo " 9 merge conflict (aborted, nothing changed)" >&2
988
+ echo " 10 merge failure (aborted, clean tree)" >&2
989
+ echo " 11 wrong sprint (task id or main tree belongs to another sprint branch)" >&2
990
+ exit 2
991
+ ;;
992
+ esac