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/kilo-task.sh ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # Delegates one task to the Kilo CLI agent inside its own worktree, then verifies it.
3
+ # Same contract as junie-task.sh (worktree, heartbeat, verification, patch), different runner.
4
+ #
5
+ # scripts/kilo-task.sh <name> "<task text>" [--task-id <id>]
6
+ # scripts/kilo-task.sh <name> --file tmp/tasks/<name>.md [--task-id <id>]
7
+ #
8
+ # --task-id reports progress to the task tracker (scripts/task-report.sh): the task is
9
+ # marked in progress at the start, and after the commit and verification it is closed
10
+ # automatically (done on success, blocked on failure); failures only warn.
11
+ #
12
+ # Env: KILO_MODEL (default kilo/deepseek/deepseek-v4-flash), KILO_AGENT (default code),
13
+ # KILO_TIMEOUT (default 1200 s), KILO_PING (default 30 s),
14
+ # KILO_VERIFY_CMD (tests only: replaces the dotnet verification).
15
+ # Output: tmp/runs/<name>.{status,events.jsonl,result.txt,verify.log,summary.json},
16
+ # tmp/patches/<name>.patch. Every status line ends with
17
+ # "model=<model> runner=kilo" so it can be parsed.
18
+ # Exit code: 0 verified, 1 verification failed, 3 Kilo itself failed.
19
+ #
20
+ # Notes:
21
+ # - ~/.config/kilo/kilo.jsonc is written by the VS Code extension in the V2 permission format,
22
+ # which the CLI rejects; the run therefore uses an empty XDG_CONFIG_HOME (credentials live in
23
+ # XDG_DATA_HOME and are unaffected). The project's kilo.json still applies in the worktree.
24
+ # - --auto approves every permission that kilo.json does not explicitly deny (git push, hard reset,
25
+ # git clean, amend and rm -rf are denied there). The worktree is disposable; review the patch.
26
+ set -euo pipefail
27
+
28
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
29
+ cd "$root"
30
+
31
+ runner="kilo"
32
+ name="${1:?usage: kilo-task.sh <name> \"<task>\" | --file <path> [--task-id <id>]}"
33
+ shift
34
+ if [ "${1:-}" = "--file" ]; then
35
+ task="$(cat "${2:?missing task file}")"
36
+ shift 2
37
+ else
38
+ task="${1:?missing task text}"
39
+ shift
40
+ fi
41
+ task_id=""
42
+ if [ "${1:-}" = "--task-id" ]; then
43
+ task_id="${2:?--task-id needs a value}"
44
+ fi
45
+
46
+ model="${KILO_MODEL:-configured-default}"
47
+ agent="${KILO_AGENT:-code}"
48
+ limit="${KILO_TIMEOUT:-1200}"
49
+ ping_every="${KILO_PING:-30}"
50
+ model_args=()
51
+ if [ -n "${KILO_MODEL:-}" ]; then
52
+ model_args=(-m "$KILO_MODEL")
53
+ fi
54
+
55
+ if [ -d "tmp/worktrees/$name" ]; then
56
+ wt="$root/tmp/worktrees/$name"
57
+ else
58
+ wt="$(bash scripts/worktree.sh new "$name" ${task_id:+--task-id "$task_id"})"
59
+ fi
60
+ mkdir -p tmp/runs tmp/xdg-empty
61
+
62
+ run_file="$root/agents/$runner/RUN.md"
63
+ [ -f "$run_file" ] || { echo "error: missing $run_file" >&2; exit 1; }
64
+ prompt="$task
65
+
66
+ $(cat "$run_file")"
67
+
68
+ status_file="$root/tmp/runs/$name.status"
69
+ events="$root/tmp/runs/$name.events.jsonl"
70
+ base="$(cat "tmp/meta/$name.base")"
71
+ started=$SECONDS
72
+ started_epoch="$(date +%s)"
73
+
74
+ report() { # report <state> [detail]: one line to stdout and the status file
75
+ local line
76
+ line="$(printf '[%02d:%02d] %s' $(((SECONDS - started) / 60)) $(((SECONDS - started) % 60)) "$1${2:+ - $2}")"
77
+ echo "$line model=$model runner=$runner"
78
+ echo "$line model=$model runner=$runner" >"$status_file"
79
+ }
80
+
81
+ # last_activity: the latest tool call or text of the agent, from the JSON event stream.
82
+ last_activity() {
83
+ python3 - "$events" <<'PY'
84
+ import json, sys
85
+ last = ""
86
+ try:
87
+ for line in open(sys.argv[1], errors="replace"):
88
+ try:
89
+ part = json.loads(line).get("part", {})
90
+ except ValueError:
91
+ continue
92
+ if part.get("type") == "tool":
93
+ last = "tool " + str(part.get("tool"))
94
+ elif part.get("type") == "text" and part.get("text"):
95
+ last = "text: " + " ".join(part["text"].split())
96
+ except OSError:
97
+ pass
98
+ print(last[:110])
99
+ PY
100
+ }
101
+
102
+ bash scripts/task-report.sh start "$task_id"
103
+ report "starting" "agent=$agent worktree=$wt${task_id:+ task=$task_id}"
104
+ GIT_CONFIG_COUNT=1 \
105
+ GIT_CONFIG_KEY_0=core.hooksPath \
106
+ GIT_CONFIG_VALUE_0="$root/scripts/coder-git-hooks" \
107
+ XDG_CONFIG_HOME="$root/tmp/xdg-empty" timeout "$limit" kilo run --dir "$wt" "${model_args[@]}" --agent "$agent" \
108
+ --format json --auto "$prompt" >"$events" 2>"$root/tmp/runs/$name.stderr.log" &
109
+ kilo_pid=$!
110
+
111
+ stalled=0
112
+ while kill -0 "$kilo_pid" 2>/dev/null; do
113
+ sleep "$ping_every" & wait $! || true
114
+ kill -0 "$kilo_pid" 2>/dev/null || break
115
+ changed="$(git -C "$wt" status --porcelain | wc -l)"
116
+ # Stall detector: an agent that has changed nothing after RUNNER_STALL seconds is stuck
117
+ # (seen with Kilo: 8+ minutes, 0 files). Kill it so the planner can switch runner early.
118
+ if [ "$changed" -eq 0 ] && [ $((SECONDS - started)) -ge "${RUNNER_STALL:-420}" ]; then
119
+ report "stalled" "no file changed after ${RUNNER_STALL:-420}s; killing the agent"
120
+ kill "$kilo_pid" 2>/dev/null || true
121
+ stalled=1
122
+ break
123
+ fi
124
+ last="$(last_activity)"
125
+ report "kilo running" "$changed file(s) changed; last: ${last:-<no output yet>}"
126
+ done
127
+ set +e
128
+ wait "$kilo_pid"
129
+ kilo_rc=$?
130
+ set -e
131
+ report "kilo finished" "exit $kilo_rc, verifying"
132
+
133
+ python3 - "$events" >"$root/tmp/runs/$name.result.txt" <<'PY'
134
+ import json, sys
135
+ out = []
136
+ for line in open(sys.argv[1], errors="replace"):
137
+ try:
138
+ part = json.loads(line).get("part", {})
139
+ except ValueError:
140
+ continue
141
+ if part.get("type") == "text" and part.get("text"):
142
+ out.append(part["text"])
143
+ print(out[-1] if out else "")
144
+ PY
145
+
146
+ # Commit the agent's work on task/<name> (path guard first); agents never commit themselves.
147
+ commit_rc=0
148
+ if [ "$kilo_rc" -eq 0 ]; then
149
+ task_head="$(git -C "$wt" rev-parse HEAD)"
150
+ if [ "$task_head" != "$base" ]; then
151
+ commit_rc=1
152
+ printf 'Kilo changed task HEAD itself; only the parent runner may commit.\n' >"tmp/runs/$name.commit.log"
153
+ elif ! git -C "$wt" add -A; then
154
+ commit_rc=1
155
+ printf 'Could not stage the Kilo task diff for guarded commit.\n' >"tmp/runs/$name.commit.log"
156
+ else
157
+ changed_lines="$(git -C "$wt" diff --cached --numstat "$base" \
158
+ | awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ { total += $1 + $2 } END { print total + 0 }')"
159
+ if [ "$changed_lines" -gt 300 ]; then
160
+ commit_rc=1
161
+ printf 'Kilo task diff has %s added/deleted lines (limit 300); split the task before committing.\n' \
162
+ "$changed_lines" >"tmp/runs/$name.commit.log"
163
+ else
164
+ title="$(printf '%s\n' "$task" | grep -m1 -v '^[[:space:]]*$' | cut -c1-72)"
165
+ bash scripts/worktree.sh commit "$name" --runner "$runner" --model "$model" \
166
+ ${task_id:+--task-id "$task_id"} --title "${title:-$name}" >"tmp/runs/$name.commit.log" 2>&1 || commit_rc=$?
167
+ fi
168
+ fi
169
+ fi
170
+ patch="$(bash scripts/worktree.sh patch "$name")"
171
+
172
+ verify_rc=0
173
+ {
174
+ if [ -n "${KILO_VERIFY_CMD:-}" ]; then
175
+ echo "== verify (KILO_VERIFY_CMD override) =="
176
+ (cd "$wt" && bash -c "$KILO_VERIFY_CMD") || verify_rc=1
177
+ elif ! bash "$root/scripts/needs-dotnet-checks.sh" "$wt" "$base" HEAD; then
178
+ echo "== conditional verification (no .NET files changed): script tests =="
179
+ (cd "$wt" && bash scripts/test_worktree.sh && bash scripts/test_runners.sh \
180
+ && python3 -m unittest discover -s scripts -p "test_*.py") || verify_rc=1
181
+ else
182
+ echo "== format =="; (cd "$wt" && dotnet format VibeEmulator.slnx --verify-no-changes) || verify_rc=1
183
+ echo "== build =="; (cd "$wt" && dotnet build VibeEmulator.slnx --no-incremental -warnaserror) || verify_rc=1
184
+ echo "== test =="; (cd "$wt" && dotnet test VibeEmulator.slnx --no-build) || verify_rc=1
185
+ fi
186
+ } >"tmp/runs/$name.verify.log" 2>&1
187
+
188
+ if [ "$commit_rc" -ne 0 ]; then
189
+ verify_rc=1
190
+ echo "== commit failed (exit $commit_rc): see tmp/runs/$name.commit.log ==" >>"tmp/runs/$name.verify.log"
191
+ fi
192
+ finished="$(date +%s)"
193
+ files_changed="$(git -C "$wt" diff --cached --name-only "$base" | wc -l)"
194
+ if [ "$kilo_rc" -ne 0 ]; then
195
+ verdict="agent_failed"
196
+ elif [ "$verify_rc" -ne 0 ]; then
197
+ verdict="verify_failed"
198
+ else
199
+ verdict="verified"
200
+ fi
201
+ if ! python3 scripts/run_summary.py "$root/tmp/runs/$name.summary.json" name="$name" runner="$runner" model="$model" \
202
+ agent="$agent" task_id="$task_id" started="$started_epoch" finished="$finished" \
203
+ agent_exit="$kilo_rc" verify_exit="$verify_rc" verdict="$verdict" \
204
+ patch="$patch" files_changed="$files_changed"; then
205
+ echo "warning: could not write run summary (tmp/runs/$name.summary.json)" >&2
206
+ fi
207
+
208
+ report "done" "kilo exit $kilo_rc, verification $([ "$verify_rc" -eq 0 ] && echo PASSED || echo FAILED)"
209
+ echo "runner: $runner"
210
+ echo "model: $model"
211
+ echo "kilo exit: $kilo_rc"
212
+ echo "verification: $([ "$verify_rc" -eq 0 ] && echo PASSED || echo FAILED) (tmp/runs/$name.verify.log)"
213
+ echo "patch: $patch"
214
+ git -C "$wt" diff --cached --stat "$base" | tail -n 15
215
+ if [ "$kilo_rc" -ne 0 ]; then
216
+ bash scripts/task-report.sh note "$task_id" "kilo '$name' failed (exit 3) model=$model; see tmp/runs/$name.verify.log"
217
+ bash scripts/task-report.sh close "$task_id" red "agent exit $kilo_rc; see tmp/runs/$name.verify.log"
218
+ exit 3
219
+ elif [ "$verify_rc" -ne 0 ]; then
220
+ bash scripts/task-report.sh note "$task_id" "kilo '$name' failed (exit 1) model=$model; see tmp/runs/$name.verify.log"
221
+ bash scripts/task-report.sh close "$task_id" red "verification failed; see tmp/runs/$name.verify.log"
222
+ exit 1
223
+ else
224
+ bash scripts/task-report.sh note "$task_id" "kilo '$name' verified model=$model; patch tmp/patches/$name.patch"
225
+ bash scripts/task-report.sh close "$task_id" green
226
+ fi
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Exit 0 when the diff needs the .NET checks (it touches C#/MSBuild/format configuration), 1 when the
3
+ # change is docs/scripts only, so the caller can run the matching script tests instead (task 6.6).
4
+ #
5
+ # scripts/needs-dotnet-checks.sh <dir> <base> [<head>]
6
+ #
7
+ # With an empty <base> the working tree against HEAD is inspected.
8
+ set -euo pipefail
9
+
10
+ dir="${1:-.}"
11
+ base="${2:-}"
12
+ head="${3:-HEAD}"
13
+
14
+ if [ -n "$base" ]; then
15
+ files="$(git -C "$dir" diff --name-only "$base" "$head" 2>/dev/null || true)"
16
+ else
17
+ files="$(git -C "$dir" diff --name-only HEAD 2>/dev/null || true)"
18
+ files+=$'\n'"$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null || true)"
19
+ fi
20
+
21
+ if printf '%s\n' "$files" | grep -qE '\.(cs|csproj|props|targets|slnx|sln|editorconfig)$|(^|/)(Directory\.[A-Za-z]+\.props|stylecop\.json|global\.json|\.editorconfig)$'; then
22
+ exit 0
23
+ fi
24
+ exit 1
scripts/plan-sprint.sh ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # Asks the Kilo planner agent (OpenAI Sol via the Kilo Gateway) to PROPOSE a sprint, then validates it.
3
+ # Nothing is written to the tracker: review tmp/plans/<name>.json, then run scripts/apply-plan.sh <name>.
4
+ # Design: knowledge/local-planner-design.md
5
+ #
6
+ # scripts/plan-sprint.sh <name> --goal "<what the sprint should achieve>" [--goal-file F]
7
+ # [--model M] [--variant V] [--no-lint]
8
+ #
9
+ # Env: VIBE_PLANNER_MODEL (default kilo/openai/gpt-6.1-sol), VIBE_PLANNER_VARIANT (default high),
10
+ # PLANNER_TIMEOUT (default 900 s), KILO_BIN (default kilo; tests put a stub here).
11
+ # Output: tmp/plans/<name>.{input.md,events.jsonl,raw.txt,json,stderr.log}, tmp/tasks/<spec_name>.md
12
+ # Exit: 0 valid plan | 1 usage | 3 Kilo failed or timed out | 4 plan invalid (specs are not written)
13
+ set -euo pipefail
14
+
15
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
16
+ cd "$root"
17
+
18
+ usage() { echo "usage: $0 <name> --goal \"<text>\" [--goal-file F] [--model M] [--variant V] [--no-lint]" >&2; exit 1; }
19
+
20
+ name="${1:-}"
21
+ [[ "$name" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "error: <name> must match ^[a-z0-9][a-z0-9-]*\$" >&2; usage; }
22
+ shift
23
+ goal="" model="${VIBE_PLANNER_MODEL:-kilo/openai/gpt-6.1-sol}" variant="${VIBE_PLANNER_VARIANT:-high}" lint_flag=""
24
+ while [ $# -gt 0 ]; do
25
+ case "$1" in
26
+ --goal) goal="${2:?--goal needs text}"; shift 2 ;;
27
+ --goal-file) [ -f "${2:?--goal-file needs a path}" ] || { echo "error: no such file '$2'" >&2; exit 1; }
28
+ goal="$(cat "$2")"; shift 2 ;;
29
+ --model) model="${2:?--model needs a value}"; shift 2 ;;
30
+ --variant) variant="${2:?--variant needs a value}"; shift 2 ;;
31
+ --no-lint) lint_flag="--no-lint"; shift ;;
32
+ *) echo "unknown option '$1'" >&2; usage ;;
33
+ esac
34
+ done
35
+ [ -n "$goal" ] || { echo "error: --goal is required" >&2; usage; }
36
+
37
+ kilo_bin="${KILO_BIN:-kilo}"
38
+ limit="${PLANNER_TIMEOUT:-900}"
39
+ mkdir -p tmp/plans tmp/tasks tmp/xdg-empty
40
+ input="tmp/plans/$name.input.md"
41
+ events="tmp/plans/$name.events.jsonl"
42
+ raw="tmp/plans/$name.raw.txt"
43
+ plan="tmp/plans/$name.json"
44
+ rm -f "$plan" "tmp/plans/$name.applied"
45
+
46
+ # Input bundle: only tracker state, recent history and the goal go to the model (see the privacy note in the design).
47
+ {
48
+ echo "# Planning input: $name"
49
+ echo
50
+ echo "## Goal"
51
+ printf '%s\n' "$goal"
52
+ echo
53
+ echo "## Tracker overview"
54
+ python3 scripts/tasks.py overview
55
+ echo
56
+ echo "## Recent history"
57
+ git log --oneline -15
58
+ echo
59
+ echo "## Current branch"
60
+ git branch --show-current
61
+ } >"$input"
62
+
63
+ prompt="Plan the next sprint for this repository. Follow agents/kilo/PLANNER.md exactly and answer with the single JSON block it specifies. Input bundle: $input"
64
+
65
+ echo "planning '$name' with $model (variant $variant), timeout ${limit}s" >&2
66
+ rc=0
67
+ XDG_CONFIG_HOME="$root/tmp/xdg-empty" timeout "$limit" "$kilo_bin" run --dir "$root" --agent planner -m "$model" \
68
+ --variant "$variant" --format json "$prompt" >"$events" 2>"tmp/plans/$name.stderr.log" || rc=$?
69
+ if [ "$rc" -ne 0 ]; then
70
+ echo "error: planner agent failed (exit $rc); see tmp/plans/$name.stderr.log" >&2
71
+ exit 3
72
+ fi
73
+
74
+ python3 - "$events" >"$raw" <<'PY'
75
+ import json, sys
76
+ texts = []
77
+ for line in open(sys.argv[1], errors="replace"):
78
+ try:
79
+ part = json.loads(line).get("part", {})
80
+ except ValueError:
81
+ continue
82
+ if part.get("type") == "text" and part.get("text"):
83
+ texts.append(part["text"])
84
+ print(texts[-1] if texts else "")
85
+ PY
86
+
87
+ python3 scripts/sprint_plan.py extract "$raw" "$plan" || { echo "error: no usable plan in $raw" >&2; exit 4; }
88
+ python3 scripts/sprint_plan.py validate "$plan" $lint_flag || { echo "error: plan rejected; raw answer kept in $raw" >&2; exit 4; }
89
+ python3 scripts/sprint_plan.py specs "$plan" >/dev/null
90
+ python3 scripts/sprint_plan.py preview "$plan"
91
+ echo ""
92
+ echo "plan: $plan (apply with: scripts/apply-plan.sh $name)"
scripts/run-task.sh ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # One entry point for the orchestrator to delegate one task to a coder runner.
3
+ # Codex is the default; Junie and Kilo remain explicit opt-ins. There is no fallback by default.
4
+ #
5
+ # scripts/run-task.sh <name> --file tmp/tasks/<name>.md [--task-id <id>] [--runner junie|kilo|codex] [--fallback kilo|codex|none]
6
+ #
7
+ # Default runner codex, default fallback none. Run it in the background (Bash run_in_background): it prints the
8
+ # runner's report and exits with the runner's code (0 verified, 1 verification failed,
9
+ # 3 agent failed or stalled on every attempt). Every attempt is appended to
10
+ # tmp/runs/<name>.attempts (runner, exit code, seconds). A verification failure (exit 1)
11
+ # is NOT retried on another runner: it needs a precise fix list from the planner.
12
+ set -euo pipefail
13
+
14
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
15
+ cd "$root"
16
+
17
+ name="${1:?usage: run-task.sh <name> --file <spec> [--task-id <id>] [--runner kilo|junie|codex] [--fallback none]}"
18
+ shift
19
+ spec="" task_id="" runner="codex" fallback="none"
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --file) spec="${2:?--file needs a path}"; shift 2 ;;
23
+ --task-id) task_id="${2:?--task-id needs a value}"; shift 2 ;;
24
+ --runner) runner="${2:?--runner needs a value}"; shift 2 ;;
25
+ --fallback) fallback="${2:?--fallback needs a value}"; shift 2 ;;
26
+ *) echo "unknown option '$1'" >&2; exit 1 ;;
27
+ esac
28
+ done
29
+ [ -n "$spec" ] && [ -f "$spec" ] || { echo "spec file missing: '$spec'" >&2; exit 1; }
30
+ bash scripts/spec-lint.sh "$spec" || { echo "run-task: spec failed lint" >&2; exit 1; }
31
+ case "$runner" in junie|kilo|codex) ;; *) echo "unknown runner '$runner'" >&2; exit 1 ;; esac
32
+ case "$fallback" in junie|kilo|codex|none) ;; *) echo "unknown fallback '$fallback'" >&2; exit 1 ;; esac
33
+ [ "$fallback" != "$runner" ] || fallback="none"
34
+ mkdir -p tmp/runs
35
+
36
+ # Record the spec's "Files:" header as scope links, used by `tasks.py plan-batch`.
37
+ if [ -n "$task_id" ]; then
38
+ python3 - "$spec" > "tmp/runs/$name.scopes" <<'PY'
39
+ import sys
40
+ lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
41
+ in_block, out = False, []
42
+ for line in lines:
43
+ text = line.strip()
44
+ if text[:6].lower() == "files:":
45
+ in_block = True
46
+ rest = text[6:].strip()
47
+ if rest:
48
+ out += [x.strip() for x in rest.split(",")]
49
+ continue
50
+ if in_block:
51
+ if not text or (text[0].isalpha() and text.endswith(":")):
52
+ in_block = False
53
+ else:
54
+ out += [x.strip() for x in text.lstrip("-").split(",")]
55
+ for value in out:
56
+ value = value.strip()
57
+ if value:
58
+ print(value)
59
+ PY
60
+ while IFS= read -r scope; do
61
+ [ -n "$scope" ] && python3 scripts/tasks.py link "$task_id" scope "$scope" >/dev/null 2>&1 || true
62
+ done < "tmp/runs/$name.scopes"
63
+ fi
64
+
65
+ attempt() { # attempt <runner>: run one runner, record it, leave its exit code in $rc
66
+ local r="$1" began=$SECONDS
67
+ rc=0
68
+ bash "scripts/$r-task.sh" "$name" --file "$spec" ${task_id:+--task-id "$task_id"} || rc=$?
69
+ printf '%s runner=%s exit=%s seconds=%s\n' "$(date +%H:%M:%S)" "$r" "$rc" "$((SECONDS - began))" >>"tmp/runs/$name.attempts"
70
+ }
71
+
72
+ rc=0
73
+ attempt "$runner"
74
+ if [ "$rc" -eq 3 ] && [ "$fallback" != "none" ]; then
75
+ echo "run-task: $runner failed or stalled (exit 3); retrying once with $fallback in the same worktree" >&2
76
+ bash scripts/task-report.sh note "$task_id" "run-task: $runner failed or stalled; falling back to $fallback" || true
77
+ attempt "$fallback"
78
+ fi
79
+ exit "$rc"
scripts/run.sh ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ cd "$root"
6
+
7
+ exec dotnet run --project src/VibeEmulator.Cli -- "$@"
scripts/run_stats.py ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Delivery-speed statistics for the delegation runners.
3
+
4
+ Reads tmp/runs/<name>.summary.json when present (exact) and falls back to the last line of
5
+ tmp/runs/<name>.status for older runs ("[mm:ss] done - junie exit 0, verification PASSED").
6
+ A run whose status never reached "done" counts as unfinished (killed, stalled or crashed).
7
+ Prints, per runner: runs, verified, failed, unfinished, median and p90 duration, plus the
8
+ share of runs that did not end verified ("rework rate"). Standard library only, read-only.
9
+
10
+ Usage: scripts/run_stats.py [--runs-dir DIR] [--json] [--since YYYY-MM-DD]
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import re
17
+ import statistics
18
+ import sys
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ LAST_STAMP = re.compile(r"^\[(\d+):(\d{2})\]\s+(.*)$")
23
+ RUNNER_TAIL = re.compile(r"\brunner=(\w+)")
24
+ DONE_RUNNER = re.compile(r"^done - (junie|kilo|codex|haiku) exit (\d+), verification (PASSED|FAILED)")
25
+
26
+
27
+ def parse_status(path: Path) -> dict | None:
28
+ """Best-effort record for a run that has only a .status file."""
29
+ try:
30
+ lines = [ln for ln in path.read_text(encoding="utf-8", errors="replace").splitlines() if ln.strip()]
31
+ except OSError:
32
+ return None
33
+ if not lines:
34
+ return None
35
+ match = LAST_STAMP.match(lines[-1])
36
+ if not match:
37
+ return None
38
+ seconds = int(match.group(1)) * 60 + int(match.group(2))
39
+ body = RUNNER_TAIL.sub("", match.group(3)).strip()
40
+ model_match = re.search(r"\bmodel=(\S+)", match.group(3))
41
+ runner_match = RUNNER_TAIL.search(match.group(3))
42
+ done = DONE_RUNNER.match(body.split(" model=")[0].strip())
43
+ record = {"name": path.stem, "duration_s": seconds, "source": "status",
44
+ "runner": runner_match.group(1) if runner_match else None,
45
+ "model": model_match.group(1) if model_match else None}
46
+ if done:
47
+ record["runner"] = record["runner"] or done.group(1)
48
+ agent_exit, verify = int(done.group(2)), done.group(3)
49
+ record["verdict"] = ("agent_failed" if agent_exit else
50
+ "verified" if verify == "PASSED" else "verify_failed")
51
+ else:
52
+ record["verdict"] = "unfinished"
53
+ return record
54
+
55
+
56
+ def load_runs(runs_dir: Path) -> list[dict]:
57
+ """All runs found in runs_dir; a summary.json wins over the .status of the same name."""
58
+ runs: dict[str, dict] = {}
59
+ for status in sorted(runs_dir.glob("*.status")):
60
+ record = parse_status(status)
61
+ if record:
62
+ record["mtime"] = status.stat().st_mtime
63
+ runs[record["name"]] = record
64
+ for summary in sorted(runs_dir.glob("*.summary.json")):
65
+ try:
66
+ data = json.loads(summary.read_text(encoding="utf-8"))
67
+ except (OSError, ValueError):
68
+ continue
69
+ name = data.get("name") or summary.name.removesuffix(".summary.json")
70
+ record = {"name": name, "duration_s": int(data.get("duration_s", 0)), "source": "summary",
71
+ "runner": data.get("runner"), "model": data.get("model"),
72
+ "verdict": data.get("verdict", "unfinished"), "mtime": summary.stat().st_mtime}
73
+ runs[name] = record
74
+ return list(runs.values())
75
+
76
+
77
+ def percentile(values: list[int], pct: float) -> int:
78
+ ordered = sorted(values)
79
+ index = max(0, min(len(ordered) - 1, round(pct * (len(ordered) - 1))))
80
+ return ordered[index]
81
+
82
+
83
+ def summarize(runs: list[dict]) -> dict:
84
+ """Per-runner aggregates plus an overall block."""
85
+ groups: dict[str, list[dict]] = {}
86
+ for run in runs:
87
+ groups.setdefault(run.get("runner") or "unknown", []).append(run)
88
+ groups["ALL"] = list(runs)
89
+ result = {}
90
+ for runner, items in sorted(groups.items()):
91
+ finished = [r for r in items if r["verdict"] != "unfinished"]
92
+ durations = [r["duration_s"] for r in finished]
93
+ verified = sum(1 for r in items if r["verdict"] == "verified")
94
+ result[runner] = {
95
+ "runs": len(items),
96
+ "verified": verified,
97
+ "verify_failed": sum(1 for r in items if r["verdict"] == "verify_failed"),
98
+ "agent_failed": sum(1 for r in items if r["verdict"] == "agent_failed"),
99
+ "unfinished": sum(1 for r in items if r["verdict"] == "unfinished"),
100
+ "median_s": int(statistics.median(durations)) if durations else None,
101
+ "p90_s": percentile(durations, 0.9) if durations else None,
102
+ "rework_rate": round(1 - verified / len(items), 2) if items else None,
103
+ }
104
+ return result
105
+
106
+
107
+ def fmt(seconds: int | None) -> str:
108
+ return "-" if seconds is None else f"{seconds // 60}:{seconds % 60:02d}"
109
+
110
+
111
+ def main(argv: list[str]) -> int:
112
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
113
+ parser.add_argument("--runs-dir", default=str(Path(__file__).resolve().parent.parent / "tmp" / "runs"))
114
+ parser.add_argument("--json", action="store_true", help="print the aggregates as JSON")
115
+ parser.add_argument("--since", help="only runs whose status/summary file is newer than YYYY-MM-DD")
116
+ args = parser.parse_args(argv)
117
+ runs_dir = Path(args.runs_dir)
118
+ if not runs_dir.is_dir():
119
+ print(f"no runs directory: {runs_dir}", file=sys.stderr)
120
+ return 1
121
+ runs = load_runs(runs_dir)
122
+ if args.since:
123
+ cutoff = datetime.strptime(args.since, "%Y-%m-%d").timestamp()
124
+ runs = [r for r in runs if r["mtime"] >= cutoff]
125
+ summary = summarize(runs)
126
+ if args.json:
127
+ print(json.dumps(summary, indent=2, sort_keys=True))
128
+ return 0
129
+ print(f"{'runner':<9}{'runs':>5}{'ok':>5}{'vfail':>6}{'afail':>6}{'unfin':>6}{'median':>8}{'p90':>8}{'rework':>8}")
130
+ for runner, s in summary.items():
131
+ rework = "-" if s["rework_rate"] is None else f"{int(s['rework_rate'] * 100)}%"
132
+ print(f"{runner:<9}{s['runs']:>5}{s['verified']:>5}{s['verify_failed']:>6}{s['agent_failed']:>6}"
133
+ f"{s['unfinished']:>6}{fmt(s['median_s']):>8}{fmt(s['p90_s']):>8}{rework:>8}")
134
+ slow = sorted((r for r in runs if r["verdict"] != "unfinished"), key=lambda r: -r["duration_s"])[:3]
135
+ if slow:
136
+ print("slowest: " + ", ".join(f"{r['name']} {fmt(r['duration_s'])} ({r.get('runner') or '?'})" for r in slow))
137
+ return 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ sys.exit(main(sys.argv[1:]))
scripts/run_summary.py ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """Writes the machine-readable run summary for the delegation runners.
3
+
4
+ Called by scripts/junie-task.sh, scripts/kilo-task.sh and scripts/codex-task.sh when a run finishes. Writes one
5
+ JSON object to <outfile> describing the run (identity, timing, exits and verdict), so
6
+ scripts/junie-status.sh and other tools can report on finished runs without parsing logs.
7
+ Standard library only. The runners pass the values as key=value arguments, so bash never
8
+ quotes them itself and odd values (spaces, special characters) cannot break the output.
9
+
10
+ Usage: scripts/run_summary.py <outfile> <key=value> [<key=value> ...]
11
+
12
+ Keys:
13
+ name, runner, model, task_id, patch str (required)
14
+ agent str (required for runner=kilo only)
15
+ started, finished int, epoch seconds (required)
16
+ agent_exit, verify_exit int (required)
17
+ files_changed int (required)
18
+ verdict verified|verify_failed|agent_failed
19
+
20
+ duration_s is derived as finished - started. Unknown keys, a wrong runner or verdict, or
21
+ a missing field are errors and nothing is written. Output is deterministic (sorted keys,
22
+ 2-space indent, trailing newline).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sys
28
+
29
+ STR_FIELDS = ("name", "runner", "model", "agent", "task_id", "patch", "verdict")
30
+ INT_FIELDS = ("started", "finished", "agent_exit", "verify_exit", "files_changed")
31
+ RUNNERS = ("junie", "kilo", "codex")
32
+ VERDICTS = ("verified", "verify_failed", "agent_failed")
33
+
34
+
35
+ def main(argv: list[str]) -> int:
36
+ if len(argv) < 3:
37
+ print(f"usage: {argv[0]} <outfile> <key=value> ...", file=sys.stderr)
38
+ return 2
39
+ outfile = argv[1]
40
+
41
+ data: dict[str, object] = {}
42
+ for pair in argv[2:]:
43
+ key, sep, value = pair.partition("=")
44
+ if not sep:
45
+ print(f"error: expected key=value, got {pair!r}", file=sys.stderr)
46
+ return 2
47
+ if key in INT_FIELDS:
48
+ try:
49
+ data[key] = int(value)
50
+ except ValueError:
51
+ print(f"error: field {key!r} must be an integer, got {value!r}", file=sys.stderr)
52
+ return 2
53
+ elif key in STR_FIELDS:
54
+ data[key] = value
55
+ else:
56
+ print(f"error: unknown field {key!r}", file=sys.stderr)
57
+ return 2
58
+
59
+ required = (set(STR_FIELDS) | set(INT_FIELDS)) - {"agent"}
60
+ missing = sorted(required - set(data))
61
+ if missing:
62
+ print(f"error: missing fields: {', '.join(missing)}", file=sys.stderr)
63
+ return 2
64
+
65
+ if data["runner"] not in RUNNERS:
66
+ print(f"error: runner must be one of {', '.join(RUNNERS)}, got {data['runner']!r}", file=sys.stderr)
67
+ return 2
68
+ if data["verdict"] not in VERDICTS:
69
+ print(f"error: verdict must be one of {', '.join(VERDICTS)}, got {data['verdict']!r}", file=sys.stderr)
70
+ return 2
71
+ if data["runner"] == "kilo" and "agent" not in data:
72
+ print("error: field 'agent' is required for runner=kilo", file=sys.stderr)
73
+ return 2
74
+ if data["runner"] == "junie" and "agent" in data:
75
+ print("error: field 'agent' is only allowed for runner=kilo", file=sys.stderr)
76
+ return 2
77
+
78
+ duration = int(data["finished"]) - int(data["started"]) # type: ignore[arg-type]
79
+ if duration < 0:
80
+ print("error: finished is before started", file=sys.stderr)
81
+ return 2
82
+ if int(data["files_changed"]) < 0: # type: ignore[arg-type]
83
+ print("error: files_changed must be >= 0", file=sys.stderr)
84
+ return 2
85
+ data["duration_s"] = duration
86
+
87
+ with open(outfile, "w", encoding="utf-8") as fh:
88
+ json.dump(data, fh, sort_keys=True, indent=2)
89
+ fh.write("\n")
90
+ return 0
91
+
92
+
93
+ if __name__ == "__main__":
94
+ sys.exit(main(sys.argv))