@iceinvein/agent-skills 0.19.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
package/skills/index.json CHANGED
@@ -283,7 +283,7 @@
283
283
  "name": "sluice",
284
284
  "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.",
285
285
  "type": "prompt",
286
- "version": "0.20.0"
286
+ "version": "0.21.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -102,7 +102,10 @@ your partner states a preference. Get the design signed off before code.
102
102
  Take the design stop through the harness's plan mode where there is one. Its
103
103
  gate is enforced rather than requested and it holds edits shut while it is open,
104
104
  so nothing gets built against a design nobody signed. It carries the first stop
105
- only; pre-flight still wants answers, and an approval is not one.
105
+ only; pre-flight still wants answers, and an approval is not one. Where there is
106
+ no plan mode, nothing is holding the draft: write the design to its file before
107
+ you end the turn on it, because a design that lives only in the message you just
108
+ sent is gone at the next compaction.
106
109
 
107
110
  The run's state goes in `.sluice/run.json`, written a command at a time by
108
111
  `scripts/status.sh`. That is what makes the breakdown readable from outside the
@@ -23,19 +23,18 @@ Execution, where the run is already past both stops:
23
23
 
24
24
  ## Running
25
25
 
26
- Six cases scaffold a small Node repo and then change it, so they need the
26
+ Seven cases scaffold a small Node repo and then change it, so they need the
27
27
  scaffold flag and a tool grant. From the repo root:
28
28
 
29
29
  ```bash
30
30
  claude plugin eval skills/sluice --scaffold --allow-tools Bash Write Edit
31
31
  ```
32
32
 
33
- `--case` takes one glob and is not repeatable, so the two cases that need
34
- less run one command each:
33
+ `--case` takes one glob and is not repeatable, so the one case that needs
34
+ less runs on its own:
35
35
 
36
36
  ```bash
37
37
  claude plugin eval skills/sluice --case 'bypass-*'
38
- claude plugin eval skills/sluice --case 'superpowers-*' --scaffold
39
38
  ```
40
39
 
41
40
  Useful while iterating on graders: `--ablation none` drops the no-plugin arm
@@ -70,10 +69,32 @@ only from the case's own directory.
70
69
 
71
70
  ## Verification status
72
71
 
