← Files NightshiftARCHIVED FILE

lib/state.sh

101 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

#!/usr/bin/env bash
# Nightshift runtime state: rules, punch-list counts, schema, retention, watch-reason.

# The owner's rules file is the one copy of every knob: .nightshift/rules.json — nightshift's
# whole life lives in nightshift's folder, and deleting the folder deletes all of it. Hooks
# read the file directly — a change applies from the next tool call. An env var of the
# matching name, when set, overrides the file for the session: the test suite's lever and the
# power user's per-session exception, never a second copy the owner maintains.
# rule <project-dir> <file-key> <env-value> — prints the effective value ('' = default).
rule() {
  if [ -n "$3" ]; then printf '%s' "$3"; return; fi
  local f
  ns_layout_set f "$1/.nightshift" rules
  [ -f "$f" ] || return 0
  ns_rules_get "$f" "$2"
}

# ns_receipts <project-dir> <field> — one field of the receipts block, or empty.
ns_receipts() {
  ns_policy_pref "$1" receipts "$2"
}

# ns_report — the old name. Same fields live under receipts now.
ns_report() {
  ns_receipts "$@"
}

# ns_receipts_enabled <project-dir> — status 0 unless the owner turned receipts off.
ns_receipts_enabled() {
  [ "$(ns_receipts "$1" enabled)" != false ]
}

ns_report_enabled() {
  ns_receipts_enabled "$1"
}

# ns_receipts_dir <project-dir> — the folder that holds the index, morning page, and item files.
ns_receipts_dir() {
  ns_layout_path "$1/.nightshift" receipts
}

# ns_receipt_slug <title> — title lowercased, non-alphanumerics collapsed to one -, trimmed, 60.
ns_receipt_slug() {
  printf '%s' "$1" | LC_ALL=C awk '
    {
      s = ""
      for (i = 1; i <= length($0); i++) {
        c = substr($0, i, 1)
        o = index("ABCDEFGHIJKLMNOPQRSTUVWXYZ", c)
        if (o) c = substr("abcdefghijklmnopqrstuvwxyz", o, 1)
        if (c ~ /[a-z0-9]/) s = s c
        else s = s "-"
      }
      gsub(/-+/, "-", s)
      gsub(/^-|-$/, "", s)
      if (length(s) > 60) {
        s = substr(s, 1, 60)
        gsub(/-$/, "", s)
      }
      print s
    }
  '
}

# ns_receipt_nn <label> — the item number as written: leading digits, or a letter+digits id.
ns_receipt_nn() {
  printf '%s' "$1" | LC_ALL=C awk '
    {
      if (match($0, /^[0-9]+/)) { print substr($0, RSTART, RLENGTH); exit }
      if (match($0, /^[A-Za-z]+[0-9]+/)) { print substr($0, RSTART, RLENGTH); exit }
    }
  '
}

# ns_receipt_title <label> — the words after the written number, for the slug. The number may be
# followed by `.`, `)`, `:`, an em or en dash, or a hyphen with space around it.
ns_receipt_title() {
  local t="$1" re dashes=$'\xe2\x80\x94|\xe2\x80\x93'
  for re in '^[0-9]+[.):][[:space:]]*' "^[0-9]+[[:space:]]*($dashes)[[:space:]]*" '^[0-9]+[[:space:]]+-[[:space:]]+'; do
    if [[ $t =~ $re ]]; then
      t="${t:${#BASH_REMATCH[0]}}"
      break
    fi
  done
  re='^[A-Za-z]+[0-9]+[[:space:]]+'
  [[ ! $t =~ $re ]] || t="${t:${#BASH_REMATCH[0]}}"
  printf '%s' "$t"
}

# ns_receipt_basename <label> — NN-slug, no suffix.
ns_receipt_basename() {
  local label="$1" nn title slug
  # A leftover `- [x]` from a ticked line must not become an `x-` sidecar file.
  label="$(printf '%s' "$label" | sed 's/^- \[[xX ]\][[:space:]]*//; s/^\*\*//; s/\*\*$//')"
  nn="$(ns_receipt_nn "$label")"
  title="$(ns_receipt_title "$label")"
  [ -n "$title" ] || title="$label"
  if [ -n "$nn" ] && [ "$title" = "$label" ]; then
    printf '%s' "$nn"
    return 0
  fi
  slug="$(ns_receipt_slug "$title")"
  if [ -n "$nn" ] && [ -n "$slug" ]; then
    printf '%s-%s' "$nn" "$slug"
  elif [ -n "$slug" ]; then
    printf '%s' "$slug"
  else
    printf '%s' "$(ns_receipt_slug "$label")"
  fi
}

# ---------------------------------------------------------------------------------------------
# Item identity
#
# An item's number keeps its place in the list and its id keeps its identity. The first time the
# shift policy is recorded, every item without an id gets one as a trailing comment on its own
# line: `- [ ] **3. Fix the resolver.** <!-- id: k7q2 -->`. The owner may renumber, reorder or
# retitle items between shifts; the receipt and its history follow the id. Every reader of an
# item's label goes through NS_AWK_ITEM, so the comment is never part of a label.

# The awk functions every item reader shares, prepended to the program text.
NS_AWK_ITEM='
function ns_item_id(line,    s) {
  sub(/\r$/, "", line)
  if (!match(line, /<!--[[:space:]]*id:[[:space:]]*[a-z0-9]+[[:space:]]*-->[[:space:]]*$/)) return ""
  s = substr(line, RSTART, RLENGTH)
  sub(/^<!--[[:space:]]*id:[[:space:]]*/, "", s)
  sub(/[[:space:]]*-->[[:space:]]*$/, "", s)
  return s
}
function ns_item_label(line,    lead) {
  sub(/\r$/, "", line)
  sub(/[[:space:]]*<!--[[:space:]]*id:[[:space:]]*[a-z0-9]+[[:space:]]*-->[[:space:]]*$/, "", line)
  sub(/^- \[[ xX]\][[:space:]]*\*\*/, "", line)
  sub(/^- \[[ xX]\][[:space:]]*/, "", line)
  # A dash right after a bare number numbers the item, as in "1 — Fix it": a number alone is no
  # label, so only a later dash ends the title. A code such as P03 before a dash is the label.
  lead = ""
  if (match(line, /^[0-9]+[[:space:]]*(—|–)[[:space:]]*/) || match(line, /^[0-9]+[[:space:]]+-[[:space:]]+/)) {
    lead = substr(line, 1, RLENGTH)
    line = substr(line, RLENGTH + 1)
  }
  sub(/[[:space:]]+—.*$/, "", line)
  sub(/[[:space:]]+-[[:space:]].*$/, "", line)
  sub(/\*\*.*$/, "", line)
  gsub(/[[:space:]]+$/, "", line)
  return lead line
}
'

# ns_item_rows <punch-list> [open|ticked|all] — `<label>\t<id>` for each top-level item under
# `## Items` that has a label, list order. The id is empty for an item that has none.
ns_item_rows() {
  ns_items_section "$1" 2>/dev/null | awk -v want="${2:-all}" "$NS_AWK_ITEM"'
    /^- \[[ xX]\]/ {
      open = ($0 ~ /^- \[ \]/)
      if (want == "open" && !open) next
      if (want == "ticked" && open) next
      label = ns_item_label($0)
      if (label != "") print label "\t" ns_item_id($0)
    }
  '
}

# ns_item_states <punch-list> — `<open|ticked>\t<label>\t<id>` for each top-level item under
# `## Items` that has a label, list order.
ns_item_states() {
  ns_items_section "$1" 2>/dev/null | awk "$NS_AWK_ITEM"'
    /^- \[[ xX]\]/ {
      label = ns_item_label($0)
      if (label != "") print (($0 ~ /^- \[ \]/) ? "open" : "ticked") "\t" label "\t" ns_item_id($0)
    }
  '
}

# ns_item_ids <punch-list> — every id the list's items carry, one per line.
ns_item_ids() {
  ns_items_section "$1" 2>/dev/null | awk "$NS_AWK_ITEM"'
    /^- \[[ xX]\]/ { id = ns_item_id($0); if (id != "") print id }
  '
}

# ns_item_title_for <punch-list> <id> — the whole title of the item carrying that id: its bold text,
# or the line after its checkbox, without the id comment.
ns_item_title_for() {
  local line box='^- \[[ xX]\][[:space:]]*' tail="[[:space:]]*<!--[[:space:]]*id:[[:space:]]*$2[[:space:]]*-->[[:space:]]*\$"
  while IFS= read -r line || [ -n "$line" ]; do
    line="${line%$'\r'}"
    [[ $line =~ $box ]] || continue
    [[ $line =~ $tail ]] || continue
    line="${line:0:$((${#line} - ${#BASH_REMATCH[0]}))}"
    [[ $line =~ $box ]] && line="${line:${#BASH_REMATCH[0]}}"
    if [ "${line#\*\*}" != "$line" ]; then
      line="${line#\*\*}"
      line="${line%%\*\**}"
    fi
    printf '%s' "${line%"${line##*[![:space:]]}"}"
    return 0
  done <<EOF
$(ns_items_section "$1" 2>/dev/null)
EOF
}

# ns_item_id_for <punch-list> <label> — the id of the first item with that label, or nothing.
ns_item_id_for() {
  ns_items_section "$1" 2>/dev/null | awk -v want="$2" "$NS_AWK_ITEM"'
    /^- \[[ xX]\]/ && ns_item_label($0) == want { print ns_item_id($0); exit }
  '
}

# ns_item_id_used <nightshift-dir> <id> [taken] — status 0 when the id is in `taken`, is carried by
# an archived list or receipt, or already names a receipt file, so an id means one item for as
# long as the history is kept.
ns_item_id_used() {
  local ns="$1" id="$2" archive receipts
  case " ${3:-} " in *" $id "*) return 0 ;; esac
  ns_layout_set archive "$ns" archive
  ns_layout_set receipts "$ns" receipts
  grep -rqsF -- "id: $id " "$archive" "$receipts" && return 0
  [ -n "$(find "$receipts" "$archive" \( -name "$id.md" -o -name "$id-*.md" -o -name "*-$id.md" \) -print 2>/dev/null | head -n1)" ]
}

# ns_item_new_id <nightshift-dir> [taken] — a fresh id: a letter, then three letters or digits, that
# ns_item_id_used does not know.
ns_item_new_id() {
  local ns="$1" taken="${2:-}" id tries=0
  while [ "$tries" -lt 64 ]; do
    tries=$((tries + 1))
    id="$(od -An -N4 -tu1 /dev/urandom 2>/dev/null | awk '
      NF >= 4 {
        a = "abcdefghijklmnopqrstuvwxyz"; b = a "0123456789"
        printf "%s%s%s%s", substr(a, $1 % 26 + 1, 1), substr(b, $2 % 36 + 1, 1),
          substr(b, $3 % 36 + 1, 1), substr(b, $4 % 36 + 1, 1)
      }')" || return 1
    [ "${#id}" -eq 4 ] || return 1
    ns_item_id_used "$ns" "$id" "$taken" && continue
    printf '%s' "$id"
    return 0
  done
  return 1
}

