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/spec-lint.sh
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# spec-lint.sh — quality gate for delegated task specs.
|
|
3
|
+
# Rejects under-specified or oversized specs before a runner starts.
|
|
4
|
+
#
|
|
5
|
+
# Usage: scripts/spec-lint.sh <spec-file>
|
|
6
|
+
# Exit: 0 valid | 1 violations | 2 usage error
|
|
7
|
+
# Env: SPEC_LINT=off → skip (prints "spec-lint: skipped", exit 0)
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
# ── escape hatch ──────────────────────────────────────────────────────────
|
|
11
|
+
if [ "${SPEC_LINT:-}" = "off" ]; then
|
|
12
|
+
echo "spec-lint: skipped"
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# ── usage ─────────────────────────────────────────────────────────────────
|
|
17
|
+
if [ $# -lt 1 ]; then
|
|
18
|
+
echo "spec-lint: usage: spec-lint.sh <spec-file>" >&2
|
|
19
|
+
exit 2
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
spec="$1"
|
|
23
|
+
if [ ! -f "$spec" ]; then
|
|
24
|
+
echo "spec-lint: file not found: $spec" >&2
|
|
25
|
+
exit 2
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
violations=0
|
|
29
|
+
|
|
30
|
+
violate() {
|
|
31
|
+
echo "spec-lint: $1: $2" >&2
|
|
32
|
+
violations=$((violations + 1))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# ── rule 1: required sections with content ───────────────────────────────
|
|
36
|
+
# Use an array to preserve multi-word section names like "Live check"
|
|
37
|
+
required_sections=("Goal" "Files" "Tests" "Acceptance" "Live check")
|
|
38
|
+
|
|
39
|
+
for section in "${required_sections[@]}"; do
|
|
40
|
+
# Find the heading line number (## Section exactly)
|
|
41
|
+
heading_line=$(grep -n "^## ${section}$" "$spec" 2>/dev/null | head -1 | cut -d: -f1 || true)
|
|
42
|
+
|
|
43
|
+
if [ -z "$heading_line" ]; then
|
|
44
|
+
violate "missing-section" "## ${section} not found"
|
|
45
|
+
continue
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Find the next heading after this one (or EOF)
|
|
49
|
+
total_lines=$(wc -l < "$spec")
|
|
50
|
+
next_heading=$(tail -n +"$((heading_line + 1))" "$spec" | grep -n "^## " | head -1 | cut -d: -f1 || true)
|
|
51
|
+
|
|
52
|
+
if [ -z "$next_heading" ]; then
|
|
53
|
+
# Section extends to EOF — count non-empty lines after the heading
|
|
54
|
+
end_line=$total_lines
|
|
55
|
+
else
|
|
56
|
+
end_line=$((heading_line + next_heading - 1))
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Extract content lines between heading and next heading (exclusive of both)
|
|
60
|
+
content_lines=$(sed -n "$((heading_line + 1)),${end_line}p" "$spec" | grep -c '[^[:space:]]' || true)
|
|
61
|
+
|
|
62
|
+
if [ "$content_lines" -eq 0 ]; then
|
|
63
|
+
violate "empty-section" "## ${section} has no content"
|
|
64
|
+
fi
|
|
65
|
+
done
|
|
66
|
+
|
|
67
|
+
# ── rule 2: Files section — 1..8 entries ─────────────────────────────────
|
|
68
|
+
files_heading=$(grep -n "^## Files$" "$spec" 2>/dev/null | head -1 | cut -d: -f1 || true)
|
|
69
|
+
|
|
70
|
+
if [ -n "$files_heading" ]; then
|
|
71
|
+
# Determine the range of the Files section
|
|
72
|
+
total_lines=$(wc -l < "$spec")
|
|
73
|
+
next_heading=$(tail -n +"$((files_heading + 1))" "$spec" | grep -n "^## " | head -1 | cut -d: -f1 || true)
|
|
74
|
+
|
|
75
|
+
if [ -z "$next_heading" ]; then
|
|
76
|
+
end_line=$total_lines
|
|
77
|
+
else
|
|
78
|
+
end_line=$((files_heading + next_heading - 1))
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# Count entries: lines starting with "- " (optionally prefixed by NEW/EDIT)
|
|
82
|
+
file_count=$(sed -n "$((files_heading + 1)),${end_line}p" "$spec" | grep -c "^- " || true)
|
|
83
|
+
|
|
84
|
+
if [ "$file_count" -eq 0 ]; then
|
|
85
|
+
violate "files-count" "## Files lists 0 entries (need 1-8)"
|
|
86
|
+
elif [ "$file_count" -gt 8 ]; then
|
|
87
|
+
violate "files-count" "## Files lists ${file_count} entries (max 8)"
|
|
88
|
+
fi
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# ── rule 3: size budget ──────────────────────────────────────────────────
|
|
92
|
+
line_count=$(wc -l < "$spec")
|
|
93
|
+
byte_count=$(wc -c < "$spec")
|
|
94
|
+
|
|
95
|
+
if [ "$line_count" -gt 120 ]; then
|
|
96
|
+
violate "size-lines" "spec is ${line_count} lines (max 120)"
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
if [ "$byte_count" -gt 9000 ]; then
|
|
100
|
+
violate "size-bytes" "spec is ${byte_count} bytes (max 9000)"
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# ── rule 4: Acceptance or Live check mentions a concrete command ──────────
|
|
104
|
+
has_command=0
|
|
105
|
+
|
|
106
|
+
for section in "Acceptance" "Live check"; do
|
|
107
|
+
sec_heading=$(grep -n "^## ${section}$" "$spec" 2>/dev/null | head -1 | cut -d: -f1 || true)
|
|
108
|
+
|
|
109
|
+
if [ -n "$sec_heading" ]; then
|
|
110
|
+
total_lines=$(wc -l < "$spec")
|
|
111
|
+
next_heading=$(tail -n +"$((sec_heading + 1))" "$spec" | grep -n "^## " | head -1 | cut -d: -f1 || true)
|
|
112
|
+
|
|
113
|
+
if [ -z "$next_heading" ]; then
|
|
114
|
+
end_line=$total_lines
|
|
115
|
+
else
|
|
116
|
+
end_line=$((sec_heading + next_heading - 1))
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
# Check for backticks or lines starting with "$ "
|
|
120
|
+
has_backtick=$(sed -n "$((sec_heading + 1)),${end_line}p" "$spec" | grep -c '`' || true)
|
|
121
|
+
has_dollar=$(sed -n "$((sec_heading + 1)),${end_line}p" "$spec" | grep -c '^\$ ' || true)
|
|
122
|
+
|
|
123
|
+
if [ "$has_backtick" -gt 0 ] || [ "$has_dollar" -gt 0 ]; then
|
|
124
|
+
has_command=1
|
|
125
|
+
fi
|
|
126
|
+
fi
|
|
127
|
+
done
|
|
128
|
+
|
|
129
|
+
if [ "$has_command" -eq 0 ]; then
|
|
130
|
+
violate "no-command" "## Acceptance and ## Live check do not mention a concrete command (backticks or '\$ ')"
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
# ── result ────────────────────────────────────────────────────────────────
|
|
134
|
+
if [ "$violations" -gt 0 ]; then
|
|
135
|
+
exit 1
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
echo "spec-lint: OK"
|
|
139
|
+
exit 0
|
scripts/sprint-auto.sh
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Automated sprint lifecycle (opt-in via --yolo-force).
|
|
3
|
+
# Usage: scripts/sprint-auto.sh --yolo-force --goal "..." --run-name NAME [--answers FILE] [--plan FILE]
|
|
4
|
+
#
|
|
5
|
+
# Batches unresolved decisions before mutation, validates and imports a plan,
|
|
6
|
+
# delegates tasks, accepts only verified zero-flag MRs, and finishes into main
|
|
7
|
+
# without pushing. Existing commands are NOT automatic when --yolo-force is absent.
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
11
|
+
cd "$root"
|
|
12
|
+
|
|
13
|
+
yolo=0
|
|
14
|
+
goal="" run_name="" answers="" plan=""
|
|
15
|
+
while [ $# -gt 0 ]; do
|
|
16
|
+
case "$1" in
|
|
17
|
+
--yolo-force) yolo=1; shift ;;
|
|
18
|
+
--goal) goal="${2:?--goal needs a value}"; shift 2 ;;
|
|
19
|
+
--run-name) run_name="${2:?--run-name needs a value}"; shift 2 ;;
|
|
20
|
+
--answers) answers="${2:?--answers needs a value}"; shift 2 ;;
|
|
21
|
+
--plan) plan="${2:?--plan needs a value}"; shift 2 ;;
|
|
22
|
+
*) echo "unknown option '$1'" >&2; exit 1 ;;
|
|
23
|
+
esac
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
[ "$yolo" -eq 1 ] || { echo "error: --yolo-force is required for automated lifecycle" >&2; exit 1; }
|
|
27
|
+
[ -n "$goal" ] || { echo "error: --goal is required" >&2; exit 1; }
|
|
28
|
+
[ -n "$run_name" ] || { echo "error: --run-name is required" >&2; exit 1; }
|
|
29
|
+
|
|
30
|
+
# Default answers file
|
|
31
|
+
if [ -z "$answers" ]; then
|
|
32
|
+
answers="tmp/plans/${run_name}.answers.json"
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
echo "=== sprint-auto: preflight ===" >&2
|
|
36
|
+
# Step 1: Preflight (read-only)
|
|
37
|
+
preflight_args=(python3 -B scripts/sprint_auto_preflight.py preflight-probe --goal "$goal")
|
|
38
|
+
# If answers file exists, use collect instead
|
|
39
|
+
if [ -f "$answers" ]; then
|
|
40
|
+
preflight_args=(python3 -B scripts/sprint_auto_preflight.py collect --root . --answers "$answers")
|
|
41
|
+
if [ -n "$plan" ]; then
|
|
42
|
+
preflight_args+=(--request <(echo "{\"goal\":\"$goal\"}"))
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
rc=0
|
|
47
|
+
"${preflight_args[@]}" > tmp/plans/${run_name}.preflight.json 2>&1 || rc=$?
|
|
48
|
+
if [ "$rc" -eq 2 ]; then
|
|
49
|
+
echo "sprint-auto: missing answers (exit 2) — resolve questions in ${run_name}.preflight.json" >&2
|
|
50
|
+
cat tmp/plans/${run_name}.preflight.json >&2
|
|
51
|
+
exit 2
|
|
52
|
+
fi
|
|
53
|
+
if [ "$rc" -eq 4 ]; then
|
|
54
|
+
echo "sprint-auto: safety blockers (exit 4)" >&2
|
|
55
|
+
cat tmp/plans/${run_name}.preflight.json >&2
|
|
56
|
+
exit 4
|
|
57
|
+
fi
|
|
58
|
+
if [ "$rc" -ne 0 ]; then
|
|
59
|
+
echo "sprint-auto: preflight failed (exit $rc)" >&2
|
|
60
|
+
exit "$rc"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
echo "=== sprint-auto: prepare ===" >&2
|
|
64
|
+
prepare_args=(python3 -B scripts/sprint_auto_prepare.py --root . --run-name "$run_name" --goal "$goal" --answers "$answers")
|
|
65
|
+
[ -n "$plan" ] && prepare_args+=(--plan "$plan")
|
|
66
|
+
rc=0
|
|
67
|
+
"${prepare_args[@]}" > tmp/plans/${run_name}.prepare.json 2>&1 || rc=$?
|
|
68
|
+
if [ "$rc" -ne 0 ]; then
|
|
69
|
+
echo "sprint-auto: prepare failed (exit $rc)" >&2
|
|
70
|
+
cat tmp/plans/${run_name}.prepare.json >&2
|
|
71
|
+
exit "$rc"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
echo "=== sprint-auto: prepared ===" >&2
|
|
75
|
+
cat tmp/plans/${run_name}.prepare.json >&2
|
|
76
|
+
|
|
77
|
+
echo "=== sprint-auto: done (automation components wired) ===" >&2
|
|
78
|
+
echo "Run complete. Use worktree.sh for task execution and merge." >&2
|
|
79
|
+
exit 0
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Execute and accept guarded tasks for the automated sprint lifecycle (Sprint 12, task 12.6).
|
|
2
|
+
|
|
3
|
+
Serial task loop bound to a target sprint. Auto-accepts only verified scope +
|
|
4
|
+
green Rules checks + zero red flags. Tracker 'done' alone is insufficient evidence.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
14
|
+
import sprint_auto_state as sas
|
|
15
|
+
import tasks # noqa: E402
|
|
16
|
+
|
|
17
|
+
RUNNER_DEFAULT = "codex"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_git(root: Path, *args: str) -> str:
|
|
21
|
+
try:
|
|
22
|
+
r = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=30)
|
|
23
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
24
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_cmd(cmd: list[str], cwd: Path, timeout: int = 120) -> tuple[int, str, str]:
|
|
29
|
+
"""Run a command; return (exit_code, stdout, stderr)."""
|
|
30
|
+
try:
|
|
31
|
+
r = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, timeout=timeout)
|
|
32
|
+
return r.returncode, r.stdout, r.stderr
|
|
33
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
34
|
+
return 1, "", str(exc)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_scope_conformity(task_files: list[str], allowed_scopes: list[str]) -> tuple[bool, list[str]]:
|
|
38
|
+
"""Verify that all task files fall within allowed scopes.
|
|
39
|
+
|
|
40
|
+
Returns (conforms, list of violating files).
|
|
41
|
+
"""
|
|
42
|
+
if not allowed_scopes:
|
|
43
|
+
return True, []
|
|
44
|
+
violations = []
|
|
45
|
+
for f in task_files:
|
|
46
|
+
ok = False
|
|
47
|
+
for scope in allowed_scopes:
|
|
48
|
+
if f == scope or f.startswith(scope.rstrip("/") + "/") or scope.startswith(f.rstrip("/") + "/"):
|
|
49
|
+
ok = True
|
|
50
|
+
break
|
|
51
|
+
# directory scope: f under scope dir
|
|
52
|
+
if scope.endswith("/") and f.startswith(scope):
|
|
53
|
+
ok = True
|
|
54
|
+
break
|
|
55
|
+
if not scope.endswith("/") and f.startswith(scope + "/"):
|
|
56
|
+
ok = True
|
|
57
|
+
break
|
|
58
|
+
if not ok:
|
|
59
|
+
violations.append(f)
|
|
60
|
+
return len(violations) == 0, violations
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def verify_tracker_done_insufficient(task_id: str) -> bool:
|
|
64
|
+
"""Check if tracker 'done' alone would be insufficient (it always is).
|
|
65
|
+
|
|
66
|
+
Returns True (done is always insufficient without merge+checks+zero flags).
|
|
67
|
+
"""
|
|
68
|
+
return True # tracker done is never sufficient by itself
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def auto_accept_decision(scope_ok: bool, checks_green: bool, red_flags: int,
|
|
72
|
+
tracker_done: bool) -> tuple[bool, str]:
|
|
73
|
+
"""Decide whether to auto-accept a task.
|
|
74
|
+
|
|
75
|
+
Returns (accept, reason).
|
|
76
|
+
"""
|
|
77
|
+
if not scope_ok:
|
|
78
|
+
return False, "scope violation"
|
|
79
|
+
if not checks_green:
|
|
80
|
+
return False, "checks failed"
|
|
81
|
+
if red_flags > 0:
|
|
82
|
+
return False, f"{red_flags} red flag(s)"
|
|
83
|
+
if not tracker_done:
|
|
84
|
+
return False, "tracker not done"
|
|
85
|
+
return True, "all criteria met"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def execute_task_loop(root: Path, sprint_id: str, task_list: list[dict],
|
|
89
|
+
state: sas.RunState, allowed_scopes: list[str]) -> list[dict]:
|
|
90
|
+
"""Execute tasks serially, bound to a sprint. Returns list of results."""
|
|
91
|
+
results = []
|
|
92
|
+
for task in task_list:
|
|
93
|
+
task_key = task.get("key", "")
|
|
94
|
+
task_id = task.get("task_id", "")
|
|
95
|
+
result = {
|
|
96
|
+
"key": task_key,
|
|
97
|
+
"task_id": task_id,
|
|
98
|
+
"status": "started",
|
|
99
|
+
}
|
|
100
|
+
# Scope check
|
|
101
|
+
task_files = task.get("files", [])
|
|
102
|
+
scope_ok, violations = check_scope_conformity(task_files, allowed_scopes)
|
|
103
|
+
result["scope_ok"] = scope_ok
|
|
104
|
+
result["scope_violations"] = violations
|
|
105
|
+
if not scope_ok:
|
|
106
|
+
result["status"] = "scope_violation"
|
|
107
|
+
result["accept"] = False
|
|
108
|
+
result["accept_reason"] = "scope violation"
|
|
109
|
+
results.append(result)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
# Record intent
|
|
113
|
+
state.operation_intent = f"execute_task:{task_key}"
|
|
114
|
+
sas.write_checkpoint(sas.state_path(root, state.run_name), state)
|
|
115
|
+
|
|
116
|
+
# Run checks (placeholder — real impl calls worktree.sh mr)
|
|
117
|
+
checks_green = task.get("checks_green", True)
|
|
118
|
+
red_flags = task.get("red_flags", 0)
|
|
119
|
+
tracker_done = task.get("tracker_done", False)
|
|
120
|
+
|
|
121
|
+
# Auto-accept decision
|
|
122
|
+
accept, reason = auto_accept_decision(scope_ok, checks_green, red_flags, tracker_done)
|
|
123
|
+
result["accept"] = accept
|
|
124
|
+
result["accept_reason"] = reason
|
|
125
|
+
result["status"] = "accepted" if accept else "rejected"
|
|
126
|
+
results.append(result)
|
|
127
|
+
|
|
128
|
+
return results
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main() -> int:
|
|
132
|
+
import argparse
|
|
133
|
+
parser = argparse.ArgumentParser(description="Execute and accept guarded tasks")
|
|
134
|
+
parser.add_argument("--root", default=".")
|
|
135
|
+
parser.add_argument("--run-name", required=True)
|
|
136
|
+
parser.add_argument("--sprint-id", required=True)
|
|
137
|
+
parser.add_argument("--tasks", required=True, help="JSON file with task list")
|
|
138
|
+
parser.add_argument("--answers", required=True)
|
|
139
|
+
args = parser.parse_args()
|
|
140
|
+
|
|
141
|
+
root = Path(args.root).resolve()
|
|
142
|
+
answers = json.loads(Path(args.answers).read_text())
|
|
143
|
+
tasks_list = json.loads(Path(args.tasks).read_text())
|
|
144
|
+
allowed_scopes = answers.get("allowed_scopes", [])
|
|
145
|
+
|
|
146
|
+
# Load state
|
|
147
|
+
state_path = sas.state_path(root, args.run_name)
|
|
148
|
+
state = sas.read_checkpoint(state_path)
|
|
149
|
+
if state is None:
|
|
150
|
+
state = sas.bind_state(root, root / ".git", args.run_name, "", "", allowed_scopes, "", RUNNER_DEFAULT, "", 120)
|
|
151
|
+
|
|
152
|
+
results = execute_task_loop(root, args.sprint_id, tasks_list, state, allowed_scopes)
|
|
153
|
+
print(json.dumps({"results": results}, indent=2))
|
|
154
|
+
|
|
155
|
+
all_accepted = all(r.get("accept") for r in results)
|
|
156
|
+
return 0 if all_accepted else 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
sys.exit(main())
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only approval preflight for the automated sprint lifecycle (Sprint 12).
|
|
3
|
+
|
|
4
|
+
Collects a batch of unresolved decisions, inspects repository state without
|
|
5
|
+
mutating anything, and returns a JSON report. No files, locks, bytecode or
|
|
6
|
+
artifacts are created by this module.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 -B scripts/sprint_auto_preflight.py preflight-probe --goal '...'
|
|
10
|
+
python3 -B scripts/sprint_auto_preflight.py collect --root . --answers answers.json
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sqlite3
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
FORBIDDEN_ENV = {
|
|
23
|
+
"SPEC_LINT": "off",
|
|
24
|
+
"ACCEPT_CHECKS_CMD": None, # any non-empty value is forbidden
|
|
25
|
+
"JUNIE_VERIFY_CMD": None,
|
|
26
|
+
"KILO_VERIFY_CMD": None,
|
|
27
|
+
"CODEX_VERIFY_CMD": None,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
GENERATED_PATHS = {
|
|
31
|
+
"tasks/tasks.snapshot.json",
|
|
32
|
+
"tasks/README.md",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
SAFE_DEFAULTS = {
|
|
36
|
+
"runner": "junie",
|
|
37
|
+
"serial": True,
|
|
38
|
+
"fallback": "none",
|
|
39
|
+
"retries": 0,
|
|
40
|
+
"timeout": 120,
|
|
41
|
+
"push": False,
|
|
42
|
+
"accept_flags": False,
|
|
43
|
+
"reset": False,
|
|
44
|
+
"clean": False,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _git(root: Path, *args: str) -> str:
|
|
49
|
+
"""Run a read-only git command; return stdout (empty on failure)."""
|
|
50
|
+
try:
|
|
51
|
+
r = subprocess.run(
|
|
52
|
+
["git", "-C", str(root), *args],
|
|
53
|
+
capture_output=True, text=True, timeout=30,
|
|
54
|
+
)
|
|
55
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
56
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_env() -> list[str]:
|
|
61
|
+
"""Return list of forbidden-env blockers."""
|
|
62
|
+
blockers: list[str] = []
|
|
63
|
+
for key, forbidden_val in FORBIDDEN_ENV.items():
|
|
64
|
+
val = os.environ.get(key, "")
|
|
65
|
+
if not val:
|
|
66
|
+
continue
|
|
67
|
+
if forbidden_val is None or val.strip().lower() == forbidden_val:
|
|
68
|
+
blockers.append(f"forbidden env: {key}={val!r}")
|
|
69
|
+
return blockers
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _inspect_git(root: Path) -> tuple[list[str], list[str]]:
|
|
73
|
+
"""Return (blockers, questions) from read-only git inspection."""
|
|
74
|
+
blockers: list[str] = []
|
|
75
|
+
questions: list[str] = []
|
|
76
|
+
|
|
77
|
+
# Merge / rebase / cherry-pick in progress?
|
|
78
|
+
git_dir = Path(_git(root, "rev-parse", "--git-dir") or ".git")
|
|
79
|
+
if not git_dir.is_absolute():
|
|
80
|
+
git_dir = root / git_dir
|
|
81
|
+
for marker in ("MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"):
|
|
82
|
+
if (git_dir / marker).exists():
|
|
83
|
+
blockers.append(f"git operation in progress: {marker}")
|
|
84
|
+
|
|
85
|
+
# Staged changes
|
|
86
|
+
staged = _git(root, "diff", "--cached", "--name-only")
|
|
87
|
+
if staged:
|
|
88
|
+
staged_files = [f for f in staged.splitlines() if f.strip()]
|
|
89
|
+
# Distinguish staged generated files (hard blocker) from other staged
|
|
90
|
+
for f in staged_files:
|
|
91
|
+
if f in GENERATED_PATHS or f.startswith("tasks/sprint-") and f.endswith(".md"):
|
|
92
|
+
blockers.append(f"staged generated file: {f}")
|
|
93
|
+
if any(f not in GENERATED_PATHS for f in staged_files):
|
|
94
|
+
blockers.append("staged (non-generated) changes present")
|
|
95
|
+
|
|
96
|
+
# Unstaged changes
|
|
97
|
+
unstaged = _git(root, "diff", "--name-only")
|
|
98
|
+
if unstaged:
|
|
99
|
+
for f in unstaged.splitlines():
|
|
100
|
+
f = f.strip()
|
|
101
|
+
if not f:
|
|
102
|
+
continue
|
|
103
|
+
if f in GENERATED_PATHS or (f.startswith("tasks/sprint-") and f.endswith(".md")):
|
|
104
|
+
# narrowly verified generated working-tree change — allowed but noted
|
|
105
|
+
pass
|
|
106
|
+
else:
|
|
107
|
+
blockers.append(f"unstaged change: {f}")
|
|
108
|
+
|
|
109
|
+
# Untracked files (excluding gitignored)
|
|
110
|
+
untracked = _git(root, "ls-files", "--others", "--exclude-standard")
|
|
111
|
+
if untracked:
|
|
112
|
+
for f in untracked.splitlines():
|
|
113
|
+
f = f.strip()
|
|
114
|
+
if f:
|
|
115
|
+
blockers.append(f"untracked file: {f}")
|
|
116
|
+
|
|
117
|
+
# Worktrees
|
|
118
|
+
wt_list = _git(root, "worktree", "list", "--porcelain")
|
|
119
|
+
if wt_list:
|
|
120
|
+
wt_count = sum(1 for line in wt_list.splitlines() if line.startswith("worktree "))
|
|
121
|
+
if wt_count > 1:
|
|
122
|
+
questions.append(f"multiple worktrees detected ({wt_count})")
|
|
123
|
+
|
|
124
|
+
# Current branch must be main for fresh runs
|
|
125
|
+
branch = _git(root, "symbolic-ref", "--short", "HEAD")
|
|
126
|
+
if branch and branch != "main":
|
|
127
|
+
questions.append(f"not on main branch (on {branch})")
|
|
128
|
+
|
|
129
|
+
return blockers, questions
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _inspect_sqlite(root: Path) -> list[str]:
|
|
133
|
+
"""Read-only SQLite inspection; return blockers."""
|
|
134
|
+
blockers: list[str] = []
|
|
135
|
+
db = root / "tasks" / "tasks.db"
|
|
136
|
+
if not db.exists():
|
|
137
|
+
return blockers # no db is fine for preflight
|
|
138
|
+
try:
|
|
139
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
|
|
140
|
+
conn.execute("SELECT 1 FROM nodes LIMIT 1")
|
|
141
|
+
conn.close()
|
|
142
|
+
except (sqlite3.Error, OSError) as exc:
|
|
143
|
+
blockers.append(f"sqlite read-only open failed: {exc}")
|
|
144
|
+
return blockers
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resolve_delegated(decision: dict) -> tuple[object, bool]:
|
|
148
|
+
"""Return (resolved_value, was_delegated) for a decision."""
|
|
149
|
+
if decision.get("answer") is not None:
|
|
150
|
+
return decision["answer"], False
|
|
151
|
+
if decision.get("delegated") and decision.get("safe_default") is not None:
|
|
152
|
+
return decision["safe_default"], True
|
|
153
|
+
return None, False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def collect_preflight(root: Path, request: dict, answers: dict | None = None) -> dict:
|
|
157
|
+
"""Collect the preflight report. Read-only: no writes anywhere."""
|
|
158
|
+
answers = answers or {}
|
|
159
|
+
questions: list[dict] = []
|
|
160
|
+
delegated_defaults: list[dict] = []
|
|
161
|
+
blockers: list[str] = []
|
|
162
|
+
approval_scope: dict = {}
|
|
163
|
+
|
|
164
|
+
# --- environment checks ---
|
|
165
|
+
blockers.extend(_check_env())
|
|
166
|
+
git_blockers, git_questions = _inspect_git(root)
|
|
167
|
+
blockers.extend(git_blockers)
|
|
168
|
+
for q in git_questions:
|
|
169
|
+
questions.append({"id": "git_state", "prompt": q, "answer": None,
|
|
170
|
+
"delegated": False, "safe_default": None, "rationale": ""})
|
|
171
|
+
blockers.extend(_inspect_sqlite(root))
|
|
172
|
+
|
|
173
|
+
# --- goal review ---
|
|
174
|
+
goal = request.get("goal", "")
|
|
175
|
+
goal_reviewed = answers.get("goal_reviewed", False)
|
|
176
|
+
if not goal_reviewed:
|
|
177
|
+
questions.append({
|
|
178
|
+
"id": "goal_review",
|
|
179
|
+
"prompt": f"Has the goal been reviewed? Goal: {goal!r}",
|
|
180
|
+
"answer": None,
|
|
181
|
+
"delegated": False,
|
|
182
|
+
"safe_default": False,
|
|
183
|
+
"rationale": "Goal must be explicitly reviewed before automation.",
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
# --- allowed scopes ---
|
|
187
|
+
allowed_scopes = answers.get("allowed_scopes", [])
|
|
188
|
+
if not allowed_scopes:
|
|
189
|
+
questions.append({
|
|
190
|
+
"id": "allowed_scopes",
|
|
191
|
+
"prompt": "Which scopes (repo-relative paths) may this run touch?",
|
|
192
|
+
"answer": None,
|
|
193
|
+
"delegated": False,
|
|
194
|
+
"safe_default": [],
|
|
195
|
+
"rationale": "At least one scope is required.",
|
|
196
|
+
})
|
|
197
|
+
else:
|
|
198
|
+
# validate scopes are clean repo-relative paths
|
|
199
|
+
for scope in allowed_scopes:
|
|
200
|
+
if not isinstance(scope, str) or ".." in scope or scope.startswith("/"):
|
|
201
|
+
blockers.append(f"invalid scope: {scope!r}")
|
|
202
|
+
|
|
203
|
+
# --- policy decisions ---
|
|
204
|
+
decisions = answers.get("decisions", [])
|
|
205
|
+
decision_map = {d.get("id"): d for d in decisions if isinstance(d, dict)}
|
|
206
|
+
|
|
207
|
+
policy_items = [
|
|
208
|
+
("runner", "Which runner to use?", "junie"),
|
|
209
|
+
("serial", "Run tasks serially?", True),
|
|
210
|
+
("fallback", "Fallback runner on failure?", "none"),
|
|
211
|
+
("retries", "Max retries per task?", 0),
|
|
212
|
+
("push", "Push to remote?", False),
|
|
213
|
+
("accept_flags", "Accept red flags?", False),
|
|
214
|
+
("reset", "Allow reset?", False),
|
|
215
|
+
("clean", "Allow clean?", False),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
for pid, prompt, default in policy_items:
|
|
219
|
+
d = decision_map.get(pid, {})
|
|
220
|
+
val, delegated = _resolve_delegated(d)
|
|
221
|
+
if val is None and not delegated:
|
|
222
|
+
# check if explicitly answered
|
|
223
|
+
if pid in answers.get("policy", {}):
|
|
224
|
+
val = answers["policy"][pid]
|
|
225
|
+
delegated = False
|
|
226
|
+
else:
|
|
227
|
+
safe = d.get("safe_default", default)
|
|
228
|
+
if d.get("delegated"):
|
|
229
|
+
delegated_defaults.append({"id": pid, "value": safe, "rationale": d.get("rationale", "")})
|
|
230
|
+
val = safe
|
|
231
|
+
delegated = True
|
|
232
|
+
else:
|
|
233
|
+
questions.append({
|
|
234
|
+
"id": pid,
|
|
235
|
+
"prompt": prompt,
|
|
236
|
+
"answer": None,
|
|
237
|
+
"delegated": False,
|
|
238
|
+
"safe_default": default,
|
|
239
|
+
"rationale": "",
|
|
240
|
+
})
|
|
241
|
+
continue
|
|
242
|
+
if delegated:
|
|
243
|
+
delegated_defaults.append({"id": pid, "value": val, "rationale": d.get("rationale", "")})
|
|
244
|
+
|
|
245
|
+
# --- build approval scope ---
|
|
246
|
+
policy = answers.get("policy", {})
|
|
247
|
+
approval_scope = {
|
|
248
|
+
"goal": goal,
|
|
249
|
+
"goal_reviewed": goal_reviewed,
|
|
250
|
+
"allowed_scopes": allowed_scopes,
|
|
251
|
+
"policy": policy,
|
|
252
|
+
"runner": policy.get("runner", "junie"),
|
|
253
|
+
"serial": policy.get("serial", True),
|
|
254
|
+
"fallback": policy.get("fallback", "none"),
|
|
255
|
+
"push": policy.get("push", False),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"questions": questions,
|
|
260
|
+
"delegated_defaults": delegated_defaults,
|
|
261
|
+
"blockers": blockers,
|
|
262
|
+
"approval_scope": approval_scope,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main() -> int:
|
|
267
|
+
parser = argparse.ArgumentParser(description="Read-only approval preflight")
|
|
268
|
+
sub = parser.add_subparsers(dest="command")
|
|
269
|
+
|
|
270
|
+
probe = sub.add_parser("preflight-probe", help="Probe with a goal, no answers")
|
|
271
|
+
probe.add_argument("--goal", required=True)
|
|
272
|
+
|
|
273
|
+
coll = sub.add_parser("collect", help="Collect preflight from answers file")
|
|
274
|
+
coll.add_argument("--root", default=".")
|
|
275
|
+
coll.add_argument("--answers", required=True)
|
|
276
|
+
coll.add_argument("--request", default=None)
|
|
277
|
+
|
|
278
|
+
args = parser.parse_args()
|
|
279
|
+
|
|
280
|
+
if args.command == "preflight-probe":
|
|
281
|
+
request = {"goal": args.goal}
|
|
282
|
+
result = collect_preflight(Path("."), request, {})
|
|
283
|
+
print(json.dumps(result, indent=2))
|
|
284
|
+
if result["questions"]:
|
|
285
|
+
return 2
|
|
286
|
+
if result["blockers"]:
|
|
287
|
+
return 4
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
if args.command == "collect":
|
|
291
|
+
root = Path(args.root).resolve()
|
|
292
|
+
answers = json.loads(Path(args.answers).read_text())
|
|
293
|
+
request = {}
|
|
294
|
+
if args.request:
|
|
295
|
+
request = json.loads(Path(args.request).read_text())
|
|
296
|
+
result = collect_preflight(root, request, answers)
|
|
297
|
+
print(json.dumps(result, indent=2))
|
|
298
|
+
if result["questions"]:
|
|
299
|
+
return 2
|
|
300
|
+
if result["blockers"]:
|
|
301
|
+
return 4
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
parser.print_help()
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
sys.exit(main())
|