@iceinvein/agent-skills 0.18.3 → 0.20.0

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 (76) hide show
  1. package/package.json +1 -1
  2. package/skills/index.json +1 -1
  3. package/skills/sluice/SKILL.md +10 -1
  4. package/skills/sluice/evals/README.md +100 -0
  5. package/skills/sluice/evals/bypass-question-stays-silent/graders/answers-the-question.md +9 -0
  6. package/skills/sluice/evals/bypass-question-stays-silent/graders/no-channel-announcement.md +7 -0
  7. package/skills/sluice/evals/bypass-question-stays-silent/graders/writes-nothing.md +5 -0
  8. package/skills/sluice/evals/bypass-question-stays-silent/prompt.md +10 -0
  9. package/skills/sluice/evals/deep-plan-across-subsystems/case.yaml +4 -0
  10. package/skills/sluice/evals/deep-plan-across-subsystems/fixture.sh +105 -0
  11. package/skills/sluice/evals/deep-plan-across-subsystems/graders/announces-deep-channel.md +11 -0
  12. package/skills/sluice/evals/deep-plan-across-subsystems/graders/design-written-to-docs.md +5 -0
  13. package/skills/sluice/evals/deep-plan-across-subsystems/graders/no-implementation-yet.md +10 -0
  14. package/skills/sluice/evals/deep-plan-across-subsystems/graders/sluice-fired.md +5 -0
  15. package/skills/sluice/evals/deep-plan-across-subsystems/graders/stopped-for-signoff.md +8 -0
  16. package/skills/sluice/evals/deep-plan-across-subsystems/prompt.md +11 -0
  17. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/case.yaml +4 -0
  18. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/fixture.sh +332 -0
  19. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/graders/contract-not-rewritten.md +6 -0
  20. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/graders/ends-on-one-decision.md +15 -0
  21. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/graders/quiet-flag-parsed.md +5 -0
  22. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/graders/sluice-fired.md +5 -0
  23. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/graders/task-4-blocked.md +7 -0
  24. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/graders/three-tasks-landed.md +7 -0
  25. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/prompt.md +11 -0
  26. package/skills/sluice/evals/deep-run-finishes-every-task/case.yaml +4 -0
  27. package/skills/sluice/evals/deep-run-finishes-every-task/fixture.sh +304 -0
  28. package/skills/sluice/evals/deep-run-finishes-every-task/graders/did-not-check-in-between-tasks.md +13 -0
  29. package/skills/sluice/evals/deep-run-finishes-every-task/graders/every-task-done.md +7 -0
  30. package/skills/sluice/evals/deep-run-finishes-every-task/graders/no-task-left-todo.md +7 -0
  31. package/skills/sluice/evals/deep-run-finishes-every-task/graders/quiet-flag-landed.md +5 -0
  32. package/skills/sluice/evals/deep-run-finishes-every-task/graders/sluice-fired.md +5 -0
  33. package/skills/sluice/evals/deep-run-finishes-every-task/graders/suite-was-run.md +6 -0
  34. package/skills/sluice/evals/deep-run-finishes-every-task/prompt.md +11 -0
  35. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/case.yaml +4 -0
  36. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/fixture.sh +73 -0
  37. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/graders/announces-fast-channel.md +7 -0
  38. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/graders/collapsed-not-negotiated.md +10 -0
  39. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/graders/no-design-or-plan-file.md +6 -0
  40. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/graders/seam-implemented.md +9 -0
  41. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/graders/sluice-fired.md +5 -0
  42. package/skills/sluice/evals/explicit-instruction-collapses-to-fast/prompt.md +11 -0
  43. package/skills/sluice/evals/fast-flag-on-existing-command/case.yaml +4 -0
  44. package/skills/sluice/evals/fast-flag-on-existing-command/fixture.sh +73 -0
  45. package/skills/sluice/evals/fast-flag-on-existing-command/graders/announces-fast-channel.md +7 -0
  46. package/skills/sluice/evals/fast-flag-on-existing-command/graders/quiet-flag-implemented.md +5 -0
  47. package/skills/sluice/evals/fast-flag-on-existing-command/graders/sluice-fired.md +5 -0
  48. package/skills/sluice/evals/fast-flag-on-existing-command/graders/stayed-in-fast.md +9 -0
  49. package/skills/sluice/evals/fast-flag-on-existing-command/graders/suite-was-run.md +6 -0
  50. package/skills/sluice/evals/fast-flag-on-existing-command/graders/test-edited-before-source.md +6 -0
  51. package/skills/sluice/evals/fast-flag-on-existing-command/prompt.md +11 -0
  52. package/skills/sluice/evals/main-new-interface/case.yaml +4 -0
  53. package/skills/sluice/evals/main-new-interface/fixture.sh +73 -0
  54. package/skills/sluice/evals/main-new-interface/graders/announces-main-channel.md +11 -0
  55. package/skills/sluice/evals/main-new-interface/graders/behaviour-preserved.md +9 -0
  56. package/skills/sluice/evals/main-new-interface/graders/shape-agreed-before-building.md +13 -0
  57. package/skills/sluice/evals/main-new-interface/graders/sluice-fired.md +5 -0
  58. package/skills/sluice/evals/main-new-interface/graders/suite-was-run.md +6 -0
  59. package/skills/sluice/evals/main-new-interface/prompt.md +11 -0
  60. package/skills/sluice/evals/results/2026-09-20T01-51-28-540Z/aggregate-result.json +105 -0
  61. package/skills/sluice/evals/results/2026-09-20T01-51-28-540Z/report.html +300 -0
  62. package/skills/sluice/evals/results/2026-09-20T01-52-06-287Z/aggregate-result.json +122 -0
  63. package/skills/sluice/evals/results/2026-09-20T01-52-06-287Z/report.html +324 -0
  64. package/skills/sluice/evals/superpowers-conflict-stands-down/case.yaml +4 -0
  65. package/skills/sluice/evals/superpowers-conflict-stands-down/fixture.sh +39 -0
  66. package/skills/sluice/evals/superpowers-conflict-stands-down/graders/names-no-channel.md +8 -0
  67. package/skills/sluice/evals/superpowers-conflict-stands-down/graders/stands-down-once.md +9 -0
  68. package/skills/sluice/evals/superpowers-conflict-stands-down/prompt.md +10 -0
  69. package/skills/sluice/references/deep-channel.md +17 -6
  70. package/skills/sluice/references/status.md +45 -11
  71. package/skills/sluice/scripts/session-start.sh +52 -1
  72. package/skills/sluice/scripts/status.sh +96 -4
  73. package/skills/sluice/scripts/statusline.sh +6 -1
  74. package/skills/sluice/scripts/stop-guard.sh +148 -16
  75. package/skills/sluice/scripts/tree-snapshot.sh +75 -0
  76. package/skills/sluice/skill.json +1 -1