# ns_punch_assign_ids <punch-list> <nightshift-dir> — give every item under `## Items` that has no
# id a new one. Items that carry one keep it, so running this again changes nothing.
ns_punch_assign_ids() {
  local list="$1" ns="$2" need taken ids="" id tmp
  [ -f "$list" ] && [ ! -L "$list" ] || return 0
  need="$(awk "$NS_AWK_ITEM"'
    { line = $0; sub(/\r$/, "", line) }
    !on { if (line ~ /^##[[:space:]]*Items[[:space:]]*$/) on = 1; next }
    line ~ /^## / { exit }
    line ~ /^- \[[ xX]\]/ && ns_item_id(line) == "" { n++ }
    END { print n + 0 }
  ' "$list")" || return 1
  [ "$need" -gt 0 ] || return 0
  taken="$(ns_item_ids "$list" | paste -sd' ' -)"
  while [ "$need" -gt 0 ]; do
    id="$(ns_item_new_id "$ns" "$taken $ids")" || return 1
    ids="$ids $id"
    need=$((need - 1))
  done
  tmp="$list.ids.$$"
  awk -v ids="$ids" "$NS_AWK_ITEM"'
    BEGIN { split(ids, pool, " "); k = 0 }
    { line = $0; cr = ""; if (sub(/\r$/, "", line)) cr = "\r" }
    !on { if (line ~ /^##[[:space:]]*Items[[:space:]]*$/) on = 1; print; next }
    line ~ /^## / { on = 0; done = 1 }
    !done && line ~ /^- \[[ xX]\]/ && ns_item_id(line) == "" {
      k++
      print line " <!-- id: " pool[k] " -->" cr
      next
    }
    { print }
  ' "$list" >"$tmp" || { rm -f "$tmp"; return 1; }
  mv "$tmp" "$list" || { rm -f "$tmp"; return 1; }
}

# ns_receipt_want <project-dir> <label> <id> — the name an item's receipt carries: its number,
# two digits at least, or its place in the list when the title has none; then its title; then its
# id. `03-fix-the-resolver-k7q2`. A label that is only a code, such as P03, takes the words of the
# item's whole title.
ns_receipt_want() {
  local project="$1" label="$2" id="$3" punch nn title full slug out=""
  punch="$(ns_layout_path "$project/.nightshift" punch-list)"
  nn="$(ns_receipt_nn "$label")"
  title="$(ns_receipt_title "$label")"
  if [ -n "$nn" ] && [ "$title" = "$label" ]; then
    full="$(ns_item_title_for "$punch" "$id")"
    title="$(ns_receipt_title "$full")"
    [ "$title" != "$full" ] || title=""
  fi
  if [ -z "$nn" ]; then
    nn="$(ns_item_rows "$punch" | awk -F'\t' -v want="$label" '$1 == want { print NR; exit }')"
  fi
  case "$nn" in
    '' | *[!0-9]*) ;;
    *) nn="$(printf '%02d' "$((10#$nn))")" ;;
  esac
  slug="$(ns_receipt_slug "$title")"
  [ -z "$nn" ] || out="$nn"
  [ -z "$slug" ] || out="${out:+$out-}$slug"
  printf '%s' "${out:+$out-}$id"
}

# ns_receipt_find_id <receipts-dir> <id> — the receipt an id already names: `<NN>-<slug>-<id>.md`,
# or the `<id>.md` and `<id>-<slug>.md` names earlier versions gave it. Nothing when there is none.
ns_receipt_find_id() {
  local f
  for f in "$1"/*-"$2".md "$1/$2.md" "$1/$2"-*.md; do
    { [ -f "$f" ] && [ ! -L "$f" ]; } || continue
    printf '%s' "$f"
    return 0
  done
}

# ns_receipt_legacy_file <receipts-dir> <label> — a receipt named by label before items had ids:
# `NN-slug.md`, or the bare `NN.md` a dash-numbered title was once cut down to. Nothing when neither
# is there.
ns_receipt_legacy_file() {
  local f nn
  f="$1/$(ns_receipt_basename "$2").md"
  if [ "$f" != "$1/.md" ] && [ -f "$f" ] && [ ! -L "$f" ]; then
    printf '%s' "$f"
    return 0
  fi
  nn="$(ns_receipt_nn "$2")"
  f="$1/$nn.md"
  if [ -n "$nn" ] && [ -f "$f" ] && [ ! -L "$f" ]; then
    printf '%s' "$f"
  fi
}

# ns_receipt_base <project-dir> <label> [id] — the item's receipt file stem under receipts/. An item
# with an id keeps the file that id already names, else an earlier shift's receipt found by its
# label, else a new `<NN>-<slug>-<id>`; an item without one is its label's `NN-slug`. With no id
# argument the id is looked up in the punch list by label. Reading a name never renames anything:
# ns_receipts_rename moves a receipt to the name its item carries now.
ns_receipt_base() {
  local project="$1" label="$2" id="${3-}" dir legacy f
  [ $# -ge 3 ] || id="$(ns_item_id_for "$(ns_layout_path "$project/.nightshift" punch-list)" "$label")"
  legacy="$(ns_receipt_basename "$label")"
  dir="$(ns_receipts_dir "$project")"
  f=""
  [ -z "$id" ] || f="$(ns_receipt_find_id "$dir" "$id")"
  [ -n "$f" ] || f="$(ns_receipt_legacy_file "$dir" "$label")"
  if [ -n "$f" ]; then
    f="${f##*/}"
    printf '%s' "${f%.md}"
    return 0
  fi
  if [ -z "$id" ]; then
    printf '%s' "$legacy"
    return 0
  fi
  ns_receipt_want "$project" "$label" "$id"
}

# ns_receipts_rename <project-dir> — move each live receipt of an item with an id to the name the
# item carries now, so a reordered, renumbered or retitled item's receipt follows it and the folder
# reads in item order. A name another file already holds is never overwritten, a receipt keeps its
# modification time, and archived receipts keep the names they were filed under.
ns_receipts_rename() {
  local project="$1" punch dir label id f want rc=0
  ns_layout_set punch "$project/.nightshift" punch-list
  dir="$(ns_receipts_dir "$project")"
  { [ -f "$punch" ] && [ -d "$dir" ] && [ ! -L "$dir" ]; } || return 0
  while IFS=$'\t' read -r label id || [ -n "$label" ]; do
    { [ -n "$label" ] && [ -n "$id" ]; } || continue
    f="$(ns_receipt_find_id "$dir" "$id")"
    [ -n "$f" ] || f="$(ns_receipt_legacy_file "$dir" "$label")"
    [ -n "$f" ] || continue
    want="$dir/$(ns_receipt_want "$project" "$label" "$id").md"
    [ "$f" != "$want" ] || continue
    { [ -e "$want" ] || [ -L "$want" ]; } && continue
    mv "$f" "$want" || rc=1
  done <<EOF
$(ns_item_rows "$punch")
EOF
  return "$rc"
}

# ns_receipt_path <project-dir> <label> [id] — the item's file under receipts/.
ns_receipt_path() {
  printf '%s/%s.md' "$(ns_receipts_dir "$1")" "$(ns_receipt_base "$@")"
}

# ns_active_item <project-dir> — the item being worked: the open item whose receipt the model
# wrote last, or the first open item while no open item has one. A receipt starts when substantive
# work on its item starts, and the runtime's own writes keep a receipt's time, so only the model's
# writing moves this. Two receipts written in the same instant go to the earlier item.
ns_active_item() {
  local punch dir label id f cand best="" best_f="" first=""
  ns_layout_set punch "$1/.nightshift" punch-list
  [ -f "$punch" ] || return 1
  dir="$(ns_receipts_dir "$1")"
  while IFS=$'\t' read -r label id; do
    [ -n "$label" ] || continue
    [ -n "$first" ] || first="$label"
    f=""
    # Inline rather than ns_receipt_find_id: the pulse runs this on every tool call.
    if [ -n "$id" ]; then
      for cand in "$dir"/*-"$id".md "$dir/$id.md" "$dir/$id"-*.md; do
        if [ -f "$cand" ] && [ ! -L "$cand" ]; then
          f="$cand"
          break
        fi
      done
    fi
    # A receipt named by label starts with the item's number; only when one might exist is the
    # exact name worked out, so the pulse that runs this on every tool call stays cheap.
    if [ -z "$f" ] && [[ "$label" =~ ^([0-9]+|[A-Za-z]+[0-9]+) ]]; then
      for cand in "$dir/${BASH_REMATCH[1]}.md" "$dir/${BASH_REMATCH[1]}"-*.md; do
        if [ -f "$cand" ]; then
          f="$dir/$(ns_receipt_basename "$label").md"
          [ -f "$f" ] || f="$dir/${BASH_REMATCH[1]}.md"
          break
        fi
      done
    elif [ -z "$f" ]; then
      f="$dir/$(ns_receipt_basename "$label").md"
    fi
    { [ -f "$f" ] && [ ! -L "$f" ]; } || continue
    if [ -z "$best_f" ] || [ "$f" -nt "$best_f" ]; then
      best="$label"
      best_f="$f"
    fi
  done <<EOF
$(ns_item_rows "$punch" open)
EOF
  printf '%s' "${best:-$first}"
}

# ns_receipt_track_label <receipt> <label> — note in the receipt which label it belongs to, and
# record a renumber or retitle once the item's label has moved since. The heading follows when it
# was the old label, and one dated line under it names what the item was called before.
#
# The runtime's writes to a receipt keep its modification time: the time says when the model last
# wrote it, which is how the pulse tells which item is being worked.
ns_receipt_track_label() {
  local f="$1" was tmp ref rc=0
  [ -f "$f" ] && [ ! -L "$f" ] || return 0
  was="$(sed -n 's/^<!-- item: \(.*\) -->[[:space:]]*$/\1/p' "$f" | tail -n1)"
  [ "$was" != "$2" ] || return 0
  ref="$f.mtime.$$"
  touch -r "$f" "$ref" 2>/dev/null || return 1
  if [ -z "$was" ]; then
    printf '\n<!-- item: %s -->\n' "$2" >>"$f" || rc=1
  else
    tmp="$f.track.$$"
    if NS_WAS="$was" NS_NOW="$2" NS_DAY="$(date +%Y-%m-%d)" awk '
      BEGIN { was = ENVIRON["NS_WAS"]; now = ENVIRON["NS_NOW"]; day = ENVIRON["NS_DAY"] }
      { line = $0; sub(/\r$/, "", line) }
      !headed && line ~ /^# / {
        headed = 1
        print (line == "# " was) ? "# " now : $0
        print ""
        print "Renamed from " was " on " day "."
        next
      }
      line ~ /^<!-- item: .* -->[[:space:]]*$/ { print "<!-- item: " now " -->"; next }
      { print }
    ' "$f" >"$tmp"; then
      mv "$tmp" "$f" || rc=1
    else
      rm -f "$tmp"
      rc=1
    fi
  fi
  touch -r "$ref" "$f" 2>/dev/null || rc=1
  rm -f "$ref"
  return "$rc"
}

# _ns_session_total <measured> <off> <sum> — a Sessions total: the sum of what was measured, off
# when the owner turned the measurement off and nothing was measured, unavailable otherwise.
_ns_session_total() {
  if [ "$1" -eq 1 ]; then
    printf '%s' "$3"
  elif [ "$2" -eq 1 ]; then
    printf 'off'
  else
    printf 'unavailable'
  fi
}

# ns_receipt_add_session <receipt> <label> <shift-id> <start> <end> <working-sec> <input> <output>
# <ended> — add one session to the receipt's Sessions table and redraw it. The table is drawn from
# the data lines kept under it, so its totals stay exact across every shift the item was worked
# in. `-` is an unknown shift or an unreported token count, and `off` a measurement the owner
# turned off; <ended> is ticked, switched-away, blocked or paused. A receipt that does not exist
# yet is created with its heading; one that does keeps its modification time.
ns_receipt_add_session() {
  local f="$1" label="$2" data line block ref tmp fresh=0 rc=0
  local sid start end work in out ended n=0 twork=0 tin=0 tout=0 word cell_work cell_in cell_out
  local havework=0 havein=0 haveout=0 offwork=0 offin=0 offout=0
  [ ! -L "$f" ] || return 0
  mkdir -p "${f%/*}" 2>/dev/null || return 1
  if [ ! -f "$f" ]; then
    printf '# %s\n' "$label" >"$f" || return 1
    fresh=1
  fi
  data="$(awk '/^<!-- session-data$/ { on = 1; next } on && /^-->$/ { on = 0 } on { print }' "$f")"
  data="$(printf '%s\n%s %s %s %s %s %s %s' "$data" "$3" "$4" "$5" "$6" "$7" "$8" "$9" | sed '/^$/d')"
  block="$(mktemp "${TMPDIR:-/tmp}/ns-sessions.XXXXXX")" || return 1
  {
    printf '<!-- sessions -->\n**Sessions**\n\n'
    printf '| # | Shift | Start | End | Working | Input | Output | Ended |\n'
    printf '| --- | --- | --- | --- | --- | ---: | ---: | --- |\n'
    while read -r sid start end work in out ended; do
      [ -n "$sid" ] || continue
      n=$((n + 1))
      case "$work" in
        off) offwork=1; cell_work=off ;;
        '' | *[!0-9]*) cell_work=unavailable ;;
        *) twork=$((twork + work)); havework=1; cell_work="$(ns_usage_duration "$work")" ;;
      esac
      case "$in" in
        off) offin=1; cell_in=off ;;
        '' | *[!0-9]*) cell_in=unavailable ;;
        *) tin=$((tin + in)); havein=1; cell_in="$(ns_usage_scale "$in")" ;;
      esac
      case "$out" in
        off) offout=1; cell_out=off ;;
        '' | *[!0-9]*) cell_out=unavailable ;;
        *) tout=$((tout + out)); haveout=1; cell_out="$(ns_usage_scale "$out")" ;;
      esac
      [ "$sid" != - ] || sid='—'
      printf '| %s | %s | %s | %s | %s | %s | %s | %s |\n' "$n" "$(printf '%s' "$sid" | cut -c1-8)" \
        "$(ns_usage_iso "$start" || printf '—')" "$(ns_usage_iso "$end" || printf '—')" \
        "$cell_work" "$cell_in" "$cell_out" "$(printf '%s' "$ended" | tr '-' ' ')"
    done <<EOF
$data
EOF
    word=sessions
    [ "$n" -ne 1 ] || word=session
    printf '| **Total** | %s %s |  |  | **%s** | **%s** | **%s** |  |\n' "$n" "$word" \
      "$(_ns_session_total "$havework" "$offwork" "$(ns_usage_duration "$twork")")" \
      "$(_ns_session_total "$havein" "$offin" "$(ns_usage_scale "$tin")")" \
      "$(_ns_session_total "$haveout" "$offout" "$(ns_usage_scale "$tout")")"
    printf '\n<!-- session-data\n%s\n-->\n<!-- /sessions -->\n' "$data"
  } >"$block"
  ref="$f.mtime.$$"
  [ "$fresh" -eq 1 ] || touch -r "$f" "$ref" 2>/dev/null || { rm -f "$block"; return 1; }
  tmp="$f.sessions.$$"
  if awk -v blockfile="$block" '
    function emit(   l) { while ((getline l < blockfile) > 0) print l; close(blockfile); done = 1 }
    /^<!-- sessions -->/ { skip = 1; emit(); next }
    skip && /^<!-- \/sessions -->/ { skip = 0; next }
    skip { next }
    { print }
    END { if (!done) { print ""; emit() } }
  ' "$f" >"$tmp"; then
    mv "$tmp" "$f" || rc=1
  else
    rm -f "$tmp"
    rc=1
  fi
  rm -f "$block"
  if [ "$fresh" -eq 0 ]; then
    touch -r "$ref" "$f" 2>/dev/null || rc=1
    rm -f "$ref"
  fi
  return "$rc"
}

# ns_receipt_has_model_text <file> — status 0 when a line exists outside the runtime block
# and the gate-written heading.
ns_receipt_has_model_text() {
  local f="$1"
  { [ -f "$f" ] && [ ! -L "$f" ]; } || return 1
  awk '
    /^[[:space:]]*$/ { next }
    /^# / { next }
    /^\*\*Usage:\*\*/ { next }
    /^\*\*Duration:\*\*/ { next }
    /^\*\*Tokens:\*\* off$/ { next }
    /^\*\*Time:\*\* off$/ { next }
    /^  Source:/ { next }
    /^  Cache reads/ { next }
    /^  The input figure/ { next }
    /^  Cached input/ { next }
    /^  Overlap between/ { next }
    /^\| Tokens \|/ { next }
    /^\| Time \|/ { next }
    /^\| ---/ { next }
    /^\| input \|/ { next }
    /^\| cache / { next }
    /^\| output \|/ { next }
    /^\| reasoning \|/ { next }
    /^\| working \|/ { next }
    /^\| paused \|/ { next }
    /^\| wall \|/ { next }
    /^\| span \|/ { next }
    /^<!-- sessions -->/ { sessions = 1; next }
    /^<!-- \/sessions -->/ { sessions = 0; next }
    sessions { next }
    /^<!-- tokens / { next }
    /^<!-- item: / { next }
    /^Renamed from .* on [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\.$/ { next }
    / · [0-9]+ segments?\./ { next }
    { found = 1; exit }
    END { exit found ? 0 : 1 }
  ' "$f"
}

# ns_receipts_missing_nns <project> — one item number per ticked item with no model text.
ns_receipts_missing_nns() {
  local project="$1" punch ns receipts label id base nn
  ns="$project/.nightshift"
  ns_layout_set punch "$ns" punch-list
  ns_layout_set receipts "$ns" receipts
  [ -f "$punch" ] || return 0
  ns_receipts_enabled "$project" || return 0
  ns_item_rows "$punch" ticked | while IFS=$'\t' read -r label id || [ -n "$label" ]; do
    [ -n "$label" ] || continue
    base="$(ns_receipt_base "$project" "$label" "$id")"
    ns_receipt_has_model_text "$receipts/${base}.md" && continue
    nn="$(ns_receipt_nn "$label")"
    [ -n "$nn" ] || nn="$label"
    printf '%s\n' "$nn"
  done
}

ns_receipts_missing_count() {
  local n
  n="$(ns_receipts_missing_nns "$1" | grep -c . || true)"
  printf '%s' "${n:-0}"
}

# ns_usage_scale <n> — integer below 1000, then one decimal k, M, or B. Tenths are rounded half up
# on the exact integer, never through a binary fraction, so 1950 is 2.0k on every runtime; a value
# that rounds to 1000.0 of a unit reads as 1.0 of the next.
ns_usage_scale() {
  local n="$1"
  case "$n" in '' | *[!0-9]*) printf '%s' "$n"; return 0 ;; esac
  awk -v n="$n" 'BEGIN {
    if (n < 1000) { printf "%d", n; exit }
    split("1000 1000000 1000000000", unit, " ")
    split("k M B", suffix, " ")
    i = 1
    while (i < 3 && n >= unit[i + 1]) i++
    tenths = int((n * 10 + unit[i] / 2) / unit[i])
    if (tenths >= 10000 && i < 3) { i++; tenths = int((n * 10 + unit[i] / 2) / unit[i]) }
    printf "%d.%d%s", int(tenths / 10), tenths % 10, suffix[i]
  }'
}

# ns_receipts_shift_date <project-dir> — Date: on the punch list, else the policy day, else today.
ns_receipts_shift_date() {
  local punch policy day
  ns_layout_set punch "$1/.nightshift" punch-list
  ns_layout_set policy "$1/.nightshift" shift-policy
  if [ -f "$punch" ]; then
    day="$(sed -n 's/^Date:[[:space:]]*//p' "$punch" | head -n1)"
    day="${day%%[$'\r\n']*}"
    [ -n "$day" ] && { printf '%s' "$day"; return 0; }
  fi
  if [ -f "$policy" ]; then
    day="$(sed -n 's/.*"createdAt"[[:space:]]*:[[:space:]]*"\([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\).*/\1/p' "$policy" | head -n1)"
    [ -n "$day" ] && { printf '%s' "$day"; return 0; }
  fi
  date -u +%Y-%m-%d
}

# ns_receipt_usage_cells <file> — input, cache_write, cache_read, output, reasoning, work
# seconds, pause seconds, the named usage cell, and the time cell, tab separated. A receipt
# with no runtime block reads as zeros and dashes. An `x-<name>` sidecar is read when the
# item file itself has no exact line (the old tick-label bug).
ns_receipt_usage_cells() {
  local f="$1" dir base sidecar exact comment in=0 cw=0 cr=0 out=0 rea=0 tok_sum=0
  local usage='—' time='—' work=0 pause=0 raw work_s pause_s
  dir="${f%/*}"
  base="${f##*/}"
  if [ -f "$f" ] && { grep -q 'exact:' "$f" 2>/dev/null || grep -q '<!-- tokens ' "$f" 2>/dev/null; }; then
    :
  else
    sidecar="$dir/x-${base}"
    [ -f "$sidecar" ] && f="$sidecar"
  fi
  if [ -f "$f" ]; then
    comment="$(sed -n 's/^<!--[[:space:]]*tokens[[:space:]]\{1,\}\(.*\)-->/\1/p' "$f" | head -n1)"
    comment="${comment%"${comment##*[![:space:]]}"}"
    if [ -n "$comment" ]; then
      in="${comment%% *}"; comment="${comment#* }"
      cw="${comment%% *}"; comment="${comment#* }"
      cr="${comment%% *}"; comment="${comment#* }"
      out="${comment%% *}"; rea="${comment#* }"
      case "$in" in '' | *[!0-9]*) in=0 ;; esac
      case "$cw" in '' | *[!0-9]*) cw=0 ;; esac
      case "$cr" in '' | *[!0-9]*) cr=0 ;; esac
      case "$out" in '' | *[!0-9]*) out=0 ;; esac
      case "$rea" in '' | *[!0-9]*) rea=0 ;; esac
      tok_sum=$((in + out))
      usage="input $(ns_usage_scale "$in") · cache_write $(ns_usage_scale "$cw") · cache_read $(ns_usage_scale "$cr") · output $(ns_usage_scale "$out") · reasoning $(ns_usage_scale "$rea")"
    else
      exact="$(sed -n 's/.*exact:[[:space:]]*\([0-9][0-9]* \/ [0-9][0-9]* \/ [0-9][0-9]* \/ [0-9][0-9]* \/ [0-9][0-9]*\).*/\1/p' "$f" | head -n1)"
      if [ -n "$exact" ]; then
        in="${exact%% /*}"; exact="${exact#* / }"
        cw="${exact%% /*}"; exact="${exact#* / }"
        cr="${exact%% /*}"; exact="${exact#* / }"
        out="${exact%% /*}"; rea="${exact#* / }"
        tok_sum=$((in + out))
        usage="input $(ns_usage_scale "$in") · cache_write $(ns_usage_scale "$cw") · cache_read $(ns_usage_scale "$cr") · output $(ns_usage_scale "$out") · reasoning $(ns_usage_scale "$rea")"
      fi
    fi
    work_s="$(sed -n 's/^| working |[[:space:]]*//p' "$f" | head -n1)"
    if [ -n "$work_s" ]; then
      work_s="${work_s%% |*}"
      work_s="${work_s%"${work_s##*[![:space:]]}"}"
      work="$(ns_usage_parse_seconds "$work_s")"
      pause_s="$(sed -n 's/^| paused |[[:space:]]*//p' "$f" | head -n1)"
      if [ -n "$pause_s" ]; then
        pause_s="${pause_s%% |*}"
        pause_s="${pause_s%%(*}"
        pause_s="${pause_s%"${pause_s##*[![:space:]]}"}"
        pause="$(ns_usage_parse_seconds "$pause_s")"
      fi
      time="$(ns_receipts_time_cell "$work" "$pause")"
    else
      raw="$(sed -n 's/^\*\*Duration:\*\*[[:space:]]*//p' "$f" | head -n1)"
      if [ -n "$raw" ]; then
        case "$raw" in
          *' working'*) work_s="${raw%% working*}" ;;
          *' ('*) work_s="${raw%% (*}" ;;
          *) work_s="$raw" ;;
        esac
        work_s="${work_s%"${work_s##*[![:space:]]}"}"
        work="$(ns_usage_parse_seconds "$work_s")"
        case "$raw" in
          *' paused '*)
            pause_s="${raw#* paused }"
            pause_s="${pause_s%%,*}"
            pause_s="${pause_s%%)*}"
            pause_s="${pause_s%"${pause_s##*[![:space:]]}"}"
            pause="$(ns_usage_parse_seconds "$pause_s")"
            ;;
        esac
        time="$(ns_receipts_time_cell "$work" "$pause")"
      fi
    fi
    # A measurement the owner turned off says so, rather than reading as one nobody reported.
    grep -qx '\*\*Tokens:\*\* off' "$f" 2>/dev/null && usage=off
    grep -qx '\*\*Time:\*\* off' "$f" 2>/dev/null && time=off
  fi
  printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
    "$in" "$cw" "$cr" "$out" "$rea" "$work" "$pause" "$usage" "$time" "$tok_sum"
}

