← Files NightshiftARCHIVED FILE

runtime/start-preflight.sh

28.2 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

#!/usr/bin/env bash
# start-preflight.sh — the Start skill's one preflight. Prints one verdict per line.
#
#   start-preflight.sh --project DIR [--host claude|codex|cursor]
#                      [--phase preflight|snapshot|bind] [--dry-run]
#
# Verdict grammar, one per line. The verdict sentence is byte-identical on POSIX and native
# Windows; only interpolated paths and a parser's own diagnostic tail differ.
#
#   ok <topic> <detail>      a resolved fact the skill may report
#   warn <topic> <detail>    arm anyway, but say this to the owner
#   refuse <topic> <detail>  do not arm
#   repair <text>            the exact repair for the refusal above it
#
# Phase preflight covers everything before `.shift-armed`; phase snapshot records the plain Start's
# shift policy right before arming; phase bind is the Codex identity checkpoint that runs after the
# binding probe and before the watchman.
#
# Exit: 0 may arm · 1 refused · 2 usage
set -u

_here="${BASH_SOURCE[0]%/*}"; [ "$_here" != "${BASH_SOURCE[0]}" ] || _here=.
_here="$(cd -P "$_here" && pwd)" || exit 2
# shellcheck source=plugins/nightshift/lib/lib.sh
. "$_here/../lib/lib.sh"
# shellcheck source=plugins/nightshift/lib/control.sh
. "$_here/../lib/control.sh"

PROJECT="${CLAUDE_PROJECT_DIR:-${CODEX_PROJECT_DIR:-$PWD}}"
HOST_NAME=""
PHASE=preflight
DRY_RUN=0
ENDED_GROUP=""

while [ $# -gt 0 ]; do
  case "$1" in
    --project)
      [ $# -ge 2 ] || { printf 'start-preflight: --project needs a value\n' >&2; exit 2; }
      PROJECT="$2"; shift 2 ;;
    --host)
      [ $# -ge 2 ] || { printf 'start-preflight: --host needs a value\n' >&2; exit 2; }
      HOST_NAME="$2"; shift 2 ;;
    --phase)
      [ $# -ge 2 ] || { printf 'start-preflight: --phase needs a value\n' >&2; exit 2; }
      PHASE="$2"; shift 2 ;;
    --dry-run) DRY_RUN=1; shift ;;
    -h | --help)
      awk 'NR == 1 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0"
      exit 2 ;;
    *) printf 'start-preflight: unknown argument: %s\n' "$1" >&2; exit 2 ;;
  esac
done

case "$PHASE" in
  preflight | snapshot | bind) ;;
  *) printf 'start-preflight: unknown phase: %s\n' "$PHASE" >&2; exit 2 ;;
esac

case "$HOST_NAME" in
  '')
    if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then HOST_NAME=cursor
    elif [ -n "${CODEX_PROJECT_DIR:-}${CODEX_SANDBOX:-}${CODEX_SANDBOX_MODE:-}" ]; then HOST_NAME=codex
    elif [ -n "${CLAUDE_PLUGIN_ROOT:-}${CLAUDE_PROJECT_DIR:-}" ]; then HOST_NAME=claude
    else HOST_NAME=unknown
    fi
    ;;
  claude | codex | cursor) ;;
  *) printf 'start-preflight: unknown host: %s\n' "$HOST_NAME" >&2; exit 2 ;;
esac

REFUSED=0
NS_EXPLAIN_FILE="$_here/../lib/preflight-explain.txt"
ok()     { printf 'ok %s\n' "$1"; }
repair() { printf 'repair %s\n' "$1"; }
# A warn or a refuse carries its own explanation, so no verdict site has to remember to print one.
# `ok` lines get none: a resolved fact explains itself.
warn()   { printf 'warn %s\n' "$1"; ns_explain_emit "$(ns_explain_topic "$1")"; }
refuse() { REFUSED=1; printf 'refuse %s\n' "$1"; ns_explain_emit "$(ns_explain_topic "$1")"; }

HOST_ROOT="$(cd -P "$PROJECT" 2>/dev/null && pwd)" || {
  refuse "workspace cannot resolve the project path $PROJECT"
  repair "invoke Start from the host-opened project folder"
  exit 1
}