@@ -30,9 +30,13 @@ bash <skill-dir>/scripts/status.sh close
30
30
 
31
31
  `--dir <path>` reads another tree, which is what the statusline uses. A tree
32
32
  with a run of its own is read as itself; one with none resolves to the main
33
- worktree of its set, which is the tree a worktree is cut from and not the
34
- controller's own worktree, so once the run lives there `--dir <implementer
35
- worktree>` finds nothing. Statuses
33
+ worktree of its set, and from there to whatever tree the run has since moved
34
+ into. So any tree in the set reads the one run, wherever in the set it lives,
35
+ and `--dir <implementer worktree>` finds it too. `show` prints a `tree` row
36
+ naming where the run is whenever that is not the tree it was pointed at, since
37
+ a run read from a tree it does not live in otherwise answers "where is this"
38
+ with the tree the reader is already in. `show --json` stays the state file
39
+ verbatim, and the row is not in it. Statuses
36
40
  are `todo`, `active`, `review`, `done` and `blocked`. A new id needs `--name`;
37
41
  after that every call is a bare flip, so keeping it current costs one command
38
42
  per transition rather than a paragraph. `close` archives the run under
@@ -56,9 +60,10 @@ is pointed at, `--dir` if given and the current tree otherwise, once; a base
56
60
  already on the row is kept. Issued from the controller's tree that is the
57
61
  controller's HEAD, which is what an implementer worktree cut from that branch
58
62
  starts at, so the default is right at dispatch. Where the implementer's tree
59
- has moved on, pass `--base $(git -C <implementer worktree> rev-parse --short
60
- HEAD)` rather than `--dir` that tree: with the run in your worktree, `--dir`
61
- pointed at the implementer's resolves to the main tree and finds no run.
63
+ has moved on, point the command at that tree: `--dir <implementer worktree>`
64
+ resolves the run through the set and takes the base from the tree it was
65
+ pointed at, which is the one about to be built in. `--base $(git -C
66
+ <implementer worktree> rev-parse --short HEAD)` says the same thing outright.
62
67
 
63
68
  `init` reports any other run live in a tree of the same set, without refusing:
64
69
  two sessions in two worktrees is legal, and a run stranded in the main tree
@@ -103,6 +108,24 @@ that already holds a run or that is not a work tree of the same repository. A
103
108
  submodule anchors on its own checkout, not the superproject's, and a directory
104
109
  that is no git work tree keeps its run exactly where it sits.
105
110
 
111
+ `move` is half a step, and the half it cannot take is the session. The harness
112
+ holds one working directory and no command here reaches it, so the controller
113
+ goes on asking about the tree it is still sitting in: its statusline draws for
114
+ that tree, so does the SessionStart hook, and so does every bare `git`, build
115
+ and test command it runs. **Move the session into the tree the run went to**,
116
+ with the harness's worktree tool where there is one, entering the worktree by
117
+ path when it was cut by hand. `move` says so on the way out, because that is
118
+ the moment it is still cheap.
119
+
120
+ Until the session moves, the tree it came from reads the run rather than
121
+ denying it: `move` leaves `.sluice/run.at` at the main worktree, one line
122
+ naming the tree the run is in, and a tree with no run of its own follows it.
123
+ The note lives at the main worktree and nowhere else, so a run moved twice
124
+ forwards once rather than down a chain of trees, and it is followed only while
125
+ the run it names is really there. `close` removes a note naming its own tree,
126
+ because a note that outlived its run would hand the next run opened in that
127
+ tree to whoever reads the main tree.
128
+
106
129
  One file for several writers is one file to contend on, so `init`, `task`,
107
130
  `preflight`, `final`, `pause`, `resume`, `close` and `move` take a lock first, `move` taking the
108
131
  destination tree's as well as its own: two flips issued at the same moment
@@ -290,11 +313,22 @@ every task done, a run idle for a day, and a turn where the harness says a
290
313
  stop hook already fired, which is what keeps it from looping. That last rule
291
314
  means it refuses once per turn and lets the next attempt through: a nudge, not
292
315
  a wall. The gate keys on the git top level of the session's working
293
- directory and nothing else, so the run has to live in the tree the session
294
- works in: open it after the worktree is cut, or `move` it there and then enter
295
- that worktree, since `move` relocates the run and not the session, and a run
296
- moved out from under a session still sitting in the main tree leaves that
297
- session unguarded for the rest of the run.
316
+ directory: the run this tree answers for is the state beside it, or the state
317
+ it forwarded into a worktree when `move` sent the run on without the session.
318
+ Both are this tree's, and a controller that moved its run out and stayed put is
319
+ guarded for the rest of the run rather than quietly let go at the moment it
320
+ moved. The main-worktree fallback stays untaken, so a session in a worktree
321
+ that merely reads the set's run is let stop as before.
322
+
323
+ Once the run is in another tree the remedy changes with it. The refusal names
324
+ that tree and says to move the session into it, and it stops offering `close`:
325
+ from here that would archive a run live somewhere else, which may be another
326
+ session's, and talking anyone into that is the one thing this hook must not do.
327
+ The offer comes back when the run is in the tree the stop came from. None of
328
+ this makes staying put correct: open the run after the worktree is cut, or
329
+ `move` it and enter that worktree, since `move` relocates the run and not the
330
+ session, and every bare `git`, build and test command meanwhile still lands in
331
+ the tree the work left.
298
332
 
299
333
  `pause --reason <text>` records why a run is standing still; `show` and the
300
334
  statusline carry it, and `resume` clears it. A pause with no reason is refused,
@@ -31,12 +31,63 @@ if [ ! -t 0 ]; then
31
31
  fi
32
32
  cwd="$PWD"
33
33
  source=""
34
+ sid=""
34
35
  if [ -n "$input" ] && command -v jq >/dev/null 2>&1; then
35
36
  got="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
36
37
  [ -n "$got" ] && cwd="$got"
37
38
  source="$(printf '%s' "$input" | jq -r '.source // empty' 2>/dev/null || true)"
39
+ sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true)"
38
40
  fi
39
41
 
42
+ # The baseline for stop-guard.sh's entry check. That guard asks whether the tree
43
+ # moved while this session held it, and this hook runs at the only moment the
44
+ # answer is still "not yet". Taken for every session, run or no run: a session
45
+ # that routes nothing is exactly the one the guard is there to catch.
46
+ #
47
+ # It lives beside the transcripts rather than in the tree, because a session
48
+ # that opens in someone's repo should not leave a directory behind in it, and
49
+ # because a baseline is spent the moment its session ends. A stamp that is
50
+ # missing, unreadable or stale costs the guard its check and nothing else, so
51
+ # every failure here is silent.
52
+ stamp_baseline() {
53
+ local sid="$1" src="$2" tree digest
54
+ [ -n "$sid" ] || return 0
55
+ case "$sid" in */* | .*) return 0 ;; esac
56
+ tree="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)" || return 0
57
+ [ -n "$tree" ] || return 0
58
+
59
+ local dir="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}/sluice/stamps"
60
+ mkdir -p "$dir" 2>/dev/null || return 0
61
+
62
+ # This hook fires again on compact, resume and clear, all carrying the
63
+ # session id the first one carried, and they are not the same event.
64
+ #
65
+ # A compact happens inside a session that never let go of the tree, so its
66
+ # baseline still stands; re-stamping there would move it onto the work
67
+ # already done and read a half-finished session as an untouched tree, and a
68
+ # session long enough to compact is the one this exists for.
69
+ #
70
+ # A resume reopens a session that had stopped, and a clear throws away what
71
+ # it was doing. Both leave the old baseline describing a tree from some
72
+ # unbounded time ago, and everything that happened to it since, by a
73
+ # colleague or an editor or another session, would be charged to whoever
74
+ # reopens it. Those two start again, and give back the nudge the previous
75
+ # sitting may have spent.
76
+ case "$src" in
77
+ resume | clear) rm -f "$dir/$sid" "${dir%/stamps}/nudged/$sid" 2>/dev/null || true ;;
78
+ *) [ -e "$dir/$sid" ] && return 0 ;;
79
+ esac
80
+
81
+ # A stamp outlives nothing but its session, and the harness never deletes
82
+ # one. Without this the directory grows for the life of the machine.
83
+ find "$dir" "${dir%/stamps}/nudged" -type f -mtime +7 -delete 2>/dev/null || true
84
+
85
+ digest="$(bash "$here/tree-snapshot.sh" "$tree" 2>/dev/null)" || return 0
86
+ [ -n "$digest" ] || return 0
87
+ printf '%s\n%s\n' "$tree" "$digest" >"$dir/$sid" 2>/dev/null || true
88
+ }
89
+ stamp_baseline "$sid" "$source"
90
+
40
91
  # No gate of its own beyond the directory existing: status.sh anchors on the
41
92
  # main worktree of whatever tree it is given, which is what lets a session
42
93
  # opened in a subdirectory or a linked worktree find the run, and on a tree
@@ -47,7 +98,7 @@ fi
47
98
  shown="$(bash "$STATUS" show --dir "$cwd" 2>/dev/null)" || exit 0
48
99
  [ -n "$shown" ] || exit 0
49
100
 
50
- echo "A sluice run is live in this tree. Its state, from .sluice/run.json:"
101
+ echo "A sluice run is live. Its state, from .sluice/run.json:"
51
102
  echo
52
103
  echo "$shown"
53
104
  echo
@@ -23,8 +23,11 @@
23
23
  # status.sh close
24
24
  #
25
25
  # --dir <path> selects the tree to read (default: $PWD). State lives at
26
- # <dir>/.sluice/run.json and closed runs at <dir>/.sluice/archive/. The
27
- # directory ignores itself, so no project needs a .gitignore line for it.
26
+ # <dir>/.sluice/run.json and closed runs at <dir>/.sluice/archive/. A tree the
27
+ # run has moved out of keeps <dir>/.sluice/run.at, one line naming the tree it
28
+ # went to, so a session still sitting there resolves the run rather than reading
29
+ # it as gone. The directory ignores itself, so no project needs a .gitignore
30
+ # line for it.
28
31
  #
29
32
  # Exit: 0 ok, 1 the state could not be written, 2 no live run, 3 a run is
30
33
  # already live (here, or at move's destination), 4 bad arguments, 5 jq missing,
@@ -107,6 +110,15 @@ if [ -z "$SUB" ]; then
107
110
  exit 4
108
111
  fi
109
112
 
113
+ # One function rather than the same pipeline at three call sites, because they
114
+ # have to agree: the tree resolution anchors on and the tree `move` and `close`
115
+ # leave their forwarding note in are the same tree by definition, and a note
116
+ # left anywhere else is a note nothing reads. Empty for a directory that is no
117
+ # git work tree.
118
+ main_tree() { # <dir>
119
+ git -C "$1" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p'
120
+ }
121
+
110
122
  # A tree's own run comes first, and only a tree with none reads the set's. Two
111
123
  # layouts share this script and pull opposite ways. A deep run plans in the main
112
124
  # tree and cuts implementer worktrees after the plan: the run directory ignores
@@ -130,9 +142,29 @@ fi
130
142
  ORIG_DIR="$DIR"
131
143
  if [ "$SUB" != "init" ] && [ ! -f "$DIR/.sluice/run.json" ] \
132
144
  && [ "$(git -C "$DIR" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ]; then
133
- MAIN_TREE="$(git -C "$DIR" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')"
145
+ MAIN_TREE="$(main_tree "$DIR")"
134
146
  if [ -n "${MAIN_TREE:-}" ] && [ -d "$MAIN_TREE" ]; then
135
147
  DIR="$MAIN_TREE"
148
+
149
+ # Then forward, where the main tree's run has moved on into a worktree.
150
+ # A `move` is half a step: it relocates the state and cannot relocate the
151
+ # session, the harness holding one working directory that no command here
152
+ # reaches. So the controller's session goes on asking about the tree it
153
+ # still sits in, and per-tree resolution answers that the run is gone --
154
+ # to its statusline, to the SessionStart hook and to a bare `show`, all
155
+ # on a run that is live two directories away.
156
+ #
157
+ # The note lives at the main tree and nowhere else, so a run moved twice
158
+ # forwards once rather than down a chain, and so the tree every other
159
+ # tree in the set already resolves to is the tree that knows. It is
160
+ # followed only while the run it names is really there: a note outliving
161
+ # its run is stale, not a second answer.
162
+ if [ ! -f "$DIR/.sluice/run.json" ] && [ -f "$DIR/.sluice/run.at" ]; then
163
+ # `read`, not `cat`: a builtin, and the first line is the whole note.
164
+ AT=""
165
+ IFS= read -r AT <"$DIR/.sluice/run.at" 2>/dev/null || true
166
+ [ -n "$AT" ] && [ -f "$AT/.sluice/run.json" ] && DIR="$AT"
167
+ fi
136
168
  fi
137
169
  fi
138
170
 
@@ -306,6 +338,16 @@ mk_dir() { # <directory to create under .sluice> [<tree whose .sluice it is, def
306
338
  [ -e "$ignore" ] || printf '*\n' >"$ignore" 2>/dev/null || true
307
339
  }
308
340
 
341
+ # Where the set's run went, written at the main tree for resolution to follow.
342
+ # Not `mk_dir`, which exits: this runs after the state has already arrived in
343
+ # the destination, and a note that could not be written is a blank statusline
344
+ # in one tree rather than a move that failed. The caller reports it instead.
345
+ leave_note() { # <main tree> <tree the run is now in>
346
+ mkdir -p "$1/.sluice" 2>/dev/null || return 1
347
+ [ -e "$1/.sluice/.gitignore" ] || printf '*\n' >"$1/.sluice/.gitignore" 2>/dev/null || true
348
+ printf '%s\n' "$2" >"$1/.sluice/run.at" 2>/dev/null
349
+ }
350
+
309
351
  # Written through a temporary file so an interrupted write cannot leave the
310
352
  # run state half-serialised, which would read as a corrupted run rather than
311
353
  # as a failed command.
@@ -582,10 +624,28 @@ case "$SUB" in
582
624
  exit 0
583
625
  fi
584
626
 
627
+ # Where the run is, said only when that is not the tree the command was
628
+ # pointed at. A run read from a tree it does not live in answers "where
629
+ # is this" silently wrong otherwise: the reader takes the tree they are
630
+ # in, which after a `move` is the one tree the run is not in.
631
+ #
632
+ # A directory inside the tree holding the run is that tree, one level
633
+ # down, not somewhere else -- without that, the row would fire on every
634
+ # session that works from a subdirectory.
635
+ ELSEWHERE=""
636
+ run_tree="$(cd "$DIR" 2>/dev/null && pwd -P)"
637
+ asked="$(cd "$ORIG_DIR" 2>/dev/null && pwd -P)"
638
+ if [ -n "$run_tree" ] && [ -n "$asked" ]; then
639
+ case "$asked" in
640
+ "$run_tree" | "$run_tree"/*) ;;
641
+ *) ELSEWHERE="$run_tree" ;;
642
+ esac
643
+ fi
644
+
585
645
  # Header and rows are laid out from the same widths, so the two cannot
586
646
  # drift apart, and an over-long value is clipped with a marker rather
587
647
  # than silently reading as the whole value.
588
- jq -r --argjson now "$(date -u +%s)" '
648
+ jq -r --argjson now "$(date -u +%s)" --arg elsewhere "$ELSEWHERE" '
589
649
  def dash: if . == null or . == "" then "-" else . end;
590
650
  # Same reason as the statusline render: state written before the check
591
651
  # on the way in, or edited by hand, holds bytes a terminal would act on
@@ -601,6 +661,7 @@ case "$SUB" in
601
661
  $c[6]] | join(" "));
602
662
  ([.tasks[]? | select(.status == "done")] | length) as $done
603
663
  | ["sluice \(.channel | clean) · \(.topic | clean) · \($done)/\(.tasks | length) done"]
664
+ + (if $elsewhere == "" then [] else ["tree \($elsewhere | clean)"] end)
604
665
  + ["plan \(.plan | dash | clean)"]
605
666
  + ["record \(.record | dash | clean)"]
606
667
  # Past a day since the last write the run is idle, and that is said
@@ -797,7 +858,26 @@ case "$SUB" in
797
858
  exit 3
798
859
  fi
799
860
  mv "$STATE" "$DEST_STATE" || { err "could not move $STATE to $DEST_STATE"; exit 1; }
861
+
862
+ # After the state has arrived, never before: a note pointing at a run
863
+ # that never got there is worse than no note, being indistinguishable
864
+ # from one pointing at a run that did.
865
+ NOTE_TREE="$(main_tree "$TO")"
866
+ if [ -n "$NOTE_TREE" ] && [ -d "$NOTE_TREE" ]; then
867
+ if [ "$NOTE_TREE" = "$TO" ]; then
868
+ # The run is back where resolution already looks, so a note would
869
+ # only point the main tree at itself.
870
+ rm -f "$NOTE_TREE/.sluice/run.at"
871
+ elif ! leave_note "$NOTE_TREE" "$TO"; then
872
+ err "note: the run moved, but $NOTE_TREE/.sluice/run.at could not be written, so a session in $NOTE_TREE will read no run until it moves to $TO"
873
+ fi
874
+ fi
875
+
800
876
  echo "moved $(jq -r '.topic // "run"' "$DEST_STATE" 2>/dev/null || echo run) to $TO"
877
+ # The session is the half of the move no command can make. Left where it
878
+ # was, every bare git, build and test command it runs still lands in the
879
+ # tree the run just left.
880
+ echo "move this session there too, with the harness's worktree tool where it has one; every status.sh call from elsewhere needs --dir $TO"
801
881
  ;;
802
882
 
803
883
  close)
@@ -844,6 +924,18 @@ case "$SUB" in
844
924
  n=$((n + 1))
845
925
  done
846
926
  mv "$STATE" "$dest" || { err "could not archive $STATE"; exit 1; }
927
+
928
+ # A note naming this tree has outlived the run it forwarded to. Left
929
+ # behind, it does not go quiet: the next run opened in this tree inherits
930
+ # the forward and reads as the main tree's, though nobody there opened
931
+ # it. Only a note naming this tree is ours to remove -- one naming
932
+ # another tree belongs to a run this close knows nothing about.
933
+ NOTE_TREE="$(main_tree "$DIR")"
934
+ if [ -n "$NOTE_TREE" ] && [ -f "$NOTE_TREE/.sluice/run.at" ]; then
935
+ AT=""
936
+ IFS= read -r AT <"$NOTE_TREE/.sluice/run.at" 2>/dev/null || true
937
+ [ "$AT" = "$(cd "$DIR" && pwd -P)" ] && rm -f "$NOTE_TREE/.sluice/run.at"
938
+ fi
847
939
  # The summary needs parseable state and close is the one command that
848
940
  # does not, so an unreadable run still gets a line naming where it went.
849
941
  [ -n "$summary" ] || summary="closed $(basename "$dest"): state was unreadable, no summary"
@@ -65,7 +65,11 @@ done
65
65
  d="$(cd "$DIR" 2>/dev/null && pwd -P)" || exit 0
66
66
  [ -n "$d" ] || exit 0
67
67
 
68
- # Four answers end the walk. A state file is a run, wherever it was found. A
68
+ # Five answers end the walk. A state file is a run, wherever it was found. So is
69
+ # a forwarding note beside where one used to be: the run moved out into a
70
+ # worktree, this tree is where the session that moved it is still sitting, and
71
+ # testing only the state file blanked its bar on a live run -- ahead of the
72
+ # `.git` directory below, which would otherwise answer for this tree first. A
69
73
  # `.git` that is a regular file is a linked worktree or a submodule, which may
70
74
  # hold no state of its own and still belong to a set that does, so it is a maybe
71
75
  # and status.sh resolves it. A `.git` that is a directory is the top of an
@@ -78,6 +82,7 @@ d="$(cd "$DIR" 2>/dev/null && pwd -P)" || exit 0
78
82
  # would put the gate's cost in the same range as the render it is avoiding.
79
83
  while :; do
80
84
  [ -f "$d/.sluice/run.json" ] && break
85
+ [ -f "$d/.sluice/run.at" ] && break
81
86
  [ -f "$d/.git" ] && break
82
87
  [ -d "$d/.git" ] && exit 0
83
88
  [ "$d" = "${HOME:-}" ] && exit 0
@@ -10,9 +10,9 @@
10
10
  # Every stop that is a real stop is let through: no run, a channel other than
11
11
  # deep, pre-flight not yet answered (that stop is owed), a blocked task, a run
12
12
  # paused on purpose with `status.sh pause --reason`, every task done (the
13
- # handback), a run idle for a day, a run that lives in another tree than the
14
- # session's, and any attempt where the harness says a stop hook already fired
15
- # this turn, which is what keeps this from looping. One refusal per turn, then:
13
+ # handback), a run idle for a day, a run another tree owns rather than one this
14
+ # tree moved out, and any attempt where the harness says a stop hook already
15
+ # fired this turn, which is what keeps this from looping. One refusal per turn, then:
16
16
  # a nudge with the state in it rather than a wall.
17
17
  #
18
18
  # Reads the harness's stop JSON on stdin for `cwd` and `stop_hook_active`. To
@@ -45,30 +45,147 @@ fi
45
45
 
46
46
  cwd="$PWD"
47
47
  active="false"
48
+ sid=""
49
+ transcript=""
48
50
  if [ -n "$input" ]; then
49
51
  got="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
50
52
  [ -n "$got" ] && cwd="$got"
51
53
  active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false)"
54
+ sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true)"
55
+ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
52
56
  fi
53
57
  [ "$active" = "true" ] && exit 0
54
58
  [ -d "$cwd" ] || exit 0
59
+
60
+ # ---- entry check --------------------------------------------------------
61
+ # The run guard below only arms once .sluice/run.json exists, and that file
62
+ # only exists once the skill has been invoked. So the session that never
63
+ # routed at all, the one this whole guard is for, walks straight past it in
64
+ # silence. This catches that session from the other end: the tree moved while
65
+ # it held it, and no channel was ever announced.
66
+ #
67
+ # The comparison is against where the tree stood when the session opened, not
68
+ # against whether it is dirty now. A session that opens on someone's
69
+ # work-in-progress and only answers questions changed nothing, and nudging it
70
+ # would teach its partner to ignore the nudge.
71
+ #
72
+ # Refused once and then never again for this session: the check cannot tell
73
+ # a partner who routed afterwards from one who read the nudge and chose to
74
+ # carry on, and only the first of those is worth a second refusal.
75
+ #
76
+ # The stamp is per session and the tree it watches is not, so anything else
77
+ # writing to the tree reads as this session's work: a second session, the
78
+ # partner's own editor, an install touching a lockfile, a build emitting
79
+ # something the tree does not ignore. There is nothing in a git tree that
80
+ # attributes a change to who made it, so this is not fixable here, only
81
+ # bounded: one refusal per session, and a reason that offers "not mine" as an
82
+ # answer and takes it.
83
+ entry_nudge() {
84
+ local stamps tree line_tree line_digest now
85
+ [ -n "$sid" ] || return 0
86
+ case "$sid" in */* | .*) return 0 ;; esac
87
+
88
+ # Two directories rather than one name and one suffixed name: sharing a
89
+ # namespace means a session id ending in `.nudged` silently disarms the
90
+ # session whose id is its prefix.
91
+ local root="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}/sluice"
92
+ stamps="$root/stamps"
93
+ [ -f "$stamps/$sid" ] || return 0
94
+ [ -f "$root/nudged/$sid" ] && return 0
95
+
96
+ tree="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)" || return 0
97
+ [ -n "$tree" ] || return 0
98
+
99
+ line_tree="$(sed -n '1p' "$stamps/$sid" 2>/dev/null)"
100
+ line_digest="$(sed -n '2p' "$stamps/$sid" 2>/dev/null)"
101
+ [ -n "$line_tree" ] && [ -n "$line_digest" ] || return 0
102
+ # A session that moved trees carries a baseline for the one it left, and
103
+ # reading this tree's changes against it would be reading someone else's.
104
+ [ "$line_tree" = "$tree" ] || return 0
105
+
106
+ # A tree that cannot be read is unknown, not moved. Losing git's exit
107
+ # status here would turn a held lock or a rebase in flight into a refused
108
+ # turn over a tree nobody touched.
109
+ now="$(bash "$here/tree-snapshot.sh" "$tree" 2>/dev/null)" || return 0
110
+ [ -n "$now" ] || return 0
111
+ [ "$now" = "$line_digest" ] && return 0
112
+
113
+ # Whether the session routed is read exactly the way run-stats.sh reads it,
114
+ # from a copy of its `marker` and `lead` patterns and its `invokes_sluice`:
115
+ # a gate that disagreed with the meter would refuse turns the ledger
116
+ # reports as a run. The copy is kept in step with that file by hand.
117
+ #
118
+ # Lines are parsed one at a time and unparseable ones dropped, because the
119
+ # harness is still appending to this file while the hook reads it and the
120
+ # last line is regularly half written. Slurping would fail on that whole
121
+ # file and read a routed session as an unrouted one.
122
+ [ -f "$transcript" ] || return 0
123
+ jq -e -n -R '
124
+ def marker: "^[*_#>[:space:]]*(fast|main|deep)[[:space:]]+channel";
125
+ def lead: "^[^.!?\n]{0,100}[:=][[:space:]]*[*_]*(fast|main|deep)[[:space:]]+channel";
126
+ def texts: [ .message.content[]? | select(.type == "text") | .text ] | join("\n");
127
+ def invokes_sluice: [ .message.content[]?
128
+ | select(.type == "tool_use" and .name == "Skill")
129
+ | .input.skill? // empty ] | any(. == "sluice");
130
+ [ inputs | fromjson? // empty ]
131
+ | any(.[];
132
+ (.type == "assistant") and (((.isMeta == true) or (.isSidechain == true)) | not)
133
+ and ((texts | test(marker; "i") or test(lead; "i")) or invokes_sluice))
134
+ ' "$transcript" >/dev/null 2>&1
135
+ case "$?" in
136
+ # 0 routed, 1 not. Anything else is jq failing rather than an answer
137
+ # about this session, and an unanswered question is not a refusal.
138
+ 0) return 0 ;;
139
+ 1) ;;
140
+ *) return 0 ;;
141
+ esac
142
+
143
+ mkdir -p "$root/nudged" 2>/dev/null || true
144
+ : >"$root/nudged/$sid" 2>/dev/null || true
145
+ jq -n '{
146
+ decision: "block",
147
+ 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."
148
+ }'
149
+ return 1
150
+ }
151
+
152
+ if ! entry_nudge; then
153
+ exit 0
154
+ fi
155
+
55
156
  [ -f "$STATUS" ] || exit 0
56
157
 
57
- # The session's own tree, and only that. status.sh lets a tree with no run of
58
- # its own read the main worktree's, for a controller that moved after init; a
59
- # Stop in such a tree may be an unrelated session, and a remedy printed to it
60
- # would reach into somebody else's run. So the run has to sit in the tree the
61
- # session's cwd belongs to, or there is nothing here to guard.
158
+ # The run the session's own tree answers for, and only that: the state beside
159
+ # it, or the state it forwarded into a worktree when `move` sent the run on
160
+ # without the session. status.sh also lets a tree with no run of its own read
161
+ # the main worktree's, and that one is not taken here: a Stop in such a tree may
162
+ # be an unrelated session, and a remedy printed to it would reach into somebody
163
+ # else's run.
164
+ #
165
+ # The forward is read here rather than left to status.sh's resolution because
166
+ # the two questions differ. Resolution answers "which run can this tree read",
167
+ # which is the right question for a render and the wrong one for a refusal. The
168
+ # tree named in the note is then passed as `--dir`, so what follows asks about
169
+ # one named tree and carries no layout knowledge of its own.
62
170
  top="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)"
63
171
  [ -n "$top" ] || top="$cwd"
64
- [ -f "$top/.sluice/run.json" ] || exit 0
172
+ if [ -f "$top/.sluice/run.json" ]; then
173
+ tree="$top"
174
+ elif [ -f "$top/.sluice/run.at" ]; then
175
+ tree=""
176
+ IFS= read -r tree <"$top/.sluice/run.at" 2>/dev/null || exit 0
177
+ # A note outliving the run it named is stale, not a run to refuse a stop over.
178
+ [ -n "$tree" ] && [ -f "$tree/.sluice/run.json" ] || exit 0
179
+ else
180
+ exit 0
181
+ fi
65
182
 
66
- run="$(bash "$STATUS" show --json --dir "$top" 2>/dev/null)" || exit 0
183
+ run="$(bash "$STATUS" show --json --dir "$tree" 2>/dev/null)" || exit 0
67
184
  [ -n "$run" ] || exit 0
68
185
 
69
186
  # One JSON object out, read back with jq: the topic is user text, and word
70
187
  # splitting it would truncate at the first space and glob on the rest.
71
- verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" '
188
+ verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" --arg tree "$tree" --arg here "$top" '
72
189
  (.tasks // []) as $t
73
190
  | ([$t[] | select(.status == "done")] | length) as $done
74
191
  | ([$t[] | select(.status == "blocked")] | length) as $blocked
@@ -84,18 +201,33 @@ verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" '
84
201
  # A run nobody has written to for a day is a stale run, not a live one;
85
202
  # refusing its stop would press an abandoned plan on whoever opened here.
86
203
  elif $idle_h >= 24 then {block: false}
87
- # The topic lands in a reason the harness prints, so a control byte in it
88
- # would be acted on by the terminal rather than read. State written before
89
- # status.sh refused those, or edited by hand, can still hold one.
204
+ # The topic and the tree land in a reason the harness prints, so a control
205
+ # byte in either would be acted on by the terminal rather than read. State
206
+ # written before status.sh refused those, or edited by hand, can still hold
207
+ # one, and so can a path.
90
208
  else {block: true, progress: "\($done)/\($t | length)",
91
- topic: (.topic // "run" | gsub("[\u0000-\u001f\u007f]"; ""))}
209
+ topic: (.topic // "run" | gsub("[\u0000-\u001f\u007f]"; "")),
210
+ tree: ($tree | gsub("[\u0000-\u001f\u007f]"; "")),
211
+ elsewhere: ($tree != $here)}
92
212
  end
93
213
  ' 2>/dev/null)" || exit 0
94
214
 
95
215
  [ "$(printf '%s' "$verdict" | jq -r '.block' 2>/dev/null)" = "true" ] || exit 0
96
216
 
217
+ # Two remedies, because the last line of the local one is wrong once the run
218
+ # has moved: `close` from here would archive a run that is live in another tree
219
+ # and may be another session's, which is the one thing this hook must never talk
220
+ # anyone into. What replaces it is the step `move` could not take -- the session
221
+ # following the run -- because a controller guarded here is a controller sitting
222
+ # in the tree its own run left.
97
223
  printf '%s' "$verdict" | jq 2>/dev/null '{
98
224
  decision: "block",
99
- 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. Continue: run status.sh ready and dispatch the next wave in this same message. If a task genuinely needs them, mark it: status.sh task <id> --status blocked. If the run has to stand still for a reason, record it: status.sh pause --reason \"<why>\", then say so and stop. If this run is not the work you were asked to do, it was left open: status.sh close.")
225
+ 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."
226
+ + (if .elsewhere then " The run lives in \(.tree), not in this tree: `move` relocated the run and not this session. If it is yours, move this session into that tree -- the worktree tool in your harness enters one that already exists -- and go on from there." else "" end)
227
+ + " Continue: run status.sh ready and dispatch the next wave in this same message. If a task genuinely needs them, mark it: status.sh task <id> --status blocked. If the run has to stand still for a reason, record it: status.sh pause --reason \"<why>\", then say so and stop."
228
+ + (if .elsewhere
229
+ then " If the run is not yours, it belongs to the session working in \(.tree): say so and stop, rather than closing it from here."
230
+ else " If this run is not the work you were asked to do, it was left open: status.sh close."
231
+ end))
100
232
  }'
101
233
  exit 0
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # Print one digest of where a git tree stands, for stop-guard.sh's entry check.
3
+ #
4
+ # Usage: tree-snapshot.sh <tree>
5
+ # Exit 0 printed a digest, non-zero the tree could not be read. Prints nothing
6
+ # on failure, and a caller that cannot read the tree has no comparison to make
7
+ # rather than a change to report: an interrupted index, a lock held by another
8
+ # git, or a rebase in flight are all "unknown", never "moved".
9
+ #
10
+ # It lives in its own file because two hooks compute it at two different moments
11
+ # and a difference between their two copies would not read as a bug, it would
12
+ # read as the tree having moved. That is the whole measurement.
13
+ #
14
+ # Three signals, because each covers a way of finishing work that the others
15
+ # call standing still:
16
+ #
17
+ # HEAD a session that committed leaves a clean tree
18
+ # diff HEAD a session that rewrote a file already modified
19
+ # leaves the porcelain's shape untouched
20
+ # status --porcelain -uall a session that filled an untracked directory
21
+ # leaves one collapsed `?? build/` either way
22
+ #
23
+ # What it still misses, and why each is left:
24
+ #
25
+ # - the content of a file that was already untracked when the session opened.
26
+ # Listing untracked paths is cheap and hashing their contents is not, and
27
+ # this runs at the end of every turn.
28
+ # - anything inside a nested repository. `status` does not descend into a
29
+ # directory carrying its own .git, so a session spent editing a scratch
30
+ # clone or a vendored checkout reads as a tree nobody touched.
31
+ # - who made the change. Nothing in a git tree records that, so a branch
32
+ # switch, a pull, or another session writing to the same tree all read as
33
+ # this session's work. stop-guard.sh bounds that rather than fixing it.
34
+
35
+ set -uo pipefail
36
+
37
+ tree="${1-}"
38
+ [ -n "$tree" ] || exit 1
39
+ command -v cksum >/dev/null 2>&1 || exit 1
40
+
41
+ # `git diff HEAD` has no meaning before the first commit, and a repo without one
42
+ # is a repo whose every file is untracked, which the porcelain covers. So an
43
+ # unresolvable HEAD is carried as "no-head" rather than refused.
44
+ #
45
+ # It would be better to refuse when HEAD is unresolvable because the ref store
46
+ # cannot be read, as opposed to because no commit exists yet, since the first is
47
+ # a tree this script cannot describe. git gives no way to tell them apart:
48
+ # `rev-parse --verify --quiet` exits 1 for both, and `for-each-ref` returns
49
+ # empty for both, hiding an unreadable ref exactly as it reports an absent one.
50
+ # Refusing on both would disarm the check permanently in any repo before its
51
+ # first commit, which is an ordinary place to be writing code. Carrying both
52
+ # costs a single nudge, and only in a tree whose refs broke partway through the
53
+ # session it had already been read in.
54
+ head="$(git -C "$tree" rev-parse --verify --quiet HEAD 2>/dev/null)" || head=""
55
+
56
+ # Streamed into cksum rather than gathered into variables first. A diff is
57
+ # unbounded, and this runs at the end of every turn until the session is either
58
+ # routed or nudged: an 80MB file rewritten peaks at 334MB resident streamed,
59
+ # against 622MB for a 61MB one held in a shell variable and then piped. What is
60
+ # left is git loading the blobs, which is the price of reading content at all
61
+ # and is the same for --numstat. Streaming also keeps NUL bytes intact, which a
62
+ # command substitution silently drops, and dropping them would hide a change
63
+ # confined to them.
64
+ #
65
+ # `exit 1` inside the group reaches the caller through pipefail, and the digest
66
+ # cksum prints from a truncated read is discarded with it: a caller that cannot
67
+ # read the tree needs no answer, and a short answer is a wrong one.
68
+ digest="$( {
69
+ printf '%s\n' "${head:-no-head}"
70
+ git -C "$tree" status --porcelain -uall 2>/dev/null || exit 1
71
+ [ -n "$head" ] && { git -C "$tree" diff HEAD 2>/dev/null || exit 1; }
72
+ true
73
+ } | cksum )" || exit 1
74
+
75
+ printf '%s\n' "$digest"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.19.3",
3
+ "version": "0.21.0",
4
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 seven 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. Claude Code only; stands down where the superpowers pipeline governs the repo.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",