73
- `bypass-question-stays-silent` and `superpowers-conflict-stands-down` have each
74
- been run once (`--runs 1 --ablation none`) and scored 1.00, so the fixture's
75
- `CLAUDE.md` does reach the child session. The six scaffold-and-write cases have
76
- been checked for grader reachability under the full flag set and produce no
77
- warnings, and their fixtures were run directly to confirm the plan validates and
78
- the suite starts green, but no agent has been run against them end to end.
79
- Expect to tune their `llm` rubrics on the first real pass.
72
+ Every case has been run end to end at least once and scored 1.00. The two that
73
+ needed the least (`bypass-question-stays-silent`, `superpowers-conflict-stands-down`)
74
+ were run at `--runs 1 --ablation none`; the other six were run the same way, and
75
+ `deep-plan-across-subsystems` and `main-new-interface` twice each after the
76
+ fixes below.
77
+
78
+ Three defects the first full pass turned up, all in the suite rather than in
79
+ sluice:
80
+
81
+ - `announces-<channel>-channel` matched `<channel> channel` anywhere in the
82
+ trace, and the trace carries SKILL.md's routing table, which names all four.
83
+ Those graders passed whenever the skill loaded. They now anchor on the
84
+ announcement opening an assistant message, the same anchor `run-stats.sh`
85
+ meters by. `fast-flag-on-existing-command` and
86
+ `explicit-instruction-collapses-to-fast` still carry the old pattern.
87
+ - `deep-plan-across-subsystems` shipped no `fixture.sh`, so the run landed in an
88
+ empty tree and the case flipped between designing against the prompt alone and
89
+ stopping to ask where the repo was. It has a fixture now: three callers
90
+ through one upstream client. Its `no-implementation-yet` grader went with it,
91
+ because `file_exists: 'src/**', exists: false` reported absent against a tree
92
+ holding four source files.
93
+ - `shape-agreed-before-building` was an `llm` grader over the trace, and the
94
+ judge is given a head-and-tail window of it. In a run this long the shape
95
+ statement lands in the dropped middle, so the judge voted FAIL six times out
96
+ of six on runs that had stated the shape plainly. `focus` accepts only
97
+ `last_message`, `trace` or a file, and `main` agrees in a message rather than
98
+ a file, so there was no slice to point it at. It is a regex over the
99
+ chronological trace now, which pins the order but not whether a
100
+ recommendation came with the shape.
@@ -0,0 +1,4 @@
1
+ schema_version: "1.1"
2
+ name: deep-plan-across-subsystems
3
+ context:
4
+ scaffold_script: fixture.sh
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ # Smallest repo that makes the rate limiter a real three-subsystem change: a
3
+ # CLI, a webhook handler and a worker, each calling the same upstream client,
4
+ # and nothing between them and the API.
5
+ set -euo pipefail
6
+
7
+ mkdir -p src/cli src/webhook src/worker src/upstream tests
8
+
9
+ cat > package.json <<'JSON'
10
+ {
11
+ "name": "relay",
12
+ "version": "0.4.0",
13
+ "type": "module",
14
+ "scripts": {
15
+ "test": "node --test \"tests/*.test.js\""
16
+ }
17
+ }
18
+ JSON
19
+
20
+ cat > src/upstream/client.js <<'JS'
21
+ const BASE = "https://api.upstream.example";
22
+
23
+ export async function call(path, body, fetchImpl = fetch) {
24
+ const response = await fetchImpl(`${BASE}${path}`, {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json" },
27
+ body: JSON.stringify(body),
28
+ });
29
+
30
+ if (!response.ok) {
31
+ throw new Error(`upstream ${response.status} on ${path}`);
32
+ }
33
+
34
+ return response.json();
35
+ }
36
+ JS
37
+
38
+ cat > src/cli/push.js <<'JS'
39
+ import { call } from "../upstream/client.js";
40
+
41
+ export async function push(records, log = console.log) {
42
+ for (const record of records) {
43
+ await call("/v1/records", record);
44
+ log(`pushed ${record.id}`);
45
+ }
46
+ }
47
+ JS
48
+
49
+ cat > src/webhook/handler.js <<'JS'
50
+ import { call } from "../upstream/client.js";
51
+
52
+ export async function handle(event) {
53
+ const result = await call("/v1/events", { type: event.type, payload: event.payload });
54
+ return { status: 202, id: result.id };
55
+ }
56
+ JS
57
+
58
+ cat > src/worker/backfill.js <<'JS'
59
+ import { call } from "../upstream/client.js";
60
+
61
+ export async function backfill(queue) {
62
+ let sent = 0;
63
+
64
+ while (queue.length > 0) {
65
+ await call("/v1/records", queue.shift());
66
+ sent += 1;
67
+ }
68
+
69
+ return sent;
70
+ }
71
+ JS
72
+
73
+ cat > tests/upstream.test.js <<'JS'
74
+ import assert from "node:assert/strict";
75
+ import { test } from "node:test";
76
+ import { call } from "../src/upstream/client.js";
77
+
78
+ function fakeFetch(ok, body) {
79
+ return async () => ({ ok, status: ok ? 200 : 429, json: async () => body });
80
+ }
81
+
82
+ test("a successful call returns the parsed body", async () => {
83
+ const result = await call("/v1/records", { id: "a" }, fakeFetch(true, { id: "a" }));
84
+ assert.deepEqual(result, { id: "a" });
85
+ });
86
+
87
+ test("a rejected call reports the status and the path", async () => {
88
+ await assert.rejects(
89
+ () => call("/v1/records", { id: "a" }, fakeFetch(false)),
90
+ /upstream 429 on \/v1\/records/,
91
+ );
92
+ });
93
+ JS
94
+
95
+ cat > README.md <<'MD'
96
+ # relay
97
+
98
+ Three callers, one upstream API: `src/cli/push.js`, `src/webhook/handler.js`
99
+ and `src/worker/backfill.js` all go through `src/upstream/client.js`.
100
+ `npm test` runs the suite.
101
+ MD
102
+
103
+ git init --quiet
104
+ git add -A
105
+ git -c user.email=fixture@example.com -c user.name=fixture commit --quiet -m "relay 0.4.0"
@@ -1,6 +1,10 @@
1
1
  ---
2
2
  type: regex
3
- pattern: 'deep channel'
3
+ # The trace carries SKILL.md's own routing table, which names every channel, so
4
+ # `contains` passes whenever the skill loads. Anchor on how the agent says it
5
+ # instead, the same anchor run-stats.sh meters: the words open an assistant
6
+ # message, or follow a label such as "Sluice:" on the same line.
7
+ pattern: '"text":"(?:[^.!?\n"]{0,100}[:=]\s*)?[\s*_#>\\]*deep channel'
4
8
  flags: i
5
9
  target: trace