WORKSPACE="$HOST_ROOT"
if [ -e "$HOST_ROOT/.nightshift-link" ] || [ -L "$HOST_ROOT/.nightshift-link" ]; then
  if WORKSPACE="$(ns_workspace_root "$HOST_ROOT" 2>/dev/null)"; then
    ok "link $HOST_ROOT -> $WORKSPACE"
  else
    refuse "link .nightshift-link does not name one existing Nightshift workspace"
    repair "rewrite .nightshift-link with one absolute path to a directory that already holds .nightshift/, or run link-workspace with an owner-provided path"
    exit 1
  fi
fi

NS="$WORKSPACE/.nightshift"
declare PUNCH LOG DEADLINE WATCHMAN_PID STOP POLICY RULES PROVISION_TXN PENDING_FILING LEASE ENDED WORK_ORDERS WORK_MODE_FILE DRAFTING_TABLE ARMED
ns_layout_set PUNCH "$NS" punch-list
ns_layout_set LOG "$NS" shift-log
ns_layout_set DEADLINE "$NS" deadline
ns_layout_set WATCHMAN_PID "$NS" watchman
ns_layout_set STOP "$NS" stop
ns_layout_set POLICY "$NS" shift-policy
ns_layout_set RULES "$NS" rules
ns_layout_set PROVISION_TXN "$NS" provision-transaction
ns_layout_set PENDING_FILING "$NS" pending-filing
ns_layout_set LEASE "$NS" lease
ns_layout_set ENDED "$NS" ended
ns_layout_set WORK_ORDERS "$NS" work-orders
ns_layout_set WORK_MODE_FILE "$NS" work-mode
ns_layout_set DRAFTING_TABLE "$NS" drafting-table
ns_layout_set ARMED "$NS" armed
ok "host $HOST_NAME"
ok "workspace $WORKSPACE"

# The hooks answer for whatever directory the host opened, and the markers this start is about to
# write land under the workspace resolved above. When a host was launched in a parent folder and
# the working directory moved afterwards, those are two different places: the shift would arm in
# one and its session and lease would be recorded against the other. Nightshift does not guess
# which was meant — it names both and refuses before anything is armed.
HOST_PROJECT="${CLAUDE_PROJECT_DIR:-${CODEX_PROJECT_DIR:-}}"
if [ -n "$HOST_PROJECT" ]; then
  HOST_PROJECT_ABS="$(cd -P "$HOST_PROJECT" 2>/dev/null && pwd)" || HOST_PROJECT_ABS=""
  if [ -n "$HOST_PROJECT_ABS" ] && [ "$HOST_PROJECT_ABS" != "$HOST_ROOT" ]; then
    BOUND="$HOST_PROJECT_ABS"
    if [ -e "$HOST_PROJECT_ABS/.nightshift-link" ] || [ -L "$HOST_PROJECT_ABS/.nightshift-link" ]; then
      BOUND="$(ns_workspace_root "$HOST_PROJECT_ABS" 2>/dev/null)" || BOUND="$HOST_PROJECT_ABS"
    fi
    if [ "$BOUND" != "$WORKSPACE" ]; then
      refuse "binding the host opened $HOST_PROJECT_ABS and this start was given $HOST_ROOT, which resolve to different workspaces ($BOUND and $WORKSPACE)"
      # Relaunching is not the only way out. When the owner named the workspace they meant, the
      # link is made from here and this conversation carries on; the link binds the host root
      # rather than this one conversation, which is why it takes their say-so.
      repair "reopen the host on the project you mean, then run Start again"
      repair "or, if $WORKSPACE is the workspace you meant, link it from this session with ns link-workspace --host-root \"$HOST_PROJECT_ABS\" --workspace \"$WORKSPACE\", then run the preflight and the binding probe again - this conversation carries on. The link binds the host root, not this one conversation, which is why it takes the owner's say-so"
      exit 1
    fi
  fi
fi

if [ ! -d "$NS" ] || [ -L "$NS" ]; then
  refuse "workspace no usable .nightshift/ at $WORKSPACE"
  repair "run Nightshift setup in this project before starting a shift"
  exit 1
fi