# ns_usage_parse_seconds <1h 25m|11m 35s|45s> — integer seconds, or 0.
ns_usage_parse_seconds() {
  local t="$1" h=0 m=0 s=0
  h="$(printf '%s' "$t" | grep -Eo '[0-9]+h' | head -n1 | tr -d h)"
  m="$(printf '%s' "$t" | grep -Eo '[0-9]+m' | head -n1 | tr -d m)"
  s="$(printf '%s' "$t" | grep -Eo '[0-9]+s' | head -n1 | tr -d s)"
  case "$h" in '' | *[!0-9]*) h=0 ;; esac
  case "$m" in '' | *[!0-9]*) m=0 ;; esac
  case "$s" in '' | *[!0-9]*) s=0 ;; esac
  printf '%s' "$((h * 3600 + m * 60 + s))"
}

# ns_receipts_morning_names <dir> — the shift summaries filed in <dir>, one name per line, byte order.
ns_receipts_morning_names() {
  local f name
  { [ -d "$1" ] && [ ! -L "$1" ]; } || return 0
  for f in "$1"/morning-*.md; do
    { [ -f "$f" ] && [ ! -L "$f" ]; } || continue
    name="${f##*/}"
    case "$name" in *.original.md) continue ;; esac
    printf '%s\n' "$name"
  done | LC_ALL=C sort
}

# ns_receipts_index_head <date> [dir] — the title, a link to each shift summary in <dir>, and the
# column headers of an index page.
ns_receipts_index_head() {
  local name
  printf '# Receipts — %s\n\n' "$1"
  if [ -n "${2:-}" ]; then
    while IFS= read -r name; do
      [ -n "$name" ] || continue
      printf 'Shift summary: [%s](./%s)\n\n' "$name" "$name"
    done <<EOF
$(ns_receipts_morning_names "$2")
EOF
  fi
  printf '| Item | State | **Usage** | **Time** | Receipt |\n'
  printf '| --- | --- | --- | --- | --- |\n'
}
# _ns_index_total <cell> <any-off> — a totals cell: off when nothing was measured because a row's
# measurement was turned off, the cell as it stands otherwise.
_ns_index_total() {
  if [ "$1" = '—' ] && [ "$2" -eq 1 ]; then printf 'off'; else printf '%s' "$1"; fi
}

# ns_receipts_index_totals <usage-cell> <time-cell> — the closing totals row of an index page.
ns_receipts_index_totals() {
  printf '| **Totals** |  | **%s** | **%s** |  |\n' "${1:-—}" "${2:-—}"
}

ns_receipts_usage_total_cell() {
  local in="$1" cw="$2" cr="$3" out="$4" rea="$5"
  if [ "$((in + cw + cr + out + rea))" -eq 0 ]; then
    printf '%s' '—'
    return 0
  fi
  printf 'input %s · cache_write %s · cache_read %s · output %s · reasoning %s' \
    "$(ns_usage_scale "$in")" "$(ns_usage_scale "$cw")" "$(ns_usage_scale "$cr")" \
    "$(ns_usage_scale "$out")" "$(ns_usage_scale "$rea")"
}

ns_receipts_time_cell() {
  local work="${1:-0}" pause="${2:-0}"
  case "$work" in '' | *[!0-9]*) work=0 ;; esac
  case "$pause" in '' | *[!0-9]*) pause=0 ;; esac
  if [ "$work" -eq 0 ] && [ "$pause" -eq 0 ]; then
    printf '%s' '—'
    return 0
  fi
  if [ "$pause" -gt 0 ]; then
    printf '%s working · %s paused' "$(ns_usage_duration "$work")" "$(ns_usage_duration "$pause")"
    return 0
  fi
  printf '%s working' "$(ns_usage_duration "$work")"
}

ns_receipts_time_total_cell() {
  ns_receipts_time_cell "${1:-0}" "${2:-0}"
}

# ns_receipts_item_names <project-dir> <open|ticked> — receipt file names for boxes in that state.
# A ticked item's receipt leaves live storage once the shift has ended; an open item's stays, so
# the next shift writes into the same file.
ns_receipts_item_names() {
  local punch state="${2:-open}" label id
  ns_layout_set punch "$1/.nightshift" punch-list
  [ -f "$punch" ] || return 0
  [ "$state" = ticked ] || state=open
  ns_item_rows "$punch" "$state" | while IFS=$'\t' read -r label id || [ -n "$label" ]; do
    [ -n "$label" ] || continue
    printf '%s.md\n' "$(ns_receipt_base "$1" "$label" "$id")"
  done
}

ns_receipts_open_names() { ns_receipts_item_names "$1" open; }
ns_receipts_ticked_names() { ns_receipts_item_names "$1" ticked; }

# ns_receipts_item_order — receipt paths on stdin, printed in item order: numbered items by value
# (1, 2, 10), then letter-and-number ids by letters and value (A1, A2, A10, B1), then the rest by
# name. Keys are tab-separated and compared bytewise, so tab ends a shorter field first.
ns_receipts_item_order() {
  LC_ALL=C awk "$NS_AWK_ORDER_KEY"'
    { n = $0; sub(/.*\//, "", n); print ns_order_key(n) "\t" $0 }
  ' | LC_ALL=C sort | cut -f5-
}

# ns_receipts_heading_order — `<heading>\t<path>` lines on stdin, printed in the item order of the
# headings. A receipt named for its item's id sorts by the number and title its heading shows.
ns_receipts_heading_order() {
  LC_ALL=C awk -F'\t' "$NS_AWK_ORDER_KEY"'
    { print ns_order_key($1) "\t" $0 }
  ' | LC_ALL=C sort | cut -f5-
}

# The item-order key of one name, as four tab-separated fields, for the two orderings above.
NS_AWK_ORDER_KEY='
function ns_order_key(n,    cls, pre, num, id) {
  cls = 2; pre = ""; num = ""
  if (match(n, /^[0-9]+/)) {
    cls = 0; num = substr(n, 1, RLENGTH)
  } else if (match(n, /^[A-Za-z]+[0-9]+/)) {
    id = substr(n, 1, RLENGTH)
    match(id, /[0-9]+$/)
    cls = 1; pre = tolower(substr(id, 1, RSTART - 1)); num = substr(id, RSTART)
  }
  sub(/^0+/, "", num)
  if (cls < 2 && num == "") num = "0"
  return sprintf("%d\t%s\t%04d%s\t%s", cls, pre, length(num), num, n)
}
'

# _ns_archive_receipt_headings <dir> — `<heading>\t<path>` for each item receipt filed in <dir>.
_ns_archive_receipt_headings() {
  local f label
  find "$1" -maxdepth 1 -type f -name '*.md' 2>/dev/null | while IFS= read -r f; do
    { [ -f "$f" ] && [ ! -L "$f" ]; } || continue
    case "${f##*/}" in
      README.md | morning-* | x-* | *.original.md) continue ;;
    esac
    label="$(sed -n 's/^# //p' "$f" | head -n1)"
    label="${label%$'\r'}"
    [ -z "$label" ] || printf '%s\t%s\n' "$label" "$f"
  done
}

# ns_receipts_write_archive_index <dir> <date> [<open-names>] — the index of the item receipts
# filed in <dir>, written only when at least one landed there. A receipt named in the
# newline-separated <open-names> belongs to an item still open and is listed as open. Links stay
# siblings, because the receipts it lists are in that directory too.
ns_receipts_write_archive_index() {
  local dir="$1" date_s="$2" open="${3:-}" index rows f base label cells state
  local in cw cr out rea work pause usage time _sum
  local tin=0 tcw=0 tcr=0 tout=0 trea=0 twork=0 tpause=0 offu=0 offt=0
  { [ -d "$dir" ] && [ ! -L "$dir" ]; } || return 0
  index="$dir/README.md"
  [ -L "$index" ] && return 0
  rows="$(mktemp "${TMPDIR:-/tmp}/ns-archive-index.XXXXXX")" || return 0
  : >"$rows"
  while IFS=$'\t' read -r label f; do
    [ -n "$f" ] || continue
    base="${f##*/}"
    cells="$(ns_receipt_usage_cells "$f")"
    IFS=$'\t' read -r in cw cr out rea work pause usage time _sum <<EOF
$cells
EOF
    tin=$((tin + in)); tcw=$((tcw + cw)); tcr=$((tcr + cr))
    tout=$((tout + out)); trea=$((trea + rea)); twork=$((twork + work)); tpause=$((tpause + pause))
    [ "$usage" != off ] || offu=1
    [ "$time" != off ] || offt=1
    case $'\n'"$open"$'\n' in
      *$'\n'"$base"$'\n'*) state=open ;;
      *) state=ticked ;;
    esac
    printf '| %s | %s | **%s** | **%s** | [./%s](./%s) |\n' \
      "$label" "$state" "$usage" "$time" "$base" "$base" >>"$rows"
  done <<FIND
$(_ns_archive_receipt_headings "$dir" | ns_receipts_heading_order)
FIND
  if [ ! -s "$rows" ]; then
    rm -f "$rows"
    return 0
  fi
  {
    ns_receipts_index_head "$date_s" "$dir"
    cat "$rows"
    ns_receipts_index_totals \
      "$(_ns_index_total "$(ns_receipts_usage_total_cell "$tin" "$tcw" "$tcr" "$tout" "$trea")" "$offu")" \
      "$(_ns_index_total "$(ns_receipts_time_total_cell "$twork" "$tpause")" "$offt")"
  } >"$index" 2>/dev/null || :
  rm -f "$rows"
}

