@iceinvein/agent-skills 0.23.5 → 0.23.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/skills/index.json +2 -2
  3. package/skills/sluice/SKILL.md +8 -0
  4. package/skills/sluice/evals/README.md +3 -2
  5. package/skills/sluice/evals/deep-plan-traces-the-example/case.yaml +4 -0
  6. package/skills/sluice/evals/deep-plan-traces-the-example/fixture.sh +151 -0
  7. package/skills/sluice/evals/deep-plan-traces-the-example/graders/announces-deep-channel.md +12 -0
  8. package/skills/sluice/evals/deep-plan-traces-the-example/graders/design-written-to-docs.md +5 -0
  9. package/skills/sluice/evals/deep-plan-traces-the-example/graders/no-source-edit.md +11 -0
  10. package/skills/sluice/evals/deep-plan-traces-the-example/graders/no-source-write.md +11 -0
  11. package/skills/sluice/evals/deep-plan-traces-the-example/graders/sluice-fired.md +5 -0
  12. package/skills/sluice/evals/deep-plan-traces-the-example/graders/spec-has-a-trace.md +7 -0
  13. package/skills/sluice/evals/deep-plan-traces-the-example/graders/stopped-for-signoff.md +8 -0
  14. package/skills/sluice/evals/deep-plan-traces-the-example/graders/trace-finds-no-accepted-slide.md +27 -0
  15. package/skills/sluice/evals/deep-plan-traces-the-example/prompt.md +17 -0
  16. package/skills/sluice/evals/rules.json +12 -2
  17. package/skills/sluice/references/deep-channel.md +38 -0
  18. package/skills/sluice/references/status.md +95 -4
  19. package/skills/sluice/scripts/board-index.sh +160 -0
  20. package/skills/sluice/scripts/board.sh +304 -0
  21. package/skills/sluice/scripts/status.sh +50 -3
  22. package/skills/sluice/scripts/stop-guard.sh +107 -44
  23. package/skills/sluice/skill.json +2 -2
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env bash
2
+ # sluice board index: which trees hold a live run and which sessions are
3
+ # routed where, kept in one place so a board can list them without walking
4
+ # every worktree on the machine.
5
+ #
6
+ # board-index.sh run-add <tree>
7
+ # board-index.sh run-drop <tree>
8
+ # board-index.sh session <id> <fast|main|deep> <tree>
9
+ # board-index.sh handback <id> [--keep-deep]
10
+ # board-index.sh prune
11
+ #
12
+ # The root is $SLUICE_BOARD_DIR, else $CLAUDE_CONFIG_DIR/sluice/board, else
13
+ # $HOME/.claude/sluice/board. With none of the three set there is nowhere safe
14
+ # to write, so every verb exits 0 having written nothing. Runs live at
15
+ # <root>/runs/<key>, one line naming the tree, where <key> is the tree with %
16
+ # encoded as %25 and / as %2F. Sessions live at <root>/sessions/<id>.json.
17
+ #
18
+ # `--keep-deep` is for a stop while the session's run is live: the board draws
19
+ # that run's bar for a deep session, so its entry stays as it is.
20
+ #
21
+ # Exit: 0 ok, 1 could not write, 4 bad arguments, 5 jq missing.
22
+
23
+ set -u
24
+
25
+ err() { echo "board-index.sh: $*" >&2; }
26
+
27
+ usage() {
28
+ echo "usage: board-index.sh run-add <tree> | run-drop <tree> | session <id> <fast|main|deep> <tree> | handback <id> [--keep-deep] | prune" >&2
29
+ exit 4
30
+ }
31
+
32
+ now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
33
+
34
+ key() {
35
+ local k="${1//%/%25}"
36
+ echo "${k//\//%2F}"
37
+ }
38
+
39
+ # Writes $2 to $1 through a temp file so a reader never sees half an entry. The
40
+ # temp is dotted so the runs/* and sessions/*.json globs never match it. Never
41
+ # call this on the right of a pipe: its exit would end only the subshell.
42
+ put() {
43
+ local tmp
44
+ tmp="$(dirname "$1")/.$(basename "$1").tmp.$$"
45
+ if ! { printf '%s\n' "$2" >"$tmp"; } 2>/dev/null || ! mv "$tmp" "$1" 2>/dev/null; then
46
+ rm -f "$tmp"
47
+ err "could not write $1"
48
+ exit 1
49
+ fi
50
+ }
51
+
52
+ need_jq() {
53
+ command -v jq >/dev/null 2>&1 || { err "jq is required"; exit 5; }
54
+ }
55
+
56
+ verb="${1:-}"
57
+ [ $# -gt 0 ] && shift
58
+ case "$verb" in
59
+ run-add|run-drop) [ $# -eq 1 ] || usage ;;
60
+ handback)
61
+ [ $# -eq 1 ] || { [ $# -eq 2 ] && [ "$2" = --keep-deep ]; } || usage
62
+ ;;
63
+ session) [ $# -eq 3 ] || usage ;;
64
+ prune) [ $# -eq 0 ] || usage ;;
65
+ *) usage ;;
66
+ esac
67
+
68
+ if [ -n "${SLUICE_BOARD_DIR:-}" ]; then
69
+ ROOT="$SLUICE_BOARD_DIR"
70
+ elif [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
71
+ ROOT="$CLAUDE_CONFIG_DIR/sluice/board"
72
+ elif [ -n "${HOME:-}" ]; then
73
+ ROOT="$HOME/.claude/sluice/board"
74
+ else
75
+ exit 0
76
+ fi
77
+
78
+ case "$verb" in
79
+ run-add)
80
+ [ -n "$1" ] || exit 0
81
+ path="$ROOT/runs/$(key "$1")"
82
+ mkdir -p "$ROOT/runs" 2>/dev/null || { err "could not write $path"; exit 1; }
83
+ put "$path" "$1"
84
+ ;;
85
+ run-drop)
86
+ [ -n "$1" ] || exit 0
87
+ path="$ROOT/runs/$(key "$1")"
88
+ # Checked rather than read off rm's status: GNU rm -f takes "not a
89
+ # directory" for "nothing there" and exits 0 where BSD rm fails.
90
+ for dir in "$ROOT" "$ROOT/runs"; do
91
+ [ ! -e "$dir" ] || [ -d "$dir" ] || { err "could not remove $path"; exit 1; }
92
+ done
93
+ rm -f "$path" 2>/dev/null && [ ! -e "$path" ] || { err "could not remove $path"; exit 1; }
94
+ ;;
95
+ session)
96
+ id="$1" channel="$2" tree="$3"
97
+ case "$id" in
98
+ */*|.*) err "bad session id: $id"; exit 4 ;;
99
+ esac
100
+ case "$channel" in
101
+ fast|main|deep) ;;
102
+ *) err "unknown channel: $channel"; exit 4 ;;
103
+ esac
104
+ [ -n "$id" ] && [ -n "$tree" ] || exit 0
105
+ need_jq
106
+ path="$ROOT/sessions/$id.json"
107
+ mkdir -p "$ROOT/sessions" 2>/dev/null || { err "could not write $path"; exit 1; }
108
+ t="$(now)"
109
+ entry="$(jq -n --arg s "$id" --arg c "$channel" --arg tr "$tree" --arg t "$t" \
110
+ '{session: $s, channel: $c, tree: $tr, routed: $t, state: "working", updated: $t}')"
111
+ put "$path" "$entry"
112
+ ;;
113
+ handback)
114
+ id="$1" keep_deep="${2:-}"
115
+ case "$id" in
116
+ ''|*/*|.*) exit 0 ;;
117
+ esac
118
+ path="$ROOT/sessions/$id.json"
119
+ [ -f "$path" ] || exit 0
120
+ need_jq
121
+ channel="$(jq -r '.channel' "$path" 2>/dev/null)" || exit 0
122
+ [ -n "$keep_deep" ] && [ "$channel" = deep ] && exit 0
123
+ updated="$(jq --arg t "$(now)" '.state = "handed back" | .updated = $t' "$path")" || exit 0
124
+ put "$path" "$updated"
125
+ ;;
126
+ prune)
127
+ need_jq
128
+ # The builtin read rather than a process per pointer, because prune runs
129
+ # on every init and route and pointers pile up; read whole, so a tree
130
+ # path holding a newline still names its tree. read -d '' stops at EOF
131
+ # with a non-zero status, so a failed open is told from a short read by
132
+ # whether the file is readable; a pointer that cannot be read is kept.
133
+ for f in "$ROOT"/runs/*; do
134
+ [ -f "$f" ] || continue
135
+ tree=""
136
+ { IFS= read -r -d '' tree; } 2>/dev/null <"$f" || [ -n "$tree" ] || [ -r "$f" ] || continue
137
+ tree="${tree%$'\n'}"
138
+ [ -f "$tree/.sluice/run.json" ] || rm -f "$f"
139
+ done
140
+ # One jq for every session rather than one each, because prune runs on
141
+ # every route and a jq per file made each route slower than the last day
142
+ # of sessions. Contents go on stdin as NUL-separated name and contents
143
+ # records, as board.sh feeds them, so no count of files meets the argv
144
+ # limit. Anything unreadable or unparseable comes back to be removed.
145
+ for f in "$ROOT"/sessions/*.json; do
146
+ [ -f "$f" ] || continue
147
+ entry=""
148
+ { IFS= read -r -d '' entry; } 2>/dev/null <"$f"
149
+ printf '%s\0%s\0' "$f" "$entry"
150
+ done | jq -R -s -j '
151
+ (split("\u0000") | .[:-1]) as $p
152
+ | range(0; $p | length; 2)
153
+ | select(($p[. + 1] | try (fromjson | (now - (.updated | fromdateiso8601)) <= 86400) catch false) != true)
154
+ | $p[.] + "\u0000"
155
+ ' 2>/dev/null | while IFS= read -r -d '' f; do
156
+ rm -f "$f"
157
+ done
158
+ ;;
159
+ esac
160
+ exit 0
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env bash
2
+ # sluice board: every live run and every routed session on the machine, drawn
3
+ # from the board index so nobody has to walk the worktrees to find them.
4
+ #
5
+ # board.sh [--json] [--watch [<seconds>]]
6
+ #
7
+ # --json prints the combined state instead of drawing it. --watch redraws every
8
+ # <seconds> (default 2) until interrupted. The index is read, never written.
9
+ # The root is $SLUICE_BOARD_DIR, else $CLAUDE_CONFIG_DIR/sluice/board, else
10
+ # $HOME/.claude/sluice/board; with none of the three set the index is empty.
11
+ #
12
+ # Exit: 0 ok, 4 bad arguments, 5 jq missing, 6 the board could not be read.
13
+
14
+ set -u
15
+
16
+ err() { echo "board.sh: $*" >&2; }
17
+
18
+ usage() {
19
+ echo "usage: board.sh [--json] [--watch [<seconds>]]" >&2
20
+ exit 4
21
+ }
22
+
23
+ json=0 watch=0 interval=2
24
+ while [ $# -gt 0 ]; do
25
+ case "$1" in
26
+ --json) json=1; shift ;;
27
+ --watch)
28
+ watch=1
29
+ shift
30
+ case "${1:-}" in
31
+ ''|--*) ;;
32
+ *) interval="$1"; shift ;;
33
+ esac
34
+ ;;
35
+ *) usage ;;
36
+ esac
37
+ done
38
+ # Leading zeros are stripped as text rather than through $((10#...)), which
39
+ # would wrap a long run of digits into some other number.
40
+ seconds="$interval"
41
+ while case "$seconds" in 0?*) true ;; *) false ;; esac; do
42
+ seconds="${seconds#0}"
43
+ done
44
+ case "$seconds" in
45
+ ''|0|*[!0-9]*) err "--watch needs a positive whole number of seconds, not $interval"; exit 4 ;;
46
+ esac
47
+ interval="$seconds"
48
+ if [ "$watch" = 1 ] && [ "$json" = 1 ]; then
49
+ err "--watch and --json do not combine"
50
+ exit 4
51
+ fi
52
+
53
+ command -v jq >/dev/null 2>&1 || { err "jq is required"; exit 5; }
54
+
55
+ if [ -n "${SLUICE_BOARD_DIR:-}" ]; then
56
+ ROOT="$SLUICE_BOARD_DIR"
57
+ elif [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
58
+ ROOT="$CLAUDE_CONFIG_DIR/sluice/board"
59
+ elif [ -n "${HOME:-}" ]; then
60
+ ROOT="$HOME/.claude/sluice/board"
61
+ else
62
+ ROOT=""
63
+ fi
64
+
65
+ ESC="$(printf '\033')"
66
+ if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
67
+ colour=1
68
+ else
69
+ colour=0
70
+ fi
71
+
72
+ paint() { # <sgr> <text>
73
+ if [ "$colour" = 1 ]; then
74
+ printf '%s[%sm%s%s[0m' "$ESC" "$1" "$2" "$ESC"
75
+ else
76
+ printf '%s' "$2"
77
+ fi
78
+ }
79
+
80
+ # The board polls for hours while runs close and sessions are pruned under it,
81
+ # so each file is read into a variable first and skipped when that read fails,
82
+ # and jq gets contents rather than paths that may be gone by the time it opens
83
+ # them. The read is the builtin, so a frame spawns no process per file. TREES
84
+ # keeps each run's path by index, so a path holding a newline or a tab reaches
85
+ # status.sh whole rather than through a line-split read. KEYS and VALUES are
86
+ # the records emit hands jq. Returns 1 when the root, runs/ or sessions/
87
+ # exists but cannot be listed: one not yet created is empty, since nothing has
88
+ # been written there, but one that cannot be read would draw as nothing running
89
+ # when it may hold every run on the machine.
90
+ gather() {
91
+ local n=0 f tree run entry d
92
+ TREES=()
93
+ KEYS=()
94
+ VALUES=()
95
+ [ -n "$ROOT" ] || return 0
96
+ for d in "$ROOT" "$ROOT/runs" "$ROOT/sessions"; do
97
+ if [ -e "$d" ] || [ -L "$d" ]; then
98
+ [ -d "$d" ] && [ -r "$d" ] && [ -x "$d" ] || return 1
99
+ fi
100
+ done
101
+ for f in "$ROOT"/runs/*; do
102
+ [ -f "$f" ] || continue
103
+ # read -d '' stops at EOF with a non-zero status, so a short read is told
104
+ # from a failed open by whether anything arrived.
105
+ tree=""
106
+ { IFS= read -r -d '' tree || [ -n "$tree" ]; } 2>/dev/null <"$f" || continue
107
+ tree="${tree%$'\n'}"
108
+ [ -n "$tree" ] || continue
109
+ run=""
110
+ { IFS= read -r -d '' run || [ -n "$run" ]; } 2>/dev/null <"$tree/.sluice/run.json" || continue
111
+ TREES[$n]="$tree"
112
+ KEYS+=("t$n" "r$n")
113
+ VALUES+=("$tree" "$run")
114
+ n=$((n + 1))
115
+ done
116
+ n=0
117
+ for f in "$ROOT"/sessions/*.json; do
118
+ [ -f "$f" ] || continue
119
+ entry=""
120
+ { IFS= read -r -d '' entry || [ -n "$entry" ]; } 2>/dev/null <"$f" || continue
121
+ KEYS+=("s$n")
122
+ VALUES+=("$entry")
123
+ n=$((n + 1))
124
+ done
125
+ }
126
+
127
+ # Contents go to jq on stdin as NUL-separated key and value records rather than
128
+ # as arguments, so no file's size runs into the argv limit. JSON text never
129
+ # holds a NUL, and a tree path cannot.
130
+ emit() {
131
+ local i=0
132
+ while [ "$i" -lt "${#KEYS[@]}" ]; do
133
+ printf '%s\0%s\0' "${KEYS[$i]}" "${VALUES[$i]}"
134
+ i=$((i + 1))
135
+ done
136
+ }
137
+
138
+ combine() { # [jq filter appended to COMBINE]
139
+ emit | jq -R -s --arg home "${HOME:-}" "$COMBINE${1:-}" 2>/dev/null
140
+ }
141
+
142
+ # Builds {board, order, lines}: board is what --json prints, order the runs to
143
+ # draw as "<index into TREES>\t<name>\t<path as shown>", lines the session rows.
144
+ # Those fields are joined rather than put through @tsv, which escapes a
145
+ # backslash that read -r then keeps, so a name would draw doubled; clean has
146
+ # already dropped every tab and newline a field could hold. Session rows are
147
+ # padded here because jq counts a string in characters whatever the locale,
148
+ # where bash under C counts bytes and would push a row holding café out of line.
149
+ # A wide CJK glyph still counts as one, so a row holding one draws a cell short.
150
+ # Each entry is evaluated on its own inside try, so one with an odd shape costs
151
+ # only itself, and the counts are taken from the same filtered list the rows
152
+ # are drawn from.
153
+ COMBINE='
154
+ def clean: if type == "string" then gsub("[\u0000-\u001f\u007f]"; "") else . end;
155
+ def base: split("/") | map(select(. != "")) | last // "/";
156
+ def age($t): ((now - ($t | fromdateiso8601)) / 60 | floor | if . < 0 then 0 else . end)
157
+ | if . < 60 then "\(.)m" else "\(. / 60 | floor)h\(. % 60)m" end;
158
+ def need(cond; why): if cond then . else error(why) end;
159
+ (split("\u0000") | .[:-1]) as $parts
160
+ | ([range(0; $parts | length; 2) | {key: $parts[.], value: $parts[. + 1]}] | from_entries) as $n
161
+ | [$n | keys[] | select(test("^t[0-9]+$")) | ltrimstr("t") | tonumber
162
+ | . as $i
163
+ | try (
164
+ $n["r\($i)"] | fromjson
165
+ | need(type == "object"; "not a run")
166
+ | need((.tasks | type) == "array"; "no tasks")
167
+ | need(all(.tasks[]; type == "object" and (.status | type) == "string"); "odd tasks")
168
+ | {i: $i, tree: $n["t\($i)"], run: .,
169
+ rank: (if any(.tasks[]; .status == "blocked") then 0
170
+ elif any(.tasks[]; .status == "active" or .status == "review") then 1
171
+ else 2 end),
172
+ t: (.updated // .started | fromdateiso8601)}
173
+ ) catch empty]
174
+ | reduce .[] as $r ([]; if any(.[]; .tree == $r.tree) then . else . + [$r] end)
175
+ | sort_by([.rank, -.t])
176
+ | . as $runs
177
+ | [$runs[].run.session | strings] as $taken
178
+ | [$n | keys[] | select(test("^s[0-9]+$")) | $n[.]
179
+ | try (
180
+ fromjson
181
+ | need(type == "object"; "not an entry")
182
+ | with_entries(.value |= clean)
183
+ | need((.tree | type) == "string" and .tree != ""; "no tree")
184
+ | need(.channel == "fast" or .channel == "main" or .channel == "deep"; "bad channel")
185
+ | need(.state == "working" or .state == "handed back"; "bad state")
186
+ | (.updated | fromdateiso8601) as $t
187
+ | select(now - $t <= 86400)
188
+ | select(.session as $s | $taken | index([$s]) | not)
189
+ | {entry: ., t: $t, cells: [.channel, (.tree | base), .state,
190
+ (if .state == "working" then "◷ \(age(.routed))" else "\(age(.updated)) ago" end)]}
191
+ ) catch empty]
192
+ | sort_by(-.t)
193
+ | . as $sessions
194
+ | [range(0; 3) as $k | [$sessions[].cells[$k] | length] | max // 0] as $widths
195
+ | def pad($w): . + (" " * ($w - length));
196
+ # A run of any other channel still draws as a run, the bar deep runs draw, so
197
+ # it is counted with them rather than vanishing from the header.
198
+ def channel: if . == "fast" or . == "main" then . else "deep" end;
199
+ def tally($state): reduce ("fast", "main", "deep") as $c ({}; .[$c] =
200
+ ([$sessions[] | select(.entry.channel == $c and .entry.state == $state)] | length)
201
+ + (if $state == "working" then [$runs[] | select(.run.channel | channel == $c)] | length else 0 end));
202
+ {board: {runs: [$runs[] | {tree, run}], sessions: [$sessions[].entry],
203
+ counts: {runs: ($runs | length), working: tally("working"), handed_back: tally("handed back")}},
204
+ order: [$runs[] | (.tree | clean) as $c | [.i, ($c | base),
205
+ (if $home != "" and $c == $home then "~"
206
+ elif $home != "" and ($c | startswith($home + "/")) then "~" + ($c | ltrimstr($home))
207
+ else $c end)] | map(tostring) | join("\t")],
208
+ lines: [$sessions[].cells | [range(0; 3) as $k | .[$k] | pad($widths[$k])] + [.[3]] | join("\t")]}
209
+ '
210
+
211
+ # Prints the frame, or returns 1 having printed nothing when the state could
212
+ # not be built at all. Colour is painted here and stripped by the caller, once
213
+ # per frame, when it is off.
214
+ render() {
215
+ local s tag a b c d e f head first=1
216
+ gather || return 1
217
+ s="$(combine)" && [ -n "$s" ] || return 1
218
+ while IFS="$TAB" read -r tag a b c d e f; do
219
+ case "$tag" in
220
+ counts)
221
+ head=""
222
+ [ "$a" -gt 0 ] && head="$head · $a deep"
223
+ [ "$b" -gt 0 ] && head="$head · $b deep handed back"
224
+ [ "$c" -gt 0 ] && head="$head · $c fast working"
225
+ [ "$d" -gt 0 ] && head="$head · $d fast handed back"
226
+ [ "$e" -gt 0 ] && head="$head · $e main working"
227
+ [ "$f" -gt 0 ] && head="$head · $f main handed back"
228
+ [ -n "$head" ] || head=" · nothing running"
229
+ paint 1 sluice
230
+ printf '%s ' "$head"
231
+ paint 2 "$CLOCK"
232
+ echo
233
+ ;;
234
+ run)
235
+ echo
236
+ paint 1 "$b"
237
+ printf ' '
238
+ paint 2 "$c"
239
+ echo
240
+ bash "$SCRIPTS/status.sh" line --full --dir "${TREES[$a]}" 2>/dev/null
241
+ ;;
242
+ session)
243
+ if [ "$first" = 1 ]; then
244
+ echo
245
+ first=0
246
+ fi
247
+ paint 1 "$a"
248
+ printf ' %s %s ' "$b" "$c"
249
+ paint 2 "$d"
250
+ echo
251
+ ;;
252
+ esac
253
+ done <<EOF
254
+ $(jq -r '
255
+ (.board.counts | ["counts", .working.deep, .handed_back.deep, .working.fast, .handed_back.fast, .working.main, .handed_back.main] | @tsv),
256
+ ("run\t" + .order[]),
257
+ ("session\t" + .lines[])' <<<"$s")
258
+ EOF
259
+ return 0
260
+ }
261
+
262
+ UNREADABLE="could not read the board"
263
+ TAB="$(printf '\t')"
264
+ SCRIPTS="$(dirname "$0")"
265
+ STRIP="s/$ESC\[[0-9;]*m//g"
266
+ [ "$colour" = 1 ] && STRIP=""
267
+
268
+ if [ "$json" = 1 ]; then
269
+ gather || { err "$UNREADABLE"; exit 6; }
270
+ combine ' | .board' || { err "$UNREADABLE"; exit 6; }
271
+ exit 0
272
+ fi
273
+
274
+ if [ "$watch" = 0 ]; then
275
+ CLOCK="$(date +%H:%M:%S)"
276
+ frame="$(render)" || { err "$UNREADABLE"; exit 6; }
277
+ printf '%s\n' "$frame" | sed "$STRIP"
278
+ exit 0
279
+ fi
280
+
281
+ sleeper=""
282
+ restore() {
283
+ [ -n "$sleeper" ] && kill "$sleeper" 2>/dev/null
284
+ printf '%s[?25h' "$ESC"
285
+ }
286
+ trap restore EXIT
287
+ trap 'exit 130' INT
288
+ trap 'exit 143' TERM
289
+
290
+ printf '%s[?25l%s[2J' "$ESC" "$ESC"
291
+ while :; do
292
+ CLOCK="$(date +%H:%M:%S)"
293
+ frame="$(render)" || frame="board.sh: $UNREADABLE"
294
+ # Each line clears its own tail, so a row shorter than the one it overwrites
295
+ # leaves nothing of the old one behind. No newline after the last row: one
296
+ # there scrolls a frame as tall as the terminal on every redraw.
297
+ frame="$(printf '%s\n' "$frame" | sed -e "$STRIP" -e "s/\$/$ESC[K/")"
298
+ printf '%s[H%s%s[J' "$ESC" "$frame" "$ESC"
299
+ # Backgrounded so a signal ends the wait at once rather than after the sleep.
300
+ sleep "$interval" &
301
+ sleeper=$!
302
+ wait "$sleeper"
303
+ sleeper=""
304
+ done
@@ -48,6 +48,15 @@
48
48
  # naming the tree the run is in, so that session finds its run from the main
49
49
  # tree however many other runs the set holds.
50
50
  #
51
+ # `init` adds the run's tree to the machine's board index through
52
+ # board-index.sh, `move` re-points it, and `close` drops it; a task flip
53
+ # leaves the index alone. `route`
54
+ # with a session id writes that session's entry (no id, nothing written).
55
+ # `init`, and `route` with a session id, sweep the index of dead entries. The
56
+ # sweep is best effort and silent, so only a run-add, run-drop or session
57
+ # write that fails costs a note: one `status.sh: note: board not updated: ...`
58
+ # line on stderr, never the command's result. scripts/board.sh draws the index.
59
+ #
51
60
  # A flag value may not begin with `--`: unchecked, an omitted value silently
52
61
  # becomes the next flag and the field holds a flag name.
53
62
 
@@ -386,10 +395,27 @@ if [ "$SUB" = "line" ]; then
386
395
  exit 0
387
396
  fi
388
397
 
398
+ # The board is a view across every tree, not part of any run, so a board that
399
+ # cannot be written costs a line on stderr and never the command's own result.
400
+ # board-index.sh's own complaint is folded into the note, so a failure reads as
401
+ # one line rather than two that look like separate problems.
402
+ board_index() { # <verb> [args...]
403
+ local msg
404
+ msg="$(bash "$(dirname "$0")/board-index.sh" "$@" 2>&1 >/dev/null)" && return 0
405
+ msg="${msg//board-index.sh: /}"
406
+ err "note: board not updated: ${msg//$'\n'/; }"
407
+ }
408
+
409
+ # Pruning only tidies what the board reads, which skips dead entries anyway.
410
+ board_prune() {
411
+ bash "$(dirname "$0")/board-index.sh" prune >/dev/null 2>&1 || true
412
+ }
413
+
389
414
  # Exists to put the channel into the transcript verbatim, as a command that
390
- # ran, which run-stats and the stop guard read back. So it touches no state:
391
- # a route is called before any run is opened, and fast opens none at all. For
392
- # the same reason it runs ahead of the jq check below, which only state needs.
415
+ # ran, which run-stats and the stop guard read back. It opens no run, since a
416
+ # route is called before any run is opened and fast opens none at all, but with
417
+ # a session id known it writes that session's entry on the board. It still runs
418
+ # ahead of the jq check below, because the announcement must work without jq.
393
419
  # Bypass is refused because it is no code change, and a marker for it would
394
420
  # read back as a routed change that never happened.
395
421
  if [ "$SUB" = "route" ]; then
@@ -400,6 +426,14 @@ if [ "$SUB" = "route" ]; then
400
426
  [ "$ROUTE" != "bypass" ] || { err "bypass is no code change, so there is nothing to route; just answer"; exit 4; }
401
427
  in_set "$ROUTE" "$ROUTE_CHANNELS" || { err "unknown channel: $ROUTE (one of: $ROUTE_CHANNELS)"; exit 4; }
402
428
  echo "sluice: $ROUTE channel"
429
+ # A route is the only sign a fast or main session leaves, so the board learns
430
+ # of it here. With no session id there is nobody to name, and none is made up.
431
+ if [ -n "$SESSION" ]; then
432
+ ROUTE_TREE="$(git -C "$ORIG_DIR" rev-parse --show-toplevel 2>/dev/null)"
433
+ [ -n "$ROUTE_TREE" ] || ROUTE_TREE="$(cd "$ORIG_DIR" 2>/dev/null && pwd -P)"
434
+ board_prune
435
+ board_index session "$SESSION" "$ROUTE" "$ROUTE_TREE"
436
+ fi
403
437
  exit 0
404
438
  fi
405
439
 
@@ -904,6 +938,10 @@ case "$SUB" in
904
938
  done
905
939
  fi
906
940
 
941
+ # Opening a run is a rare enough moment to sweep the board of runs and
942
+ # sessions that ended without saying so.
943
+ board_prune
944
+
907
945
  jq -n \
908
946
  --arg topic "$TOPIC" \
909
947
  --arg channel "$CHANNEL" \
@@ -917,6 +955,9 @@ case "$SUB" in
917
955
  + (if $session == "" then {} else {session: $session} end)
918
956
  + {tasks: []}
919
957
  ' | write_state || exit $?
958
+ # Here and in move only, not on every write: a flip spawning a board
959
+ # write was what a plan of sixty tasks paid sixty times over.
960
+ board_index run-add "$(cd "$DIR" && pwd -P)"
920
961
 
921
962
  POINTER_TREE="$(main_tree "$DIR")"
922
963
  if [ -n "$SESSION" ] && [ -n "$POINTER_TREE" ] && [ -d "$POINTER_TREE" ] \
@@ -1326,6 +1367,10 @@ case "$SUB" in
1326
1367
  exit 3
1327
1368
  fi
1328
1369
  mv "$STATE" "$DEST_STATE" || { err "could not move $STATE to $DEST_STATE"; exit 1; }
1370
+ # Only once the state has arrived, so the board never loses a run that
1371
+ # failed to move.
1372
+ board_index run-drop "$(cd "$DIR" && pwd -P)"
1373
+ board_index run-add "$TO"
1329
1374
 
1330
1375
  # After the state has arrived, never before: a note pointing at a run
1331
1376
  # that never got there is worse than no note, being indistinguishable
@@ -1407,6 +1452,8 @@ case "$SUB" in
1407
1452
  n=$((n + 1))
1408
1453
  done
1409
1454
  mv "$STATE" "$dest" || { err "could not archive $STATE"; exit 1; }
1455
+ # After the archive, so a close that failed leaves the run on the board.
1456
+ board_index run-drop "$(cd "$DIR" && pwd -P)"
1410
1457
 
1411
1458
  # A note naming this tree has outlived the run it forwarded to. Left
1412
1459
  # behind, it does not go quiet: the next run opened in this tree inherits