# ---------------------------------------------------------------- phase bind
# Codex exposes task identity through hook payloads, so the recorded id can only be classified
# after the binding probe has written .shift-session.
if [ "$PHASE" = bind ]; then
  SID="$(ns_session_line "$NS" 1)"
  REC_HOST="$(ns_session_host "$NS")"
  ok "session-host $REC_HOST"
  if [ "$REC_HOST" != codex ]; then
    ok "codex-identity not-applicable"
    exit 0
  fi
  KIND="$(ns_codex_identity_kind "$SID")"
  case "$KIND" in
    resumable)
      ok "codex-identity resumable" ;;
    missing)
      warn "codex-identity missing - same-thread recovery is unavailable until an identity is recorded, so revival falls back to a fresh session whose handover is the punch list" ;;
    *)
      refuse "codex-identity $KIND - the watchman must not claim it resumed that thread"
      repair "remove the markers this start created (.shift-armed and its new .shift-session) and reset the lease in the same call, append one failed-preflight line to shift-log.md, and stop before the watchman or item work" ;;
  esac
  [ "$REFUSED" -eq 0 ] || exit 1
  exit 0
fi

# ------------------------------------------------------------ phase snapshot
# Start runs this right before it arms, after any draft or order was cut into the list, so the gate
# holds the shift to the contract and items it actually starts with. A composed shift already has
# its snapshot and keeps it.
if [ "$PHASE" = snapshot ]; then
  if [ -e "$POLICY" ] || [ -L "$POLICY" ]; then
    ok "snapshot composed - this shift keeps the policy it was composed with"
    exit 0
  fi
  OPEN="$(ns_open_boxes "$PUNCH" 2>/dev/null)" || OPEN=0
  case "$OPEN" in '' | *[!0-9]*) OPEN=0 ;; esac
  if [ "$OPEN" -eq 0 ]; then
    warn "snapshot none recorded - the punch list has no open item to hold a shift to"
    exit 0
  fi
  if [ "$DRY_RUN" -eq 1 ]; then
    ok "snapshot dry-run - nothing recorded"
    exit 0
  fi
  if SNAPSHOT_ID="$(ns_start_snapshot "$WORKSPACE" "$_here/shift-policy.sh")"; then
    ok "snapshot start-defaults recorded for shift $SNAPSHOT_ID"
  else
    warn "snapshot none recorded - the gate cannot hold this shift to the list it armed with; Start again, or compose it through Hunt"
  fi
  exit 0
fi

# ------------------------------------------------------------ state version
STATE_KIND="$(ns_state_kind "$WORKSPACE")"
case "$STATE_KIND" in
  current)
    ok "state-version $(ns_state_version "$WORKSPACE" || true) ($STATE_KIND)" ;;
  legacy)
    STATE_VER="$(ns_state_version "$WORKSPACE" || true)"
    ok "state-version $STATE_VER ($STATE_KIND)"
    warn "state-version $STATE_VER keeps every state file at the top of .nightshift/ - Doctor offers the move to version $NS_STATE_VERSION, previewed by ns migrate-state and made only with --apply" ;;
  *)
    refuse "state-version $(ns_state_refuse_message "$STATE_KIND")"
    repair "Setup or Doctor repairs the marker with migrate-state; Start never writes it" ;;
esac

# --------------------------------------------------------- work mode/target
WORK_MODE=""
if WORK_MODE="$(ns_work_mode "$WORKSPACE" 2>/dev/null)"; then
  ok "work-mode $WORK_MODE"
  if [ ! -s "$WORK_MODE_FILE" ]; then
    proposed="$(ns_propose_work_mode "$WORKSPACE" 2>/dev/null)" || proposed=""
    if [ "$proposed" = artifact ]; then
      refuse "work-mode unset and Setup would propose artifact"
      repair "run Setup to record artifact mode; never git init a notes folder to make it a repository"
    fi
  fi
  if [ "$WORK_MODE" = artifact ]; then
    recv="$(ns_receipts_dir "$WORKSPACE")"
    if { [ -e "$recv" ] || [ -L "$recv" ]; } && ! ns_receipts_usable_dir "$WORKSPACE" >/dev/null; then
      refuse "receipts artifact receipts path exists but is not a usable directory"
      repair "replace $recv with a real directory so receipts can land"
    fi
  fi
else
  WORK_MODE=""
  refuse "work-mode malformed - the site is unusable until Setup rewrites it"
  repair "run Setup to record repository or artifact as one word"
fi

if WORK_TARGET="$(ns_work_target "$WORKSPACE" 2>/dev/null)"; then
  ok "work-target $WORK_TARGET"
  if [ "$DRY_RUN" -eq 0 ]; then
    ns_ensure_work_target_link "$WORKSPACE" || true
  fi
