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/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
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))
|