# ns_receipts_write_index <project-dir> [remaining] — rewrite receipts/README.md from the list,
# marks, files. `remaining` writes the index a live receipts folder still needs — every open item
# and every ticked item whose receipt is still there — and removes it when nothing is left.
ns_receipts_write_index() {
  local project="$1" mode="${2:-}" punch
  local dir index date_s state base file cells
  local in cw cr out rea work pause usage time _sum
  local tin=0 tcw=0 tcr=0 tout=0 trea=0 twork=0 tpause=0 offu=0 offt=0
  local label id items rows armed
  ns_layout_set punch "$project/.nightshift" punch-list
  dir="$(ns_receipts_dir "$project")"
  [ -n "$dir" ] || return 0
  if [ "$mode" = remaining ]; then
    [ -d "$dir" ] || return 0
  else
    mkdir -p "$dir" 2>/dev/null || return 0
  fi
  [ ! -L "$dir" ] || return 0
  ns_layout_set index "$project/.nightshift" receipts-index
  [ -L "$index" ] && return 0
  # Between shifts a receipt moves to the name its item carries now; while one is armed the names
  # hold still, so the model keeps writing the file it was given.
  ns_layout_set armed "$project/.nightshift" armed
  { [ -e "$armed" ] || [ -L "$armed" ]; } || ns_receipts_rename "$project"
  date_s="$(ns_receipts_shift_date "$project")"
  items="$(mktemp "${TMPDIR:-/tmp}/ns-receipts-index.XXXXXX")" || return 0
  rows="$(mktemp "${TMPDIR:-/tmp}/ns-receipts-rows.XXXXXX")" || { rm -f "$items"; return 0; }
  : >"$items"
  if [ -f "$punch" ]; then
    ns_item_states "$punch" >"$items" || :
  fi
  : >"$rows"
  while IFS=$'\t' read -r state label id || [ -n "$state" ]; do
    [ -n "$label" ] || continue
    base="$(ns_receipt_base "$project" "$label" "$id")"
    if [ "$mode" = remaining ] && [ "$state" = ticked ] && [ ! -f "$dir/${base}.md" ]; then
      continue
    fi
    file="./${base}.md"
    cells="$(ns_receipt_usage_cells "$dir/${base}.md")"
    IFS=$'\t' read -r in cw cr out rea work pause usage time _sum <<EOF
$cells
EOF
    tin=$((tin + in)); tcw=$((tcw + cw)); tcr=$((tcr + cr))
    tout=$((tout + out)); trea=$((trea + rea)); twork=$((twork + work)); tpause=$((tpause + pause))
    [ "$usage" != off ] || offu=1
    [ "$time" != off ] || offt=1
    printf '| %s | %s | **%s** | **%s** | [%s](%s) |\n' \
      "$label" "$state" "$usage" "$time" "$file" "$file" >>"$rows"
  done <"$items"
  if [ "$mode" = remaining ] && [ ! -s "$rows" ]; then
    rm -f "$index" "$items" "$rows"
    return 0
  fi
  {
    ns_receipts_index_head "$date_s" "$dir"
    cat "$rows"
    ns_receipts_index_totals \
      "$(_ns_index_total "$(ns_receipts_usage_total_cell "$tin" "$tcw" "$tcr" "$tout" "$trea")" "$offu")" \
      "$(_ns_index_total "$(ns_receipts_time_total_cell "$twork" "$tpause")" "$offt")"
  } >"$index" 2>/dev/null || :
  rm -f "$items" "$rows"
}

# ns_archive <project-dir> <field> — one field of the archive block, or empty.
ns_archive() {
  ns_policy_pref "$1" archive "$2"
}

# The ending marker carries what filing still needs after the live policy has moved.
#
# Clock-out archives the policy, and a later Archive would then have no shift id to name a
# directory after and no frozen archive settings to file into — so one shift's records could land
# half under its own name and half under a date, and an owner edit between the two would move the
# destination. The marker that already says the shift ended says which shift, and where it files.
# One line per field, `key=value`, and an empty marker stays a valid ending.
#
# ns_ended_record <state-dir> <shift-id> <archive-root-name> <archive-layout> [<shift-name>
# <archive-folder>] — the folder is the one clock-out claimed for the shift, by its name under the
# archive root, so every later filing of that shift returns to it whatever day it runs.
ns_ended_record() {
  local ns="$1" ended
  [ -d "$ns" ] || return 0
  ns_layout_set ended "$ns" ended
  [ -L "$ended" ] && rm -f "$ended"
  printf 'shiftId=%s\narchiveRoot=%s\narchiveLayout=%s\nshiftName=%s\narchiveFolder=%s\n' \
    "$2" "$3" "$4" "${5-}" "${6-}" >"$ended" 2>/dev/null || :
}

# ns_shift_name <punch-list> — the name the owner gave the shift on the list's title line, as in
# `# Punch List — Archive follow-ups`, with an em or en dash, a hyphen or a colon after the words.
# Nothing when the title carries no name.
ns_shift_name() {
  local line="" re
  [ -f "$1" ] && [ ! -L "$1" ] || return 0
  IFS= read -r line <"$1" || [ -n "$line" ] || return 0
  line="${line%$'\r'}"
  re=$'^#[[:space:]]+Punch[[:space:]]+List[[:space:]]*(\xe2\x80\x94|\xe2\x80\x93|-|:)[[:space:]]*(.*[^[:space:]])[[:space:]]*$'
  [[ $line =~ $re ]] || return 0
  printf '%s' "${BASH_REMATCH[2]}"
}

# ns_ended_field <project-dir> <key> — one field of the ending marker, or empty.
ns_ended_field() {
  local f
  ns_layout_set f "$1/.nightshift" ended
  [ -f "$f" ] && [ ! -L "$f" ] || return 0
  sed -n "s/^$2=//p" "$f" 2>/dev/null | head -n1
}

# ns_state_path <state-dir> <relative-name> — a nested path under the Nightshift state area, or
# status 2. The whole chain is checked, not just its last component: a link anywhere along it is
# what an escape actually looks like, because `linked/history` reaches outside while `history` is
# an ordinary directory nobody would question.
#
# Refused: an absolute or ~ path, any component that is empty, `.`, `..` or begins with a dot,
# an existing component that is a symlink, an existing component that is not a directory, and the
# state directory itself. Then the deepest ancestor that exists is canonicalised and checked to
# be the state directory or inside it — comparing the real paths rather than trusting that the
# text of one is a prefix of the other.
ns_state_path() {
  local ns="$1" rel="$2" path comp rest deepest canon_ns canon_deep
  case "$rel" in
    '' | . | /* | '~'*) return 2 ;;
  esac
  path="$ns"
  deepest="$ns"
  rest="$rel"
  while [ -n "$rest" ]; do
    comp="${rest%%/*}"
    case "$rest" in */*) rest="${rest#*/}" ;; *) rest="" ;; esac
    case "$comp" in
      '' | .*) return 2 ;;
    esac
    path="$path/$comp"
    [ ! -L "$path" ] || return 2
    if [ -e "$path" ]; then
      [ -d "$path" ] || return 2
      deepest="$path"
    fi
  done
  canon_ns="$(cd -P "$ns" 2>/dev/null && pwd -P)" || return 2
  canon_deep="$(cd -P "$deepest" 2>/dev/null && pwd -P)" || return 2
  case "$canon_deep" in
    "$canon_ns" | "$canon_ns"/*) ;;
    *) return 2 ;;
  esac
  printf '%s' "$path"
}

# ns_archive_root <project-dir> — the directory dated archives live in, as an absolute path.
# The name is the owner's; where it may sit is not. It stays inside the Nightshift state area,
# and a request to leave it is refused with status 2 so the caller says so rather than writing
# the owner's records somewhere they cannot find them. Writing outside the state area is an
# unsupported request, not a setting.
ns_archive_root() {
  local ns="$1/.nightshift" name key live
  name="$(ns_archive "$1" root)"
  [ -n "$name" ] || ns_layout_rel_set name "$ns" archive
  # The live records are not an archive destination: filing into them would file a shift on top
  # of the shift that is still running.
  for key in receipts inbox staging product run; do
    ns_layout_rel_set live "$ns" "$key" || continue
    case "$name" in
      "$live" | "$live"/*) return 2 ;;
    esac
  done
  ns_state_path "$ns" "$name" || return 2
}

# ns_archive_dest <path> — status 0 when one file may be written at that exact path. A directory
# containment check says nothing about the leaf: a link left where a receipt is about to land
# would still carry its bytes somewhere else.
ns_archive_dest() {
  [ ! -L "$1" ] || return 2
  [ ! -e "$1" ] || [ -f "$1" ] || return 2
}

# ns_archive_dir <project-dir> <date> <shift-id> [<shift-name>] — the directory one shift is filed
# into.
#
# The shift layout gives each shift `shift-<id>/`. The date layout gives the first shift of a day
# `<date>/` and each later one `<date>-shift-2/`, `<date>-shift-3/` and so on, so two shifts never
# share a punch list, a log or a receipt name. The name layout uses the shift's name instead of the
# date, and date-name both, as in `2026-09-25-archive-follow-ups/`; a shift with no name falls back
# to the date. A second shift under the same name takes `-shift-2` the same way. A folder records the shift it belongs to in
# `.shift-id`, `unknown` for a shift that ended without an id, and a shift filed again that day
# comes back to its own folder. An empty folder without that record is claimed; one that already
# holds records without it belongs to nobody we can name and is never claimed. A candidate that is
# a link or not a directory is returned as it is, for the caller to refuse.
ns_archive_dir() {
  local root layout base dir n=1 owner id slug=""
  root="$(ns_archive_root "$1")" || return 2
  layout="$(ns_archive "$1" layout)"
  id="${3:-unknown}"
  if [ "$layout" = shift ] && [ "$id" != unknown ]; then
    printf '%s/shift-%s' "$root" "$id"
    return 0
  fi
  [ -z "${4-}" ] || slug="$(ns_receipt_slug "$4")"
  base="$root/$2"
  if [ -n "$slug" ]; then
    case "$layout" in
      name) base="$root/$slug" ;;
      date-name) base="$root/$2-$slug" ;;
    esac
  fi
  dir="$base"
  while :; do
    if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then
      printf '%s' "$dir"
      return 0
    fi
    if [ ! -e "$dir" ]; then
      mkdir -p "$dir" 2>/dev/null || return 2
      printf '%s\n' "$id" >"$dir/.shift-id" 2>/dev/null || return 2
      printf '%s' "$dir"
      return 0
    fi
    owner=""
    if [ -f "$dir/.shift-id" ] && [ ! -L "$dir/.shift-id" ]; then
      IFS= read -r owner <"$dir/.shift-id" || :
    elif [ ! -e "$dir/.shift-id" ] && [ -z "$(ls -A "$dir" 2>/dev/null)" ]; then
      printf '%s\n' "$id" >"$dir/.shift-id" 2>/dev/null || return 2
      owner="$id"
    fi
    if [ "$owner" = "$id" ]; then
      printf '%s' "$dir"
      return 0
    fi
    n=$((n + 1))
    dir="$base-shift-$n"
  done
}

# ns_archive_group <project-dir> <date> <shift-id> — the folder one shift files into. Once clock-out
# has claimed it, the ending marker names it and every later filing of that shift returns there,
# whatever day it runs; before that, or when the named folder is gone or is another shift's, it is
# resolved from the date, the id and the shift's name.
ns_archive_group() {
  local project="$1" id="${3:-unknown}" root folder="" owner="" name=""
  root="$(ns_archive_root "$project")" || return 2
  if [ "$(ns_ended_field "$project" shiftId)" = "$id" ]; then
    folder="$(ns_ended_field "$project" archiveFolder)"
    name="$(ns_ended_field "$project" shiftName)"
  fi
  case "$folder" in '' | */* | .*) folder="" ;; esac
  if [ -n "$folder" ] && [ -d "$root/$folder" ] && [ ! -L "$root/$folder" ] &&
    [ -f "$root/$folder/.shift-id" ] && [ ! -L "$root/$folder/.shift-id" ]; then
    IFS= read -r owner <"$root/$folder/.shift-id" || :
    if [ "$owner" = "$id" ]; then
      printf '%s/%s' "$root" "$folder"
      return 0
    fi
  fi
  [ -n "$name" ] || name="$(ns_shift_name "$(ns_layout_path "$project/.nightshift" punch-list)")"
  ns_archive_dir "$project" "$2" "$id" "$name"
}

# ns_archive_folder_of <project-dir> <shift-id> — the archive folder a shift with that id claimed, or
# nothing. A shift without an id owns no folder anyone can find by it.
ns_archive_folder_of() {
  local root d owner
  case "$2" in '' | unknown | *[!A-Za-z0-9-]*) return 0 ;; esac
  root="$(ns_archive_root "$1")" || return 0
  for d in "$root"/*/; do
    d="${d%/}"
    { [ -d "$d" ] && [ ! -L "$d" ] && [ -f "$d/.shift-id" ] && [ ! -L "$d/.shift-id" ]; } || continue
    owner=""
    IFS= read -r owner <"$d/.shift-id" || :
    if [ "$owner" = "$2" ]; then
      printf '%s' "$d"
      return 0
    fi
  done
}

# ns_archived_policy <project-dir> <shift-id> — the shift policy filed for that shift: in the folder
# the ending marker names, then in any folder the shift claimed, at the path the policy has live
# (`run/shift-policy.json`, or `shift-policy.json` in the older layout), then under the
# `shift-policy-<id>.json` name earlier versions filed it by. Nothing when none is filed.
ns_archived_policy() {
  local project="$1" id="$2" root folder f
  case "$id" in '' | unknown | *[!0-9a-f-]*) return 0 ;; esac
  root="$(ns_archive_root "$project")" || return 0
  { [ -d "$root" ] && [ ! -L "$root" ]; } || return 0
  for folder in "$(ns_archive_group_if_claimed "$project" "$id")" "$(ns_archive_folder_of "$project" "$id")"; do
    [ -n "$folder" ] || continue
    for f in "$folder/run/shift-policy.json" "$folder/shift-policy.json" "$folder/shift-policy-$id.json"; do
      if [ -f "$f" ] && [ ! -L "$f" ]; then
        printf '%s' "$f"
        return 0
      fi
    done
  done
  f="$(find "$root" -type f -name "shift-policy-$id.json" -print 2>/dev/null | LC_ALL=C sort | head -n 1)"
  [ -z "$f" ] || printf '%s' "$f"
}

# ns_archive_group_if_claimed <project-dir> <shift-id> — the folder the ending marker names for that
# shift, only when it exists and that shift still owns it.
ns_archive_group_if_claimed() {
  local root folder owner=""
  [ "$(ns_ended_field "$1" shiftId)" = "$2" ] || return 0
  folder="$(ns_ended_field "$1" archiveFolder)"
  case "$folder" in '' | */* | .*) return 0 ;; esac
  root="$(ns_archive_root "$1")" || return 0
  { [ -f "$root/$folder/.shift-id" ] && [ ! -L "$root/$folder/.shift-id" ] && [ ! -L "$root/$folder" ]; } || return 0
  IFS= read -r owner <"$root/$folder/.shift-id" || :
  [ "$owner" = "$2" ] && printf '%s/%s' "$root" "$folder"
  return 0
}

