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