else
  rc=$?
  WORK_TARGET="$WORKSPACE"
  if [ "$rc" -eq 3 ]; then
    refuse "work-target resolves to a disposable scratch path"
    repair "open the project from a persistent folder or Git repository, then run Setup there"
  else
    refuse "work-target cannot be resolved from $WORKSPACE"
    repair "run Setup to record one work target; several child repositories make the choice ambiguous and Nightshift never guesses"
  fi
fi

# ---------------------------------------------------- one shift, one agent
LEASE_STATE=absent
if [ -e "$LEASE" ] || [ -L "$LEASE" ]; then
  if ns_lease_valid "$NS"; then
    LEASE_STATE=valid
  else
    LEASE_STATE=malformed
    refuse "lease malformed - ownership cannot be proven, so this is unowned state"
    repair "issue STOP, then run ns stop-shift in a terminal and start again; never edit or delete .shift-lease by hand"
    # The owner runs this themselves, in a terminal, after STOP. Never from the blocked session.
    repair "if the lease is still unowned after that, reset it yourself with: bash -c '. \"\$NIGHTSHIFT_PLUGIN_ROOT/lib/lib.sh\"; ns_lease_reset_stale \"\$NIGHTSHIFT_WORKSPACE/.nightshift\"' - a false result is a refusal, not permission to delete the lease directly"
  fi
fi

SESSION_LIVE=0
SESSION_UNKNOWN=0
SITE_PAUSED=0
ns_site_paused "$NS" && SITE_PAUSED=1
if ns_session_present "$NS"; then
  s_pid="$(ns_session_line "$NS" 3 | tr -d '[:space:]')"
  s_start="$(ns_session_line "$NS" 4)"
  s_host="$(ns_session_host "$NS")"
  case "$s_pid" in
    '' | *[!0-9]*) ;;
    *)
      ns_recorded_process "$s_pid" "$s_start"
      case "$?" in
        0) SESSION_LIVE=1 ;;
        1) ;;
        *) SESSION_UNKNOWN=1 ;;
      esac
      ;;
  esac
  if [ "$SITE_PAUSED" -eq 0 ] && [ "$SESSION_LIVE" -eq 1 ]; then
    refuse "session an agent is already working this punch list on $s_host"
    repair "ask Nightshift for status, or pause it with ns stop-shift before starting a second shift"
    repair "if that shift is already stopped, clear the leftover session with ns reset-shift"
  elif [ "$SITE_PAUSED" -eq 0 ] && [ "$SESSION_UNKNOWN" -eq 1 ]; then
    refuse "session process-evidence-unavailable - a pid that kill -0 cannot classify is not a dead session"
    repair "run Start from a shell that can see the recorded process, or pause the shift with ns stop-shift"
    repair "if that shift is already stopped, clear the leftover session with ns reset-shift"
  fi
fi

if [ "$SITE_PAUSED" -eq 0 ] && [ "$LEASE_STATE" = valid ] && ns_lease_pid_live "$NS"; then
  refuse "lease a live process holds generation $NS_LEASE_GENERATION of this shift"
  repair "wait for that worker to exit, or pause the shift with ns stop-shift"
  repair "if that shift is already stopped, clear the leftover session with ns reset-shift"
fi

if [ "$(ns_reason_code "$NS")" = clock-out-failed ] && [ "$LEASE_STATE" = valid ] && [ -z "$NS_LEASE_NONCE" ]; then
  warn "lease terminal clock-out failed without releasing the shift - reopen the recorded conversation rather than resetting the lease"
fi

OPEN=0
TICKED=0
if [ -f "$PUNCH" ] && [ ! -L "$PUNCH" ]; then
  OPEN="$(ns_open_boxes "$PUNCH")"
  TICKED="$(ns_ticked_boxes "$PUNCH")"
fi

WATCHMAN_LIVE=0
if [ -f "$WATCHMAN_PID" ] && [ ! -L "$WATCHMAN_PID" ]; then
  w_pid="$(sed -n 1p "$WATCHMAN_PID" 2>/dev/null | tr -d '[:space:]')"
  w_start="$(sed -n 2p "$WATCHMAN_PID" 2>/dev/null || true)"
  case "$w_pid" in
    '' | *[!0-9]*) ;;
    *) ns_recorded_process "$w_pid" "$w_start" && WATCHMAN_LIVE=1 ;;
  esac
