diff --git a/.devloop/specs/2026-07-05-nightshift.md b/.devloop/specs/2026-07-05-nightshift.md new file mode 100644 index 0000000..f5fdab1 --- /dev/null +++ b/.devloop/specs/2026-07-05-nightshift.md @@ -0,0 +1,94 @@ +--- +status: draft +type: feat +created: 2026-07-05 +pr: null +--- + +# Devloop Nightshift Mode +Add a headless `devloop nightshift` mode that surveys a repo, picks the 2-3 highest-leverage improvements, runs each through the existing spec-to-PR loop while you sleep, and leaves a single morning digest to review. + +```mermaid +flowchart LR + Schedule["launchd overnight trigger"] --> Survey["Survey agent picks top N specs"] + Survey --> Loop["Sequential devloop --create-pr per spec"] + Loop --> Digest["Morning digest: verdicts + draft PRs"] +``` + +## Problem +During waking hours the user drives agents interactively to ship focused work. Overnight that leverage is idle: the machine sits unused while a queue of high-value, well-scoped improvements goes unstarted. Today the only way to get autonomous overnight work is to hand-write specs the night before and manually launch `devloop --create-pr` on each, which defeats the point. There is no headless path that both *decides what to work on* and *executes it* without a human at the terminal, and no single artifact to review in the morning. + +## Outcome +A scheduled, non-interactive `devloop nightshift` run selects the 2-3 highest-leverage improvements in a target repository, drives each through the existing coder/reviewer loop in its own worktree, opens a draft PR per accepted change, and writes one `digest.md` summarizing every run's verdict and PR link. The user installs the overnight trigger once with `devloop nightshift --install-schedule 02:00`, wakes up to a set of review-ready draft PRs plus a digest, and never touches a terminal to make it happen. Both Claude Code and Codex run headlessly across the night (survey/review by one, implementation by the other). + +## Scope +- In: `devloop` (new `nightshift` subcommand and its `main()` dispatch), `README.md`, `scripts/devloop_test.sh`, and help/usage text. +- In: headless survey that writes N devloop-ready specs, a sequential orchestrator over those specs reusing the existing `--create-pr` run path, and a per-repo morning digest. +- In: new global/local config keys `nightshift_repos`, `nightshift_count`, `nightshift_coder`, `nightshift_reviewer`, `nightshift_survey_agent`, `nightshift_max_passes`, `nightshift_schedule`, wired through the config whitelist and the interactive Settings menu. +- In: macOS launchd install/uninstall (`--install-schedule`, `--uninstall-schedule`) writing a `LaunchAgent` plist, plus a `--dry-run` that surveys and writes specs without running the loop, and a `--status` that prints the latest digest path. +- In: test seams so external effects are injectable — `DEVLOOP_RUN_CMD` (the per-spec runner), `DEVLOOP_LAUNCHCTL`, `DEVLOOP_LAUNCH_AGENTS_DIR`. +- Out: Linux/systemd/cron scheduling, `sudo pmset` power-management side effects (the command is printed, never executed), remote/CI execution, Slack/email notification delivery, parallel worktree execution, auto-merging or un-drafting PRs, and any change to the interactive `devloop spec` flow. + +## Behavior +### Happy path +1. The user runs `devloop nightshift --install-schedule 02:00` once, having set `nightshift_repos` (or passing `--repo `); devloop writes `~/Library/LaunchAgents/sh.devloop.nightshift.plist`, loads it via `launchctl`, saves `nightshift_schedule=02:00`, and prints the plist path plus the optional `sudo pmset repeat wake ...` line to wake a sleeping Mac. +2. At 02:00 launchd runs `devloop nightshift` non-interactively for each configured repo. Nightshift forces plain output, never opens a worktree shell, and never runs the update prompt. +3. Per repo, nightshift creates `.devloop/nightshift//` and launches the survey agent headlessly with the bundled `devloop-spec` skill, instructing it to select the top `nightshift_count` independent, worktree-sized improvements and write one devloop-ready spec each to `.devloop/nightshift//specs/NN-.md`, plus a `survey.md` rationale. The agent writes specs only; it does not implement. +4. Nightshift lints each generated spec, then for each valid spec in sorted order runs the existing loop headlessly (`devloop --plain --create-pr --coder --reviewer --timeout-minutes `), one at a time. +5. Each run implements in an isolated sibling worktree, self-reviews across passes, commits, and opens/maintains a draft PR — exactly as `--create-pr` does today. +6. After the last run, nightshift writes `.devloop/nightshift//digest.md` (and `digest.html` when the report format is html) with one row per spec: title, final verdict/status, branch, draft PR link, and report path, followed by the survey rationale. It updates a `.devloop/nightshift/latest` pointer. +7. In the morning the user runs `devloop nightshift --status`, opens the digest, and reviews the draft PRs. + +### Edge cases +- No repos resolvable: `devloop nightshift` (and `--install-schedule`) exit nonzero with a clear message that `nightshift_repos` or `--repo` is required. +- Survey writes zero valid specs: that repo's digest records a survey-failure row and nightshift continues to the next repo without aborting the night. +- A single spec's run fails, stalls, or times out: its digest row records the terminal status (e.g. `stalled`, `unclear`, `timeout`, `coder-error`); remaining specs still run. +- `--dry-run`: surveys and writes specs plus a digest listing them and the rationale, but runs no loop and opens no PR. +- Invalid schedule time: `--install-schedule 9pm` exits nonzero; only `HH:MM` with `HH` 00-23 and `MM` 00-59 is accepted. +- `--count N` out of range: clamped to 1-5; config `nightshift_count` is likewise clamped when read. +- Already scheduled: re-running `--install-schedule` replaces the existing plist and reloads it idempotently. +- `--uninstall-schedule` when nothing is installed: exits 0, reporting nothing to remove. +- No TTY / scheduled context: no prompts, no gum/fzf/glow UI, no post-run shell, regardless of terminal state. +- `gh` missing or unauthenticated, or repo has no origin: the run's existing preflight fails and that spec's digest row records the preflight error; the night continues. + +## Acceptance criteria +1. `devloop nightshift` is dispatched from `main()` and documented in plain help, TUI help, and the README command table. +2. `devloop nightshift` and every scheduled invocation force non-interactive behavior: plain output, no worktree shell, and no automatic update prompt, even when stdin/stdout are TTYs. +3. The survey step builds a prompt that references the installed `devloop-spec` skill, the requested count, the current date, the `.devloop/nightshift//specs` write directory, and an explicit "write specs only, do not implement" constraint, and launches the survey agent through the existing headless `run_with_prompt` path (not the interactive spec launcher). +4. Given a survey agent that writes N specs, nightshift runs each valid spec exactly once, in sorted order, through the `--create-pr` run path with the resolved coder, reviewer, per-spec max passes, and timeout. +5. Per-spec runs are sequential: run K+1 does not start until run K's process exits. +6. A run that fails, stalls, is unclear, or times out is recorded in the digest and does not prevent subsequent specs from running. +7. `.devloop/nightshift//digest.md` is created with one row per spec containing its title, terminal status, branch, draft PR link (or a clear "no PR" marker), and report path, plus the survey rationale, and `.devloop/nightshift/latest` points at the newest run. +8. `--dry-run` writes specs and a digest but invokes no per-spec run and opens no PR. +9. `--status` prints the path to the latest digest, or a clear message when none exists. +10. `nightshift_repos`, `nightshift_count`, `nightshift_coder`, `nightshift_reviewer`, `nightshift_survey_agent`, `nightshift_max_passes`, and `nightshift_schedule` are readable and writable through the config helpers with local-overrides-global precedence; the config key whitelist rejects unknown keys; `nightshift_count` is clamped to 1-5 and agent keys are normalized to `codex`/`claude`. +11. `--install-schedule HH:MM` writes a `sh.devloop.nightshift` plist under `${DEVLOOP_LAUNCH_AGENTS_DIR:-$HOME/Library/LaunchAgents}` whose `StartCalendarInterval` encodes the parsed hour and minute, whose `ProgramArguments` invoke the resolved absolute `devloop` path with `nightshift` and the resolved repos, and whose `EnvironmentVariables.PATH`, `StandardOutPath`, and `StandardErrorPath` are populated; it then loads the agent via `${DEVLOOP_LAUNCHCTL:-launchctl}` and persists `nightshift_schedule`. +12. `--install-schedule` never invokes `sudo`; it prints the exact `pmset` wake command for the user to run and the written plist path. +13. `--uninstall-schedule` unloads via `${DEVLOOP_LAUNCHCTL:-launchctl}`, removes the plist, and clears `nightshift_schedule`, exiting 0 whether or not one existed. +14. An invalid `--install-schedule` time and an unresolvable repo set each exit nonzero with a specific error and write no plist. +15. `scripts/devloop_test.sh` covers: dispatch/help/README presence, headless invariants, the survey prompt builder, orchestration over a stubbed `DEVLOOP_RUN_CMD` including a mid-list failure, digest and `latest` generation, `--dry-run`, `--status`, every new config key (set/get/remove, precedence, clamping, whitelist rejection), plist generation contents, install load via a stubbed `DEVLOOP_LAUNCHCTL`, uninstall removal, and the two nonzero error paths. +16. The project function coverage gate in `scripts/devloop_test.sh` stays at 100% for every new function. + +## Test plan +- Red: Add failing Bash tests in `scripts/devloop_test.sh` that source `devloop` with `DEVLOOP_LIB=1` and exercise the new functions directly, using `PATH` stubs for the survey agent, a `DEVLOOP_RUN_CMD` stub that records invocations and writes fake track/PR outputs, and `DEVLOOP_LAUNCHCTL` / `DEVLOOP_LAUNCH_AGENTS_DIR` overrides under a temp `HOME` so no real `launchctl`, `pmset`, agent, or network call runs. +- Green: `bash scripts/devloop_test.sh` +- Full: `bash scripts/devloop_test.sh` +- Coverage: `bash scripts/devloop_test.sh` must keep the project function coverage gate passing at 100%. + +## Constraints +- Must: keep devloop a single Bash CLI and reuse existing machinery — `run_with_prompt` for the headless survey, the `run_command`/`--create-pr` run path for execution, `lint_spec_file` for spec validation, the `config_file_value`/`write_config_value`/`remove_config_value` helpers (extending their key whitelist) for the new keys, and the bundled `devloop-spec` skill for spec quality. Do not fork the spec format or the loop. +- Must: shell out to a fresh `devloop` process per spec (via `DEVLOOP_RUN_CMD`, defaulting to the resolved script path) so each run gets clean global state (`STATUS`, `PASSES`, the `COMMIT_*` arrays) rather than reusing `run_devloop` in-process across specs. +- Must: run specs sequentially and only ever produce feature branches and draft PRs; never push to or modify the base branch, never un-draft or merge. +- Must: make all external effects injectable for tests — the per-spec runner, `launchctl`, and the LaunchAgents directory — and never call `sudo`, `pmset`, real `launchctl`, or a live agent from the test suite. +- Must: force `USE_TUI=false` and `ENTER_WORKTREE=false` on entry and skip `maybe_prompt_update`, so scheduled runs never block on a prompt or open a shell. +- Avoid: introducing Python, Node, cron, systemd, telemetry, a daemon that runs outside launchd, parallel execution, or committing generated `.devloop/nightshift/` artifacts. +- Existing convention: `VERSION` is the single version source, `.devloop/specs/*.md` is where tracked specs live, `scripts/devloop_test.sh` is the shell suite, config is `key=value` in `.devloop/config` and `~/.devloop/config`, and user-visible CLI changes are documented in `README.md`. + +## Notes +- Recommended landing as a small stack to honor the small-PR rule: (1) core loop — `nightshift` subcommand, survey, sequential orchestrator, digest, config keys, headless invariants, `--dry-run`/`--status`; then (2) scheduler — `--install-schedule`/`--uninstall-schedule` launchd plist stacked on (1). If the combined diff stays tight it can land as one PR; split if it exceeds ~300 LOC. +- Survey agent default: `nightshift_survey_agent` defaults to the configured reviewer (`claude`); `nightshift_coder`/`nightshift_reviewer` default to `devloop_coder`/`devloop_reviewer` (`codex`/`claude`). A full night therefore exercises both Claude Code and Codex headlessly, as intended. +- launchd gotcha to handle: LaunchAgents inherit a minimal `PATH`, so the plist must bake a working `PATH` (capture the installer's `PATH` into `EnvironmentVariables`) or `codex`/`claude`/`gh` will not resolve at 02:00. Load with modern `launchctl bootstrap gui/$(id -u) ` and fall back to `launchctl load -w`; mirror for unload/`bootout`. +- Power management: a fully asleep Mac will not fire `StartCalendarInterval` on time. `--install-schedule` prints `sudo pmset repeat wake MTWRFSU HH:MM` (and notes it needs `sudo` and an awake-or-plugged-in machine) rather than running it, keeping nightshift side-effect-free on power settings. +- Digest status vocabulary should reuse the loop's existing terminal statuses (`accepted`, `unclear`, `stalled`, `no-verdict`, `timeout`, `coder-error`, `reviewer-error`, `verify-error`, `preflight-error`, `pr-error`, `review-missing`, `max-turns`) read from each run's track file, so the morning summary speaks the same language as `devloop status`. +- Optional future hook (out of scope): a `nightshift_notify` command (à la `.devloop/verify`) receiving the digest path, so Slack/email delivery can be wired without baking a provider into devloop. +- Spec placement: written to `~/Projects/devloop/.devloop/specs/` to match sibling specs and be runnable as `devloop --create-pr .devloop/specs/2026-07-05-nightshift.md`; copy to `~/Projects/specs/devloop/` if you also track it in the personal specs repo. \ No newline at end of file diff --git a/README.md b/README.md index 2d0e588..21838cd 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Uninstall with `./scripts/uninstall.sh` (`--dry-run` to preview). |---|---| | `devloop` | Interactive menu: create, run, or continue a spec | | `devloop spec "..."` | Have an agent interview you and write a spec | +| `devloop nightshift` | Survey configured repos, run selected specs headlessly, and write a morning digest | | `devloop ` | Run a spec | | `devloop --create-pr ` | Run a spec and maintain a draft PR (requires `gh`) | | `devloop update` | Install the latest released Devloop | diff --git a/devloop b/devloop index b3b91cb..98e2ba7 100755 --- a/devloop +++ b/devloop @@ -9,6 +9,8 @@ DEFAULT_TIMEOUT_MINUTES=30 DEFAULT_SPEC_DIR=".devloop/specs" DEFAULT_CODER="codex" DEFAULT_REVIEWER="claude" +DEFAULT_NIGHTSHIFT_COUNT=3 +DEFAULT_NIGHTSHIFT_MAX_PASSES=5 SCRIPT_PATH="${BASH_SOURCE[0]}" while [ -L "$SCRIPT_PATH" ]; do @@ -102,6 +104,12 @@ main() { return $? fi + if [ "${1:-}" = "nightshift" ]; then + shift + nightshift_command "$@" + return $? + fi + if [ "${1:-}" = "menu" ]; then shift if [ "$#" -eq 0 ]; then maybe_prompt_update; fi @@ -190,6 +198,8 @@ Common commands: devloop continue devloop menu devloop spec "add retry behavior to the chat sender" + devloop nightshift + devloop nightshift --install-schedule 02:00 devloop .devloop/specs/change.md devloop --tui .devloop/specs/change.md devloop --plain .devloop/specs/change.md @@ -229,6 +239,8 @@ welcome_tui() { printf ' %-42s %s\n' "devloop continue" "resume a tracked run" printf ' %-42s %s\n' "devloop menu" "open the guided UI" printf ' %-42s %s\n' 'devloop spec "add retry behavior"' "launch a spec agent" + printf ' %-42s %s\n' "devloop nightshift" "survey and run overnight specs" + printf ' %-42s %s\n' "devloop nightshift --install-schedule 02:00" "install the overnight LaunchAgent" printf ' %-42s %s\n' "devloop .devloop/specs/change.md" "run a spec" printf ' %-42s %s\n' "devloop --create-pr .devloop/specs/change.md" "open and maintain a draft PR during the loop" printf '\n' @@ -547,9 +559,30 @@ write_config_value() { local value="$3" local file tmp case "$key" in - spec_dir|timeout_minutes|coder|reviewer) ;; + spec_dir|timeout_minutes|coder|reviewer|nightshift_repos|nightshift_count|nightshift_coder|nightshift_reviewer|nightshift_survey_agent|nightshift_max_passes|nightshift_schedule) ;; *) printf 'unknown config key: %s\n' "$key" >&2; return 2 ;; esac + case "$key" in + coder|reviewer|nightshift_coder|nightshift_reviewer|nightshift_survey_agent) + value="$(normalize_agent "$value")" + if [ -z "$value" ]; then + printf '%s must be Codex or Claude Code\n' "$key" >&2 + return 2 + fi + ;; + nightshift_count) + value="$(normalize_nightshift_count "$value")" || { + printf '%s\n' "nightshift_count must be an integer between 1 and 5" >&2 + return 2 + } + ;; + nightshift_max_passes) + value="$(normalize_nightshift_max_passes "$value")" || { + printf '%s\n' "nightshift_max_passes must be an integer between 1 and 10" >&2 + return 2 + } + ;; + esac file="$(devloop_config_file_for_scope "$scope")" || return $? mkdir -p "$(dirname "$file")" || return 1 tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")" @@ -565,7 +598,7 @@ remove_config_value() { local key="$2" local file tmp case "$key" in - spec_dir|timeout_minutes|coder|reviewer) ;; + spec_dir|timeout_minutes|coder|reviewer|nightshift_repos|nightshift_count|nightshift_coder|nightshift_reviewer|nightshift_survey_agent|nightshift_max_passes|nightshift_schedule) ;; *) printf 'unknown config key: %s\n' "$key" >&2; return 2 ;; esac file="$(devloop_config_file_for_scope "$scope")" || return $? @@ -858,6 +891,66 @@ devloop_reviewer() { devloop_agent_setting reviewer "$DEFAULT_REVIEWER" } +normalize_nightshift_count() { + local value="$1" + value="$(trim_string "$value")" + if ! printf '%s\n' "$value" | grep -Eq '^[0-9]+$'; then return 1; fi + value=$((10#$value)) + if [ "$value" -lt 1 ]; then value=1; fi + if [ "$value" -gt 5 ]; then value=5; fi + printf '%s\n' "$value" +} + +normalize_nightshift_max_passes() { + local value="$1" + value="$(trim_string "$value")" + if ! printf '%s\n' "$value" | grep -Eq '^[0-9]+$'; then return 1; fi + value=$((10#$value)) + if [ "$value" -lt 1 ]; then value=1; fi + if [ "$value" -gt 10 ]; then value=10; fi + printf '%s\n' "$value" +} + +devloop_nightshift_repos() { + devloop_config_value nightshift_repos +} + +devloop_nightshift_count() { + local value normalized + value="$(devloop_config_value nightshift_count 2>/dev/null || true)" + if [ -n "$value" ] && normalized="$(normalize_nightshift_count "$value")"; then + printf '%s\n' "$normalized" + return + fi + printf '%s\n' "$DEFAULT_NIGHTSHIFT_COUNT" +} + +devloop_nightshift_coder() { + devloop_agent_setting nightshift_coder "$(devloop_coder)" +} + +devloop_nightshift_reviewer() { + devloop_agent_setting nightshift_reviewer "$(devloop_reviewer)" +} + +devloop_nightshift_survey_agent() { + devloop_agent_setting nightshift_survey_agent "$(devloop_nightshift_reviewer)" +} + +devloop_nightshift_max_passes() { + local value normalized + value="$(devloop_config_value nightshift_max_passes 2>/dev/null || true)" + if [ -n "$value" ] && normalized="$(normalize_nightshift_max_passes "$value")"; then + printf '%s\n' "$normalized" + return + fi + printf '%s\n' "$DEFAULT_NIGHTSHIFT_MAX_PASSES" +} + +devloop_nightshift_schedule() { + devloop_config_value nightshift_schedule +} + write_config_coder() { write_config_agent coder "$@" } @@ -1918,6 +2011,592 @@ print_track_status() { fi } +nightshift_usage() { + cat <<'EOF' +usage: devloop nightshift [--repo ] [--count N] [--coder codex|claude] [--reviewer codex|claude] [--survey-agent codex|claude] [--max-passes N] [--timeout-minutes N] [--dry-run] + devloop nightshift --status [--repo ] + devloop nightshift --install-schedule HH:MM [--repo ] + devloop nightshift --uninstall-schedule +EOF +} + +nightshift_force_headless() { + USE_TUI=false + ENTER_WORKTREE=false + DEVLOOP_UPDATE_PROMPTED=true +} + +nightshift_date() { + if [ -n "${DEVLOOP_NIGHTSHIFT_DATE:-}" ]; then + printf '%s\n' "$DEVLOOP_NIGHTSHIFT_DATE" + else + date +%F + fi +} + +nightshift_survey_prompt() { + local count="$1" + local today="$2" + local specs_dir="$3" + local survey_file="$4" + local skill="$5" + cat <&2 + return 2 + fi + for item in "${raw_items[@]}"; do + item="$(trim_string "$item")" + [ -n "$item" ] || continue + if ! root="$(git -C "$item" rev-parse --show-toplevel 2>/dev/null)"; then + printf 'error: nightshift repo is not resolvable: %s\n' "$item" >&2 + return 2 + fi + root="$(cd "$root" >/dev/null 2>&1 && pwd -P)" || { + printf 'error: nightshift repo is not resolvable: %s\n' "$item" >&2 + return 2 + } + case "$seen" in + *"|$root|"*) continue ;; + esac + seen="$seen|$root|" + found=true + printf '%s\n' "$root" + done + if [ "$found" = false ]; then + printf '%s\n' "error: nightshift_repos or --repo is required" >&2 + return 2 + fi +} + +nightshift_run_dir() { + local repo="$1" + local today="$2" + printf '%s/.devloop/nightshift/%s\n' "${repo%/}" "$today" +} + +nightshift_run_survey() { + local repo="$1" + local run_dir="$2" + local today="$3" + local count="$4" + local agent="$5" + local specs_dir survey_file skill prompt log + specs_dir="$run_dir/specs" + survey_file="$run_dir/survey.md" + skill="$ROOT_DIR/skills/devloop-spec/SKILL.md" + prompt="$(nightshift_survey_prompt "$count" "$today" "$specs_dir" "$survey_file" "$skill")" + log="$run_dir/survey.log" + case "$agent" in + codex) + run_with_prompt "$repo" "$log" "nightshift-survey" "$prompt" codex exec "${CODEX_MODEL_ARGS[@]}" "${CODEX_REASONING_ARGS[@]}" --dangerously-bypass-approvals-and-sandbox -C "$repo" - + ;; + claude) + run_with_prompt "$repo" "$log" "nightshift-survey" "$prompt" claude -p "${CLAUDE_MODEL_ARGS[@]}" "${CLAUDE_EFFORT_ARGS[@]}" --dangerously-skip-permissions --add-dir "$repo" --add-dir "$specs_dir" + ;; + esac + [ "$RUN_CODE" -eq 0 ] +} + +nightshift_result_field() { + local label="$1" + local file="$2" + awk -v label="$label" ' + $1 == label { + $1 = "" + sub(/^[[:space:]]+/, "") + print + exit + } + ' "$file" +} + +nightshift_result_status() { + local file="$1" + local code="$2" + local status + status="$(sed -nE 's/^devloop[[:space:]]+([^[:space:]]+).*/\1/p' "$file" | tail -n 1)" + if [ -n "$status" ]; then + printf '%s\n' "$status" + elif [ "$code" -eq 124 ]; then + printf '%s\n' "timeout" + else + printf '%s\n' "run-error" + fi +} + +nightshift_markdown_cell() { + printf '%s' "${1:-}" | sed 's/|/\\|/g' +} + +nightshift_append_digest_row() { + local file="$1" + local title="$2" + local status="$3" + local branch="$4" + local pr="$5" + local report="$6" + printf '| %s | %s | %s | %s | %s |\n' \ + "$(nightshift_markdown_cell "$title")" \ + "$(nightshift_markdown_cell "$status")" \ + "$(nightshift_markdown_cell "$branch")" \ + "$(nightshift_markdown_cell "$pr")" \ + "$(nightshift_markdown_cell "$report")" >> "$file" +} + +nightshift_run_spec() { + local repo="$1" + local run_dir="$2" + local spec="$3" + local title="$4" + local coder="$5" + local reviewer="$6" + local max_passes="$7" + local timeout_minutes="$8" + local runner output code status branch pr report + runner="${DEVLOOP_RUN_CMD:-$ROOT_DIR/devloop}" + output="$run_dir/logs/$(basename "${spec%.md}").out" + mkdir -p "$(dirname "$output")" + ( + cd "$repo" >/dev/null 2>&1 && + "$runner" --plain --no-shell --create-pr --coder "$coder" --reviewer "$reviewer" --timeout-minutes "$timeout_minutes" "$spec" "$max_passes" + ) > "$output" 2>&1 + code=$? + status="$(nightshift_result_status "$output" "$code")" + branch="$(nightshift_result_field Branch "$output")" + pr="$(nightshift_result_field PR "$output")" + report="$(nightshift_result_field Report "$output")" + [ -n "$branch" ] || branch="unknown" + [ -n "$pr" ] || pr="no PR" + [ -n "$report" ] || report="no report" + printf '%s\t%s\t%s\t%s\t%s\n' "$title" "$status" "$branch" "$pr" "$report" +} + +nightshift_write_digest() { + local digest="$1" + local today="$2" + local rows="$3" + local survey="$4" + { + printf '# Devloop Nightshift Digest - %s\n\n' "$today" + printf '| Spec | Status | Branch | PR | Report |\n' + printf '| --- | --- | --- | --- | --- |\n' + cat "$rows" + printf '\n## Survey rationale\n\n' + if [ -s "$survey" ]; then + cat "$survey" + printf '\n' + else + printf '%s\n' "No survey rationale was written." + fi + } > "$digest" +} + +nightshift_update_latest() { + local repo="$1" + local today="$2" + local link="$repo/.devloop/nightshift/latest" + rm -rf "$link" + ln -s "$today" "$link" +} + +nightshift_valid_spec_title() { + local spec="$1" + local spec_text criteria_file criteria_count title + spec_text="$(cat "$spec")" + criteria_file="$(mktemp "${TMPDIR:-/tmp}/devloop-nightshift-criteria.XXXXXX")" + parse_criteria "$spec" > "$criteria_file" + criteria_count="$(line_count "$criteria_file")" + if ! lint_spec_file "$spec" "$spec_text" "$criteria_count" true; then + rm -f "$criteria_file" + return 1 + fi + rm -f "$criteria_file" + title="$(spec_title "$spec")" + [ -n "$title" ] || return 1 + printf '%s\n' "$title" +} + +nightshift_run_repo() { + local repo="$1" + local count="$2" + local coder="$3" + local reviewer="$4" + local survey_agent="$5" + local max_passes="$6" + local timeout_minutes="$7" + local dry_run="$8" + local today run_dir specs_dir rows digest survey spec title status branch pr report valid_count survey_ok + today="$(nightshift_date)" + run_dir="$(nightshift_run_dir "$repo" "$today")" + specs_dir="$run_dir/specs" + rows="$(mktemp "${TMPDIR:-/tmp}/devloop-nightshift-rows.XXXXXX")" + mkdir -p "$specs_dir" "$run_dir/logs" + : > "$rows" + survey_ok=true + if ! nightshift_run_survey "$repo" "$run_dir" "$today" "$count" "$survey_agent"; then + survey_ok=false + if [ ! -s "$run_dir/survey.md" ]; then printf '%s\n' "${RUN_OUTPUT:-survey agent failed}" > "$run_dir/survey.md"; fi + nightshift_append_digest_row "$rows" "Survey" "survey-error" "no branch" "no PR" "no report" + fi + valid_count=0 + if [ "$survey_ok" = true ]; then + while IFS= read -r spec; do + [ -n "$spec" ] || continue + if title="$(nightshift_valid_spec_title "$spec")"; then + valid_count=$((valid_count + 1)) + if [ "$dry_run" = true ]; then + nightshift_append_digest_row "$rows" "$title" "dry-run" "no branch" "no PR" "not run" + else + IFS=$'\t' read -r title status branch pr report </dev/null | LC_ALL=C sort) +EOF + fi + if [ "$valid_count" -eq 0 ] && ! grep -q '| Survey | survey-error |' "$rows"; then + nightshift_append_digest_row "$rows" "Survey" "survey-failure" "no branch" "no PR" "no report" + fi + digest="$run_dir/digest.md" + survey="$run_dir/survey.md" + nightshift_write_digest "$digest" "$today" "$rows" "$survey" + nightshift_update_latest "$repo" "$today" + rm -f "$rows" + printf '%s\n' "$digest" +} + +nightshift_latest_digest() { + local repo="$1" + local base="$repo/.devloop/nightshift" + local latest="$base/latest" + local target digest + if [ -L "$latest" ]; then + target="$(readlink "$latest")" + case "$target" in + /*) digest="$target/digest.md" ;; + *) digest="$base/$target/digest.md" ;; + esac + else + digest="$latest/digest.md" + fi + if [ -f "$digest" ]; then + printf '%s/%s\n' "$(cd "$(dirname "$digest")" >/dev/null 2>&1 && pwd -P)" "$(basename "$digest")" + return 0 + fi + return 1 +} + +nightshift_status_command() { + local repo_args=("$@") + local repos=() + local resolved repo digest found=false root + if [ "${#repo_args[@]}" -gt 0 ] || devloop_nightshift_repos >/dev/null 2>&1; then + resolved="$(nightshift_resolve_repos ${repo_args[@]+"${repo_args[@]}"})" || return $? + while IFS= read -r repo; do + [ -n "$repo" ] && repos+=("$repo") + done </dev/null)"; then + repos+=("$(cd "$root" >/dev/null 2>&1 && pwd -P)") + else + repos+=("$(pwd -P)") + fi + fi + for repo in "${repos[@]}"; do + if digest="$(nightshift_latest_digest "$repo")"; then + printf '%s\n' "$digest" + found=true + fi + done + if [ "$found" = false ]; then + printf '%s\n' "no nightshift digest found" >&2 + return 1 + fi +} + +nightshift_parse_time() { + local time="$1" + local hour minute + if ! [[ "$time" =~ ^([0-9]{2}):([0-9]{2})$ ]]; then return 1; fi + hour="${BASH_REMATCH[1]}" + minute="${BASH_REMATCH[2]}" + hour=$((10#$hour)) + minute=$((10#$minute)) + if [ "$hour" -gt 23 ] || [ "$minute" -gt 59 ]; then return 1; fi + printf '%s %s\n' "$hour" "$minute" +} + +nightshift_plist_path() { + local dir="${DEVLOOP_LAUNCH_AGENTS_DIR:-${HOME%/}/Library/LaunchAgents}" + printf '%s/sh.devloop.nightshift.plist\n' "$dir" +} + +plist_escape() { + printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' -e "s/'/\'/g" +} + +nightshift_write_plist() { + local plist="$1" + local time="$2" + shift 2 + local repos=("$@") + local parsed hour minute devloop_path path_value log_dir repo + parsed="$(nightshift_parse_time "$time")" || return 1 + read -r hour minute <' + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' ' Label' + printf '%s\n' ' sh.devloop.nightshift' + printf '%s\n' ' ProgramArguments' + printf '%s\n' ' ' + printf ' %s\n' "$(plist_escape "$devloop_path")" + printf '%s\n' ' nightshift' + for repo in "${repos[@]}"; do + printf '%s\n' ' --repo' + printf ' %s\n' "$(plist_escape "$repo")" + done + printf '%s\n' ' ' + printf '%s\n' ' StartCalendarInterval' + printf '%s\n' ' ' + printf '%s\n' ' Hour' + printf ' %s\n' "$hour" + printf '%s\n' ' Minute' + printf ' %s\n' "$minute" + printf '%s\n' ' ' + printf '%s\n' ' EnvironmentVariables' + printf '%s\n' ' ' + printf '%s\n' ' PATH' + printf ' %s\n' "$(plist_escape "$path_value")" + printf '%s\n' ' ' + printf '%s\n' ' StandardOutPath' + printf ' %s\n' "$(plist_escape "$log_dir/devloop-nightshift.out.log")" + printf '%s\n' ' StandardErrorPath' + printf ' %s\n' "$(plist_escape "$log_dir/devloop-nightshift.err.log")" + printf '%s\n' '' + printf '%s\n' '' + } > "$plist" +} + +nightshift_load_schedule() { + local plist="$1" + local launchctl="${DEVLOOP_LAUNCHCTL:-launchctl}" + local domain + domain="gui/$(id -u)" + "$launchctl" bootout "$domain" "$plist" >/dev/null 2>&1 || true + if "$launchctl" bootstrap "$domain" "$plist"; then return 0; fi + "$launchctl" load -w "$plist" +} + +nightshift_unload_schedule() { + local plist="$1" + local launchctl="${DEVLOOP_LAUNCHCTL:-launchctl}" + local domain + domain="gui/$(id -u)" + "$launchctl" bootout "$domain" "$plist" >/dev/null 2>&1 || "$launchctl" unload "$plist" >/dev/null 2>&1 || true +} + +nightshift_install_schedule() { + local time="$1" + shift + local parsed resolved repos=() repo plist + parsed="$(nightshift_parse_time "$time")" || { + printf 'invalid nightshift schedule time: %s\n' "$time" >&2 + return 2 + } + resolved="$(nightshift_resolve_repos "$@")" || return $? + while IFS= read -r repo; do + [ -n "$repo" ] && repos+=("$repo") + done </dev/null 2>&1 || true + remove_config_value local nightshift_schedule >/dev/null 2>&1 || true +} + +nightshift_command() { + local repo_args=() + local count coder reviewer survey_agent max_passes timeout_minutes + local dry_run=false + local status=false + local install_schedule="" + local uninstall_schedule=false + local arg value normalized resolved repos=() repo code=0 + nightshift_force_headless + count="$(devloop_nightshift_count)" + coder="$(devloop_nightshift_coder)" + reviewer="$(devloop_nightshift_reviewer)" + survey_agent="$(devloop_nightshift_survey_agent)" + max_passes="$(devloop_nightshift_max_passes)" + timeout_minutes="$(devloop_timeout_minutes)" + while [ "$#" -gt 0 ]; do + arg="$1" + shift + case "$arg" in + --repo) + if [ "$#" -eq 0 ]; then printf '%s\n' "--repo requires a value" >&2; nightshift_usage >&2; return 2; fi + repo_args+=("$1") + shift + ;; + --count) + if [ "$#" -eq 0 ]; then printf '%s\n' "--count requires a value" >&2; nightshift_usage >&2; return 2; fi + count="$(normalize_nightshift_count "$1")" || { + printf '%s\n' "nightshift count must be an integer between 1 and 5" >&2 + return 2 + } + shift + ;; + --coder) + if [ "$#" -eq 0 ]; then printf '%s\n' "--coder requires a value" >&2; nightshift_usage >&2; return 2; fi + normalized="$(normalize_agent "$1")" + [ -n "$normalized" ] || { printf '%s\n' "coder must be Codex or Claude Code" >&2; return 2; } + coder="$normalized" + shift + ;; + --reviewer) + if [ "$#" -eq 0 ]; then printf '%s\n' "--reviewer requires a value" >&2; nightshift_usage >&2; return 2; fi + normalized="$(normalize_agent "$1")" + [ -n "$normalized" ] || { printf '%s\n' "reviewer must be Codex or Claude Code" >&2; return 2; } + reviewer="$normalized" + shift + ;; + --survey-agent) + if [ "$#" -eq 0 ]; then printf '%s\n' "--survey-agent requires a value" >&2; nightshift_usage >&2; return 2; fi + normalized="$(normalize_agent "$1")" + [ -n "$normalized" ] || { printf '%s\n' "survey agent must be Codex or Claude Code" >&2; return 2; } + survey_agent="$normalized" + shift + ;; + --max-passes) + if [ "$#" -eq 0 ]; then printf '%s\n' "--max-passes requires a value" >&2; nightshift_usage >&2; return 2; fi + max_passes="$(normalize_nightshift_max_passes "$1")" || { + printf '%s\n' "nightshift max passes must be an integer between 1 and 10" >&2 + return 2 + } + shift + ;; + --timeout-minutes) + if [ "$#" -eq 0 ]; then printf '%s\n' "--timeout-minutes requires a value" >&2; nightshift_usage >&2; return 2; fi + timeout_minutes="$(normalize_timeout_minutes "$1")" || { + printf '%s\n' "timeout must be an integer between 1 and 1440 minutes" >&2 + return 2 + } + shift + ;; + --dry-run) dry_run=true ;; + --status) status=true ;; + --install-schedule) + if [ "$#" -eq 0 ]; then printf '%s\n' "--install-schedule requires HH:MM" >&2; nightshift_usage >&2; return 2; fi + install_schedule="$1" + shift + ;; + --uninstall-schedule) uninstall_schedule=true ;; + -h|--help) nightshift_usage; return 0 ;; + *) + printf 'unknown option: %s\n' "$arg" >&2 + nightshift_usage >&2 + return 2 + ;; + esac + done + if [ "$uninstall_schedule" = true ]; then + nightshift_uninstall_schedule + return $? + fi + if [ -n "$install_schedule" ]; then + nightshift_install_schedule "$install_schedule" ${repo_args[@]+"${repo_args[@]}"} + return $? + fi + if [ "$status" = true ]; then + nightshift_status_command ${repo_args[@]+"${repo_args[@]}"} + return $? + fi + resolved="$(nightshift_resolve_repos ${repo_args[@]+"${repo_args[@]}"})" || return $? + while IFS= read -r repo; do + [ -n "$repo" ] && repos+=("$repo") + done </dev/null 2>&1; then fail "removed nightshift repos still resolved"; fi + +HOME="$night_config_home" write_config_value global nightshift_count 9 +equals "$(HOME="$night_config_home" devloop_config_value nightshift_count)" "5" "nightshift count write clamps high" +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_count)" "5" "nightshift count read clamps high" +(cd "$night_config_repo" && HOME="$night_config_home" write_config_value local nightshift_count 0) +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_count)" "1" "nightshift count read clamps low" +if HOME="$night_config_home" write_config_value global nightshift_count nope >/dev/null 2>&1; then fail "nightshift count accepted non-numeric"; fi +(cd "$night_config_repo" && HOME="$night_config_home" remove_config_value local nightshift_count) +(cd "$night_config_repo" && HOME="$night_config_home" remove_config_value global nightshift_count) +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_count)" "3" "removed nightshift count defaults" + +HOME="$night_config_home" write_config_value global nightshift_coder "Claude Code" +HOME="$night_config_home" write_config_value global nightshift_reviewer Codex +HOME="$night_config_home" write_config_value global nightshift_survey_agent "Claude_Code" +equals "$(HOME="$night_config_home" devloop_config_value nightshift_coder)" "claude" "nightshift coder write normalizes" +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_coder)" "claude" "nightshift coder read" +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_reviewer)" "codex" "nightshift reviewer read" +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_survey_agent)" "claude" "nightshift survey agent read" +if HOME="$night_config_home" write_config_value global nightshift_coder nope >/dev/null 2>&1; then fail "nightshift coder accepted invalid agent"; fi +HOME="$night_config_home" remove_config_value global nightshift_coder +HOME="$night_config_home" remove_config_value global nightshift_reviewer +HOME="$night_config_home" remove_config_value global nightshift_survey_agent +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_coder)" "codex" "removed nightshift coder falls back" +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_reviewer)" "claude" "removed nightshift reviewer falls back" +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_survey_agent)" "claude" "removed nightshift survey agent falls back" + +HOME="$night_config_home" write_config_value global nightshift_max_passes 11 +equals "$(HOME="$night_config_home" devloop_config_value nightshift_max_passes)" "10" "nightshift max passes write clamps high" +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_max_passes)" "10" "nightshift max passes read" +(cd "$night_config_repo" && HOME="$night_config_home" write_config_value local nightshift_max_passes 0) +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_max_passes)" "1" "nightshift max passes local clamps low" +if HOME="$night_config_home" write_config_value global nightshift_max_passes nope >/dev/null 2>&1; then fail "nightshift max passes accepted non-numeric"; fi +(cd "$night_config_repo" && HOME="$night_config_home" remove_config_value local nightshift_max_passes) +(cd "$night_config_repo" && HOME="$night_config_home" remove_config_value global nightshift_max_passes) +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_max_passes)" "5" "removed nightshift max passes defaults" + +HOME="$night_config_home" write_config_value global nightshift_schedule 02:30 +equals "$(cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_schedule)" "02:30" "nightshift schedule read" +HOME="$night_config_home" remove_config_value global nightshift_schedule +if (cd "$night_config_repo" && HOME="$night_config_home" devloop_nightshift_schedule) >/dev/null 2>&1; then fail "removed nightshift schedule still resolved"; fi +if HOME="$night_config_home" write_config_value global nightshift_unknown x >/dev/null 2>&1; then fail "nightshift config whitelist accepted unknown key"; fi +ok "nightshift config" + +night_prompt_specs="$work/night-prompt/specs" +night_prompt_survey="$work/night-prompt/survey.md" +contains "$(nightshift_usage)" "usage: devloop nightshift" "nightshift usage" +night_prompt="$(nightshift_survey_prompt 3 2026-07-05 "$night_prompt_specs" "$night_prompt_survey" "$REPO_ROOT/skills/devloop-spec/SKILL.md")" +contains "$night_prompt" "Skill: use the installed devloop-spec skill." "nightshift survey prompt" +contains "$night_prompt" "Requested spec count: 3" "nightshift survey prompt count" +contains "$night_prompt" "Current date: 2026-07-05" "nightshift survey prompt date" +contains "$night_prompt" "Write specs only to: $night_prompt_specs" "nightshift survey prompt write dir" +contains "$night_prompt" "write specs only, do not implement" "nightshift survey prompt no implementation" +contains "$night_prompt" "$REPO_ROOT/skills/devloop-spec/SKILL.md" "nightshift survey prompt skill path" +ok "nightshift survey prompt" + +night_repo="$work/night-repo" +git init -q "$night_repo" +night_repo_real="$(cd "$night_repo" && pwd -P)" +printf '%s\n' "# Night Repo" > "$night_repo/README.md" +git -C "$night_repo" add README.md +git -C "$night_repo" -c user.email=devloop-test@example.com -c user.name="devloop test" commit -q -m init +night_runner="$work/night-runner" +night_run_log="$work/night-run.log" +night_leak_capture="$work/night-leak.txt" +cat > "$night_runner" <<'RUNNER' +#!/usr/bin/env bash +set -euo pipefail +spec="" +for arg in "$@"; do + case "$arg" in + *.md) spec="$arg" ;; + esac +done +base="$(basename "$spec")" +slug="${base%.md}" +printf 'args %s\n' "$*" >> "$DEVLOOP_RUN_LOG" +printf 'start %s\n' "$base" >> "$DEVLOOP_RUN_LOG" +status="accepted" +code=0 +case "$base" in + 02-second.md) + status="stalled" + code=1 + ;; + 04-timeout.md) + printf 'end %s\n' "$base" >> "$DEVLOOP_RUN_LOG" + exit 124 + ;; + 05-crash.md) + printf 'end %s\n' "$base" >> "$DEVLOOP_RUN_LOG" + exit 2 + ;; +esac +printf '\n' +printf 'devloop %s\n\n' "$status" +printf ' Branch feat/%s\n' "$slug" +printf '\nOpen Next\n' +if [ "$status" = "accepted" ]; then + printf ' PR https://example.test/%s\n' "$slug" +fi +printf ' Report .devloop/reports/%s.html\n' "$slug" +printf ' Track .devloop/tracks/%s.md\n' "$slug" +printf 'end %s\n' "$base" >> "$DEVLOOP_RUN_LOG" +exit "$code" +RUNNER +chmod +x "$night_runner" +night_prompt_capture="$work/night-survey-prompt.txt" +night_survey_args_capture="$work/night-survey-args.txt" +if ! ( + run_with_prompt() { + local cwd="$1" + local log="$2" + local id="$3" + local prompt="$4" + shift 4 + local run_dir specs_dir + run_dir="$(dirname "$log")" + specs_dir="$run_dir/specs" + mkdir -p "$specs_dir" + printf '%s' "$prompt" > "$night_prompt_capture" + printf '%s\n' "$cwd|$id|$*" > "$night_survey_args_capture" + cat > "$specs_dir/03-third.md" <<'SPEC' +--- +type: feat +--- +# Third + +## Acceptance criteria +1. Third thing +SPEC + cat > "$specs_dir/01-first.md" <<'SPEC' +--- +type: feat +--- +# First + +## Acceptance criteria +1. First thing +SPEC + cat > "$specs_dir/02-second.md" <<'SPEC' +--- +type: feat +--- +# Second + +## Acceptance criteria +1. Second thing +SPEC + cat > "$specs_dir/04-timeout.md" <<'SPEC' +--- +type: feat +--- +# Timeout + +## Acceptance criteria +1. Timeout thing +SPEC + cat > "$specs_dir/05-crash.md" <<'SPEC' +--- +type: feat +--- +# Crash + +## Acceptance criteria +1. Crash thing +SPEC + printf '%s\n' "Survey rationale: first, second, third." > "$run_dir/survey.md" + RUN_CODE=0 + RUN_OUTPUT="survey complete" + RUN_STDOUT="" + RUN_STDERR="" + } + branch="global-branch" + pr="global-pr" + report="global-report" + DEVLOOP_NIGHTSHIFT_DATE=2026-07-05 DEVLOOP_RUN_CMD="$night_runner" DEVLOOP_RUN_LOG="$night_run_log" \ + nightshift_command --repo "$night_repo" --count 5 --coder claude --reviewer codex --survey-agent claude --max-passes 4 --timeout-minutes 12 >/tmp/devloop-nightshift.out + printf '%s|%s|%s\n' "$branch" "$pr" "$report" > "$night_leak_capture" +); then + fail "nightshift orchestration failed" +fi +night_digest="$night_repo_real/.devloop/nightshift/2026-07-05/digest.md" +[[ -f "$night_digest" ]] || fail "nightshift digest was not created" +contains "$(cat "$night_prompt_capture")" "write specs only, do not implement" "nightshift survey prompt capture" +contains "$(cat "$night_survey_args_capture")" "$night_repo_real|nightshift-survey|claude -p" "nightshift survey uses run_with_prompt" +equals "$(grep -c '^args ' "$night_run_log" | tr -d ' ')" "5" "nightshift runner invocation count" +contains "$(cat "$night_run_log")" "--plain --no-shell --create-pr --coder claude --reviewer codex --timeout-minutes 12" "nightshift runner headless flags" +contains "$(cat "$night_run_log")" "$night_repo_real/.devloop/nightshift/2026-07-05/specs/01-first.md 4" "nightshift runner max passes" +equals "$(grep -E '^(start|end) ' "$night_run_log")" $'start 01-first.md\nend 01-first.md\nstart 02-second.md\nend 02-second.md\nstart 03-third.md\nend 03-third.md\nstart 04-timeout.md\nend 04-timeout.md\nstart 05-crash.md\nend 05-crash.md' "nightshift runner sequential order" +equals "$(cat "$night_leak_capture")" "global-branch|global-pr|global-report" "nightshift run repo local scoping" +contains "$(cat "$night_digest")" "| First | accepted | feat/01-first | https://example.test/01-first | .devloop/reports/01-first.html |" "nightshift digest accepted row" +contains "$(cat "$night_digest")" "| Second | stalled | feat/02-second | no PR | .devloop/reports/02-second.html |" "nightshift digest failed row" +contains "$(cat "$night_digest")" "| Third | accepted | feat/03-third | https://example.test/03-third | .devloop/reports/03-third.html |" "nightshift digest subsequent row" +contains "$(cat "$night_digest")" "| Timeout | timeout | unknown | no PR | no report |" "nightshift digest timeout fallback row" +contains "$(cat "$night_digest")" "| Crash | run-error | unknown | no PR | no report |" "nightshift digest run-error fallback row" +contains "$(cat "$night_digest")" "Survey rationale: first, second, third." "nightshift digest survey rationale" +[[ -L "$night_repo/.devloop/nightshift/latest" ]] || fail "nightshift latest was not a symlink" +equals "$(readlink "$night_repo/.devloop/nightshift/latest")" "2026-07-05" "nightshift latest target" +equals "$(cd "$night_repo" && nightshift_command --status)" "$night_digest" "nightshift status latest digest" + +headless_output="$( + maybe_prompt_update() { printf '%s\n' "unexpected update prompt"; } + nightshift_run_repo() { printf '%s %s %s\n' "$USE_TUI" "$ENTER_WORKTREE" "$DEVLOOP_UPDATE_PROMPTED"; } + USE_TUI=true + ENTER_WORKTREE=true + DEVLOOP_UPDATE_PROMPTED=false + main nightshift --repo "$night_repo" --dry-run +)" +equals "$headless_output" "false false true" "nightshift dispatch forces headless mode" + +dry_repo="$work/night-dry-repo" +git init -q "$dry_repo" +dry_repo_real="$(cd "$dry_repo" && pwd -P)" +dry_log="$work/night-dry-run.log" +if ! ( + run_with_prompt() { + local log="$2" + local run_dir specs_dir + run_dir="$(dirname "$log")" + specs_dir="$run_dir/specs" + mkdir -p "$specs_dir" + cat > "$specs_dir/01-first.md" <<'SPEC' +--- +type: feat +--- +# First + +## Acceptance criteria +1. First thing +SPEC + cat > "$specs_dir/02-second.md" <<'SPEC' +--- +type: feat +--- +# Second + +## Acceptance criteria +1. Second thing +SPEC + printf '%s\n' "Dry-run rationale." > "$run_dir/survey.md" + RUN_CODE=0 + RUN_OUTPUT="survey complete" + RUN_STDOUT="" + RUN_STDERR="" + } + DEVLOOP_NIGHTSHIFT_DATE=2026-07-06 DEVLOOP_RUN_CMD="$night_runner" DEVLOOP_RUN_LOG="$dry_log" \ + nightshift_command --repo "$dry_repo" --dry-run --count 2 >/tmp/devloop-nightshift-dry.out +); then + fail "nightshift dry run failed" +fi +dry_digest="$dry_repo_real/.devloop/nightshift/2026-07-06/digest.md" +[[ -f "$dry_digest" ]] || fail "nightshift dry-run digest was not created" +[[ ! -s "$dry_log" ]] || fail "nightshift dry run invoked per-spec runner" +contains "$(cat "$dry_digest")" "| First | dry-run | no branch | no PR | not run |" "nightshift dry-run digest" +contains "$(cat "$dry_digest")" "Dry-run rationale." "nightshift dry-run rationale" + +status_empty_repo="$work/night-status-empty" +mkdir -p "$status_empty_repo" +if status_empty_output="$(cd "$status_empty_repo" && nightshift_command --status 2>&1)"; then + printf '%s\n' "$status_empty_output" >&2 + fail "nightshift status accepted missing digest" +fi +contains "$status_empty_output" "no nightshift digest found" "nightshift status missing digest" +ok "nightshift orchestration" + +schedule_home="$work/night-schedule-home" +schedule_agents="$work/night-launch-agents" +schedule_log="$work/night-launchctl.log" +schedule_launchctl="$work/night-launchctl" +mkdir -p "$schedule_home" "$schedule_agents" +cat > "$schedule_launchctl" <<'LAUNCHCTL' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$DEVLOOP_LAUNCHCTL_LOG" +LAUNCHCTL +chmod +x "$schedule_launchctl" +schedule_output="$( + HOME="$schedule_home" DEVLOOP_LAUNCH_AGENTS_DIR="$schedule_agents" DEVLOOP_LAUNCHCTL="$schedule_launchctl" DEVLOOP_LAUNCHCTL_LOG="$schedule_log" \ + nightshift_command --repo "$night_repo" --install-schedule 02:30 +)" +schedule_plist="$schedule_agents/sh.devloop.nightshift.plist" +[[ -f "$schedule_plist" ]] || fail "nightshift schedule plist was not written" +contains "$schedule_output" "$schedule_plist" "nightshift schedule output plist" +contains "$schedule_output" "sudo pmset repeat wake MTWRFSU 02:30" "nightshift schedule wake command" +contains "$(cat "$schedule_plist")" "StartCalendarInterval" "nightshift plist schedule" +contains "$(cat "$schedule_plist")" "Hour" "nightshift plist hour" +contains "$(cat "$schedule_plist")" "2" "nightshift plist hour value" +contains "$(cat "$schedule_plist")" "Minute" "nightshift plist minute" +contains "$(cat "$schedule_plist")" "30" "nightshift plist minute value" +contains "$(cat "$schedule_plist")" "ProgramArguments" "nightshift plist program arguments" +contains "$(cat "$schedule_plist")" "$REPO_ROOT/devloop" "nightshift plist devloop path" +contains "$(cat "$schedule_plist")" "nightshift" "nightshift plist command" +contains "$(cat "$schedule_plist")" "--repo" "nightshift plist repo flag" +contains "$(cat "$schedule_plist")" "$night_repo_real" "nightshift plist repo path" +contains "$(cat "$schedule_plist")" "EnvironmentVariables" "nightshift plist env" +contains "$(cat "$schedule_plist")" "PATH" "nightshift plist PATH" +contains "$(cat "$schedule_plist")" "StandardOutPath" "nightshift plist stdout" +contains "$(cat "$schedule_plist")" "StandardErrorPath" "nightshift plist stderr" +contains "$(cat "$schedule_log")" "bootstrap gui/" "nightshift schedule launchctl load" +equals "$(HOME="$schedule_home" devloop_nightshift_schedule)" "02:30" "nightshift schedule persisted" +uninstall_output="$( + HOME="$schedule_home" DEVLOOP_LAUNCH_AGENTS_DIR="$schedule_agents" DEVLOOP_LAUNCHCTL="$schedule_launchctl" DEVLOOP_LAUNCHCTL_LOG="$schedule_log" \ + nightshift_command --uninstall-schedule +)" +contains "$uninstall_output" "removed nightshift schedule" "nightshift uninstall output" +[[ ! -e "$schedule_plist" ]] || fail "nightshift schedule plist was not removed" +contains "$(cat "$schedule_log")" "bootout gui/" "nightshift schedule launchctl unload" +if HOME="$schedule_home" devloop_nightshift_schedule >/dev/null 2>&1; then fail "nightshift uninstall left schedule config"; fi +HOME="$schedule_home" DEVLOOP_LAUNCH_AGENTS_DIR="$schedule_agents" DEVLOOP_LAUNCHCTL="$schedule_launchctl" DEVLOOP_LAUNCHCTL_LOG="$schedule_log" \ + nightshift_command --uninstall-schedule >/tmp/devloop-nightshift-uninstall-empty.out +contains "$(cat /tmp/devloop-nightshift-uninstall-empty.out)" "no nightshift schedule installed" "nightshift uninstall missing" + +bad_schedule_agents="$work/night-bad-launch-agents" +mkdir -p "$bad_schedule_agents" +if bad_schedule_output="$( + HOME="$schedule_home" DEVLOOP_LAUNCH_AGENTS_DIR="$bad_schedule_agents" DEVLOOP_LAUNCHCTL="$schedule_launchctl" DEVLOOP_LAUNCHCTL_LOG="$schedule_log" \ + nightshift_command --repo "$night_repo" --install-schedule 9pm 2>&1 +)"; then + printf '%s\n' "$bad_schedule_output" >&2 + fail "nightshift install accepted invalid time" +fi +contains "$bad_schedule_output" "invalid nightshift schedule time: 9pm" "nightshift invalid schedule time" +[[ ! -e "$bad_schedule_agents/sh.devloop.nightshift.plist" ]] || fail "nightshift invalid time wrote plist" +bad_repo_agents="$work/night-bad-repo-agents" +mkdir -p "$bad_repo_agents" +if bad_repo_output="$( + HOME="$schedule_home" DEVLOOP_LAUNCH_AGENTS_DIR="$bad_repo_agents" DEVLOOP_LAUNCHCTL="$schedule_launchctl" DEVLOOP_LAUNCHCTL_LOG="$schedule_log" \ + nightshift_command --repo "$work/does-not-exist" --install-schedule 02:00 2>&1 +)"; then + printf '%s\n' "$bad_repo_output" >&2 + fail "nightshift install accepted unresolvable repo" +fi +contains "$bad_repo_output" "nightshift repo is not resolvable" "nightshift unresolvable repo" +[[ ! -e "$bad_repo_agents/sh.devloop.nightshift.plist" ]] || fail "nightshift bad repo wrote plist" +if missing_repo_output="$(nightshift_command --repo "$work/does-not-exist" 2>&1)"; then + printf '%s\n' "$missing_repo_output" >&2 + fail "nightshift accepted unresolvable run repo" +fi +contains "$missing_repo_output" "nightshift repo is not resolvable" "nightshift run unresolvable repo" +if no_repo_output="$(HOME="$work/night-no-repo-home" nightshift_command 2>&1)"; then + printf '%s\n' "$no_repo_output" >&2 + fail "nightshift accepted missing repos" +fi +contains "$no_repo_output" "nightshift_repos or --repo is required" "nightshift missing repos" +ok "nightshift scheduling" + lint_spec_text=$'---\ntype: feat\n---\n# Title\n\n## Acceptance criteria\n1. Thing' lint_spec_file "$criteria_file" "$lint_spec_text" 1 true || fail "lint_spec_file rejected valid spec" bad_lint_spec=$'---\ntype: invalid\n---\n# Title\n\n## Acceptance criteria\n1. Thing'