← Files NightshiftARCHIVED FILE
runtime/doctor.sh
25 KB · Oct 2, 2026 · 00:30 UTC
#!/usr/bin/env bash
# doctor.sh — read-only site diagnosis. Prints what Nightshift sees; never repairs.
#
# doctor.sh [--project DIR]
#
# Exit: 0 report printed · 1 usage
set -u
_here="${BASH_SOURCE[0]%/*}"; [ "$_here" != "${BASH_SOURCE[0]}" ] || _here=.
_here="$(cd -P "$_here" && pwd)" || exit 1
# shellcheck source=plugins/nightshift/lib/lib.sh
. "$_here/../lib/lib.sh"
# shellcheck source=plugins/nightshift/lib/migrate.sh
. "$_here/../lib/migrate.sh"
PROJECT="${CLAUDE_PROJECT_DIR:-${CODEX_PROJECT_DIR:-$PWD}}"
while [ $# -gt 0 ]; do
case "$1" in
--project)
[ $# -ge 2 ] || { printf 'doctor: --project needs a value\n' >&2; exit 1; }
PROJECT="$2"
shift 2
;;
-h | --help)
awk 'NR == 1 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0"
exit 1
;;
*) printf 'doctor: unknown argument: %s\n' "$1" >&2; exit 1 ;;
esac
done
HOST="$(cd -P "$PROJECT" 2>/dev/null && pwd)" || {
printf 'doctor: cannot cd to %s\n' "$PROJECT" >&2
exit 1
}
emit() { printf '%s\n' "$1"; }
fact() { FACTS="${FACTS}${FACTS:+
}$1"; }
warn() { WARNS="${WARNS}${WARNS:+
}$1"; }
act() { # safe|confirm|blocked message
ACTIONS="${ACTIONS}${ACTIONS:+
}[$1] $2"
}
FACTS=""
WARNS=""
ACTIONS=""
LINK="$HOST/.nightshift-link"
LINK_STATE="absent"
WORKSPACE="$HOST"
if [ -e "$LINK" ] || [ -L "$LINK" ]; then
if WORKSPACE="$(ns_workspace_root "$HOST" 2>/dev/null)"; then
LINK_STATE="valid"
fact "task root $HOST links to workspace $WORKSPACE"
else
LINK_STATE="invalid"
WORKSPACE="$HOST"
warn "invalid .nightshift-link — Nightshift will not guess a workspace"
act confirm "replace .nightshift-link with an absolute path to a directory that already contains .nightshift/, using $_here/link-workspace.sh"
fi
else
fact "task root is the workspace: $HOST"
fi
NS="$WORKSPACE/.nightshift"
declare ARMED_FILE ENDED_FILE STOP_FILE SESSION_END_FILE PULSE_FILE STALL_FILE SESSION_FILE LEASE_FILE WATCHMAN_FILE CAPABILITIES_FILE PROVISION_TXN WORK_MODE_FILE WORK_ORDERS DRAFTING_TABLE STATE_VERSION_FILE SUPPORT_DIR
ns_layout_set ARMED_FILE "$NS" armed
ns_layout_set ENDED_FILE "$NS" ended
ns_layout_set STOP_FILE "$NS" stop
ns_layout_set SESSION_END_FILE "$NS" session-end
ns_layout_set PULSE_FILE "$NS" pulse
ns_layout_set STALL_FILE "$NS" stall
ns_layout_set SESSION_FILE "$NS" session
ns_layout_set LEASE_FILE "$NS" lease
ns_layout_set WATCHMAN_FILE "$NS" watchman
ns_layout_set CAPABILITIES_FILE "$NS" capabilities
ns_layout_set PROVISION_TXN "$NS" provision-transaction
ns_layout_set WORK_MODE_FILE "$NS" work-mode
ns_layout_set WORK_ORDERS "$NS" work-orders
ns_layout_set DRAFTING_TABLE "$NS" drafting-table
ns_layout_set STATE_VERSION_FILE "$NS" state-version
ns_layout_set SUPPORT_DIR "$NS" support
STATE_KIND="absent"
STATE_VER=""
if [ -d "$NS" ]; then
STATE_KIND="$(ns_state_kind "$WORKSPACE")"
STATE_VER="$(ns_state_version "$WORKSPACE" || true)"
fi
if [ ! -d "$NS" ]; then
warn "no .nightshift/ at $WORKSPACE"
act confirm "run Nightshift setup from the project you want to change (not a ChatGPT scratch workspace)"
emit "Nightshift Doctor"
emit "Host: $HOST"
emit "Workspace: $WORKSPACE"
emit "Link: $LINK_STATE"
emit "Nightshift: missing"
emit ""
emit "Facts"
printf '%s\n' "${FACTS:-none}"
emit ""
emit "resolved policy"
emit "none"
emit ""
emit "Warnings"
printf '%s\n' "${WARNS:-none}"
emit ""
emit "Actions (Doctor does not perform these)"
printf '%s\n' "${ACTIONS:-none}"
exit 0
fi
TARGET=""
UNUSABLE_RECV=0
if MODE="$(ns_work_mode "$WORKSPACE" 2>/dev/null)"; then
fact "work mode $MODE"
if [ ! -s "$WORK_MODE_FILE" ]; then
proposed="$(ns_propose_work_mode "$WORKSPACE" 2>/dev/null)" || proposed=""
if [ "$proposed" = artifact ]; then
warn "work mode is unset; Setup would propose artifact"
act confirm "persist the proposed artifact mode with Setup; Doctor does not write work-mode"
fi
fi
if [ "$MODE" = artifact ]; then
fact "artifact receipts $(ns_receipts_count "$WORKSPACE")"
if latest="$(ns_latest_receipt "$WORKSPACE")"; then
fact "latest artifact receipt ${latest##*/}"
fi
recv="$(ns_receipts_dir "$WORKSPACE")"
if [ -e "$recv" ] || [ -L "$recv" ]; then
if ! ns_receipts_usable_dir "$WORKSPACE" >/dev/null; then
UNUSABLE_RECV=1
warn "artifact receipts path is not a usable directory"
act confirm "replace the unusable receipts path with a real directory so receipts can land; Doctor does not rewrite it"
fi
fi
fi
else
MODE=""
warn "work mode is malformed; treating the site as unusable until Setup rewrites it"
fi
if TARGET="$(ns_work_target "$WORKSPACE" 2>/dev/null)"; then
fact "work target $TARGET"
else
rc=$?
TARGET="$WORKSPACE"
if [ "$rc" -eq 3 ]; then
warn "work target is a disposable scratch workspace"
else
warn "work target could not be resolved; treating workspace as the code root"
fi
fi
# The one resolver renders the one view: every effective setting, where it came from, and when
# it expires. Doctor reads it and never re-derives precedence — same order and wording as the
# PowerShell Doctor, so both hosts print the same lines.
POLICY_LINES=""
POLICY_OUT="$(ns_policy_read_shift "$WORKSPACE")"
POLICY_RC=$?
if [ "$POLICY_RC" -eq 2 ]; then
warn "shift-policy.json is malformed ($POLICY_OUT); the shift resolves to built-in defaults and rules only"
act confirm "repair the named field in shift-policy.json, or delete the file so the next Start writes safe defaults"
fi
if POLICY_LINES="$(ns_policy_resolve_table "$WORKSPACE" 2>/dev/null)"; then
:
else
POLICY_LINES=""
warn "the shift policy could not be resolved; treat the shift as built-in defaults plus rules"
fi
# A provisioning transaction on disk means an install stopped mid-flight. The recovery helper
# owns the reading and the proof; Doctor prints its one diagnosis line and never settles it.
if [ -e "$PROVISION_TXN" ] || [ -L "$PROVISION_TXN" ]; then
PROVISION_TAB=$(printf '\t')
if PROVISION_LINE="$("$_here/provision-recover.sh" --project "$WORKSPACE" --diagnose 2>/dev/null)" &&
[ -n "$PROVISION_LINE" ]; then
PROVISION_KIND="${PROVISION_LINE%%"$PROVISION_TAB"*}"
PROVISION_TEXT="${PROVISION_LINE#*"$PROVISION_TAB"}"
if [ "$PROVISION_KIND" = provable ]; then
fact "$PROVISION_TEXT"
else
warn "$PROVISION_TEXT; Start will refuse to arm"
act confirm "inspect $(ns_layout_name "$NS" provision-transaction) and $(ns_layout_name "$NS" provision-baseline)/, restore by hand or run provision.sh rollback after fixing the target, then Start again"
fi
else
warn "provision-transaction.json cannot be read; Start will refuse to arm"
act confirm "inspect $(ns_layout_name "$NS" provision-transaction) and $(ns_layout_name "$NS" provision-baseline)/, restore by hand or run provision.sh rollback after fixing the target, then Start again"
fi
fi
declare PUNCH
ns_layout_set PUNCH "$NS" punch-list
OPEN=0
TICKED=0
if [ -f "$PUNCH" ]; then
OPEN="$(ns_open_boxes "$PUNCH")"
TICKED="$(ns_ticked_boxes "$PUNCH")"
fact "punch list open=$OPEN ticked=$TICKED"
else
warn "punch-list.md is missing"
fi
# Reports gaps between what open items need and what the resolver allows; it never refuses —
# a gap is parked, not blocked.
if ns_policy_json_tool >/dev/null 2>&1; then
PREFLIGHT_OUT="$("$_here/preflight-needs.sh" --project "$WORKSPACE" 2>/dev/null)" || PREFLIGHT_OUT=""
if [ -n "$PREFLIGHT_OUT" ]; then
fact "$(printf '%s\n' "$PREFLIGHT_OUT" | sed -n '1p')"
fact "$(printf '%s\n' "$PREFLIGHT_OUT" | tail -n1)"
fi
fi
if ns_receipts_enabled "$WORKSPACE"; then
fact "completion record per-item receipt"
# A planted file where receipts/ belongs is reported as itself; do not also warn
# about missing receipt text for that path.
if [ "${UNUSABLE_RECV:-0}" -eq 0 ]; then
MISSING="$(ns_receipts_missing_count "$WORKSPACE" 2>/dev/null)" || MISSING=0
if [ "${MISSING:-0}" -gt 0 ]; then
fact "receipts missing model text $MISSING"
warn "$MISSING ticked items have no receipt text; each item completes through its receipt file"
act confirm "write the missing receipts under $(ns_layout_name "$NS" receipts)/; Doctor does not rewrite the punch list"
fi
fi
else
fact "completion record none; the owner disabled receipts"
fi
ORDERS="$(ns_open_boxes_file "$WORK_ORDERS")"
if [ "$ORDERS" -gt 0 ]; then
fact "pending Hunt work orders=$ORDERS"
fi
DRAFTS="$(ns_open_drafts "$DRAFTING_TABLE")"
if [ "$DRAFTS" -gt 0 ]; then
fact "staged drafting-table items=$DRAFTS"
fi
# Archive files only `- ` bullets, so an inbox entry written any other way stays live for good.
INBOX_STRAYS=0
declare INBOX_FILE
for INBOX_KEY in parking-lot snag-log; do
ns_layout_set INBOX_FILE "$NS" "$INBOX_KEY"
while IFS=$'\t' read -r STRAY_LINE STRAY_TEXT; do
warn "$(ns_layout_name "$NS" "$INBOX_KEY") line $STRAY_LINE is not a \`- \` bullet, so Archive never files it: $STRAY_TEXT"
INBOX_STRAYS=$((INBOX_STRAYS + 1))
done < <(ns_inbox_strays "$INBOX_FILE")
done
if [ "$INBOX_STRAYS" -gt 0 ]; then
act confirm "rewrite each inbox entry Doctor names as one \`- \` bullet, keeping its text; Doctor does not edit the inbox"
fi
ARMED=0
[ -f "$ARMED_FILE" ] && ARMED=1
ENDED=0
if [ -f "$ENDED_FILE" ] && [ ! -L "$ENDED_FILE" ]; then
ENDED=1
elif [ -L "$ENDED_FILE" ]; then
warn "ended path is not a usable file"
fi
STOP=0
[ -f "$STOP_FILE" ] && STOP=1
SESSION_END=0
if [ -f "$SESSION_END_FILE" ] && [ ! -L "$SESSION_END_FILE" ]; then
SESSION_END=1
elif [ -L "$SESSION_END_FILE" ]; then
warn "session-end path is not a usable file"
fi
PULSE=0
if [ -f "$PULSE_FILE" ] && [ ! -L "$PULSE_FILE" ]; then
PULSE=1
elif [ -L "$PULSE_FILE" ]; then
warn "shift-pulse path is not a usable file"
fi
STALL=""
if [ -L "$STALL_FILE" ]; then
warn "stall path is not a usable file"
elif [ -f "$STALL_FILE" ]; then
STALL="$(tr -d '[:space:]' <"$STALL_FILE" 2>/dev/null)"
fi
if [ "$ARMED" -eq 1 ]; then fact "shift is armed"; else fact "shift is not armed"; fi
case "$STATE_KIND" in
current | legacy)
if [ "$STATE_KIND" = current ]; then
fact "state version ${STATE_VER:-$NS_STATE_VERSION} (current)"
elif [ "${STATE_VER:-0}" = 0 ]; then
fact "state version 0 (legacy — no state-version marker; every state file sits at the top of .nightshift/)"
else
fact "state version $STATE_VER (every state file sits at the top of .nightshift/)"
fi
# The move into the current layout, described from the plan migrate-state would carry out.
MIGRATE_PLAN="$(mktemp "${TMPDIR:-/tmp}/ns-doctor-migrate.XXXXXX")" || MIGRATE_PLAN=""
if [ -n "$MIGRATE_PLAN" ] && ns_migrate_plan "$WORKSPACE" >"$MIGRATE_PLAN" 2>/dev/null \
&& MIGRATE_OFFER="$(ns_migrate_offer "$MIGRATE_PLAN" "$_here/migrate-state.sh")"; then
if grep -q '^refuse' "$MIGRATE_PLAN"; then
warn "the move into layout $NS_STATE_VERSION waits: $(sed -n 's/^refuse\t//p' "$MIGRATE_PLAN" | head -n1)"
act blocked "$MIGRATE_OFFER"
elif grep -q '^conflict' "$MIGRATE_PLAN"; then
warn "the move into layout $NS_STATE_VERSION is blocked: $(awk -F '\t' '$1 == "conflict" { printf "%s%s", (n++ ? "; " : ""), ($4 != "" ? $2 " " $4 : $2 " and " $3 " differ") }' "$MIGRATE_PLAN")"
act confirm "keep one copy of each conflicting file by hand, then preview the move with $_here/migrate-state.sh"
else
act confirm "$MIGRATE_OFFER"
fi
fi
[ -z "$MIGRATE_PLAN" ] || rm -f "$MIGRATE_PLAN"
;;
future)
warn "state version ${STATE_VER:-unknown} is newer than this plugin supports ($NS_STATE_VERSION)"
act blocked "upgrade Nightshift; never rewrite or downgrade a newer state-version"
;;
malformed)
warn "state-version is malformed"
act confirm "inspect $STATE_VERSION_FILE and replace it with a single integer while unarmed — never guess"
;;
esac
[ "$ENDED" -eq 1 ] && fact "gate has clocked the shift out (.ended)"
[ "$STOP" -eq 1 ] && fact "STOP is present"
[ "$SESSION_END" -eq 1 ] && fact "clean session-end marker is present"
[ "$PULSE" -eq 1 ] && fact "shift-pulse marker is present"
[ -n "$STALL" ] && fact "stall count $STALL"
declare DEADLINE
ns_layout_set DEADLINE "$NS" deadline
dl_raw=""
if [ -L "$DEADLINE" ]; then
warn "deadline path is not a usable file"
elif [ ! -f "$DEADLINE" ]; then
fact "deadline=none"
else
dl_raw="$(tr -d '[:space:]' <"$DEADLINE" 2>/dev/null)"
if printf '%s' "$dl_raw" | grep -qE '^[0-9]+$'; then
now="$(date +%s)"
if [ "$now" -ge "$dl_raw" ]; then
fact "deadline=$dl_raw remaining=0s (elapsed)"
else
fact "deadline=$dl_raw remaining=$((dl_raw - now))s"
fi
else
warn "deadline is not a UNIX epoch — watchmen compare integer seconds"
dl_raw=""
fi
fi
# shift-policy.json is authoritative for the deadline; the file is a derived projection. A
# mismatch is tampering or a stale projection either way, so Doctor names both values.
POLICY_DEADLINE="$(ns_policy_deadline_epoch "$WORKSPACE" 2>/dev/null)" || POLICY_DEADLINE=""
if [ -n "$dl_raw" ] && [ -n "$POLICY_DEADLINE" ] && [ "$dl_raw" != "$POLICY_DEADLINE" ]; then
warn "deadline file $dl_raw does not match shift-policy deadlineEpoch $POLICY_DEADLINE; the gate honours the earlier value"
act confirm "synchronize the deadline projection with the policy before the next arm; Doctor rewrites neither"
fi
if [ "$ARMED" -eq 1 ] && [ "$OPEN" -eq 0 ] && [ "$ENDED" -eq 0 ]; then
warn "armed with no open boxes and no .ended — clock-out may still be due"
act confirm "ask Nightshift for status, or start so the watchman can spawn the clock-out"
fi
if [ "$STOP" -eq 1 ] && [ "$ARMED" -eq 1 ]; then
warn "stop-work order is pending until the next stop attempt"
act confirm "run $_here/stop-shift.sh --project $HOST to disarm immediately; a bare STOP file waits for the next Stop event; do not delete STOP by hand"
fi
if [ "$STOP" -eq 1 ] && [ "$ARMED" -eq 0 ]; then
warn "STOP leftover while no shift is armed — start will clear it"
act confirm "run start when you want a new shift, which clears stale STOP"
fi
if [ -f "$PUNCH" ] && [ "$OPEN" -eq 0 ]; then
fact "punch list has no open items — leftover Shift contract and Gates still bind the next Hunt or Start cut"
if [ "$ARMED" -eq 0 ]; then
warn "empty punch list will inherit the current contract"
act confirm "review punch-list.md contract and Gates before composing a new campaign; Archive files ticked items but never resets them"
fi
fi
# Staged work is only an offer when there is nothing already approved to do. An open checkbox
# under ## Items is the shift, so Doctor reports what is staged and stops there — the same
# precedence Start applies, said the same way.
if [ "$ORDERS" -gt 0 ] && [ "$ARMED" -eq 0 ] && [ "$OPEN" -eq 0 ]; then
act confirm "start to promote a parked Hunt order, or hunt to compose a new one"
fi
if [ "$DRAFTS" -gt 0 ] && [ "$ARMED" -eq 0 ] && [ "$OPEN" -eq 0 ]; then
act confirm "promote agreed drafting-table items into punch-list.md, or start to be offered them"
fi
if [ "$OPEN" -gt 0 ] && { [ "$ORDERS" -gt 0 ] || [ "$DRAFTS" -gt 0 ]; }; then
fact "staged work is informational while $OPEN punch-list items are open — start works the current list, and drafts and Hunt orders stay staged for a later shift"
fi
json_is_object() {
ns_rules_load "$1"
}
json_tool_rule_state() { # $1 = rules file, $2 = exact tool name
ns_rules_tool_state "$1" "$2"
}
declare RULES
ns_layout_set RULES "$NS" rules
if [ ! -f "$RULES" ]; then
warn "rules.json is missing — watchman will refuse to arm"
act confirm "re-run setup and accept the shipped rules template"
elif json_is_object "$RULES"; then
fact "rules.json is a JSON object"
wm="$(rule "$WORKSPACE" watchMinutes "")"
case "$wm" in
'' | *[!0-9]*) warn "watchMinutes missing or not a whole number"; act confirm "restore watchMinutes from the shipped template (10, or 0 to disarm)" ;;
*) fact "watchMinutes $wm" ;;
esac
retry="$(rule "$WORKSPACE" watchRetrySeconds "${NIGHTSHIFT_WATCH_RETRY:-}")"
resume="$(ns_expand_injected_paths "$WORKSPACE" "$(rule "$WORKSPACE" revivalPrompt "${NIGHTSHIFT_REVIVAL_PROMPT:-}")")"
fresh="$(ns_expand_injected_paths "$WORKSPACE" "$(rule "$WORKSPACE" freshRevivalPrompt "${NIGHTSHIFT_FRESH_PROMPT:-}")")"
if [ -z "$retry" ]; then
warn "watchRetrySeconds is empty — watchman will refuse to arm"
act confirm "restore watchRetrySeconds from the shipped template"
fi
if [ -z "$resume" ]; then
warn "revivalPrompt is empty — watchman will refuse to arm"
act confirm "restore revivalPrompt from the shipped template"
fi
if [ -z "$fresh" ]; then
warn "freshRevivalPrompt is empty — watchman will refuse to arm"
act confirm "restore freshRevivalPrompt from the shipped template"
fi
for tool in AskUserQuestion request_user_input AskQuestion; do
tool_state="$(json_tool_rule_state "$RULES" "$tool")"
case "$tool_state" in
allow) fact "toolDeny.$tool explicitly allows the question tool" ;;
deny) fact "toolDeny.$tool explicitly denies the question tool" ;;
missing | invalid)
warn "toolDeny.$tool is $tool_state — question behavior has no explicit policy"
act confirm "re-run setup and review the shipped $tool entry (non-empty denies; empty allows)"
;;
esac
done
else
if [ -n "$NS_RULES_ERR" ]; then
warn "rules.json is unreadable or not a JSON object ($NS_RULES_ERR)"
else
warn "rules.json is unreadable or not a JSON object"
fi
act confirm "fix $RULES or re-run setup — never half-apply a broken file"
fi
HOST_REC="none"
SID=""
TPATH=""
SPID=""
if [ -L "$SESSION_FILE" ]; then
warn "shift-session path is not a usable file"
elif [ -f "$SESSION_FILE" ]; then
SID="$(sed -n 1p "$SESSION_FILE" 2>/dev/null || true)"
TPATH="$(sed -n 2p "$SESSION_FILE" 2>/dev/null || true)"
SPID="$(sed -n 3p "$SESSION_FILE" 2>/dev/null | tr -d '[:space:]')"
HOST_REC="$(ns_session_host "$NS")"
fact "recorded host $HOST_REC"
if [ -n "$SID" ]; then
fact "session id is present (not printed)"
if [ "$HOST_REC" = "codex" ]; then
kind="$(ns_codex_identity_kind "$SID")"
fact "Codex identity kind $kind"
if [ "$kind" != "resumable" ]; then
warn "recorded Codex identity is $kind — watchman must not claim it resumed that thread"
act blocked "capture a resumable Codex session id before relying on overnight revival"
fi
fi
else
warn "session id line is empty"
act confirm "let the next tool call record identity, or accept a fresh-session fallback"
fi
if [ -n "$SPID" ] && printf '%s' "$SPID" | grep -qE '^[0-9]+$'; then
if kill -0 "$SPID" 2>/dev/null; then
fact "recorded pid $SPID is alive"
else
fact "recorded pid $SPID is dead"
fi
fi
else
fact "no .shift-session yet"
if [ "$ARMED" -eq 1 ] && [ "$OPEN" -gt 0 ]; then
warn "armed shift has no session record — a 500 can land before first work"
fi
fi
LEASE_STATE="absent"
LEASE_GENERATION=""
if [ -e "$LEASE_FILE" ] || [ -L "$LEASE_FILE" ]; then
if ns_lease_valid "$NS"; then
LEASE_STATE="valid"
LEASE_HOST="$NS_LEASE_HOST"
LEASE_GENERATION="$NS_LEASE_GENERATION"
LEASE_NONCE="$NS_LEASE_NONCE"
LEASE_PID="$NS_LEASE_PID"
fact "process lease host $LEASE_HOST generation $LEASE_GENERATION"
if [ -n "$LEASE_NONCE" ]; then
fact "process lease belongs to a watchman recovery (capability not printed)"
else
fact "process lease belongs to the interactive shift process"
fi
if [ "$HOST_REC" != "none" ] && [ "$LEASE_HOST" != "$HOST_REC" ]; then
warn "process lease host $LEASE_HOST disagrees with recorded session host $HOST_REC"
act blocked "run $_here/stop-shift.sh --project $HOST, then Start again; do not rewrite the lease by hand"
fi
if [ -n "$LEASE_PID" ]; then
if ns_recorded_process "$LEASE_PID" "$NS_LEASE_START"; then
fact "lease holder pid $LEASE_PID is alive"
else
fact "lease holder pid $LEASE_PID is not confirmed alive"
fi
fi
if [ "$(ns_reason_code "$NS")" = clock-out-failed ]; then
warn "terminal clock-out failed without releasing the shift"
if [ -n "$LEASE_NONCE" ]; then
if [ -n "$LEASE_PID" ] && ns_recorded_process "$LEASE_PID" "$NS_LEASE_START"; then
fact "recovery worker is alive; the recorded conversation cannot reclaim yet"
act confirm "wait until the recovery worker exits, or run $_here/stop-shift.sh --project $HOST; reopening the recorded conversation stays blocked while that worker holds the lease"
else
fact "recovery worker is not confirmed alive after a failed clock-out; run $_here/stop-shift.sh --project $HOST, then Start"
fi
else
fact "process lease restored to the interactive shift; the recorded conversation can operate"
act confirm "reopen the recorded conversation to continue or run $_here/stop-shift.sh --project $HOST; Start re-arms after Stop"
fi
elif [ -n "$LEASE_NONCE" ] && [ -n "$LEASE_PID" ] && ns_recorded_process "$LEASE_PID" "$NS_LEASE_START"; then
fact "recovery worker is alive; the recorded conversation cannot reclaim yet"
act confirm "wait until the recovery worker exits, or run $_here/stop-shift.sh --project $HOST; reopening the recorded conversation stays blocked while that worker holds the lease"
elif [ -n "$LEASE_NONCE" ] && [ -n "$LEASE_PID" ] && [ -n "$SID" ]; then
ns_recorded_process "$LEASE_PID" "$NS_LEASE_START"
rc=$?
if [ "$rc" -eq 1 ]; then
fact "lease held by a dead recovery attempt (generation $LEASE_GENERATION, pid $LEASE_PID); the recorded conversation reclaims it on its next tool call"
fi
fi
else
LEASE_STATE="malformed"
warn "process lease is malformed — ownership cannot be proven"
act blocked "run $_here/stop-shift.sh --project $HOST, then Start again; never guess or edit .shift-lease"
fi
elif [ "$ARMED" -eq 1 ] && [ -n "$SID" ]; then
warn "armed shift has no process lease — the bound session's next tool call must bootstrap it"
fi
WPID=""
WATCHMAN_UNUSABLE=0
WATCHMAN_ALIVE=0
if [ -L "$WATCHMAN_FILE" ]; then
warn "watchman pidfile path is not a usable file"
WATCHMAN_UNUSABLE=1
elif [ -f "$WATCHMAN_FILE" ]; then
WPID="$(sed -n 1p "$WATCHMAN_FILE" 2>/dev/null | tr -d '[:space:]')"
fi
if [ -n "$WPID" ] && printf '%s' "$WPID" | grep -qE '^[0-9]+$'; then
if kill -0 "$WPID" 2>/dev/null; then
fact "watchman pid $WPID is alive"
WATCHMAN_ALIVE=1
else
fact "watchman pid $WPID is stale"
if [ "$ARMED" -eq 0 ]; then
act safe "remove leftover .watchman — the recorded process is gone and no shift is armed"
fi
fi
elif [ "$WATCHMAN_UNUSABLE" -eq 0 ]; then
fact "no live watchman pid file"
fi
RCODE="$(ns_reason_code "$NS")"
if [ -n "$RCODE" ]; then
fact "watchman reason $RCODE ($(ns_reason_label "$RCODE"))"
fi
# An armed shift with work left and nothing watching it is not revived after a crash or a usage
# limit. Only watchMinutes 0 means that on purpose; a missing or unreadable value stops the watchman
# arming too, so it is flagged the same way.
WATCH_MINUTES="$(rule "$WORKSPACE" watchMinutes "${NIGHTSHIFT_WATCH:-}")"
if [ "$ARMED" -eq 1 ] && [ "$OPEN" -gt 0 ] && [ "$WATCHMAN_ALIVE" -eq 0 ] &&
[ "$WATCHMAN_UNUSABLE" -eq 0 ] && [ "$WATCH_MINUTES" != 0 ]; then
warn "shift is armed with open boxes and no watchman — a crash or usage limit will not be revived"
act confirm "arm it again with ns start-watchman --host <this host>: it changes nothing else about the shift, and says why if it cannot arm (its output is in $(ns_layout_name "$NS" watchman-log)); or work the list in the live session"
fi
fact "evidence $(ns_evidence_counts "$WORKSPACE")"
fact "liveness $(ns_status_liveness "$NS" "$(rule "$WORKSPACE" watchMinutes "${NIGHTSHIFT_WATCH:-}")")"
activity="$(ns_status_last_activity "$NS")"
if [ -n "$activity" ]; then
fact "last activity epoch $activity"
else
fact "last activity none"
fi
fact "last checkpoint $(ns_status_last_checkpoint "$WORKSPACE")"
fact "stall attempts $(ns_status_stall_attempts "$NS")"
# capabilities.json is the tooling cache the shift keeps after a tooling commit lands.
if [ -f "$CAPABILITIES_FILE" ] && [ ! -L "$CAPABILITIES_FILE" ] && command -v jq >/dev/null 2>&1; then
inv_n="$(jq '.items | length' "$CAPABILITIES_FILE" 2>/dev/null || printf 0)"
fact "tooling cache items=$inv_n"
fi
[ -n "$TPATH" ] && [ ! -f "$TPATH" ] && warn "recorded transcript/rollout path is not a readable file"
act confirm "export a local support bundle with $_here/export-support.sh — written under $SUPPORT_DIR/, never uploaded"
act blocked "Doctor never repairs, arms, stops, revives, or deletes"
emit "Nightshift Doctor"
emit "Host: $HOST"
emit "Workspace: $WORKSPACE"
emit "Link: $LINK_STATE"
emit "Work target: $TARGET"
emit "Recorded: ${HOST_REC:-none}"
emit "Lease: $LEASE_STATE${LEASE_GENERATION:+ (generation $LEASE_GENERATION)}"
emit "State: ${STATE_VER:--} ($STATE_KIND)"
emit "Armed: $ARMED Open: $OPEN Ticked: $TICKED STOP: $STOP Ended: $ENDED"
emit ""
emit "Facts"
printf '%s\n' "${FACTS:-none}"
emit ""
emit "resolved policy"
printf '%s\n' "${POLICY_LINES:-none}"
emit ""
emit "Warnings"
printf '%s\n' "${WARNS:-none}"
emit ""
emit "Actions (Doctor does not perform these)"
printf '%s\n' "${ACTIONS:-none}"
exit 0
SHA-256: dbaecb826d69b71fbf3da5d068a0f462d9c95342bae2afd519a142704c65ff65