fi
if [ "$WATCHMAN_LIVE" -eq 1 ] && [ -f "$ARMED" ] && [ "$OPEN" -gt 0 ]; then
  refuse "watchman a live watchman is recovering this shift, including between recovery attempts"
  repair "ask Nightshift for status, or pause it with ns stop-shift; never kill that watchman as stale"
  # The panic form when the helper cannot be run: it only writes the marker, so the watchman stands
  # down at its next Stop event rather than immediately.
  repair "if you cannot run that, write the marker yourself with: touch \"$STOP\" - the watchman then stands down at its next Stop event"
fi

[ "$REFUSED" -eq 0 ] || exit 1

# ------------------------------------------- cross-host handoff, then reset
if [ "$LEASE_STATE" = valid ]; then
  ns_fence_check "$NS" >/dev/null 2>&1
  case "$?" in
    0) ok "fence takeover allowed - the prior worker is fenced and no duplicate is live" ;;
    1)
      refuse "fence the on-disk fence does not permit takeover"
      repair "pause the shift with ns stop-shift, then start again" ;;
    *)
      refuse "fence the on-disk fence is missing or unreadable"
      repair "pause the shift with ns stop-shift, then start again" ;;
  esac
else
  ok "fence no prior worker to fence"
fi

[ "$REFUSED" -eq 0 ] || exit 1

# A paused shift with a spent deadline never gets a silent new budget.
CONTROL_REASON="$(ns_control_start_refuse_reason "$NS")"
if [ -n "$CONTROL_REASON" ]; then
  refuse "control a paused shift with an expired deadline does not get a silent new budget"
  repair "run Reset then Start, or write the new UNIX epoch yourself; never clear STOP and never invent a time budget"
  exit 1
fi

if [ "$DRY_RUN" -eq 0 ]; then
  if ! ns_control_stop_watchman "$NS"; then
    refuse "watchman a recorded watchman pid could not be verified, so it was left running"
    repair "pause the shift with ns stop-shift, then start again"
    exit 1
  fi
  # The folder the last shift filed into, read before its ending marker is cleared: an oversized
  # journal joins it below.
  ENDED_GROUP="$(ns_archive_group_if_claimed "$WORKSPACE" "$(ns_ended_field "$WORKSPACE" shiftId)")"
  # The last sign of work, read before its marker is cleared: where an interrupted shift's gap began.
  LAST_ACTIVITY="$(ns_pulse_epoch "$NS")"
  WAS_ARMED=0
  [ -f "$ARMED" ] && WAS_ARMED=1
  CLEARED=""
  for key in stop stall notified ended session-end pulse mint-failed session armed watchman-tick lock; do
    ns_layout_set marker "$NS" "$key"
    if [ -e "$marker" ] || [ -L "$marker" ]; then
      CLEARED="${CLEARED}${CLEARED:+ }${marker##*/}"
    fi
  done
  # Accounting follows the work. A shift that finished its list has its readings set aside, so the
  # next list starts clean. One that stopped, reached quitting time or died with items still open
  # is continued, and an open item keeps the time and tokens already spent on it: the readings
  # stay live, and an ended shift gets its own copy for its archive. The gap since its last work is
  # recorded as a pause unless one already covers it, so the timeline shows where it broke off.
  OPEN_NOW=0
  [ -f "$PUNCH" ] && OPEN_NOW="$(ns_open_boxes "$PUNCH")"
  CONTINUED=0
  if [ -f "$ENDED" ] && [ ! -L "$ENDED" ]; then
    if [ "${OPEN_NOW:-0}" -gt 0 ]; then
      CONTINUED=1
      KEPT_USAGE="$(ns_usage_snapshot "$NS" "$(ns_ended_field "$WORKSPACE" shiftId)")" || KEPT_USAGE=""
      [ -z "$KEPT_USAGE" ] || CLEARED="${CLEARED}${CLEARED:+ }usage=>${KEPT_USAGE##*/}"
    else
      RETIRED_USAGE="$(ns_usage_retire "$NS" "$(ns_ended_field "$WORKSPACE" shiftId)")" || RETIRED_USAGE=""
      [ -z "$RETIRED_USAGE" ] || CLEARED="${CLEARED}${CLEARED:+ }usage->${RETIRED_USAGE##*/}"
    fi
  elif { [ -f "$STOP" ] && [ ! -L "$STOP" ]; } || [ "$WAS_ARMED" -eq 1 ]; then
    CONTINUED=1
  else
    RETIRED_USAGE="$(ns_usage_retire "$NS" "$(ns_ended_field "$WORKSPACE" shiftId)")" || RETIRED_USAGE=""
    [ -z "$RETIRED_USAGE" ] || CLEARED="${CLEARED}${CLEARED:+ }usage->${RETIRED_USAGE##*/}"
  fi
  LAST_PAUSE="$(ns_usage_last_pause "$NS")"
  if [ "$CONTINUED" -eq 1 ] && [ -d "$(ns_usage_dir "$NS")" ] && [ -n "$LAST_ACTIVITY" ] &&
    [ "$LAST_ACTIVITY" -gt "${LAST_PAUSE:-0}" ]; then
    ns_usage_pause "$NS" "the shift broke off here and Start resumed it" "$LAST_ACTIVITY" || :
  fi
  ns_control_drop "$STOP"
  ns_control_drop_runtime_markers "$NS"
  if ns_control_deadline_passed "$NS"; then
    ns_control_drop "$DEADLINE"
    CLEARED="${CLEARED}${CLEARED:+ }deadline"
  fi
  # The markers Start writes next land where the layout keeps them.
  ns_layout_parent "$NS" armed || :
  ok "markers ${CLEARED:-none}"
  ok "lease reset"
