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.
- scripts/__init__.py +0 -0
- scripts/agents_sync.py +61 -0
- scripts/apply-plan.sh +51 -0
- scripts/build.sh +7 -0
- scripts/codex-task.sh +198 -0
- scripts/install-hooks.sh +9 -0
- scripts/junie-status.sh +52 -0
- scripts/junie-task.sh +183 -0
- scripts/kilo-task.sh +226 -0
- scripts/needs-dotnet-checks.sh +24 -0
- scripts/plan-sprint.sh +92 -0
- scripts/run-task.sh +79 -0
- scripts/run.sh +7 -0
- scripts/run_stats.py +141 -0
- scripts/run_summary.py +94 -0
- scripts/spec-lint.sh +139 -0
- scripts/sprint-auto.sh +79 -0
- scripts/sprint_auto_execute.py +160 -0
- scripts/sprint_auto_preflight.py +309 -0
- scripts/sprint_auto_prepare.py +271 -0
- scripts/sprint_auto_resume.py +153 -0
- scripts/sprint_auto_state.py +191 -0
- scripts/sprint_plan.py +238 -0
- scripts/task-report.sh +45 -0
- scripts/tasks.py +1813 -0
- scripts/tasks_cli.py +14 -0
- scripts/tasks_commands.py +11 -0
- scripts/tasks_init.py +140 -0
- scripts/tasks_mcp.py +309 -0
- scripts/tasks_model.py +173 -0
- scripts/tasks_plan_import.py +269 -0
- scripts/tasks_render.py +148 -0
- scripts/tasks_storage.py +373 -0
- scripts/tasks_watch.py +159 -0
- scripts/test.sh +43 -0
- scripts/test_agent_config.py +97 -0
- scripts/test_plan_sprint.sh +172 -0
- scripts/test_python_fast.sh +26 -0
- scripts/test_python_lint.sh +22 -0
- scripts/test_python_slow.sh +14 -0
- scripts/test_run_stats.py +64 -0
- scripts/test_runners.sh +588 -0
- scripts/test_spec_lint.sh +438 -0
- scripts/test_sprint_auto.py +50 -0
- scripts/test_sprint_auto_execute.py +112 -0
- scripts/test_sprint_auto_preflight.py +143 -0
- scripts/test_sprint_auto_prepare.py +150 -0
- scripts/test_sprint_auto_resume.py +104 -0
- scripts/test_sprint_auto_state.py +157 -0
- scripts/test_tasks.py +1678 -0
- scripts/test_tasks_mcp.py +233 -0
- scripts/test_tasks_plan_import.py +129 -0
- scripts/test_tasks_storage.py +67 -0
- scripts/test_tasks_watch.py +239 -0
- scripts/test_worktree.sh +2112 -0
- scripts/worktree.sh +992 -0
- scripts/worktree_mr.py +133 -0
- scripts/worktree_path.py +70 -0
- scripts/worktree_sprint.py +70 -0
- tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
- tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
- tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
- tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
- tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
- 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
|