# The link-repointing script the archive shares, resolved without dirname.
_NS_ARCHIVE_LINKS_AWK="${BASH_SOURCE[0]%/*}"
[ "$_NS_ARCHIVE_LINKS_AWK" != "${BASH_SOURCE[0]}" ] || _NS_ARCHIVE_LINKS_AWK=.
_NS_ARCHIVE_LINKS_AWK="$_NS_ARCHIVE_LINKS_AWK/../runtime/archive-links.awk"

# ns_archive_relocate <record> <filed> <group> <ns> — print <record> with its relative links
# repointed for where <filed> sits inside the archive folder <group>. A link to a record filed in
# <group> stays a sibling link; one to a record that stayed live climbs back to it.
ns_archive_relocate() {
  local record="$1" filed="$2" group="$3" ns="$4" rel from back="" archived awk_bin saved_ifs
  [ -f "$record" ] || return 1
  case "$filed" in "$group"/*) ;; *) return 1 ;; esac
  case "$group" in "$ns"/*) ;; *) return 1 ;; esac
  from="${filed#"$group"/}"
  case "$from" in */*) from="${from%/*}" ;; *) from="" ;; esac
  rel="${filed#"$ns"/}"
  rel="${rel%/*}"
  saved_ifs="$IFS"
  IFS=/
  # shellcheck disable=SC2086
  set -- $rel
  IFS="$saved_ifs"
  for _ in "$@"; do back="../$back"; done
  archived="$(cd "$group" 2>/dev/null && find . -type f ! -name '.*' ! -name '*.original.md' 2>/dev/null |
    sed 's#^\./##')"
  awk_bin="$(ns_rules_awk_bin)" || awk_bin="awk"
  NS_ARCHIVED_PATHS="$archived" "$awk_bin" -v back="$back" -v dir="$from" \
    -f "$_NS_ARCHIVE_LINKS_AWK" <"$record"
}