else
  ok "markers dry-run"
  ok "lease dry-run"
fi

# ------------------------------------------------------------------- rules
RULES_RC=0
RULES_REASON="$(ns_rules_check "$WORKSPACE")" || RULES_RC=$?
case "$RULES_RC" in
  0) ok "rules readable" ;;
  3)
    refuse "rules rules.json is missing"
    repair "run Setup and accept the shipped rules template" ;;
  *)
    refuse "rules rules.json is not the accepted shape: ${RULES_REASON:-unreadable}"
    repair "fix that named reason in $RULES or re-run Setup; never half-apply a broken file" ;;
esac

if [ "$RULES_RC" -eq 0 ]; then
  WATCH_MINUTES="$(rule "$WORKSPACE" watchMinutes "${NIGHTSHIFT_WATCH:-}")"
  case "$WATCH_MINUTES" in '' | *[!0-9]*) WATCH_MINUTES=0 ;; esac
  if [ "$WATCH_MINUTES" -gt 0 ]; then
    ok "watch-minutes $WATCH_MINUTES"
    for key in watchRetrySeconds revivalPrompt freshRevivalPrompt; do
      case "$key" in
        watchRetrySeconds) value="$(rule "$WORKSPACE" "$key" "${NIGHTSHIFT_WATCH_RETRY:-}")" ;;
        revivalPrompt) value="$(ns_expand_injected_paths "$WORKSPACE" "$(rule "$WORKSPACE" "$key" "${NIGHTSHIFT_REVIVAL_PROMPT:-}")")" ;;
        *) value="$(ns_expand_injected_paths "$WORKSPACE" "$(rule "$WORKSPACE" "$key" "${NIGHTSHIFT_FRESH_PROMPT:-}")")" ;;
      esac
      if [ -z "$value" ]; then
        refuse "rules $key is empty, so the watchman would refuse to arm"
        repair "restore $key from the shipped rules template with Setup"
      fi
    done
  else
    ok "watch-minutes 0 (watchman disarmed)"
  fi

  # New knobs: the shipped template's top-level keys and its three native question-tool
  # entries. A key the template has and the file lacks means a plugin update brought a knob
  # nobody has reviewed. Start names it once and never adds it.
  NEW_KEYS=""
  TEMPLATE="$_here/../skills/nightshift/references/nightshift-rules-template.json"
  if [ -f "$TEMPLATE" ]; then
    present="$(ns_rules_keys "$RULES" 2>/dev/null || true)"
    for key in $(ns_rules_keys "$TEMPLATE" 2>/dev/null || true); do
      case "
$present
" in
        *"
$key
"*) ;;
        *) NEW_KEYS="${NEW_KEYS}${NEW_KEYS:+ }$key" ;;
      esac
    done
  fi
  for tool in AskUserQuestion request_user_input AskQuestion; do
    case "$(ns_rules_tool_state "$RULES" "$tool")" in
      allow | deny) ;;
      *)
        warn "rules toolDeny.$tool has no explicit policy and must be repaired with Setup before that ask tool can run; a non-empty value denies, an empty value allows" ;;
    esac
  done
  [ -z "$NEW_KEYS" ] || warn "rules a plugin update brought knobs this file lacks: $NEW_KEYS - review them with Setup; Start never adds them"
