@iceinvein/agent-skills 0.23.5 → 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/index.json +2 -2
- 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
package/package.json
CHANGED
package/skills/index.json
CHANGED
|
@@ -293,9 +293,9 @@
|
|
|
293
293
|
},
|
|
294
294
|
{
|
|
295
295
|
"name": "sluice",
|
|
296
|
-
"description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries nine rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Two Stop hooks hold a handback: one refuses a tree that moved since its last recorded check, the other refuses a changed existing test not proven to fail on the session's starting code (or recorded with a reason). Claude Code only; stands down where the superpowers pipeline governs the repo.",
|
|
296
|
+
"description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries nine rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), lists every live deep run and routed fast or main session on the machine in one board.sh view (drawn once, watched, or as JSON), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Two Stop hooks hold a handback: one refuses a tree that moved since its last recorded check, the other refuses a changed existing test not proven to fail on the session's starting code (or recorded with a reason). Claude Code only; stands down where the superpowers pipeline governs the repo.",
|
|
297
297
|
"type": "prompt",
|
|
298
|
-
"version": "0.
|
|
298
|
+
"version": "0.26.1"
|
|
299
299
|
},
|
|
300
300
|
{
|
|
301
301
|
"name": "temporal-coupling-detector",
|
|
@@ -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
|
|
@@ -62,6 +62,106 @@ if [ -n "$input" ]; then
|
|
|
62
62
|
sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true)"
|
|
63
63
|
transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
|
|
64
64
|
fi
|
|
65
|
+
|
|
66
|
+
# The run the session's own tree answers for, and only that: the state beside
|
|
67
|
+
# it, or the state it forwarded into a worktree when `move` sent the run on
|
|
68
|
+
# without the session. status.sh also lets a tree with no run of its own read
|
|
69
|
+
# the main worktree's, and that one is not taken here: a Stop in such a tree may
|
|
70
|
+
# be an unrelated session, and a remedy printed to it would reach into somebody
|
|
71
|
+
# else's run.
|
|
72
|
+
#
|
|
73
|
+
# The forward is read here rather than left to status.sh's resolution because
|
|
74
|
+
# the two questions differ. Resolution answers "which run can this tree read",
|
|
75
|
+
# which is the right question for a render and the wrong one for a refusal. The
|
|
76
|
+
# tree named in the note is then passed as `--dir`, so what follows asks about
|
|
77
|
+
# one named tree and carries no layout knowledge of its own.
|
|
78
|
+
#
|
|
79
|
+
# The run this session opened in a worktree it never entered is its own too,
|
|
80
|
+
# found through the pointer `init` left at the main tree, and it comes ahead of
|
|
81
|
+
# the forward: the forward names whichever run was moved last, which with
|
|
82
|
+
# several controllers in the main tree may be another session's.
|
|
83
|
+
#
|
|
84
|
+
# Sets top to the stop's tree and run_tree to the tree holding that run, or
|
|
85
|
+
# leaves run_tree empty when there is none. One function for the run guard and
|
|
86
|
+
# the board, so the run that keeps a deep session off the board is the run the
|
|
87
|
+
# guard would refuse over.
|
|
88
|
+
top=""
|
|
89
|
+
run_tree=""
|
|
90
|
+
resolved=""
|
|
91
|
+
resolve_run_tree() {
|
|
92
|
+
local main session_tree="" forward=""
|
|
93
|
+
resolved=1
|
|
94
|
+
run_tree=""
|
|
95
|
+
top="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)"
|
|
96
|
+
[ -n "$top" ] || top="$cwd"
|
|
97
|
+
case "$sid" in
|
|
98
|
+
"" | */* | .*) ;;
|
|
99
|
+
*)
|
|
100
|
+
main="$(git -C "$cwd" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')"
|
|
101
|
+
[ -n "$main" ] && [ -f "$main/.sluice/sessions/$sid" ] \
|
|
102
|
+
&& IFS= read -r session_tree <"$main/.sluice/sessions/$sid" 2>/dev/null
|
|
103
|
+
if [ -z "$session_tree" ] || [ ! -f "$session_tree/.sluice/run.json" ] \
|
|
104
|
+
|| [ "$(jq -r '.session // empty' "$session_tree/.sluice/run.json" 2>/dev/null)" != "$sid" ]; then
|
|
105
|
+
session_tree=""
|
|
106
|
+
fi
|
|
107
|
+
;;
|
|
108
|
+
esac
|
|
109
|
+
if [ -f "$top/.sluice/run.json" ]; then
|
|
110
|
+
run_tree="$top"
|
|
111
|
+
elif [ -n "$session_tree" ]; then
|
|
112
|
+
run_tree="$session_tree"
|
|
113
|
+
elif [ -f "$top/.sluice/run.at" ]; then
|
|
114
|
+
IFS= read -r forward <"$top/.sluice/run.at" 2>/dev/null || return 0
|
|
115
|
+
# A note outliving the run it named is stale, not a run to refuse a stop over.
|
|
116
|
+
[ -n "$forward" ] && [ -f "$forward/.sluice/run.json" ] || return 0
|
|
117
|
+
run_tree="$forward"
|
|
118
|
+
fi
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# A stop let through is the session handing back, so the board marks it. A
|
|
122
|
+
# trap rather than a call before each exit: the hook lets a stop through from
|
|
123
|
+
# a dozen places, and a new one added later is covered without anyone having
|
|
124
|
+
# to remember the board. Every block decision sets refused first, since a
|
|
125
|
+
# refused stop is the session still working. The board's output and failure
|
|
126
|
+
# are dropped: stdout carries the decision, and the hook always exits 0.
|
|
127
|
+
#
|
|
128
|
+
# A deep session whose run is live keeps its entry, because the board draws the
|
|
129
|
+
# run's bar for it. Whether the run is live is asked here rather than taken
|
|
130
|
+
# from the run guard, since most exits come before the guard runs, and a deep
|
|
131
|
+
# session mid-run must not read as handed back on any of them. Resolving costs
|
|
132
|
+
# git calls on every stop, so it is skipped for a session with no entry, and
|
|
133
|
+
# not repeated when the run guard already did it.
|
|
134
|
+
#
|
|
135
|
+
# The run resolved is only the one this tree can reach, which through a forward
|
|
136
|
+
# or a shared tree may be another session's; that run's bar is not this
|
|
137
|
+
# session's work, so the entry is kept only for a run that names this session.
|
|
138
|
+
# A run naming no session is no session's own, as the board draws it, and a
|
|
139
|
+
# state file that cannot be read names nobody it can be kept for.
|
|
140
|
+
refused=""
|
|
141
|
+
board_handback() {
|
|
142
|
+
local keep="" root owner
|
|
143
|
+
[ -z "$refused" ] && [ -n "$sid" ] || return 0
|
|
144
|
+
case "$sid" in */* | .*) return 0 ;; esac
|
|
145
|
+
# Must match the root board-index.sh and board.sh resolve.
|
|
146
|
+
if [ -n "${SLUICE_BOARD_DIR:-}" ]; then
|
|
147
|
+
root="$SLUICE_BOARD_DIR"
|
|
148
|
+
elif [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
149
|
+
root="$CLAUDE_CONFIG_DIR/sluice/board"
|
|
150
|
+
elif [ -n "${HOME:-}" ]; then
|
|
151
|
+
root="$HOME/.claude/sluice/board"
|
|
152
|
+
else
|
|
153
|
+
return 0
|
|
154
|
+
fi
|
|
155
|
+
[ -f "$root/sessions/$sid.json" ] || return 0
|
|
156
|
+
[ -n "$resolved" ] || resolve_run_tree
|
|
157
|
+
if [ -n "$run_tree" ] && owner="$(jq -r '.session // empty' "$run_tree/.sluice/run.json" 2>/dev/null)" \
|
|
158
|
+
&& [ "$owner" = "$sid" ]; then
|
|
159
|
+
keep="--keep-deep"
|
|
160
|
+
fi
|
|
161
|
+
bash "$here/board-index.sh" handback "$sid" $keep >/dev/null 2>&1 || true
|
|
162
|
+
}
|
|
163
|
+
trap board_handback EXIT
|
|
164
|
+
|
|
65
165
|
[ "$active" = "true" ] && exit 0
|
|
66
166
|
[ -d "$cwd" ] || exit 0
|
|
67
167
|
|
|
@@ -175,6 +275,7 @@ entry_nudge() {
|
|
|
175
275
|
|
|
176
276
|
mkdir -p "$root/nudged" 2>/dev/null || true
|
|
177
277
|
: >"$root/nudged/$sid" 2>/dev/null || true
|
|
278
|
+
refused=1
|
|
178
279
|
jq -n '{
|
|
179
280
|
decision: "block",
|
|
180
281
|
reason: "sluice: this session has changed the tree since it opened and no channel was ever announced, so the work is running with none of the rules that its shape calls for and nothing to meter it from. Invoke the sluice skill now, route what you have been doing, and say which channel it is. If it genuinely changed nothing you own (a scratch file, or an edit that was not yours), say so and stop; this will not ask twice."
|
|
@@ -225,6 +326,7 @@ verify_gate() {
|
|
|
225
326
|
[ -f "$moved_tree/.sluice/verified" ] && recorded="$(sed -n '1p' "$moved_tree/.sluice/verified" 2>/dev/null)"
|
|
226
327
|
[ "$recorded" = "$now" ] && return 0
|
|
227
328
|
|
|
329
|
+
refused=1
|
|
228
330
|
jq -n --arg channel "$channel" '{
|
|
229
331
|
decision: "block",
|
|
230
332
|
reason: ("sluice: this \($channel) session has changed the tree and no passing check is recorded against it as it stands now, so the handback would carry a change nothing has proved. Run the check that proves it through the recorder: status.sh verify -- <cmd> (the tests, the build, whatever exercises this change). If no check can cover it (docs, config), record why: status.sh verify --none \"<reason>\". Then hand back.")
|
|
@@ -305,6 +407,7 @@ test_gate() {
|
|
|
305
407
|
|
|
306
408
|
# Paths land in a reason the harness prints, so control bytes are stripped
|
|
307
409
|
# as the run guard strips them from the topic and tree.
|
|
410
|
+
refused=1
|
|
308
411
|
jq -n --arg paths "$missing" --arg base "$base" '{
|
|
309
412
|
decision: "block",
|
|
310
413
|
reason: ("sluice: this session changed existing tests in a way that asks less of the code (an edited or removed assertion, a deleted test, a skip or focus marker), and nothing records why: "
|
|
@@ -320,50 +423,9 @@ fi
|
|
|
320
423
|
|
|
321
424
|
[ -f "$STATUS" ] || exit 0
|
|
322
425
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
# the main worktree's, and that one is not taken here: a Stop in such a tree may
|
|
327
|
-
# be an unrelated session, and a remedy printed to it would reach into somebody
|
|
328
|
-
# else's run.
|
|
329
|
-
#
|
|
330
|
-
# The forward is read here rather than left to status.sh's resolution because
|
|
331
|
-
# the two questions differ. Resolution answers "which run can this tree read",
|
|
332
|
-
# which is the right question for a render and the wrong one for a refusal. The
|
|
333
|
-
# tree named in the note is then passed as `--dir`, so what follows asks about
|
|
334
|
-
# one named tree and carries no layout knowledge of its own.
|
|
335
|
-
#
|
|
336
|
-
# The run this session opened in a worktree it never entered is its own too,
|
|
337
|
-
# found through the pointer `init` left at the main tree, and it comes ahead of
|
|
338
|
-
# the forward: the forward names whichever run was moved last, which with
|
|
339
|
-
# several controllers in the main tree may be another session's.
|
|
340
|
-
top="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)"
|
|
341
|
-
[ -n "$top" ] || top="$cwd"
|
|
342
|
-
session_tree=""
|
|
343
|
-
case "$sid" in
|
|
344
|
-
"" | */* | .*) ;;
|
|
345
|
-
*)
|
|
346
|
-
main="$(git -C "$cwd" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')"
|
|
347
|
-
[ -n "$main" ] && [ -f "$main/.sluice/sessions/$sid" ] \
|
|
348
|
-
&& IFS= read -r session_tree <"$main/.sluice/sessions/$sid" 2>/dev/null
|
|
349
|
-
if [ -z "$session_tree" ] || [ ! -f "$session_tree/.sluice/run.json" ] \
|
|
350
|
-
|| [ "$(jq -r '.session // empty' "$session_tree/.sluice/run.json" 2>/dev/null)" != "$sid" ]; then
|
|
351
|
-
session_tree=""
|
|
352
|
-
fi
|
|
353
|
-
;;
|
|
354
|
-
esac
|
|
355
|
-
if [ -f "$top/.sluice/run.json" ]; then
|
|
356
|
-
tree="$top"
|
|
357
|
-
elif [ -n "$session_tree" ]; then
|
|
358
|
-
tree="$session_tree"
|
|
359
|
-
elif [ -f "$top/.sluice/run.at" ]; then
|
|
360
|
-
tree=""
|
|
361
|
-
IFS= read -r tree <"$top/.sluice/run.at" 2>/dev/null || exit 0
|
|
362
|
-
# A note outliving the run it named is stale, not a run to refuse a stop over.
|
|
363
|
-
[ -n "$tree" ] && [ -f "$tree/.sluice/run.json" ] || exit 0
|
|
364
|
-
else
|
|
365
|
-
exit 0
|
|
366
|
-
fi
|
|
426
|
+
resolve_run_tree
|
|
427
|
+
[ -n "$run_tree" ] || exit 0
|
|
428
|
+
tree="$run_tree"
|
|
367
429
|
|
|
368
430
|
run="$(bash "$STATUS" show --json --dir "$tree" 2>/dev/null)" || exit 0
|
|
369
431
|
[ -n "$run" ] || exit 0
|
|
@@ -413,6 +475,7 @@ verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" --arg tree
|
|
|
413
475
|
# anyone into. What replaces it is the step `move` could not take -- the session
|
|
414
476
|
# following the run -- because a controller guarded here is a controller sitting
|
|
415
477
|
# in the tree its own run left.
|
|
478
|
+
refused=1
|
|
416
479
|
printf '%s' "$verdict" | jq 2>/dev/null '{
|
|
417
480
|
decision: "block",
|
|
418
481
|
reason: ("sluice: the deep run \(.topic) is \(.progress) done with tasks still to go and nothing marked blocked or paused, so ending the turn here hands a live run back with nothing for your partner to decide. Still open: \(.open)."
|
package/skills/sluice/skill.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sluice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries nine rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Two Stop hooks hold a handback: one refuses a tree that moved since its last recorded check, the other refuses a changed existing test not proven to fail on the session's starting code (or recorded with a reason). Claude Code only; stands down where the superpowers pipeline governs the repo.",
|
|
3
|
+
"version": "0.26.1",
|
|
4
|
+
"description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries nine rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), lists every live deep run and routed fast or main session on the machine in one board.sh view (drawn once, watched, or as JSON), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Two Stop hooks hold a handback: one refuses a tree that moved since its last recorded check, the other refuses a changed existing test not proven to fail on the session's starting code (or recorded with a reason). Claude Code only; stands down where the superpowers pipeline governs the repo.",
|
|
5
5
|
"author": "iceinvein",
|
|
6
6
|
"type": "prompt",
|
|
7
7
|
"tools": [
|