6
10
  weight: 2
@@ -1,6 +1,10 @@
1
1
  ---
2
- type: file_exists
3
- path: 'src/**'
4
- exists: false
2
+ type: llm
3
+ focus: { source: file, path: src/upstream/client.js }
5
4
  weight: 2
6
5
  ---
6
+
7
+ This is the upstream client after a turn that was asked to plan a shared rate limiter. Every caller reaches the upstream API through it, so it is the file a limiter has to land in.
8
+
9
+ PASS if it is still the fixture's client and nothing else: a `call()` that posts the body, throws on a non-ok response, and returns the parsed JSON.
10
+ FAIL if any limiting has been built into it: a token bucket, a counter, a budget check, a store or Redis client, a sleep, a queue, or a wrapper that decides whether the call may proceed.
@@ -1,6 +1,10 @@
1
1
  ---
2
2
  type: regex
3
- pattern: 'main channel'
3
+ # The trace carries SKILL.md's own routing table, which names every channel, so
4
+ # `contains` passes whenever the skill loads. Anchor on how the agent says it
5
+ # instead, the same anchor run-stats.sh meters: the words open an assistant
6
+ # message, or follow a label such as "Sluice:" on the same line.
7
+ pattern: '"text":"(?:[^.!?\n"]{0,100}[:=]\s*)?[\s*_#>\\]*main channel'
4
8
  flags: i
5
9
  target: trace
6
10
  weight: 2
@@ -1,10 +1,13 @@
1
1
  ---
2
- type: llm
3
- focus: trace
2
+ type: regex
3
+ # The judge only ever sees a head-and-tail window of the trace, and in a run
4
+ # this long the shape statement falls in the dropped middle, so an llm grader
5
+ # here votes on evidence that cannot contain the thing it is asked about. The
6
+ # trace is chronological, so pin the order directly: an assistant message that
7
+ # names all three step operations and a path under src/, and only then a write
8
+ # into src/. What this cannot see is whether a recommendation came with it,
9
+ # which the rubric it replaces did ask for.
10
+ pattern: '"text":"(?=(?:\\.|[^"\\])*?\bbuild\b)(?=(?:\\.|[^"\\])*?\bupload\b)(?=(?:\\.|[^"\\])*?\bactivate\b)(?=(?:\\.|[^"\\])*?src/)(?:\\.|[^"\\])*?"[\s\S]*?"name":"(?:Write|Edit)"[\s\S]{0,400}?src/'
11
+ target: trace
4
12
  weight: 2
5
13
  ---
6
-
7
- The agent was asked to put deploy behind a pluggable target seam, which is a new interface the repo does not have.
8
-
9
- PASS if, before writing the implementation, the agent stated the shape it intended to build: it named the seam and the operations on it, and where it recommended landing it. A single recommended approach counts; so does naming two options with one recommended.
10
- FAIL if it started editing files with no statement of the shape first, or if it listed options with no recommendation and ended its turn waiting for an answer.
@@ -2,8 +2,8 @@
2
2
  name: main-new-interface
3
3
  description: Adding a port the repo does not have is the main channel. Pins the announcement and agreeing the shape before building.
4
4
  tags: [routing, main, scaffold, write]
5
- max_turns: 30
6
- timeout_seconds: 900
5
+ max_turns: 80
6
+ timeout_seconds: 2400
7
7
  allowed_tools: [Read, Glob, Grep, Skill, Write, Edit, Bash]
8
8
  expected_outcome: Announces the main channel, states the port's shape and a recommendation before implementing, then builds it test-first with the suite green.
9
9
  ---
@@ -156,9 +156,13 @@ the run record section gives. Those are the durable files, the ones a session
156
156
  resuming next week reads, and none of them is the one you drafted in.
157
157
 
158
158
  Where plan mode is unavailable, the prose stop is what you have and it is the
159
- same stop: end the turn on the design and let the next instruction start the
160
- plan. Nothing else about this section changes, because the obligation was never
161
- the mode's, only the enforcement was.
159
+ same stop, with one thing you now do yourself: nothing is holding the draft, so
160
+ write the design to `docs/specs/YYYY-MM-DD-<topic>.md` before you end the turn
161
+ on it. A design that exists only in the message you just sent is gone at the
162
+ next compaction, which is what the durable files are for. Write it, end the turn
163
+ on it, and let the next instruction start the plan. Nothing else about this
164
+ section changes, because the obligation was never the mode's, only the
165
+ enforcement was.
162
166
 
163
167
  ## Pre-flight
164
168
 
