@iceinvein/agent-skills 0.23.4 → 0.23.7
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.
- package/package.json +1 -1
- package/skills/bug-hunter/SKILL.md +5 -0
- package/skills/bug-hunter/evals/README.md +26 -2
- package/skills/bug-hunter/evals/every-member-of-a-set-claim/case.yaml +4 -0
- package/skills/bug-hunter/evals/every-member-of-a-set-claim/fixture.sh +116 -0
- package/skills/bug-hunter/evals/every-member-of-a-set-claim/graders/bug-for-unlogged-cap-cut.md +50 -0
- package/skills/bug-hunter/evals/every-member-of-a-set-claim/graders/bug-hunter-fired.md +5 -0
- package/skills/bug-hunter/evals/every-member-of-a-set-claim/prompt.md +11 -0
- package/skills/bug-hunter/evals/false-success-is-a-bug/case.yaml +4 -0
- package/skills/bug-hunter/evals/false-success-is-a-bug/fixture.sh +112 -0
- package/skills/bug-hunter/evals/false-success-is-a-bug/graders/bug-for-close-after-rename.md +50 -0
- package/skills/bug-hunter/evals/false-success-is-a-bug/graders/bug-hunter-fired.md +5 -0
- package/skills/bug-hunter/evals/false-success-is-a-bug/prompt.md +11 -0
- package/skills/bug-hunter/evals/rules.json +14 -3
- package/skills/bug-hunter/skill.json +1 -1
- package/skills/index.json +4 -4
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +2 -1
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +2 -1
- package/skills/migrate/skill.json +1 -1
- package/skills/sluice/references/status.md +95 -4
- package/skills/sluice/scripts/board-index.sh +160 -0
- package/skills/sluice/scripts/board.sh +304 -0
- package/skills/sluice/scripts/status.sh +50 -3
- package/skills/sluice/scripts/stop-guard.sh +107 -44
- package/skills/sluice/skill.json +2 -2
|
@@ -29,14 +29,21 @@ bash <skill-dir>/scripts/status.sh close
|
|
|
29
29
|
bash <skill-dir>/scripts/status.sh route deep
|
|
30
30
|
bash <skill-dir>/scripts/status.sh verify -- bun test
|
|
31
31
|
bash <skill-dir>/scripts/status.sh verify --none "docs only, nothing executes them"
|
|
32
|
+
bash <skill-dir>/scripts/board.sh
|
|
33
|
+
bash <skill-dir>/scripts/board.sh --watch 5
|
|
34
|
+
bash <skill-dir>/scripts/board.sh --json
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
`route` and `verify` are the two exceptions: neither needs a run.
|
|
35
38
|
|
|
36
|
-
`route`
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
`route` prints `sluice: <channel> channel` so the channel lands in the
|
|
40
|
+
transcript as a tool input, which is kept as written where the announcement's
|
|
41
|
+
prose may not be. `references/meter.md` says why that matters. It opens no run,
|
|
42
|
+
but with a session id (`--session`, defaulting to `$CLAUDE_CODE_SESSION_ID`) it
|
|
43
|
+
writes that session's entry on the board, because a route is the only sign a
|
|
44
|
+
`fast` or `main` session leaves. With no id it writes nothing: there is nobody
|
|
45
|
+
to name, and none is made up. Either way it exits 0 once the channel is
|
|
46
|
+
printed, since the announcement is the part that matters.
|
|
40
47
|
|
|
41
48
|
`verify -- <cmd>` runs the check from the tree's top level, passes a failing
|
|
42
49
|
check's exit code through, and on a pass writes the tree's content digest to
|
|
@@ -483,6 +490,90 @@ cannot redirect. That is the same argument the channel announcement makes, and
|
|
|
483
490
|
the statusline is where it holds for the hour after the announcement scrolled
|
|
484
491
|
away.
|
|
485
492
|
|
|
493
|
+
## Board
|
|
494
|
+
|
|
495
|
+
Everything above answers from inside one worktree set. `scripts/board.sh`
|
|
496
|
+
answers across the machine: every live `deep` run and every routed `fast` or
|
|
497
|
+
`main` session, drawn once, or redrawn in place every two seconds (or every
|
|
498
|
+
`<seconds>` given) with `--watch` until interrupted. `<seconds>` is a positive
|
|
499
|
+
whole number read in base ten, leading zeros allowed (`05` is five, `08`
|
|
500
|
+
eight); `0`, `00` or anything else exits 4. `--json` prints the combined state
|
|
501
|
+
for another reader instead, with the counts the header shows, and does not
|
|
502
|
+
combine with `--watch`.
|
|
503
|
+
|
|
504
|
+
It reads an index of pointers, never copies, at `$SLUICE_BOARD_DIR`, else
|
|
505
|
+
`$CLAUDE_CONFIG_DIR/sluice/board`, else `$HOME/.claude/sluice/board`; with none
|
|
506
|
+
of the three set nothing is written and the board is empty. `runs/` holds one
|
|
507
|
+
file per tree with a live run, naming the tree, and `sessions/<id>.json` one
|
|
508
|
+
per routed session. `run.json` stays the single authority on a run, so the
|
|
509
|
+
board can never show a stale copy of one. `SLUICE_BOARD_DIR` exists so the
|
|
510
|
+
tests can point every script at a temporary directory and never touch the real
|
|
511
|
+
board: the suite's preload sets it to a fresh one and makes every child a test
|
|
512
|
+
spawns inherit it, since Bun hands a child spawned with no `env` the
|
|
513
|
+
environment bun started with rather than the one the preload changed.
|
|
514
|
+
|
|
515
|
+
`scripts/board-index.sh` is the only writer. `init` adds the run's pointer,
|
|
516
|
+
`move` re-points it and `close` drops it. A task flip writes nothing to the
|
|
517
|
+
board, because a plan's flips are many and each write is a process; the cost
|
|
518
|
+
is that a run opened before the board existed stays off it until it is
|
|
519
|
+
closed and a new one opened. `route` writes the session's
|
|
520
|
+
entry as `working`, a later route in the same session overwriting it, so an
|
|
521
|
+
escalation from `fast` to `main` shows as `main`. The Stop hook marks a `fast`
|
|
522
|
+
or `main` entry `handed back` when it lets a stop through, and only then: a
|
|
523
|
+
refused stop is the session still working. It marks a `deep` entry the same
|
|
524
|
+
way when no run is live (the design stop before any run exists, the stop
|
|
525
|
+
after `close`, a session that routed deep and never opened a run), and leaves
|
|
526
|
+
it as it is while one is, since the board draws that run's bar for it. The
|
|
527
|
+
run is found the way the hook's run guard finds one, taking the first in this
|
|
528
|
+
order: a run in the stop's own tree, then the run found through the session's
|
|
529
|
+
pointer at the main tree, then the tree's `run.at` forward, never the main
|
|
530
|
+
tree's run read from a runless worktree. That first run counts as live only
|
|
531
|
+
when it is the session's own: its `run.json` names that session. A run that
|
|
532
|
+
names no session is no session's own, matching the board, which hides a
|
|
533
|
+
session row only behind a run naming it. When it names none or another session,
|
|
534
|
+
whether sitting in the stop's own tree or reached through the forward, or its
|
|
535
|
+
`run.json` cannot be read, the entry is marked `handed back`, and no later run
|
|
536
|
+
in the order is looked at. A failed
|
|
537
|
+
board write is one
|
|
538
|
+
`status.sh: note: board not updated: ...` line on stderr and never changes the
|
|
539
|
+
command's result, because the board is a view across trees and not part of
|
|
540
|
+
any run.
|
|
541
|
+
|
|
542
|
+
The header counts each channel's working and handed-back work. A drawn run
|
|
543
|
+
counts as working in its `run.json` channel: a `deep` run toward `N deep`
|
|
544
|
+
alongside the working `deep` sessions, a `main` run toward `N main working`, a
|
|
545
|
+
`fast` run toward `N fast working`. A run naming any other channel, or none,
|
|
546
|
+
counts toward `deep`, since it still draws as a run's bar. `--json` `counts`
|
|
547
|
+
holds the same numbers: `counts.working.<channel>` is that channel's working
|
|
548
|
+
sessions plus its drawn runs (so `working.deep` includes the `deep` runs, where
|
|
549
|
+
before it held the working `deep` sessions only and the header added the runs
|
|
550
|
+
itself), and `counts.runs` is every run drawn, whatever its channel.
|
|
551
|
+
|
|
552
|
+
A run block is `status.sh line --full` for its tree, unchanged, so it reads
|
|
553
|
+
exactly as the statusline does. Blocked runs sort first, then those with a task
|
|
554
|
+
active or in review, then the rest. A session row shows its channel, the tree's
|
|
555
|
+
name and its state: `working` with the time since it was routed, `handed back`
|
|
556
|
+
with the time since. The channel, name and state columns are padded to the
|
|
557
|
+
widest value among that frame's session rows, two spaces apart, so the ages
|
|
558
|
+
line up. Width is counted in characters of the drawn text (jq's count, so
|
|
559
|
+
the same under a C locale), never colour codes. A wide glyph such as `日` that
|
|
560
|
+
takes two terminal cells counts as one, so a row holding one draws a cell
|
|
561
|
+
short; a name stored decomposed (a letter plus a combining accent, as some
|
|
562
|
+
filesystems write paths) counts one column per code point, so it can draw a
|
|
563
|
+
cell short too. A `deep` session row reads `handed back` once a turn ends with
|
|
564
|
+
no run live for it, and otherwise stays `working` while its run's bar is
|
|
565
|
+
drawn. A session of any channel whose id is a drawn run's `session` is not
|
|
566
|
+
listed or counted again, so one piece of work is one row and one count. Session
|
|
567
|
+
entries past a day since their last update are hidden, the same one-day line
|
|
568
|
+
`show` uses for an idle run, and the next `init`, or `route` with a session
|
|
569
|
+
id, removes them along with pointers to trees that no longer hold a run. An
|
|
570
|
+
entry that cannot be read is skipped on its own, so one bad file costs one row
|
|
571
|
+
rather than the board.
|
|
572
|
+
|
|
573
|
+
The board writes nothing, so it takes no lock. It needs `jq` and exits 5
|
|
574
|
+
without it, and 6 when the board cannot be read at all, because unlike the
|
|
575
|
+
statusline it has somewhere to put an error.
|
|
576
|
+
|
|
486
577
|
## Friction
|
|
487
578
|
|
|
488
579
|
"I know which task I'm on." You do, until this session is summarised. The file
|
|
@@ -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.
|
|
391
|
-
#
|
|
392
|
-
#
|
|
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
|