# ns_archive_same <source> <filed> [<group> <ns>] — status 0 when the filed copy is this record: the
# same bytes, or, given its archive folder, the source with its links repointed for the archive.
ns_archive_same() {
  local view
  [ -f "$1" ] && [ -f "$2" ] || return 1
  cmp -s "$1" "$2" && return 0
  [ $# -ge 4 ] || return 1
  view="$(mktemp)" || return 1
  if ns_archive_relocate "$1" "$2" "$3" "$4" >"$view" 2>/dev/null && cmp -s "$view" "$2"; then
    rm -f "$view"
    return 0
  fi
  rm -f "$view"
  return 1
}

# _ns_archive_missing_blocks <filed> <chunk> — print each entry of <chunk> that <filed> does not
# already hold, as whole lines. An entry is a top-level `- ` line with the lines under it, its
# `Default:` and `Rollback:` lines included; trailing blank lines are not part of it. Prints
# nothing when every entry is already filed; given /dev/null, prints every entry.
_ns_archive_missing_blocks() {
  awk -v filed="$1" '
    BEGIN {
      have = "\n"
      while ((getline l <filed) > 0) { sub(/\r$/, "", l); have = have l "\n" }
      close(filed)
    }
    function flush(   b) {
      if (buf == "") return
      b = buf
      sub(/\n+$/, "", b)
      if (index(have, "\n" b "\n") == 0) printf "%s\n", b
      buf = ""
    }
    { l = $0; sub(/\r$/, "", l) }
    buf != "" && l ~ /^ *(- )?(\*\*)?(Default|Rollback):/ { buf = buf l "\n"; next }
    l ~ /^- / { flush(); buf = l "\n"; next }
    buf != "" { buf = buf l "\n"; next }
    END { flush() }
  ' "$2"
}

# _ns_archive_items <mode> <list> — the punch list, read by item. `closed` prints the list less its
# open items; `ticked` prints only the ticked items, each with the lines under it.
_ns_archive_items() {
  awk -v mode="$1" '
    { line = $0; sub(/\r$/, "", line) }
    !items { if (mode == "closed") print; if (line ~ /^## Items[[:space:]]*$/) items = 1; next }
    done { if (mode == "closed") print; next }
    line ~ /^## / { if (mode == "closed") { printf "%s", blanks; print } blanks = ""; done = 1; next }
    line == "" { blanks = blanks $0 "\n"; next }
    line ~ /^- \[[ xX]\]/ {
      take = (line ~ /^- \[[xX]\]/)
      if (take) { if (mode == "closed") printf "%s", blanks; print }
      blanks = ""
      next
    }
    line ~ /^[[:space:]]/ { if (take) { printf "%s", blanks; print } blanks = ""; next }
    { take = 0; if (mode == "closed") { printf "%s", blanks; print } blanks = "" }
    END { if (mode == "closed") printf "%s", blanks }
  ' "$2"
}

# _ns_archive_insert_items <filed> <items> — <filed> with <items> added at the end of its
# `## Items` section.
_ns_archive_insert_items() {
  awk -v add="$2" '
    function put(   l) { while ((getline l <add) > 0) print l; close(add); added = 1 }
    { line = $0; sub(/\r$/, "", line) }
    !items { print; if (line ~ /^## Items[[:space:]]*$/) items = 1; next }
    added { print; next }
    line ~ /^## / { put(); printf "%s", blanks; blanks = ""; print; next }
    line == "" { blanks = blanks $0 "\n"; next }
    { printf "%s", blanks; blanks = ""; print }
    END { if (!added) put(); printf "%s", blanks }
  ' "$1"
}

# ns_archive_punch_list <project-dir> <folder> <shift-id> <date> — file the ended shift's ticked
# items into its folder, at the path the list has live, then take them out of the live list.
#
# The filed list is the contract and the ticked items; open items stay live, and only there. A
# later filing of the same shift adds the items ticked since. Prints the filed path, or nothing
# when no item is ticked. Status 3 when a list with a different contract is already filed there;
# the live list is then left as it is.
ns_archive_punch_list() {
  local live live_rel dir="$2" ns="$1/.nightshift" dest tmp ticked view chunk missing
  ns_layout_set live "$ns" punch-list
  ns_layout_rel_set live_rel "$ns" punch-list
  [ -f "$live" ] && [ ! -L "$live" ] || return 0
  ticked="$(ns_punch_items "$live" | grep -c '^- \[[xX]\]')"
  [ "$ticked" -gt 0 ] || return 0
  dest="$dir/$live_rel"
  ns_archive_dest "$dest" || return 2
  mkdir -p "${dest%/*}" 2>/dev/null || return 2
  view="$(mktemp)" || return 2
  chunk="$(mktemp)" || { rm -f "$view"; return 2; }
  missing="$(mktemp)" || { rm -f "$view" "$chunk"; return 2; }
  # The ticked items as they read from where they are filed: the check that they all landed.
  if ! { _ns_archive_items ticked "$live" >"$view" &&
    ns_archive_relocate "$view" "$dest" "$dir" "$ns" >"$chunk" 2>/dev/null; }; then
    rm -f "$view" "$chunk" "$missing"
    return 2
  fi
  if [ -e "$dest" ]; then
    # Only this shift's list takes more items: the contract filed there must be the live one.
    tmp="$(mktemp)" || { rm -f "$view" "$chunk" "$missing"; return 2; }
    if ! { _ns_archive_items closed "$live" >"$tmp" &&
      ns_archive_relocate "$tmp" "$dest" "$dir" "$ns" >"$view" 2>/dev/null; }; then
      rm -f "$tmp" "$view" "$chunk" "$missing"
      return 2
    fi
    rm -f "$tmp"
    if [ "$(awk '{ print } /^## Items[[:space:]]*\r?$/ { exit }' "$view")" != \
      "$(awk '{ print } /^## Items[[:space:]]*\r?$/ { exit }' "$dest")" ]; then
      rm -f "$view" "$chunk" "$missing"
      return 3
    fi
    _ns_archive_missing_blocks "$dest" "$chunk" >"$missing"
    if [ -s "$missing" ]; then
      if ! { _ns_archive_insert_items "$dest" "$missing" >"$view" && mv "$view" "$dest"; }; then
        rm -f "$view" "$chunk" "$missing"
        return 2
      fi
    fi
  else
    tmp="$(mktemp)" || { rm -f "$view" "$chunk" "$missing"; return 2; }
    if ! { _ns_archive_items closed "$live" >"$tmp" &&
      ns_archive_relocate "$tmp" "$dest" "$dir" "$ns" >"$view" 2>/dev/null && mv "$view" "$dest"; }; then
      rm -f "$tmp" "$view" "$chunk" "$missing" "$dest"
      return 2
    fi
    rm -f "$tmp"
  fi
  # Nothing leaves the live list until every ticked item reads back from the filed one.
  _ns_archive_missing_blocks "$dest" "$chunk" >"$missing"
  if [ -s "$missing" ]; then
    rm -f "$view" "$chunk" "$missing"
    return 2
  fi
  rm -f "$view" "$chunk" "$missing"
  tmp="$live.tmp.$$"
  awk '
    { line = $0; sub(/\r$/, "", line) }
    !items { print; if (line ~ /^## Items[[:space:]]*$/) items = 1; next }
    done { print; next }
    line ~ /^## / { printf "%s", blanks; blanks = ""; done = 1; print; next }
    line == "" { blanks = blanks $0 "\n"; next }
    line ~ /^- \[[xX]\]/ { drop = 1; blanks = ""; next }
    line ~ /^[[:space:]]/ { if (!drop) { printf "%s", blanks; print } blanks = ""; next }
    { drop = 0; printf "%s", blanks; blanks = ""; print }
    END { if (!drop) printf "%s", blanks }
  ' "$live" >"$tmp" 2>/dev/null || { rm -f "$tmp"; return 2; }
  mv "$tmp" "$live" || { rm -f "$tmp"; return 2; }
  printf '%s' "$dest"
}

# ns_archive_file_journal <project-dir> <folder> — move the shift log into the folder at the path it
# has live and start the live one again under the same heading. A journal already filed there keeps
# every line and gains the ones written since, so a shift filed twice loses nothing. Prints the
# filed path, or nothing when the log holds no line past its heading.
ns_archive_file_journal() {
  local live rel dest head tmp
  ns_layout_set live "$1/.nightshift" shift-log
  ns_layout_rel_set rel "$1/.nightshift" shift-log
  [ -f "$live" ] && [ ! -L "$live" ] || return 0
  head="# Shift Log"
  IFS= read -r tmp <"$live" || :
  tmp="${tmp%$'\r'}"
  case "$tmp" in '# '*) head="$tmp" ;; esac
  [ -n "$(awk -v head="$head" 'NR == 1 && $0 == head { next } /[^[:space:]]/ { print; exit }' "$live")" ] || return 0
  dest="$2/$rel"
  ns_archive_dest "$dest" || return 2
  mkdir -p "${dest%/*}" 2>/dev/null || return 2
  tmp="$dest.tmp.$$"
  if [ -f "$dest" ]; then
    { cat "$dest" && awk -v head="$head" 'NR == 1 && $0 == head { next } { print }' "$live"; } >"$tmp" 2>/dev/null ||
      { rm -f "$tmp"; return 2; }
  else
    cp "$live" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 2; }
  fi
  mv "$tmp" "$dest" || { rm -f "$tmp"; return 2; }
  printf '%s\n' "$head" >"$live" || return 2
  printf '%s' "$dest"
}

# ns_archive_automatic <project-dir> — status 0 when the owner asked for filing at clock-out.
# Filing is a copy; it never implies deleting anything.
ns_archive_automatic() {
  [ "$(ns_archive "$1" automatic)" = true ]
}

# The dispositions Archive files. An inbox entry that carries one after a ` · ` separator is
# closed; one without is open and waits for the owner. The morning receipt reads the same list, and
# the parking-lot and snag-log templates name it.
NS_REVIEW_DISPOSITIONS='fixed|ignored|answered|rejected-because|accepted-tradeoff'

# The inbox reader's awk half sits next to this file, resolved without dirname.
_NS_INBOX_AWK="${BASH_SOURCE[0]%/*}"
[ "$_NS_INBOX_AWK" != "${BASH_SOURCE[0]}" ] || _NS_INBOX_AWK=.
_NS_INBOX_AWK="$_NS_INBOX_AWK/inbox-entries.awk"

# ns_inbox_strays <file> — `<line>\t<text>` for each paragraph below the rule of a parking lot or
# snag log: text that is not a `- ` bullet, which Archive never files.
ns_inbox_strays() {
  [ -f "$1" ] && [ ! -L "$1" ] || return 0
  awk -v op=strays -f "$_NS_INBOX_AWK" "$1"
}

# ns_archive_review_label <folder-name> <shift-id> <layout> — what a Filed pointer is labelled: the
# shift id in the shift layout, the folder's own name otherwise (`2026-09-09`, `2026-09-09-shift-2`,
# `2026-09-09-archive-follow-ups`), so two shifts on one day are told apart.
ns_archive_review_label() {
  if [ "$3" = shift ] && [ -n "$2" ] && [ "$2" != unknown ]; then
    printf '%s' "$2"
  else
    printf '%s' "$1"
  fi
}

# ns_archive_pointer_line <label> <relpath>
ns_archive_pointer_line() {
  printf 'Filed: [%s](%s)' "$1" "$2"
}

# ns_archive_file_review_source <project> <parking-lot|snag-log> <folder> <label> — file the handled
# entries of the live review file into the shift's folder, at the path it has live, then take them
# out of the live file and leave one pointer to the filed copy, written relative to the live file.
# Entries still open stay live, and only there: they wait for the owner. A file with no handled
# entry files nothing. A later filing of the same shift adds the entries handled since.
ns_archive_file_review_source() {
  local project="$1" key="$2" group="$3" label="$4"
  local ns live rel dest tmp filed chunk view missing ptr prel
  ns="$project/.nightshift"
  ns_layout_set live "$ns" "$key" || return 2
  ns_layout_rel_set rel "$ns" "$key" || return 2
  [ -f "$live" ] && [ ! -L "$live" ] || return 0
  grep -v '^- Filed:' "$live" | grep -q '^- ' || return 0
  dest="$group/$rel"
  case "$dest" in
    "$ns"/*) ;;
    *) return 2 ;;
  esac
  ns_archive_dest "$dest" || return 2
  tmp="$(mktemp)" || return 2
  filed="$(mktemp)" || { rm -f "$tmp"; return 2; }
  awk -v op=file -v filed="$filed" -v dispositions="$NS_REVIEW_DISPOSITIONS" -f "$_NS_INBOX_AWK" \
    "$live" >"$tmp" || {
    rm -f "$tmp" "$filed"
    return 2
  }
  if [ ! -s "$filed" ]; then
    rm -f "$tmp" "$filed"
    return 0
  fi
  chunk="$(mktemp)" || { rm -f "$tmp" "$filed"; return 2; }
  view="$(mktemp)" || { rm -f "$tmp" "$filed" "$chunk"; return 2; }
  missing="$(mktemp)" || { rm -f "$tmp" "$filed" "$chunk" "$view"; return 2; }
  # The handled entries as they read from where they are filed: the check that they all landed.
  if ! ns_archive_relocate "$filed" "$dest" "$group" "$ns" >"$chunk" 2>/dev/null ||
    ! mkdir -p "${dest%/*}" 2>/dev/null; then
    rm -f "$tmp" "$filed" "$chunk" "$view" "$missing"
    return 2
  fi
  if [ -e "$dest" ]; then
    _ns_archive_missing_blocks "$dest" "$chunk" >"$missing"
    if [ -s "$missing" ]; then
      if ! { { cat "$dest"; [ -z "$(tail -c 1 "$dest")" ] || printf '\n'; cat "$missing"; } >"$view" &&
        mv "$view" "$dest"; }; then
        rm -f "$tmp" "$filed" "$chunk" "$view" "$missing"
        return 2
      fi
    fi
  else
    # A first filing carries the file's heading, down to its `---` rule, above the entries.
    if ! { {
      awk '
        { l = $0; sub(/\r$/, "", l) }
        l ~ /^--- *$/ { printf "%s%s", head, blanks; print; done = 1; exit }
        l ~ /^- / { exit }
        l == "" { blanks = blanks $0 "\n"; next }
        { head = head blanks $0 "\n"; blanks = "" }
        END { if (!done) printf "%s", head }
      ' "$live"
      printf '\n'
      _ns_archive_missing_blocks /dev/null "$filed"
    } >"$view" && ns_archive_relocate "$view" "$dest" "$group" "$ns" >"$missing" 2>/dev/null &&
      mv "$missing" "$dest"; }; then
      rm -f "$tmp" "$filed" "$chunk" "$view" "$missing" "$dest"
      return 2
    fi
  fi
  # Nothing leaves the live file until every handled entry reads back from the filed one.
  _ns_archive_missing_blocks "$dest" "$chunk" >"$missing"
  if [ -s "$missing" ]; then
    rm -f "$tmp" "$filed" "$chunk" "$view" "$missing"
    return 2
  fi
  rm -f "$filed" "$chunk" "$view" "$missing"
  prel="$(ns_relative_path "${live%/*}" "$dest")"
  case "$prel" in
    '' | /*)
      rm -f "$tmp"
      return 2
      ;;
  esac
  ptr="$(ns_archive_pointer_line "$label" "$prel")"
  if ! grep -qxF "$ptr" "$tmp"; then
    # One blank line before the pointer, never two.
    [ -z "$(tail -n 1 "$tmp" | tr -d '[:space:]')" ] || printf '\n' >>"$tmp"
    printf '%s\n' "$ptr" >>"$tmp"
  fi
  mv "$tmp" "$live" || {
    rm -f "$tmp"
    return 2
  }
  return 0
}

# ns_archive_check_review_pointers <project> — one snag per missing Filed: target. A pointer is
# read relative to the file that carries it and must stay inside the state directory.
ns_archive_check_review_pointers() {
  local project="$1" ns live dest rel line snag key
  ns="$project/.nightshift"
  ns_layout_set snag "$ns" snag-log
  for key in snag-log parking-lot; do
    ns_layout_set live "$ns" "$key"
    { [ -f "$live" ] && [ ! -L "$live" ]; } || continue
    while IFS= read -r line || [ -n "$line" ]; do
      printf '%s\n' "$line" | grep -qE '^Filed: \[[^]]+\]\([^)]+\)$' || continue
      rel="${line#*']('}"
      rel="${rel%')'}"
      [ -n "$rel" ] || continue
      dest=""
      case "$rel" in
        /*) ;;
        *)
          dest="$(ns_normalize_path "${live%/*}/$rel")"
          case "$dest" in "$ns"/*) ;; *) dest="" ;; esac
          ;;
      esac
      if [ -n "$dest" ] && [ -f "$dest" ] && [ ! -L "$dest" ]; then
        continue
      fi
      if [ -f "$snag" ] && grep -qF "broken archive pointer · $rel " "$snag"; then
        continue
      fi
      if [ ! -f "$snag" ]; then
        ns_layout_parent "$ns" snag-log || return 2
        printf '# Snag Log\n\n' >"$snag" || return 2
      fi
      printf -- '- broken archive pointer · %s is not a readable file\n' "$rel" >>"$snag"
    done <"$live"
  done
  return 0
}

# ns_archive_file_review_records <project> <folder> <label> — both review files, then a check of every
# pointer they carry. Status 3 when either kept its handled entries live; 2 when filing failed.
ns_archive_file_review_records() {
  local rc=0 one
  for one in snag-log parking-lot; do
    ns_archive_file_review_source "$1" "$one" "$2" "$3"
    case "$?" in
      0) ;;
      3) rc=3 ;;
      *) return 2 ;;
    esac
  done
  ns_archive_check_review_pointers "$1" || return 2
  return "$rc"
}

# ns_handoff <project-dir> <field> — one field of the handoff block, or empty when the file says
# nothing. Presentation only: none of it decides whether a check ran.
ns_handoff() {
  ns_policy_pref "$1" handoff "$2"
}

# ns_handoff_enabled <project-dir> — status 0 unless the owner turned the page off. A shift that
# writes no page still keeps every factual record it made.
ns_handoff_enabled() {
  [ "$(ns_handoff "$1" enabled)" != false ]
}

# ns_handoff_view <project-dir> — the configured reader, or owner.
ns_handoff_view() {
  local v
  v="$(ns_handoff "$1" view)"
  case "$v" in
    owner | reviewer | release | artifact) printf '%s' "$v" ;;
    *) printf 'owner' ;;
  esac
}

# ns_recovery_launch_scope <project-dir> — the permission scope a revived session starts under.
# host-grant is the documented grant for the host; host-default adds no permission argument and
# takes whatever the host gives. Anything else, or an unreadable file, is host-grant: recovery
# keeps working, and the scope in force is logged either way. The watchman never widens it.
ns_recovery_launch_scope() {
  local v=""
  if [ -n "${NIGHTSHIFT_LAUNCH_SCOPE:-}" ]; then
    v="$NIGHTSHIFT_LAUNCH_SCOPE"
  else
    v="$(ns_policy_pref "$1" recovery launchScope)"
  fi
  case "$v" in
    host-default) printf 'host-default' ;;
    host-grant) printf 'host-grant' ;;
    *) printf 'inherit-recorded-scope' ;;
  esac
}

# ns_policy_host_name — which host this session is, from what the host itself sets.
ns_policy_host_name() {
  if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
    printf 'cursor'
  elif [ -n "${CODEX_PROJECT_DIR:-}${CODEX_SANDBOX:-}${CODEX_SANDBOX_MODE:-}" ]; then
    printf 'codex'
  elif [ -n "${CLAUDE_PLUGIN_ROOT:-}${CLAUDE_PROJECT_DIR:-}" ]; then
    printf 'claude'
  else
    printf 'unknown'
  fi
}

# ns_launch_scope_supported <host> <scope> — true when the host can actually be asked to start a
# session at that scope. A recorded scope is only useful if a revival can name it on the command
# line, so this is the vocabulary the watchmen are allowed to pass through, and nothing else
# reaches a native flag.
ns_launch_scope_supported() {
  case "$1" in
    codex)
      case "$2" in
        read-only | workspace-write | danger-full-access) return 0 ;;
      esac
      ;;
    claude)
      case "$2" in
        dangerously-skip-permissions | bypass-permissions) return 0 ;;
      esac
      ;;
  esac
  return 1
}

# A recorded observed scope that host-default cannot reproduce.
ns_launch_scope_elevated() {
  case "$1" in
    danger-full-access | workspace-write | dangerously-skip-permissions | bypass-permissions | bypassPermissions)
      return 0
      ;;
  esac
  return 1
}

# ns_launch_observed <host> — the execution scope this session is running under, in the host's own
# words, and whether the host actually told us. Read only from what the host already exposes; a
# scope nobody reported is unavailable, never assumed.
#
# Only Codex names a session's sandbox, and only in its own environment. Claude Code and Cursor
# hand a session its permissions at launch and expose no name for them anywhere a hook can read,
# so there is nothing to observe and this says so. Calling that 'inherited' would have been a
# label for a measurement never taken.
_ns_scan_process_scope() {
  local pid="$1" hops=0 args
  while [ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" != 1 ] && [ "$hops" -lt 16 ]; do
    args="$(ps -o args= -p "$pid" 2>/dev/null)" || break
    case "$args" in
      *dangerously-skip-permissions* | *bypass-permissions*)
        printf '%s\tobserved' 'dangerously-skip-permissions'
        return 0
        ;;
    esac
    pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
    hops=$((hops + 1))
  done
  return 1
}

ns_launch_observed() {
  case "$1" in
    codex)
      if [ -n "${CODEX_SANDBOX_MODE:-}" ]; then
        printf '%s\tobserved' "$CODEX_SANDBOX_MODE"
        return 0
      fi
      if [ -n "${CODEX_SANDBOX:-}" ]; then
        printf '%s\tobserved' "$CODEX_SANDBOX"
        return 0
      fi
      ;;
    claude | cursor)
      if [ -n "${CLAUDE_PROJECT_DIR:-}${CLAUDE_PLUGIN_ROOT:-}${CURSOR_PLUGIN_ROOT:-}" ] \
        && _ns_scan_process_scope "$$"; then
        return 0
      fi
      ;;
  esac
  printf 'unknown\tunavailable'
}

# ns_recovery_effective_scope <project-dir> <host> — what a revival may actually ask for.
#
# The shipped choice inherits the scope the shift was started under, so recovery reproduces the
# session rather than improving on it. Nothing here ever widens what the original session had — an
# owner who wants the documented broad grant writes host-grant in their own file, and that is the
# only way it happens.
#
# There are four answers, and the caller logs the one it got:
#
#   host-default      the owner asked for it, or no scope was ever recorded. No permission
#                     argument is passed and the host decides. This is the baseline, not a proof
#                     that it is narrower than the original session — no host reports enough for
#                     that claim, and it is not made.
#   host-grant        the owner wrote it by name. Only ever from their own file.
#   recorded:<scope>  the shift recorded a scope the host observed and can be asked for again.
#   unavailable:<s>   a scope was recorded that this host has no way to request. A revival would
#                     run at some other scope, so the caller refuses rather than guess.
ns_recovery_effective_scope() {
  local configured recorded provenance
  configured="$(ns_recovery_launch_scope "$1")"
  case "$configured" in
    host-grant)
      printf 'host-grant'
      return 0
      ;;
    host-default)
      _ns_policy_load_shift "$1"
      case "$NS_POLICY_SHIFT_STATE" in
        ok)
          recorded="$(ns_policy_launch "$1" scope 2>/dev/null)" || recorded=""
          provenance="$(ns_policy_launch "$1" provenance 2>/dev/null)" || provenance=""
          if [ "$provenance" = observed ] && [ -n "$recorded" ] && ns_launch_scope_elevated "$recorded"; then
            printf 'unavailable:narrower:%s' "$recorded"
            return 0
          fi
          ;;
      esac
      printf 'host-default'
      return 0
      ;;
  esac
  _ns_policy_load_shift "$1"
  case "$NS_POLICY_SHIFT_STATE" in
    ok) ;;
    absent)
      printf 'unavailable:unrecorded'
      return 0
      ;;
    *)
      printf 'unavailable:unreadable'
      return 0
      ;;
  esac
  recorded="$(ns_policy_launch "$1" scope 2>/dev/null)" || recorded=""
  provenance="$(ns_policy_launch "$1" provenance 2>/dev/null)" || provenance=""
  if [ "$provenance" = observed ] && [ -n "$recorded" ] && [ "$recorded" != unknown ]; then
    if ns_launch_scope_supported "$2" "$recorded"; then
      printf 'recorded:%s' "$recorded"
    else
      printf 'unavailable:unsupported:%s' "$recorded"
    fi
    return 0
  fi
  printf 'unavailable:unrecorded'
}

# ns_recovery_refusal <effective-scope> — the one sentence that says why a revival is refused.
# Status 1 for a scope that is not a refusal.
ns_recovery_refusal() {
  case "$1" in
    unavailable:unrecorded)
      printf 'the host named no scope for the session this shift was started in, so there is nothing to inherit and no way to show a revival would be no broader'
      ;;
    unavailable:unreadable)
      printf 'the policy that records the launch scope cannot be read, so what this shift was started under is unknown'
      ;;
    unavailable:unsupported:*)
      printf "the shift was started under '%s', which this host has no way to be asked for again" "${1#unavailable:unsupported:}"
      ;;
    unavailable:narrower:*)
      printf "the shift was started under '%s', so a host-default revival would be too narrow" "${1#unavailable:narrower:}"
      ;;
    *) return 1 ;;
  esac
}

# toolDeny requires exact key matching. The shipped reader accepts the template's
# object-of-strings shape and nothing else. Malformed input fails closed.
ns_tool_map_ok() { # stdin = a JSON object of string values
  local raw
  raw="$(cat)"
  ns_rules_map_parse "$raw" || return 1
  printf '%s' "$raw"
}

ns_tool_rules() { # $1 = project dir, $2 = session override
  local f raw
  ns_layout_set f "$1/.nightshift" rules
  if [ -n "$2" ]; then
    raw="$2"
    ns_rules_map_parse "$raw" || {
      printf '%s' '__nightshift_invalid_tool_rules__'
      return
    }
    printf '%s' "$raw"
    return
  fi
  [ -f "$f" ] || return 0
  ns_rules_load "$f" || {
    printf '%s' '__nightshift_invalid_tool_rules__'
    return
  }
  ns_rules_tool_deny_json "$f"
}

# The punch list's `## Items` heading is the boundary between the owner's contract and the work.
# A checkbox above it is prose — an example, a note — and holds nobody. Both the gate and the
# watchman must agree on that boundary: a watchman counting a different range would keep reviving
# a shift the gate considers finished. One implementation is how they cannot disagree.
ns_items_section() { sed -n '/^## Items[[:space:]]*$/,$p' "$1" 2>/dev/null; }

# A count is a verdict about how much work is open, so it is either a number or a failure —
# never a silent zero. An absent list is the one honest zero: there is no work because there is
# no list. Everything else that can go wrong — the file exists but cannot be read, sed or grep is
# missing from a stripped PATH, the ERE is rejected — returns non-zero and prints nothing, and
# callers hold the site armed and the gate shut on that.
#
# grep -c prints the count AND exits 1 on zero matches, so status 1 is a real answer here and
# only status 2 and up is an error. The pipeline is split so the reader's status is its own.
ns_count_boxes() { # $1 = punch list, $2 = ERE for the box state
  local section n rc
  [ -e "$1" ] || { printf '0'; return 0; }
  [ -r "$1" ] || return 1
  section="$(ns_items_section "$1")" || return 1
  n="$(printf '%s\n' "$section" | grep -cE "$2" 2>/dev/null)"
  rc=$?
  [ "$rc" -le 1 ] || return 1
  case "$n" in
    '' | *[!0-9]*) return 1 ;;
  esac
  printf '%s' "$n"
}

ns_open_boxes()   { ns_count_boxes "$1" '^[[:space:]]*-[[:space:]]*\[[[:space:]]\]'; }
ns_ticked_boxes() { ns_count_boxes "$1" '^[[:space:]]*-[[:space:]]*\[[xX]\]'; }

# Work orders have no ## Items heading. Count every top-level open box in the file.
ns_open_boxes_file() {
  local n
  n="$(grep -cE '^[[:space:]]*-[[:space:]]*\[[[:space:]]\]' "$1" 2>/dev/null || true)"
  printf '%s' "${n:-0}"
}

# Drafting table: the fenced item-shape example sits above the first --- rule.
ns_open_drafts() {
  [ -f "$1" ] || { printf '0'; return 0; }
  awk '
    /^---[[:space:]]*$/ { seen=1; next }
    seen && /^[[:space:]]*-[[:space:]]*\[[[:space:]]\]/ { n++ }
    END { print n+0 }
  ' "$1"
}

# Watchman reason codes — one token, no transcript. Written to .nightshift/run/.watch-reason
# (line 1 = code, line 2 = optional non-sensitive detail). Status and Doctor render the same
# labels. Adding a code here is the contract; callers must not invent ad-hoc strings.
ns_reason_label() {
  case "$1" in
    completed) printf 'shift completed' ;;
    owner-stop) printf 'owner stop-work order' ;;
    owner-disarm) printf 'shift disarmed - the armed marker is gone' ;;
    stale-pid) printf 'recorded process is stale' ;;
    invalid-session) printf 'session identity is missing or unreadable' ;;
    exhausted-retry) printf 'revival retries exhausted this wake' ;;
    unknown-wedge) printf 'session looks wedged without a verified error signature' ;;
    revived) printf 'session revived into its own conversation' ;;
    stand-down) printf 'watchman stood down' ;;
    wrong-host) printf 'watchman stood down - shift belongs to another host' ;;
    deadline) printf 'quitting time passed' ;;
    clean-session-end) printf 'owner closed the session' ;;
    esc-standby) printf 'standing by - owner interrupt in the transcript' ;;
    silent-standby) printf 'standing by - session alive and quiet' ;;
    api-error) printf 'session stopped on an API error - reviving' ;;
    usage-limit) printf 'waiting for the usage limit to reset' ;;
    non-resumable-session) printf 'recorded Codex identity cannot be resumed' ;;
    unreadable-rules) printf 'rules file missing or incomplete' ;;
    fresh-fallback) printf 'fresh session - punch list is the handover' ;;
    unsupported-state) printf 'workspace state-version is unsupported' ;;
    recovery-scope-unavailable) printf 'recorded launch scope cannot be requested on this host' ;;
    process-evidence-unavailable) printf 'process evidence is unavailable' ;;
    clock-out-failed) printf 'terminal clock-out failed without releasing the shift' ;;
    *) printf 'unknown watchman outcome' ;;
  esac
}

ns_record_reason() { # <nightshift-dir> <code> [detail]
  local dir="$1" code="$2" detail="${3:-}"
  [ -d "$dir" ] || return 1
  case "$code" in
    completed|owner-stop|owner-disarm|stale-pid|invalid-session|exhausted-retry|unknown-wedge|revived|stand-down|wrong-host|deadline|clean-session-end|esc-standby|silent-standby|non-resumable-session|unreadable-rules|fresh-fallback|unsupported-state|process-evidence-unavailable|clock-out-failed|recovery-scope-unavailable|api-error|usage-limit) ;;
    *) code="stand-down" ;;
  esac
  detail="$(printf '%s' "$detail" | tr -d '\000-\037' | sed 's/[[:space:]]*$//')"
  ns_layout_parent "$dir" watch-reason || return 1
  printf '%s\n%s\n' "$code" "$detail" >"$(ns_layout_path "$dir" watch-reason)"
}

ns_reason_code() { sed -n 1p "$(ns_layout_path "$1" watch-reason)" 2>/dev/null | tr -d '[:space:]'; }
ns_reason_detail() { sed -n 2p "$(ns_layout_path "$1" watch-reason)" 2>/dev/null; }

# The shift log is the owner's record of what the runtime did. Append-only, one line per
# event, in the format the gate and the control helpers already write. Hooks do not load
# the control module, so this is the writer they share.
ns_shift_log() { # <nightshift-dir> <line>
  local log
  [ -d "$1" ] || return 0
  ns_layout_set log "$1" shift-log
  mkdir -p "${log%/*}" 2>/dev/null || :
  printf '%s · %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$2" >>"$log"
}

# Workspace schema. One integer in .nightshift/state-version is the authority, and it names the
# layout the state files sit in. This plugin writes version 2. A missing marker is legacy version
# 0, and version 1 is the flat layout before it; both stay operable in the paths they have, and
# only an explicit migrate-state moves them. Newer integers fail closed. Never rewrite or downgrade
# a future marker; never migrate from hooks, start, status, archive, or recovery.
NS_STATE_VERSION="$NS_LAYOUT_VERSION"

# ns_state_kind <workspace>
# Prints: absent | legacy | current | malformed | future. Legacy is any version below this one.
# Return: 0 operable (legacy or current) · 1 malformed · 2 future · 3 absent
ns_state_kind() {
  local ws="$1" ns marker raw lines
  ns="$ws/.nightshift"
  if [ ! -d "$ns" ]; then
    printf 'absent'
    return 3
  fi
  ns_layout_set marker "$ns" state-version
  if [ ! -e "$marker" ] && [ ! -L "$marker" ]; then
    printf 'legacy'
    return 0
  fi
  if [ -L "$marker" ] || [ ! -f "$marker" ]; then
    printf 'malformed'
    return 1
  fi
  IFS= read -r raw <"$marker" || true
  raw="$(printf '%s' "$raw" | tr -d '\r')"
  lines="$(awk 'END { print NR + 0 }' "$marker" 2>/dev/null)"
  case "$raw" in
    '' | *[!0-9]*)
      printf 'malformed'
      return 1
      ;;
    0) ;;
    0*)
      printf 'malformed'
      return 1
      ;;
  esac
  if [ "${#raw}" -gt 8 ] || [ "$lines" -gt 1 ]; then
    printf 'malformed'
    return 1
  fi
  if [ "$raw" -gt "$NS_STATE_VERSION" ]; then
    printf 'future'
    return 2
  fi
  if [ "$raw" -eq "$NS_STATE_VERSION" ]; then
    printf 'current'
    return 0
  fi
  printf 'legacy'
  return 0
}

# ns_state_version <workspace>
# Prints the integer when it can be read (0 if the marker is missing). Empty on
# absent or malformed. Return matches ns_state_kind.
ns_state_version() {
  local ws="$1" kind raw="" marker
  kind="$(ns_state_kind "$ws")"
  ns_layout_set marker "$ws/.nightshift" state-version
  case "$kind" in
    absent)
      return 3
      ;;
    legacy)
      if [ -f "$marker" ]; then
        IFS= read -r raw <"$marker" || true
        raw="$(printf '%s' "$raw" | tr -d '\r')"
      fi
      printf '%s' "${raw:-0}"
      return 0
      ;;
    current)
      printf '%s' "$NS_STATE_VERSION"
      return 0
      ;;
    future)
      IFS= read -r raw <"$marker" || true
      raw="$(printf '%s' "$raw" | tr -d '\r')"
      printf '%s' "$raw"
      return 2
      ;;
    *)
      return 1
      ;;
  esac
}

# ns_state_refuse_message <kind> — hook and skill diagnostic; no paths, no guesses.
ns_state_refuse_message() {
  case "$1" in
    future)
      printf 'Nightshift state-version is newer than this plugin supports (supported: %s). Upgrade Nightshift; never rewrite or downgrade the marker.' "$NS_STATE_VERSION"
      ;;
    malformed)
      printf 'Nightshift state-version is malformed. Inspect it only while unarmed; never guess a version.'
      ;;
    *)
      printf 'Nightshift state-version is unsupported.'
      ;;
  esac
}

# ns_write_state_version <workspace> <integer>
# Atomic replace of the marker. Refuses a symlink destination. Touches no other file.
ns_write_state_version() {
  local ws="$1" n="$2" ns marker tmp
  ns="$ws/.nightshift"
  ns_layout_set marker "$ns" state-version
  case "$n" in
    '' | *[!0-9]* | 0?*) return 1 ;;
  esac
  [ -d "$ns" ] || return 1
  if [ -L "$marker" ]; then
    return 1
  fi
  tmp="${marker%/*}/.state-version.$$"
  printf '%s\n' "$n" >"$tmp" || { rm -f "$tmp"; return 1; }
  mv "$tmp" "$marker" || { rm -f "$tmp"; return 1; }
}

# Retention — archive-only, preview-first. 0 means keep forever. Unreadable rules
# also mean 0: a broken file must never become a delete. Hooks, start, status,
# Doctor, and recovery must not call these apply helpers.

# ns_retention_days <workspace> <runtimeLogDays|archiveDays>
# Prints a non-negative integer. Missing, nested, or unreadable → 0.
ns_retention_days() {
  local ws="$1" key="$2" f raw=""
  ns_layout_set f "$ws/.nightshift" rules
  case "$key" in
    runtimeLogDays)
      [ -z "${NIGHTSHIFT_RETENTION_RUNTIME_LOG_DAYS:-}" ] || { printf '%s' "$NIGHTSHIFT_RETENTION_RUNTIME_LOG_DAYS"; return 0; }
      ;;
    archiveDays)
      [ -z "${NIGHTSHIFT_RETENTION_ARCHIVE_DAYS:-}" ] || { printf '%s' "$NIGHTSHIFT_RETENTION_ARCHIVE_DAYS"; return 0; }
      ;;
    *)
      printf '0'
      return 0
      ;;
  esac
  if [ -f "$f" ]; then
    ns_rules_load "$f" && raw="$(_ns_rules_row retention "$key" "")" && {
      raw="${raw#*"$_NS_RULES_TAB"}"
    } || raw=0
  fi
  case "$raw" in
    '' | *[!0-9]*) printf '0' ;;
    *) printf '%s' "$raw" ;;
  esac
}

# True when an archive folder still holds open punch-list work or an armed marker. A folder a shift
# claimed holds a copy of its list as it ended, whose open items stayed live; only a page filed by
# an older version, without that claim, can hold work nothing else has.
ns_archive_has_open_work() {
  local dir="$1" f
  [ -d "$dir" ] || return 1
  [ ! -e "$dir/.shift-armed" ] || return 0
  [ ! -L "$dir/.shift-armed" ] || return 0
  { [ -f "$dir/.shift-id" ] && [ ! -L "$dir/.shift-id" ]; } && return 1
  for f in "$dir"/*; do
    if [ ! -f "$f" ] || [ -L "$f" ]; then
      continue
    fi
    case "${f##*/}" in
      punch-list.md | shipped.md)
        [ "$(ns_open_boxes "$f")" -eq 0 ] || return 0
        ;;
    esac
  done
  return 1
}

# ns_retention_eligible <workspace>
# Print "kind<TAB>rel<TAB>age<TAB>days" for allowlisted, old-enough, unprotected targets.
ns_retention_eligible() {
  local ws="$1" ns log_days arch_days age path rel log_rel archive
  ns="$ws/.nightshift"
  [ -d "$ns" ] || return 0
  log_days="$(ns_retention_days "$ws" runtimeLogDays)"
  arch_days="$(ns_retention_days "$ws" archiveDays)"
  ns_layout_rel_set log_rel "$ns" scheduled-log
  ns_layout_rel_set archive "$ns" archive

  if [ "$log_days" -gt 0 ] && [ -e "$ns/$log_rel" ]; then
    path="$(ns_under_nightshift "$ws" "$log_rel")" && {
      age="$(ns_age_days "$path")" || age=""
      if [ -n "$age" ] && [ "$age" -ge "$log_days" ]; then
        printf '%s\t%s\t%s\t%s\n' runtime-log "$log_rel" "$age" "$log_days"
      fi
    }
  fi

  [ "$arch_days" -gt 0 ] || return 0
  [ -d "$ns/$archive" ] && [ ! -L "$ns/$archive" ] || return 0
  for rel in "$ns/$archive"/*; do
    [ -e "$rel" ] || continue
    rel="${rel#"$ns/"}"
    rel="${rel%/}"
    # A dated folder, the same with a shift number or a name after the date, or any folder a shift
    # claimed, as the name layout files one.
    case "$rel" in
      "$archive"/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
      "$archive"/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-shift-[1-9]*)
        case "${rel##*-shift-}" in *[!0-9]*) continue ;; esac
        ;;
      *)
        { [ -f "$ns/$rel/.shift-id" ] && [ ! -L "$ns/$rel/.shift-id" ]; } || continue
        ;;
    esac
    if [ ! -d "$ns/$rel" ] || [ -L "$ns/$rel" ]; then
      continue
    fi
    path="$(ns_under_nightshift "$ws" "$rel")" || continue
    ns_archive_has_open_work "$path" && continue
    age="$(ns_age_days "$path")" || continue
    [ "$age" -ge "$arch_days" ] || continue
    printf '%s\t%s\t%s\t%s\n' archive "$rel" "$age" "$arch_days"
  done
}

# ns_retention_apply <workspace> — delete currently eligible allowlisted targets.
# Return: 0 deleted or nothing eligible · 1 armed · 2 refused/failed
ns_retention_apply() {
  local ws="$1" ns kind rel path armed
  ns="$ws/.nightshift"
  [ -d "$ns" ] || return 2
  ns_layout_set armed "$ns" armed
  if [ -f "$armed" ]; then
    return 1
  fi
  while IFS="$(printf '\t')" read -r kind rel _ _; do
    [ -n "$rel" ] || continue
    path="$(ns_under_nightshift "$ws" "$rel")" || return 2
    case "$kind" in
      runtime-log)
        [ -f "$path" ] && [ ! -L "$path" ] || return 2
        rm -f "$path" || return 2
        ;;
      archive)
        [ -d "$path" ] && [ ! -L "$path" ] || return 2
        ns_archive_has_open_work "$path" && return 2
        rm -rf "$path" || return 2
        ;;
      *)
        return 2
        ;;
    esac
  done <<EOF
$(ns_retention_eligible "$ws")
EOF
}

# Artifact completion receipts live in .nightshift/receipts/. They replace a work-target
# git commit only while work-mode is artifact. Repository mode still requires a real commit.

# Real receipts directory only. A symlink here would let count, latest, and
# fingerprint follow files outside .nightshift/.
ns_receipts_usable_dir() {
  local dir
  dir="$(ns_receipts_dir "$1")"
  [ -d "$dir" ] && [ ! -L "$dir" ] || return 1
  printf '%s' "$dir"
}

ns_file_sha256() {
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$1" 2>/dev/null | awk '{print $1}'
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
  else
    return 1
  fi
}

ns_receipts_count() {
  local dir n
  dir="$(ns_receipts_usable_dir "$1")" || {
    printf '0'
    return 0
  }
  n="$(find "$dir" -maxdepth 1 -type f ! -name '.*' 2>/dev/null | wc -l | tr -d ' ')"
  printf '%s' "${n:-0}"
}

# Newest completion receipt path, or status 1 when none exist.
# Primary key is mtime. Same-second uniqueness suffixes (`stamp-slug-n.md`)
# sort before `stamp-slug.md` in C locale (`-` < `.`), so a name-only sort
# can name the first write as latest. Tie-break maps `.md` → `-0.md` so the
# unsuffixed sibling sorts first and `-n` wins.
# The sort-row helper stays outside $(...) — a `case` `)` would close the substitution.
ns_latest_receipt_sort_row() {
  local path="$1" m key
  m="$(ns_mtime "$path")" || return 0
  case "$m" in
    '' | *[!0-9]*) return 0 ;;
  esac
  case "$path" in
    *.md) key="${path%.md}-0.md" ;;
    *) key="$path" ;;
  esac
  printf '%020d\t%s\t%s\n' "$m" "$key" "$path"
}

ns_latest_receipt() {
  local dir out tab
  dir="$(ns_receipts_usable_dir "$1")" || return 1
  out="$(
    find "$dir" -maxdepth 1 -type f ! -name '.*' -print 2>/dev/null | while IFS= read -r path; do
      [ -n "$path" ] || continue
      ns_latest_receipt_sort_row "$path"
    done | LC_ALL=C sort | tail -n 1
  )"
  [ -n "$out" ] || return 1
  tab="$(printf '\t')"
  printf '%s' "${out##*"$tab"}"
}

# Stable stall token: none when the directory is empty, otherwise a cksum of every receipt.
ns_receipts_fingerprint() {
  local dir out
  dir="$(ns_receipts_usable_dir "$1")" || {
    printf 'none'
    return 0
  }
  out="$(find "$dir" -maxdepth 1 -type f ! -name '.*' -print 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
    cksum "$f" 2>/dev/null
  done)"
  if [ -z "$out" ]; then
    printf 'none'
    return 0
  fi
  printf '%s\n' "$out" | cksum | awk '{print $1"-"$2}'
}

# ---------------------------------------------------------------------------------------------
# Reading one item out of the punch list, and holding the contract to what it was
#
# The bounded rule the gate and Status already use: a top-level checkbox line owns the indented
# lines that follow it, up to the next top-level line. Fenced code and nested lists inside an item
# are indented, so they belong to it and come through whole. This is not a Markdown parser and is
# not trying to be one.

# ns_punch_gates <punch-list> — the gates block verbatim, heading included, or nothing.
# The owner may change it mid-shift by design, so it is never digested and always reprinted.
ns_punch_gates() {
  awk '
    /^## Gates[[:space:]]*$/ { on = 1; print; next }
    on && /^## / { exit }
    on { print }
  ' "$1" 2>/dev/null
}

# ns_punch_items <punch-list> — the lines under `## Items`, stopping at the next top-level heading.
#
# Not ns_items_section, which runs to the end of the file: that is the right boundary for counting
# boxes and the wrong one for a digest, because it would put a `## Notes` section the owner is free
# to edit inside the thing the gate holds still.
ns_punch_items() {
  awk '
    { sub(/\r$/, "") }
    !on { if ($0 ~ /^##[[:space:]]*Items[[:space:]]*$/) on = 1; next }
    /^## / { exit }
    { print }
  ' "$1" 2>/dev/null
}

# ns_punch_item <punch-list> <item> — one item with its sub-bullets, exactly as written. The item is
# named by its whole label, its number (`5`, `P03`), or its id; empty means the first still-open
# one. The first item that matches wins. Prints nothing when there is no such item.
ns_punch_item() {
  ns_punch_items "$1" | awk -v want="$2" "$NS_AWK_ITEM"'
    function starts_item(line) { return line ~ /^- \[[ xX]\]/ }
    # A top-level line is anything not indented: the next item, a heading, a note. Either way this
    # item has ended.
    function top_level(line) { return line !~ /^[[:space:]]/ && line != "" }
    function number(label) {
      if (match(label, /^[0-9]+/)) return substr(label, RSTART, RLENGTH)
      if (match(label, /^[A-Za-z]+[0-9]+/)) return substr(label, RSTART, RLENGTH)
      return ""
    }
    {
      if (!on && starts_item($0)) {
        if (want == "") {
          if ($0 !~ /^- \[ \]/) next
          on = 1
          print
          next
        }
        label = ns_item_label($0)
        if (label != want && ns_item_id($0) != want && number(label) != want) next
        on = 1
        print
        next
      }
      if (on) {
        if (top_level($0)) exit
        print
      }
    }
  '
}

# ns_punch_contract <punch-list> — everything above `## Items` except the gates block: the shift
# contract the owner wrote and nobody may edit while a shift is armed.
#
# The gates block is excluded from this digest and from the items one. It sits above `## Items` in
# the file, but the owner is meant to be able to change it mid-shift — tightening a gate after a
# near miss, relaxing one that is costing more than it catches — and `gatesDigest` already tracks
# it on its own terms.
ns_punch_contract() {
  awk '
    { sub(/\r$/, "") }
    /^## Items[[:space:]]*$/ { exit }
    /^## Gates[[:space:]]*$/ { skip = 1; next }
    skip && /^## / { skip = 0 }
    skip { next }
    { print }
  ' "$1" 2>/dev/null
}

# ns_punch_items_normalised <punch-list> — every item line and sub-bullet with the checkbox state
# flattened, so ticking a box changes nothing and any other edit — a reworded item, a deleted one,
# an inserted one — changes everything.
#
# Line endings are flattened with it, here and in the contract. A shift can be handed from a macOS
# host to a Windows one, and a checkout that converts on the way would otherwise present a contract
# nobody touched as tampered with. The digest is a property of what the list says, not of how the
# filesystem it is sitting on ends a line.
ns_punch_items_normalised() {
  ns_punch_items "$1" | sed 's/^- \[[xX]\]/- [ ]/'
}

# ns_punch_digest — a stable digest of stdin, from whatever the machine has. Same shape as every
# other digest Nightshift records: 64 lowercase hex characters.
ns_punch_digest() {
  if command -v shasum >/dev/null 2>&1; then
    shasum -a 256 2>/dev/null | cut -d' ' -f1
  elif command -v sha256sum >/dev/null 2>&1; then
    sha256sum 2>/dev/null | cut -d' ' -f1
  else
    return 1
  fi
}

# ns_punch_contract_digest <punch-list> / ns_punch_items_digest <punch-list>
ns_punch_contract_digest() { ns_punch_contract "$1" | ns_punch_digest; }
ns_punch_items_digest() { ns_punch_items_normalised "$1" | ns_punch_digest; }

# ---------------------------------------------------------------- preflight explanations
#
# The explanation belongs on the verdict line that occurred, not in a paragraph the model reads on
# every Start for verdicts that did not. It is printed here from lib/preflight-explain.txt, which
# the PowerShell twin reads too: one copy of the text, so the two hosts cannot word the same
# verdict differently.

# ns_explain_lines <kind> <topic> — the records of that kind for that topic, in file order.
# Prints nothing when the topic has none, which is not an error: a topic without a record keeps
# its verdict and its own repairs.
ns_explain_lines() {
  local file
  file="${NS_EXPLAIN_FILE:-}"
  [ -n "$file" ] || return 0
  [ -f "$file" ] || return 0
  awk -F '\t' -v kind="$1" -v topic="$2" '
    /^#/ || NF < 3 { next }
    $1 == kind && $2 == topic { print $3 }
  ' "$file" 2>/dev/null
}

# ns_explain_emit <topic> — the explanation for a topic, then any repairs the table carries for it.
# Called by the warn and refuse emitters, so no verdict site has to remember to do it.
ns_explain_emit() {
  ns_explain_lines explain "$1" | while IFS= read -r line; do
    [ -n "$line" ] && printf 'explain %s %s\n' "$1" "$line"
  done
  ns_explain_lines repair "$1" | while IFS= read -r line; do
    [ -n "$line" ] && printf 'repair %s\n' "$line"
  done
}

# ns_explain_topic <verdict text> — the first word, which every verdict leads with.
ns_explain_topic() {
  case "$1" in
    *' '*) printf '%s' "${1%% *}" ;;
    *) printf '%s' "$1" ;;
  esac
}

# ---------------------------------------------------------------- the facts Status renders
#
# Counting is mechanics — the boxes below a heading, drafting-table boxes only after the first
# rule, the deadline against the clock, the stall counter. The skill renders; these produce, so
# none of it is derived by hand.
#
# Bounded readers, never Markdown parsers: each one takes the first line of an entry under the
# shape the file already has, so a file the owner has written prose into still yields facts rather
# than a guess.

# ns_status_open_title <punch-list> — the title line of the first still-open item, without its
# checkbox or bold markers. Empty when nothing is open.
ns_status_open_title() {
  ns_punch_item "$1" "" 2>/dev/null | awk '
    NR == 1 {
      sub(/[[:space:]]*<!--[[:space:]]*id:[[:space:]]*[a-z0-9]+[[:space:]]*-->[[:space:]]*$/, "")
      sub(/^- \[[ xX]\][[:space:]]*/, "")
      gsub(/\*\*/, "")
      sub(/[[:space:]]+$/, "")
      print
      exit
    }
  '
}

# ns_status_entry_titles <file> <max> — the first line of each top-level `- ` entry, trimmed.
# Used for the parking lot and the snag log, which share that shape.
ns_status_entry_titles() {
  [ -f "$1" ] && [ ! -L "$1" ] || return 0
  awk -v max="${2:-0}" '
    /^Filed:/ { next }
    /^- Filed:/ { next }
    /^- / {
      line = $0
      sub(/^- /, "", line)
      gsub(/\*\*/, "", line)
      sub(/[[:space:]]+$/, "", line)
      if (length(line) > 100) line = substr(line, 1, 97) "..."
      out[++n] = line
    }
    END {
      first = 1
      if (max > 0 && n > max) first = n - max + 1
      for (i = first; i <= n; i++) print out[i]
    }
  ' "$1" 2>/dev/null
}

# ns_status_entry_count <file> — how many such entries the file holds.
ns_status_entry_count() {
  if ! { [ -f "$1" ] && [ ! -L "$1" ]; }; then printf '0'; return 0; fi
  awk '/^Filed:/ { next } /^- Filed:/ { next } /^- / { n++ } END { printf "%d", n + 0 }' "$1" 2>/dev/null || printf '0'
}

# ns_status_opportunity_counts <opportunity-map> — `candidate=N building=N shipped=N rejected=N
# parked=N` from the `Status:` lines the map already carries.
ns_status_opportunity_counts() {
  if ! { [ -f "$1" ] && [ ! -L "$1" ]; }; then printf 'candidate=0 building=0 shipped=0 rejected=0 parked=0'; return 0; fi
  awk '
    /<!--/ { comment = 1 }
    /-->/  { comment = 0; next }
    comment { next }
    /^[[:space:]]*Status:[[:space:]]*/ {
      s = $0
      sub(/^[[:space:]]*Status:[[:space:]]*/, "", s)
      sub(/[[:space:]].*$/, "", s)
      gsub(/[^a-zA-Z]/, "", s)
      if (s != "") c[tolower(s)]++
    }
    END {
      printf "candidate=%d building=%d shipped=%d rejected=%d parked=%d",
        c["candidate"] + 0, c["building"] + 0, c["shipped"] + 0, c["rejected"] + 0, c["parked"] + 0
    }
  ' "$1" 2>/dev/null || printf 'candidate=0 building=0 shipped=0 rejected=0 parked=0'
}

# ns_status_building <opportunity-map> — the building entry's title, then its `Phase:`, `Next:` and
# `Verify remaining:` lines, one per line. Nothing when none is building.
#
# An entry runs from a heading to the next heading. More than one building entry is inconsistent
# state the model reports without changing; this prints the first, and the count says there is more.
ns_status_building() {
  [ -f "$1" ] && [ ! -L "$1" ] || return 0
  awk '
    /<!--/ { comment = 1 }
    /-->/  { comment = 0; next }
    comment { next }
    /^#{2,}[[:space:]]/ {
      if (found) exit
      title = $0
      sub(/^#+[[:space:]]*/, "", title)
      gsub(/\*\*/, "", title)
      building = 0
      next
    }
    /^[[:space:]]*Status:[[:space:]]*building/ {
      building = 1
      found = 1
      print "title\t" title
      next
    }
    building && /^[[:space:]]*(Phase|Next|Verify remaining):/ {
      line = $0
      sub(/^[[:space:]]*/, "", line)
      key = line
      sub(/:.*$/, "", key)
      sub(/^[^:]*:[[:space:]]*/, "", line)
      print tolower(key) "\t" line
    }
  ' "$1" 2>/dev/null
}

# ns_status_stop_reason <ns> — the first line of the stop-work marker, or nothing.
ns_status_stop_reason() {
  local ns_marker
  ns_layout_set ns_marker "$1" stop
  [ -f "$ns_marker" ] && [ ! -L "$ns_marker" ] || return 0
  IFS= read -r ns_line <"$ns_marker" 2>/dev/null || return 0
  printf '%s' "$ns_line"
}

# ns_status_transitions <shift-log> <max> — the journal lines that record a shift changing hands:
# a stand-down, a revival, a host change. Compacted to their first sentence.
ns_status_transitions() {
  [ -f "$1" ] && [ ! -L "$1" ] || return 0
  awk -v max="${2:-3}" '
    {
      line = $0
      # Both writers lead with a dash, a timestamp and a separator before the message. Everything up
      # to the first letter is that preamble, in any locale and with any separator byte.
      sub(/^[^A-Za-z]*/, "", line)
    }
    # A transition is a line whose SUBJECT is the shift changing hands. Matching the words anywhere
    # would catch an item summary that merely mentions one.
    tolower(line) ~ /^(watchman|the watchman|shift started|shift ended|the session ended|revived|host change)/ {
      if (length(line) > 120) line = substr(line, 1, 117) "..."
      out[++n] = line
    }
    END {
      first = 1
      if (max > 0 && n > max) first = n - max + 1
      for (i = first; i <= n; i++) print out[i]
    }
  ' "$1" 2>/dev/null
}

# ns_status_deadline_remaining <ns> — `<n>h<m>m remaining`, `passed`, or nothing when there is no
# deadline. The clock is read once, here, rather than in the skill.
ns_status_deadline_remaining() {
  local ns_file
  ns_layout_set ns_file "$1" deadline
  [ -f "$ns_file" ] && [ ! -L "$ns_file" ] || return 0
  IFS= read -r ns_epoch <"$ns_file" 2>/dev/null || return 0
  case "$ns_epoch" in '' | *[!0-9]*) return 0 ;; esac
  ns_now="$(date +%s 2>/dev/null)" || return 0
  if [ "$ns_epoch" -le "$ns_now" ]; then
    printf 'passed'
    return 0
  fi
  ns_left=$((ns_epoch - ns_now))
  printf '%dh%02dm remaining' "$((ns_left / 3600))" "$(((ns_left % 3600) / 60))"
}

SHA-256: df4e0308dfdcb67c0c4abae5e333c67168310448d904d35569b9364a9b23efab