← Files NightshiftARCHIVED FILE
runtime/archive-receipts.sh
17.2 KB · Oct 3, 2026 · 06:31 UTC
#!/usr/bin/env bash
# archive-receipts.sh — file a shift into its archive folder, laid out the way it was live.
#
# The folder holds the shift's records at the paths they have under .nightshift/: the punch list,
# receipts/ with its index and the morning page, the parking lot and the snag log where the
# workspace keeps them (inbox/), and the shift log, the usage readings and the policy where the
# runtime keeps them (run/). Links between those records keep working as written. A link to one
# that stays live is repointed back to it in the filed page, which is the only copy filed.
#
# Only what is closed is filed, and each record lives in one place: the punch list's ticked items
# with its contract, the receipts of ticked items, and the parking lot's and snag log's entries
# that carry a disposition. The live side keeps what is still open: the contract and the open
# items, their receipts, and the entries with no disposition, plus one Filed: pointer to the filed
# copy. A record leaves live storage only once its filed copy reads back. Once the shift has ended, its shift log moves and starts again,
# its usage readings and a policy of that shift still live move too, and a usage-<id>/ folder the
# Start preflight retired moves into the folder of the shift it belongs to. While a shift is armed,
# or before it has ended, nothing leaves live storage. A leftover shift report leaves only when
# named.
#
# Two different records under one name never overwrite each other: the filed one stands and the
# live one is kept. Skips hidden files and does not follow symlinks. Missing or empty receipts is
# success and creates no receipts folder.
# Archive-only. Hooks, start, status, Doctor, and recovery must never invoke this.
#
# archive-receipts.sh [--project DIR] [--date YYYY-MM-DD] [--retire NAME]...
#
# --retire NAME a record established as closed, by file name. Repeatable. On an ended shift the
# receipts of ticked items and the shift's own records leave without a name; an
# open item's receipt never does.
#
# Exit: 0 filed or nothing to file · 1 usage · 2 refused
set -u
_here="${BASH_SOURCE[0]%/*}"; [ "$_here" != "${BASH_SOURCE[0]}" ] || _here=.
# shellcheck source=plugins/nightshift/lib/lib.sh
. "$_here/../lib/lib.sh"
PROJECT="${CLAUDE_PROJECT_DIR:-${CODEX_PROJECT_DIR:-$PWD}}"
DATE=""
RETIRE=""
while [ $# -gt 0 ]; do
case "$1" in
--project)
[ $# -ge 2 ] || { printf 'archive-receipts: --project needs a value\n' >&2; exit 1; }
PROJECT="$2"
shift 2
;;
--date)
[ $# -ge 2 ] || { printf 'archive-receipts: --date needs a value\n' >&2; exit 1; }
DATE="$2"
shift 2
;;
--retire)
[ $# -ge 2 ] || { printf 'archive-receipts: --retire needs a value\n' >&2; exit 1; }
case "$2" in
'' | */* | .*) printf 'archive-receipts: --retire takes a record name, not a path: %s\n' "$2" >&2; exit 1 ;;
esac
RETIRE="$RETIRE$2
"
shift 2
;;
-h | --help)
awk 'NR == 1 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0"
exit 1
;;
*) printf 'archive-receipts: unknown argument: %s\n' "$1" >&2; exit 1 ;;
esac
done
HOST="$(cd -P "$PROJECT" 2>/dev/null && pwd)" || {
printf 'archive-receipts: cannot cd to %s\n' "$PROJECT" >&2
exit 1
}
WORKSPACE="$HOST"
if [ -e "$HOST/.nightshift-link" ] || [ -L "$HOST/.nightshift-link" ]; then
WORKSPACE="$(ns_workspace_root "$HOST" 2>/dev/null)" || {
printf 'archive-receipts: invalid .nightshift-link — Nightshift will not guess a workspace\n' >&2
exit 2
}
fi
KIND="$(ns_state_kind "$WORKSPACE")"
case "$KIND" in
malformed | future)
printf 'archive-receipts: %s\n' "$(ns_state_refuse_message "$KIND")" >&2
exit 2
;;
absent)
printf 'archive-receipts: no .nightshift/ at %s\n' "$WORKSPACE" >&2
exit 2
;;
esac
NS="$WORKSPACE/.nightshift"
declare ARMED_FILE ENDED_FILE USAGE_DIR USAGE_PREFIX POLICY_FILE RECEIPTS_REL USAGE_REL POLICY_REL JOURNAL_REL PUNCH_REL
urel=""
krel=""
ns_layout_set ARMED_FILE "$NS" armed
ns_layout_set ENDED_FILE "$NS" ended
ns_layout_set USAGE_DIR "$NS" usage
ns_layout_set USAGE_PREFIX "$NS" usage-shift ""
ns_layout_set POLICY_FILE "$NS" shift-policy
ns_layout_rel_set RECEIPTS_REL "$NS" receipts
ns_layout_rel_set USAGE_REL "$NS" usage
ns_layout_rel_set POLICY_REL "$NS" shift-policy
ns_layout_rel_set JOURNAL_REL "$NS" shift-log
ns_layout_rel_set PUNCH_REL "$NS" punch-list
if [ -z "$DATE" ]; then
DATE="$(date +%Y-%m-%d)"
fi
case "$DATE" in
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
*) printf 'archive-receipts: --date must be YYYY-MM-DD\n' >&2; exit 1 ;;
esac
# A closed record leaves live storage only when a shift has actually ended and the archived copy
# has been read back and matches. While a shift is armed nothing is removed at all: its receipts
# are what its own progress checks read, and a half-filed night is worse than an unfiled one.
ARMED=0
{ [ -e "$ARMED_FILE" ] || [ -L "$ARMED_FILE" ]; } && ARMED=1
ENDED=0
{ [ -f "$ENDED_FILE" ] && [ ! -L "$ENDED_FILE" ]; } && ENDED=1
ROTATE=0
[ "$ARMED" -eq 0 ] && [ "$ENDED" -eq 1 ] && ROTATE=1
if [ "$ROTATE" -eq 0 ] && [ -n "$RETIRE" ]; then
if [ "$ARMED" -eq 1 ]; then
printf 'archive-receipts: refuse to retire anything while the shift is armed\n' >&2
else
printf 'archive-receipts: refuse to retire anything before the shift has ended\n' >&2
fi
exit 2
fi
src="$(ns_receipts_dir "$WORKSPACE")"
# Where a shift is filed is the owner's, inside the state area. A root that would climb out of it,
# or reach through a symlink, is refused rather than followed.
if ! root="$(ns_archive_root "$WORKSPACE")"; then
printf 'archive-receipts: archive.root must name a directory inside .nightshift/ — an absolute path, a path with .., or a symlink is not supported\n' >&2
exit 2
fi
# Whose records these are. Once the shift has ended, the ending marker says which shift that was,
# even when a policy for the next one is already live; before then the live policy answers.
policy_id="$(ns_policy_shift_id "$WORKSPACE" 2>/dev/null)" || policy_id=""
ended_id="$(ns_ended_field "$WORKSPACE" shiftId)"
shift_id="$policy_id"
if [ "$ROTATE" -eq 1 ] && [ -n "$ended_id" ]; then
shift_id="$ended_id"
elif [ -z "$shift_id" ] || [ "$shift_id" = unknown ]; then
[ -z "$ended_id" ] || shift_id="$ended_id"
fi
if ! group="$(ns_archive_group "$WORKSPACE" "$DATE" "$shift_id")"; then
printf 'archive-receipts: archive.root must name a directory inside .nightshift/\n' >&2
exit 2
fi
dest="$group/$RECEIPTS_REL"
if [ -L "$src" ]; then
printf 'archive-receipts: refuse to write through a symlink receipts path\n' >&2
exit 2
fi
if [ -e "$src" ] && [ ! -d "$src" ]; then
printf 'archive-receipts: receipts path is not a directory\n' >&2
exit 2
fi
if [ -L "$root" ] || [ -L "$group" ] || [ -L "$dest" ]; then
printf 'archive-receipts: refuse to write through a symlink archive path\n' >&2
exit 2
fi
if { [ -e "$root" ] && [ ! -d "$root" ]; } \
|| { [ -e "$group" ] && [ ! -d "$group" ]; } \
|| { [ -e "$dest" ] && [ ! -d "$dest" ]; }; then
printf 'archive-receipts: refuse to write through a non-directory archive path\n' >&2
exit 2
fi
# The receipts of items nobody finished. They stay live and are not filed, exactly as the box stays
# in the punch list, so the next shift extends the same file; they are filed once their item is
# ticked.
OPEN_NAMES="$(ns_receipts_open_names "$WORKSPACE")
"
TICKED_NAMES="$(ns_receipts_ticked_names "$WORKSPACE")
"
# in_list <name> <list> — status 0 when the newline-separated list holds that name.
in_list() {
case "
$2" in
*"
$1
"*) return 0 ;;
esac
return 1
}
# The names actually filed, so a --retire the run never copied is refused rather than ignored.
FILED=""
copied=0
removed=0
kept=""
filed_lines=""
# The pages this folder already held before this run. They were repointed when they were filed.
PREFILED="$(cd "$group" 2>/dev/null && find . -type f -name '*.md' ! -name '.*' 2>/dev/null | sed 's#^\./##')
"
# ensure_dir <dir> — create a folder inside the shift's folder, refusing one reached through a link.
ensure_dir() {
mkdir -p "$1" || {
printf 'archive-receipts: cannot create %s\n' "$1" >&2
exit 2
}
if [ -L "$1" ]; then
printf 'archive-receipts: refuse to write through a symlink archive path\n' >&2
exit 2
fi
}
# file_one <path> <dir> <keep|closed|own> — copy one record into dir, verify it, and retire the
# source when the shift has ended: `closed` when it was named or its item is ticked, `own` always,
# `keep` never. FILE_ONE_FILED says whether the record now has a verified archived copy.
FILE_ONE_FILED=0
file_one() {
local f="$1" dir="$2" rule="$3" base
FILE_ONE_FILED=0
base="${f##*/}"
case "$base" in
.* | '') return 0 ;;
esac
ensure_dir "$dir"
# The leaf is checked too. A link left where this record is about to land would carry its bytes
# somewhere else and then read back as a faithful copy, so the source stays put instead.
if ! ns_archive_dest "$dir/$base"; then
kept="$kept$base (a link or a directory is in the way of its archived copy)
"
return 0
fi
if [ -e "$dir/$base" ]; then
if ! ns_archive_same "$f" "$dir/$base" "$group" "$NS"; then
# Two different records under one name. Neither is worth losing, so the one already filed
# stands and the live one stays exactly where it is.
kept="$kept$base (a different record is already filed under that name)
"
return 0
fi
else
cp "$f" "$dir/$base" || {
printf 'archive-receipts: failed to copy %s\n' "$base" >&2
exit 2
}
copied=$((copied + 1))
if ! cmp -s "$f" "$dir/$base"; then
kept="$kept$base (the archived copy does not match the source)
"
return 0
fi
fi
FILED="$FILED$base
"
FILE_ONE_FILED=1
[ "$ROTATE" -eq 1 ] || return 0
case "$rule" in
own) ;;
closed) in_list "$base" "$RETIRE" || in_list "$base" "$TICKED_NAMES" || return 0 ;;
*) return 0 ;;
esac
rm -f "$f" || {
kept="$kept$base (could not be removed from live storage)
"
return 0
}
removed=$((removed + 1))
}
# file_folder <live-dir> <archived-dir> <name> — one folder of readings, filed as one record: under
# its own name, only when every record in it was, and removed from live storage only then.
file_folder() {
local live="$1" to="$2" name="$3" whole=1 f
while IFS= read -r f; do
[ -n "$f" ] || continue
if [ -f "$f" ] && [ ! -L "$f" ]; then
file_one "$f" "$to" keep
[ "$FILE_ONE_FILED" -eq 1 ] || whole=0
else
whole=0
fi
done <<FIND
$(find "$live" -mindepth 1 -maxdepth 1 ! -name '.*' 2>/dev/null)
FIND
if [ "$whole" -eq 0 ]; then
kept="$kept$name (not every record in it could be filed)
"
return 0
fi
FILED="$FILED$name
"
[ "$ROTATE" -eq 1 ] || return 0
if rm -rf "$live" 2>/dev/null && [ ! -e "$live" ]; then
removed=$((removed + 1))
else
kept="$kept$name (could not be removed from live storage)
"
fi
}
if [ -d "$src" ]; then
while IFS= read -r f; do
[ -n "$f" ] || continue
[ -f "$f" ] || continue
[ -L "$f" ] && continue
base="${f##*/}"
# The index is a view of a set of receipts, so each side of the move gets its own, written
# below from what is actually there. The live one is never filed as a record of its own.
[ "$base" = README.md ] && continue
# An open item's receipt stays live only, until its item is ticked.
in_list "$base" "$OPEN_NAMES" && continue
file_one "$f" "$dest" closed
done <<FIND
$(find "$src" -maxdepth 1 -type f ! -name '.*' 2>/dev/null)
FIND
fi
# Once the shift has ended, its own records follow it: the usage readings, the policy when no
# clock-out filed it, and the shift log.
if [ "$ROTATE" -eq 1 ]; then
if [ -d "$USAGE_DIR" ] && [ ! -L "$USAGE_DIR" ]; then
file_folder "$USAGE_DIR" "$group/$USAGE_REL" "${USAGE_DIR##*/}"
fi
if [ -f "$POLICY_FILE" ] && [ ! -L "$POLICY_FILE" ] && [ -n "$policy_id" ] && [ "$policy_id" = "$shift_id" ]; then
policy_dir="$group"
case "$POLICY_REL" in */*) policy_dir="$group/${POLICY_REL%/*}" ;; esac
file_one "$POLICY_FILE" "$policy_dir" own
[ "$FILE_ONE_FILED" -eq 0 ] || filed_lines="${filed_lines}archive-receipts: filed the shift policy as $group/$POLICY_REL
"
fi
if journal="$(ns_archive_file_journal "$WORKSPACE" "$group")"; then
[ -z "$journal" ] || filed_lines="${filed_lines}archive-receipts: filed the shift log as $journal
"
else
kept="$kept${JOURNAL_REL##*/} (the shift log could not be filed)
"
fi
fi
# A usage-<id>/ folder is a closed shift's readings, set aside by the Start preflight. It goes to the
# folder that shift claimed, at the path the readings have live, or into this shift's folder under
# its own name when no folder is that shift's or its readings are already there.
for u in "$USAGE_PREFIX"*; do
if ! { [ -d "$u" ] && [ ! -L "$u" ]; }; then continue; fi
ubase="${u##*/}"
[ "$ubase" != "${USAGE_PREFIX##*/}" ] || continue
owner_dir="$(ns_archive_folder_of "$WORKSPACE" "${ubase#"${USAGE_PREFIX##*/}"}")"
if [ -n "$owner_dir" ] && [ ! -e "$owner_dir/$USAGE_REL" ]; then
to="$owner_dir/$USAGE_REL"
else
ns_layout_rel_set urel "$NS" usage-shift "${ubase#"${USAGE_PREFIX##*/}"}"
to="$group/$urel"
fi
file_folder "$u" "$to" "$ubase"
done
# A leftover shift-report.md (not yet migrated into receipts/) still travels.
ns_layout_rel_at report 0 previous-report
report="$NS/$report"
if [ -f "$report" ] && [ ! -L "$report" ]; then
file_one "$report" "$group" closed
fi
# The parking lot and the snag log: their handled entries are filed, their open entries stay live.
# They are written with their links already repointed, so the link pass below leaves them alone.
label="$(ns_archive_review_label "${group##*/}" "$shift_id" "$(ns_archive "$WORKSPACE" layout)")"
REPOINTED="$PUNCH_REL
"
for key in snag-log parking-lot; do
ns_layout_rel_set krel "$NS" "$key"
REPOINTED="$REPOINTED$krel
"
ns_archive_file_review_source "$WORKSPACE" "$key" "$group" "$label" || {
printf 'archive-receipts: could not file snag or parking records\n' >&2
exit 2
}
done
ns_archive_check_review_pointers "$WORKSPACE" || {
printf 'archive-receipts: could not check the filed pointers\n' >&2
exit 2
}
# The punch list, once the shift has ended: its contract and ticked items are filed, and the
# contract and the open items stay live. While it is armed the list is its contract and nothing here
# touches it.
if [ "$ROTATE" -eq 1 ]; then
punch_filed="$(ns_archive_punch_list "$WORKSPACE" "$group" "$shift_id" "$DATE")"
case "$?" in
0) [ -z "$punch_filed" ] || filed_lines="${filed_lines}archive-receipts: filed the punch list as $punch_filed
" ;;
3) printf 'archive-receipts: a different punch list is already filed at %s; the live list is unchanged\n' \
"$group/$PUNCH_REL" >&2 ;;
*) printf 'archive-receipts: could not file the punch list into %s\n' "$group" >&2 ;;
esac
fi
# The archive gets the index of what landed in it, written before the link pass so a receipt that
# links to its index has one to link to.
if [ -d "$dest" ]; then
ns_receipts_write_archive_index "$dest" "$(ns_receipts_shift_date "$WORKSPACE")" "$OPEN_NAMES"
fi
# Every page filed by this run keeps working from where it now sits. A record filed beside it is
# reached exactly as written; one that stayed live is further away and its link says so. A page
# filed on an earlier run was repointed then and is left alone. The shift log is raw evidence and
# stays as written.
ARCHIVED_PATHS="$(cd "$group" 2>/dev/null && find . -type f ! -name '.*' ! -name '*.original.md' 2>/dev/null |
sed 's#^\./##')"
# rewrite_moved <filed page>
rewrite_moved() {
local page="$1" base
[ -f "$page" ] && [ ! -L "$page" ] || return 0
base="${page##*/}"
if ns_archive_relocate "$page" "$page" "$group" "$NS" >"${page%/*}/.$base.relocated" 2>/dev/null; then
if cmp -s "$page" "${page%/*}/.$base.relocated"; then
rm -f "${page%/*}/.$base.relocated"
else
mv "${page%/*}/.$base.relocated" "$page" || rm -f "${page%/*}/.$base.relocated"
fi
else
rm -f "${page%/*}/.$base.relocated"
kept="$kept$base (its links were left as written)
"
fi
}
while IFS= read -r rel; do
[ -n "$rel" ] || continue
case "$rel" in
*.md) ;;
*) continue ;;
esac
[ "$rel" != "$JOURNAL_REL" ] || continue
# The index is written into the folder it describes, so its links are already siblings there.
[ "$rel" != "$RECEIPTS_REL/README.md" ] || continue
in_list "$rel" "$PREFILED" && continue
in_list "$rel" "$REPOINTED" && continue
rewrite_moved "$group/$rel"
done <<PAGES
$ARCHIVED_PATHS
PAGES
# The live folder lists the work still in hand, and loses its index when there is nothing left
# to list.
ns_receipts_write_index "$WORKSPACE" remaining
unmatched=""
while IFS= read -r wanted; do
[ -n "$wanted" ] || continue
in_list "$wanted" "$FILED" && continue
unmatched="$unmatched $wanted
"
done <<RETIRE_NAMES
$RETIRE
RETIRE_NAMES
if [ -n "$unmatched" ]; then
printf 'archive-receipts: refused to retire — this run filed no such record:\n' >&2
printf '%s' "$unmatched" >&2
fi
if [ -n "$kept" ]; then
printf 'archive-receipts: kept in live storage:\n' >&2
printf '%s' "$kept" >&2
fi
if [ "$copied" -ne 0 ] || [ "$removed" -ne 0 ] || [ -n "$filed_lines" ]; then
printf '%s\n' "$group"
[ "$removed" -eq 0 ] || printf 'archive-receipts: retired %s closed record(s) from live storage\n' "$removed"
fi
printf '%s' "$filed_lines"
exit 0
SHA-256: 4fd18915d914f9596bcce50dd85819e2c8283e90135408869593da4650382e99