fi

# ----------------------------------------------------------- provisioning
if [ -e "$PROVISION_TXN" ] || [ -L "$PROVISION_TXN" ]; then
  PROVISION_TAB="$(printf '\t')"
  PROVISION_LINE=""
  if [ -x "$_here/provision-recover.sh" ]; then
    PROVISION_LINE="$("$_here/provision-recover.sh" --project "$WORKSPACE" --diagnose 2>/dev/null)" || PROVISION_LINE=""
  fi
  if [ -n "$PROVISION_LINE" ] && [ "${PROVISION_LINE%%"$PROVISION_TAB"*}" = provable ]; then
    ok "provision an interrupted install is proven recovered"
  else
    refuse "provision an interrupted install cannot be proven recovered"
    repair "$(ns_layout_name "$NS" provision-transaction) and $(ns_layout_name "$NS" provision-baseline)/, restore by hand or run ns provision rollback after fixing the target, then Start again"
  fi
else
  ok "provision none pending"
fi

# ---------------------------------------------------------- tonight's policy
# The snapshot reads the same on every host: jq or python3 where one is installed, and the
# bounded reader where neither is. Only a host without awk either has nothing left to read it.
if ns_policy_json_tool >/dev/null 2>&1 || ns_rules_awk_bin >/dev/null 2>&1; then
  POLICY_OUT="$(ns_policy_read_shift "$WORKSPACE")"
  case "$?" in
    2)
      refuse "policy shift-policy.json is malformed: $POLICY_OUT"
      repair "repair the named field in $POLICY, or delete the file so the next Start writes safe defaults" ;;
    *)
      if [ -f "$POLICY" ]; then
        ok "policy resolved"
        # A snapshot is one night's approval. One the archive has already filed would run that
        # approval again, one-shift allowances included; Start's own snapshot draws a fresh id.
        REPLAY_FILE="$(ns_policy_replayed "$WORKSPACE")" || REPLAY_FILE=""
        if [ -n "$REPLAY_FILE" ]; then
          refuse "replay shift-policy.json is shift $(ns_policy_shift_id "$WORKSPACE"), which has already run and is filed as $REPLAY_FILE"
          repair "remove $POLICY so the next Start writes a fresh snapshot, or compose the shift again with Hunt or Quality"
        fi
      else
        ok "policy none - arming from rules.json"
      fi
      ;;
  esac
else
  refuse "policy no reader for tonight's snapshot on this host"
  repair "restore a POSIX text environment, or install jq or python3; a shift never arms on a policy nothing here can read"
fi

# ------------------------------------------------------- work and deadline
ok "punch-list open=$OPEN ticked=$TICKED"
ORDERS="$(ns_open_boxes_file "$WORK_ORDERS")"
DRAFTS="$(ns_open_drafts "$DRAFTING_TABLE")"
ok "staged orders=$ORDERS drafts=$DRAFTS"
if [ "$OPEN" -eq 0 ]; then
  if [ "$ORDERS" -gt 0 ] || [ "$DRAFTS" -gt 0 ]; then
    warn "punch-list empty - offer the staged orders and drafts, and cut the owner's choice"
  else
    warn "punch-list empty and nothing is staged - Setup, Hunt, or a hand-written item is the next step"
  fi
fi

# Whether an unattended revival could happen at all, said before the night rather than discovered
# after it. Inheriting means reproducing the scope the session was started under, and a host that
# names none leaves nothing to inherit — so the owner hears now that recovery will refuse, and how
# to authorize one.
RECOVERY_SCOPE="$(ns_recovery_effective_scope "$WORKSPACE" "$HOST_NAME" 2>/dev/null)" || RECOVERY_SCOPE=""
case "$RECOVERY_SCOPE" in
  unavailable:*)
    warn "recovery $(ns_recovery_refusal "$RECOVERY_SCOPE") - an unattended revival will refuse rather than launch at permissions it cannot show are no broader. Set recovery.launchScope to host-default or host-grant in $(ns_layout_name "$NS" rules) to authorize one."
    ;;
  '') ;;
  *) ok "recovery revival scope $RECOVERY_SCOPE" ;;
esac