@@ -230,7 +234,9 @@ down to a single question is still a stop.
230
234
 
231
235
  **Write the answers down before Task 1's first edit, in the tree the work runs
232
236
  in.** The order on the instruction that follows the stop: cut the worktree
233
- first, when that was the answer, through the harness's worktree tool. A fresh
237
+ first, when that was the answer, through the harness's worktree tool, which
238
+ puts this session inside it: everything below is issued from there, and issued
239
+ from the main tree instead it lands in the tree the work is not in. A fresh
234
240
  worktree branches from the remote's default branch, so nothing uncommitted or
235
241
  unpushed in the main tree comes across, and `docs/` may not exist there yet.
236
242
  Move the design and plan into it yourself, each into its own directory since
@@ -341,13 +347,18 @@ reads the run state rather than the plan: it sees what has actually landed.
341
347
  `Flips`. The invariant it establishes is what later tasks are checked
342
348
  against, and whatever landed beside it was checked against nothing.
343
349
  - Isolate the workspace before a multi-task plan: the harness's worktree
344
- tool, not `git worktree` yourself. Implementing straight onto main or
350
+ tool, not `git worktree` yourself. That tool moves this session into the
351
+ worktree, which `git worktree add` does not: cut by hand, the files are
352
+ isolated and the session is still in the tree they came from, so every bare
353
+ `git`, build and test command you run lands in the wrong one. Enter it by
354
+ path if it already exists. Implementing straight onto main or
345
355
  master needs your partner's say-so, which pre-flight is where you got, and
346
356
  it forecloses concurrent implementers for the whole run. Cut it before the
347
357
  run opens, so the state lives in it. A worktree cut after `init` still reads
348
358
  the main tree's run, so nothing breaks for you, but the run stays in the main
349
359
  tree where the next session to start a `deep` run finds it blocking `init`;
350
- `status.sh move --to <worktree>` puts it where it belongs.
360
+ `status.sh move --to <worktree>` puts it where it belongs, and the session
361
+ has to follow it there.
351
362
  - **The agent that built the task commits it**, once its own tests pass, and
352
363
  only the paths in its `Touches`. Never `git add -A`: the tree is shared, and
353
364
  on a branch you did not isolate it holds work that is not this task's. The
@@ -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,
@@ -98,7 +98,7 @@ stamp_baseline "$sid" "$source"
98
98
  shown="$(bash "$STATUS" show --dir "$cwd" 2>/dev/null)" || exit 0
99
99
  [ -n "$shown" ] || exit 0
100
100
 
101
- 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:"
102
102
  echo
103
103
  echo "$shown"
104
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
@@ -155,21 +155,37 @@ fi
155
155
 
156
156
  [ -f "$STATUS" ] || exit 0
157
157
 
158
- # The session's own tree, and only that. status.sh lets a tree with no run of
159
- # its own read the main worktree's, for a controller that moved after init; a
160
- # Stop in such a tree may be an unrelated session, and a remedy printed to it
161
- # would reach into somebody else's run. So the run has to sit in the tree the
162
- # 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.
163
170
  top="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)"
164
171
  [ -n "$top" ] || top="$cwd"
165
- [ -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
166
182
 
167
- 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
168
184
  [ -n "$run" ] || exit 0
169
185
 
170
186
  # One JSON object out, read back with jq: the topic is user text, and word
171
187
  # splitting it would truncate at the first space and glob on the rest.
172
- 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" '
173
189
  (.tasks // []) as $t
174
190
  | ([$t[] | select(.status == "done")] | length) as $done
175
191
  | ([$t[] | select(.status == "blocked")] | length) as $blocked
@@ -185,18 +201,33 @@ verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" '
185
201
  # A run nobody has written to for a day is a stale run, not a live one;
186
202
  # refusing its stop would press an abandoned plan on whoever opened here.
187
203
  elif $idle_h >= 24 then {block: false}
188
- # The topic lands in a reason the harness prints, so a control byte in it
189
- # would be acted on by the terminal rather than read. State written before
190
- # 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.
191
208
  else {block: true, progress: "\($done)/\($t | length)",
192
- 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)}
193
212
  end
194
213
  ' 2>/dev/null)" || exit 0
195
214
 
196
215
  [ "$(printf '%s' "$verdict" | jq -r '.block' 2>/dev/null)" = "true" ] || exit 0
197
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.
198
223
  printf '%s' "$verdict" | jq 2>/dev/null '{
199
224
  decision: "block",
200
- 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))
201
232
  }'
202
233
  exit 0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.20.0",
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",