# A shift that asked for filing at clock-out and ended before it could. The next explicit Archive
# is where it gets picked up; Start neither files nor clears it.
if [ -f "$PENDING_FILING" ] && [ ! -L "$PENDING_FILING" ]; then
  warn "pending-filing the last shift asked for filing at clock-out and ended before it could - the next explicit Archive picks it up from $(ns_layout_name "$NS" pending-filing)"
fi

OPEN_ENDED=0
if [ -f "$PUNCH" ] && [ ! -L "$PUNCH" ]; then
  ns_items_section "$PUNCH" | grep -qF 'Ending: open-ended' && OPEN_ENDED=1
fi

DEADLINE_FILE=""
if [ -f "$DEADLINE" ] && [ ! -L "$DEADLINE" ]; then
  DEADLINE_FILE="$(tr -d '[:space:]' <"$DEADLINE" 2>/dev/null || true)"
  case "$DEADLINE_FILE" in '' | *[!0-9]*) DEADLINE_FILE="" ;; esac
fi
POLICY_DEADLINE="$(ns_policy_deadline_epoch "$WORKSPACE" 2>/dev/null)" || POLICY_DEADLINE=""
case "$POLICY_DEADLINE" in *[!0-9]*) POLICY_DEADLINE="" ;; esac

if [ -n "$POLICY_DEADLINE" ]; then
  ok "deadline $POLICY_DEADLINE (policy - write it to $DEADLINE)"
elif [ -n "$DEADLINE_FILE" ]; then
  ok "deadline $DEADLINE_FILE (file - keep it and adopt it as the policy deadlineEpoch)"
elif [ "$OPEN_ENDED" -eq 1 ]; then
  refuse "deadline an open-ended item has no clock, and a walkthrough with no clock never ends"
  repair "compose the shift through Hunt, which asks for hours; never invent a number"
else
  ok "deadline none (finite list - the last tick is the natural end)"
fi

# --------------------------------------------------------------- journal
if [ "$DRY_RUN" -eq 0 ] && [ -f "$LOG" ] && [ ! -L "$LOG" ]; then
  LOG_BYTES="$(wc -c <"$LOG" 2>/dev/null | tr -d '[:space:]')"
  case "$LOG_BYTES" in '' | *[!0-9]*) LOG_BYTES=0 ;; esac
  if [ "$LOG_BYTES" -gt 512000 ]; then
    # The journal joins the folder of the shift that ended last, beside anything Archive filed there
    # already, or a folder claimed for today when no shift is on record.
    ROTATE_GROUP="$ENDED_GROUP"
    [ -n "$ROTATE_GROUP" ] || ROTATE_GROUP="$(ns_archive_dir "$WORKSPACE" "$(date +%Y-%m-%d)" unknown 2>/dev/null)" ||
      ROTATE_GROUP=""
    if [ -z "$ROTATE_GROUP" ] || ! ROTATED="$(ns_archive_file_journal "$WORKSPACE" "$ROTATE_GROUP")"; then
      warn "journal could not be rotated into the archive; it stays at ${LOG#"$NS"/}"
    elif [ -n "$ROTATED" ]; then
      ok "journal rotated to ${ROTATED#"$NS"/}"
    fi
  fi
fi

# ------------------------------------------------- host permission mode
case "$HOST_NAME" in
  claude)
    GRANT=0
    for f in "$HOST_ROOT/.claude/settings.local.json" "$HOST_ROOT/.claude/settings.json"; do
      [ -f "$f" ] || continue
      if grep -q 'bypassPermissions\|"allow"' "$f" 2>/dev/null; then GRANT=1; break; fi
    done
    if [ "$GRANT" -eq 1 ]; then
      ok "permissions frictionless permissions are granted at $HOST_ROOT"
    else
      warn "permissions no frictionless grant in $HOST_ROOT/.claude - a permission prompt mid-shift freezes the night and a headless revival is denied outright; Setup offers the fix"
    fi
    ;;
  codex)
    warn "permissions approvals are per launch - an unattended shift is started codex -a never -s danger-full-access, and the workspace-write sandbox blocks git commit; a contract that only ticks needs only workspace-write" ;;
  cursor)
    warn "permissions arm the Cursor watchman only; revival mints or resumes a CLI worker in .shift-worker and never passes the IDE conversation id to agent --resume" ;;
  *)
    ok "permissions host unknown - no permission-mode note" ;;
esac

[ "$REFUSED" -eq 0 ] || exit 1
exit 0

SHA-256: a8933eb25200296a9b49541ddb65354e12902e20bf1918ecf271dbc035856079