@iceinvein/agent-skills 0.15.1 → 0.17.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/dist/cli/index.js CHANGED
@@ -299,6 +299,9 @@ function validateManifest(data) {
299
299
  if (a.claudeHookScript !== undefined && typeof a.claudeHookScript !== "string") {
300
300
  return { ok: false, error: "'activation.claudeHookScript' must be a string" };
301
301
  }
302
+ if (a.claudeStopScript !== undefined && typeof a.claudeStopScript !== "string") {
303
+ return { ok: false, error: "'activation.claudeStopScript' must be a string" };
304
+ }
302
305
  }
303
306
  return { ok: true, manifest: d };
304
307
  }
@@ -522,6 +525,51 @@ async function unwireSessionStartHook(settingsPath, skillName, directive) {
522
525
  await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
523
526
  `);
524
527
  }
528
+ async function wireStopHook(settingsPath, skillName, scriptPath) {
529
+ let settings = {};
530
+ if (existsSync2(settingsPath)) {
531
+ settings = await Bun.file(settingsPath).json();
532
+ }
533
+ if (!settings.hooks)
534
+ settings.hooks = {};
535
+ if (!settings.hooks.Stop)
536
+ settings.hooks.Stop = [];
537
+ const command = `if [ -f ${shq(scriptPath)} ]; then bash ${shq(scriptPath)}; fi`;
538
+ let found = false;
539
+ for (const group of settings.hooks.Stop) {
540
+ for (const hook of group.hooks ?? []) {
541
+ if (hook.skill !== skillName)
542
+ continue;
543
+ found = true;
544
+ hook.command = command;
545
+ }
546
+ }
547
+ if (!found) {
548
+ settings.hooks.Stop.push({ hooks: [{ type: "command", command, skill: skillName }] });
549
+ }
550
+ mkdirSync(dirname(settingsPath), { recursive: true });
551
+ await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
552
+ `);
553
+ }
554
+ async function unwireStopHook(settingsPath, skillName) {
555
+ if (!existsSync2(settingsPath))
556
+ return;
557
+ const settings = await Bun.file(settingsPath).json();
558
+ const stop = settings.hooks?.Stop;
559
+ if (!stop)
560
+ return;
561
+ const filtered = stop.map((group) => ({ hooks: (group.hooks ?? []).filter((h) => h.skill !== skillName) })).filter((group) => group.hooks.length > 0);
562
+ if (filtered.length === 0) {
563
+ delete settings.hooks.Stop;
564
+ } else {
565
+ settings.hooks.Stop = filtered;
566
+ }
567
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
568
+ delete settings.hooks;
569
+ }
570
+ await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
571
+ `);
572
+ }
525
573
  var claudeAdapter = {
526
574
  name: "claude",
527
575
  async install(cwd, manifest, files, activation) {
@@ -584,6 +632,13 @@ var claudeAdapter = {
584
632
  installed.push(".claude/settings.json");
585
633
  }
586
634
  }
635
+ if (activation === "global" && manifest.activation?.claudeStopScript && config.bundleRoot) {
636
+ const settingsPath = join2(cwd, ".claude/settings.json");
637
+ await wireStopHook(settingsPath, manifest.name, join2(cwd, config.bundleRoot, manifest.activation.claudeStopScript));
638
+ if (!installed.includes(".claude/settings.json")) {
639
+ installed.push(".claude/settings.json");
640
+ }
641
+ }
587
642
  if (config.postinstall && config.bundleRoot) {
588
643
  const scriptPath = join2(cwd, config.bundleRoot, config.postinstall);
589
644
  const result = runScript(scriptPath, join2(cwd, config.bundleRoot));
@@ -646,6 +701,9 @@ var claudeAdapter = {
646
701
  const settingsPath = join2(cwd, ".claude/settings.json");
647
702
  await unwireSessionStartHook(settingsPath, manifest.name, manifest.activation.claudeHookDirective);
648
703
  }
704
+ if (manifest.activation?.claudeStopScript) {
705
+ await unwireStopHook(join2(cwd, ".claude/settings.json"), manifest.name);
706
+ }
649
707
  }
650
708
  };
651
709
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.15.1",
3
+ "version": "0.17.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.16.1"
286
+ "version": "0.18.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -155,7 +155,9 @@ there at all, which `references/deep-channel.md` handles separately. Review that
155
155
  turns out to be missing is only actionable while the plan can still change.
156
156
 
157
157
  All three answers go into `run.json` and the run record before Task 1's first
158
- edit, the answer in the first and the reason in the second. Those rows are what
158
+ edit, the answer in the first and the reason in the second, and both files
159
+ open inside the worktree once it is cut, not in the main tree before it
160
+ exists. Those rows are what
159
161
  discharge pre-flight, not the approval: one reply arrives for several
160
162
  obligations, so a "yes" with no rows behind it signed off the plan and nothing
161
163
  else.
@@ -167,9 +169,14 @@ something, not quietly ship without one, and the run ends: `status.sh final`
167
169
  when the plan's own review clears, `status.sh close` once the work is no longer
168
170
  yours to act on, after a local merge, when the branch is left as it stands, or
169
171
  when an open PR lands, not when it opens. A run left open reads as live to the
170
- statusline and blocks the next one. The SessionStart hook prints a live run at every session start,
171
- compaction and resume included, so a run you did not start is one you were
172
- shown, not one you have to remember.
172
+ statusline and blocks the next one. The SessionStart hook prints a live run at
173
+ every session start, compaction and resume included, so a run you did not
174
+ start is one you were shown, not one you have to remember. A Stop hook refuses,
175
+ once per turn, to end a turn while a `deep` run in this tree is past pre-flight
176
+ with tasks still to go and nothing `blocked` or paused:
177
+ `references/deep-channel.md` says why a run never ends a turn between
178
+ pre-flight and the handback, and `status.sh pause --reason` is how one stands
179
+ still on purpose.
173
180
 
174
181
  ## Conflicts
175
182
 
@@ -89,9 +89,14 @@ in follows from who has to read it.
89
89
  and commit, its tier, its `Model` mark and its `Flips` line, and the answers
90
90
  pre-flight settled. `scripts/status.sh` writes and reads it, and
91
91
  `references/status.md` carries the commands and the statusline segment that
92
- makes a run visible without anyone asking. Open it with `init` when you open the
93
- record, seed the rows with `scripts/plan.sh import <plan>` rather than typing a
94
- command per task, then flip each task as it moves. It carries the ids, the names,
92
+ makes a run visible without anyone asking. Open it with `init` when you open
93
+ the record, which is after pre-flight and inside the tree the work runs in, the
94
+ worktree when pre-flight bought one: a run opened in the main tree before the
95
+ worktree exists lives where every later session's `init` collides with it, and
96
+ that session cannot tell an abandoned run from yours. One already stranded
97
+ there moves with `status.sh move --to <worktree>`. Seed the rows with
98
+ `scripts/plan.sh import <plan>` rather than typing a command per task, then
99
+ flip each task as it moves. It carries the ids, the names,
95
100
  the flip, the model marks and the tiers, the last of these floored off `Touches`
96
101
  and the contract graph rather than guessed. Import is safe to re-run: a status, a
97
102
  review mark or a ratified model already recorded is left alone and a tier is only
@@ -144,10 +149,11 @@ mode has exited and the plan is written. One enforced gate does not collapse two
144
149
  stops into one; it only makes the first of them hold.
145
150
 
146
151
  **The harness's plan file is not the artifact.** It belongs to the mode and not
147
- to the run. On approval, write the design to `docs/specs/YYYY-MM-DD-<topic>.md`,
148
- the plan to `docs/plans/YYYY-MM-DD-<topic>.md`, and open the run record and
149
- `run.json`. Those are the durable files, the ones a session resuming next week
150
- reads, and none of them is the one you drafted in.
152
+ to the run. On approval, write the design to `docs/specs/YYYY-MM-DD-<topic>.md`
153
+ and the plan to `docs/plans/YYYY-MM-DD-<topic>.md`. The run record and
154
+ `run.json` open after pre-flight, in the tree the work runs in, for the reason
155
+ the run record section gives. Those are the durable files, the ones a session
156
+ resuming next week reads, and none of them is the one you drafted in.
151
157
 
152
158
  Where plan mode is unavailable, the prose stop is what you have and it is the
153
159
  same stop: end the turn on the design and let the next instruction start the
@@ -215,9 +221,27 @@ is the declared schedule the dispatch rules reject.
215
221
  If one of the two has only one live answer, say which and ask the other. A stop
216
222
  down to a single question is still a stop.
217
223
 
218
- **Write the answers down before Task 1's first edit.** Both files:
219
- `status.sh preflight` for the answers, the run record for the reason each one
220
- went that way. That pair is what discharges pre-flight, rather than the
224
+ **Write the answers down before Task 1's first edit, in the tree the work runs
225
+ in.** The order on the instruction that follows the stop: cut the worktree
226
+ first, when that was the answer, through the harness's worktree tool. A fresh
227
+ worktree branches from the remote's default branch, so nothing uncommitted or
228
+ unpushed in the main tree comes across, and `docs/` may not exist there yet.
229
+ Move the design and plan into it yourself, each into its own directory since
230
+ the two share a basename and one `mv` into one directory would leave the plan
231
+ where the design was:
232
+
233
+ ```
234
+ mkdir -p <worktree>/docs/specs <worktree>/docs/plans
235
+ mv docs/specs/YYYY-MM-DD-<topic>.md <worktree>/docs/specs/
236
+ mv docs/plans/YYYY-MM-DD-<topic>.md <worktree>/docs/plans/
237
+ ```
238
+
239
+ The main tree is then clean and the worktree holds the only copy. Then `init`,
240
+ `plan.sh import` and `status.sh preflight` from inside it; then the record
241
+ with the reason each answer went that way; then commit design, plan and record
242
+ there, on the branch the work is on rather than on master. Both files carry the
243
+ answers: `status.sh preflight` for what was decided, the run record for why.
244
+ That pair is what discharges pre-flight, rather than the
221
245
  approval you got, and the distinction is the whole point: a stop
222
246
  that carries the plan and pre-flight together has one reply for two obligations,
223
247
  so a bare "yes" satisfies the plan and leaves no trace either way of the
@@ -265,6 +289,22 @@ reads the run state rather than the plan: it sees what has actually landed.
265
289
  compaction; your memory doesn't.
266
290
  - Each task goes to a fresh agent carrying the brief below and nothing this
267
291
  session accumulated. What you hold is yours to hold, not theirs.
292
+ - **The run never ends a turn between pre-flight and the handback.** A
293
+ message with no tool call in it ends the turn, whatever it says, and a run
294
+ handed back that way stands still until your partner notices, which
295
+ overnight is the next morning. So an announcement rides in the same message
296
+ as the dispatch it announces, a wave's completion is followed in the same
297
+ message by `status.sh ready` and the next dispatch, and "T4 goes next" is
298
+ never the last thing a message says. The turns that do end are the two
299
+ stops, a task marked `blocked` because it genuinely needs your partner, and
300
+ the handback. Everything else that has to wait on them goes through one of
301
+ those two doors: a finding still open after three review rounds marks its
302
+ task `blocked`, and re-dispatching it flips the row back to `active` when
303
+ your partner has answered; a mid-run request for a dispatch, or a
304
+ show-or-say offer, is a pause, `status.sh pause --reason "<why>"`, so the reason is on disk and
305
+ the Stop hook lets you go, with `resume` when it moves again. The hook
306
+ refuses once per turn and then lets the next attempt through, so it is a
307
+ nudge with the state in it rather than a wall: the rule is yours to keep.
268
308
  - **Label the dispatch `T<n>: <task name>`.** The harness lists running agents
269
309
  under whatever label the dispatch gave them, so labelled by task that list
270
310
  reads as the plan and labelled anything else it reads as a row of anonymous
@@ -293,9 +333,11 @@ reads the run state rather than the plan: it sees what has actually landed.
293
333
  - Isolate the workspace before a multi-task plan: the harness's worktree
294
334
  tool, not `git worktree` yourself. Implementing straight onto main or
295
335
  master needs your partner's say-so, which pre-flight is where you got, and
296
- it forecloses concurrent implementers for the whole run. The run state
297
- follows the set rather than the tree, so a worktree cut after the plan still
298
- reads the rows the plan seeded and flips them where you are watching.
336
+ it forecloses concurrent implementers for the whole run. Cut it before the
337
+ run opens, so the state lives in it. A worktree cut after `init` still reads
338
+ the main tree's run, so nothing breaks for you, but the run stays in the main
339
+ tree where the next session to start a `deep` run finds it blocking `init`;
340
+ `status.sh move --to <worktree>` puts it where it belongs.
299
341
  - **The agent that built the task commits it**, once its own tests pass, and
300
342
  only the paths in its `Touches`. Never `git add -A`: the tree is shared, and
301
343
  on a branch you did not isolate it holds work that is not this task's. The
@@ -37,7 +37,8 @@ offer, so neither is that pass.
37
37
  Send findings back to the agent that wrote the code: it already holds the
38
38
  task and its reasoning, memory you would otherwise rebuild. Three rounds is
39
39
  the cap, and a finding still open when the third one ends is structural, not
40
- local, so stop there and hand it to your partner.
40
+ local, so stop there and hand it to your partner; in `deep`, mark the task
41
+ `blocked` first, which is what lets that stop through.
41
42
 
42
43
  Receiving a finding: check it against the codebase before acting, and argue
43
44
  back with specifics when it is wrong. Agreeing just to move things along is
@@ -3,7 +3,8 @@
3
3
  This is never offered at the start. What triggers it is a specific moment in
4
4
  the conversation: a question arrives that turns on how something looks rather
5
5
  than on what it means. Offer then, in a message carrying nothing else, and
6
- wait. Plenty of conversations never raise such a question, and in those the
6
+ wait; inside a `deep` run, `status.sh pause --reason` first, since the Stop
7
+ hook otherwise reads that wait as a stalled run. Plenty of conversations never raise such a question, and in those the
7
8
  offer is simply never made.
8
9
 
9
10
  Apply the test to each question rather than deciding once: could you settle
@@ -21,13 +21,18 @@ bash <skill-dir>/scripts/status.sh preflight --review "tier 3 only" --model "6 o
21
21
  bash <skill-dir>/scripts/status.sh show
22
22
  bash <skill-dir>/scripts/status.sh ready
23
23
  bash <skill-dir>/scripts/status.sh final
24
+ bash <skill-dir>/scripts/status.sh move --to <worktree>
25
+ bash <skill-dir>/scripts/status.sh pause --reason "waiting on the API key"
26
+ bash <skill-dir>/scripts/status.sh resume
24
27
  bash <skill-dir>/scripts/status.sh line --full
25
28
  bash <skill-dir>/scripts/status.sh close
26
29
  ```
27
30
 
28
- `--dir <path>` reads another tree, which is what the statusline uses; it names
29
- a tree in the set rather than a state file, so a worktree resolves to the same
30
- run as the tree it was cut from. Statuses
31
+ `--dir <path>` reads another tree, which is what the statusline uses. A tree
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
31
36
  are `todo`, `active`, `review`, `done` and `blocked`. A new id needs `--name`;
32
37
  after that every call is a bare flip, so keeping it current costs one command
33
38
  per transition rather than a paragraph. `close` archives the run under
@@ -44,8 +49,16 @@ which is the row the debt count counts.
44
49
 
45
50
  A task going `active` with no `--base` takes the HEAD of the tree the command
46
51
  is pointed at, `--dir` if given and the current tree otherwise, once; a base
47
- already on the row is kept. Pass `--base` when the implementer's tree is
48
- neither.
52
+ already on the row is kept. Issued from the controller's tree that is the
53
+ controller's HEAD, which is what an implementer worktree cut from that branch
54
+ starts at, so the default is right at dispatch. Where the implementer's tree
55
+ has moved on, pass `--base $(git -C <implementer worktree> rev-parse --short
56
+ HEAD)` rather than `--dir` that tree: with the run in your worktree, `--dir`
57
+ pointed at the implementer's resolves to the main tree and finds no run.
58
+
59
+ `init` reports any other run live in a tree of the same set, without refusing:
60
+ two sessions in two worktrees is legal, and a run stranded in the main tree
61
+ beside a fresh one in a worktree looks the same until someone says so.
49
62
 
50
63
  Every write stamps `updated`. Past a day since the last one, `show` and the
51
64
  statusline both say how long the run has sat idle, because a finished plan
@@ -69,37 +82,35 @@ that `.gitignore` if you want a run tracked; it is only written when absent.
69
82
 
70
83
  ## Worktrees
71
84
 
72
- Ignoring itself is what makes the run invisible to a worktree unless something
73
- is done about it, and a `deep` run makes worktrees after the plan is written:
74
- `git worktree add` gives the implementer a clean checkout, and an ignored
75
- directory is not in a checkout. Read from the tree it was called in, the run
76
- the plan seeded would be absent from every implementer, `init` there would
77
- start a second run nothing else reads, and the worktree would take that state
78
- with it when it went.
79
-
80
- So a tree with no run of its own anchors on the main worktree of its set, and
81
- the set's run covers it: the statusline renders the controller's run in every
82
- implementer's window, and a flip issued from any of those trees lands where
83
- every other one is watching. A tree's own run comes first, though. `init`
84
- always lands in the tree it is given, and every other command reads that
85
- tree's state when it has one, so two sessions working independently in two
86
- worktrees of one repo each keep their own run and neither is shown the
87
- other's. Anchored unconditionally, as this once was, the first `init` in the
88
- set took over every other session's statusline and refused every other `init`.
89
- A submodule anchors on its own checkout, not the superproject's, and a
90
- directory that is no git work tree keeps its run exactly where it sits.
85
+ The run lives with the controller: in the worktree the work runs in when
86
+ pre-flight bought one, in the main tree otherwise. Implementers never read or
87
+ write it, the controller flips every row, so nothing an implementer does
88
+ depends on seeing the run from its own worktree, and two sessions working
89
+ independently in two worktrees of one repo each keep their own run with
90
+ neither shown the other's.
91
+
92
+ `init` therefore always lands in the tree it is given, and every other command
93
+ reads that tree's own state when it has one. A tree with none falls back to the
94
+ main worktree of its set, which is what keeps a session working before this
95
+ rule existed, run in the main tree and worktree cut afterwards, reading the run
96
+ it started. That run stays in the main tree, where it blocks the next
97
+ session's `init`; `move --to <worktree>` relocates it, refusing a destination
98
+ that already holds a run or that is not a work tree of the same repository. A
99
+ submodule anchors on its own checkout, not the superproject's, and a directory
100
+ that is no git work tree keeps its run exactly where it sits.
91
101
 
92
102
  One file for several writers is one file to contend on, so `init`, `task`,
93
- `preflight`, `final` and `close` take a lock first: two flips issued at the
94
- same moment from different trees would otherwise have the later write built on
95
- a snapshot taken before the earlier one landed, dropping that row without
96
- saying so. The lock
103
+ `preflight`, `final`, `pause`, `resume`, `close` and `move` take a lock first, `move` taking the
104
+ destination tree's as well as its own: two flips issued at the same moment
105
+ from different trees would otherwise have the later write built on a snapshot
106
+ taken before the earlier one landed, dropping that row without saying so. The lock
97
107
  carries its holder's pid, so a killed run is broken through rather than waited
98
108
  out. Reads take nothing, state being installed through a rename, which is what
99
109
  keeps `line` cheap enough to render on.
100
110
 
101
111
  Open it with `init` when you open the run record, at the same point and for the
102
- same reason, then seed the rows with `plan.sh import <plan>` rather than a
112
+ same reason, and in the same tree: after pre-flight, inside the worktree when
113
+ one was bought. Then seed the rows with `plan.sh import <plan>` rather than a
103
114
  command per task. The ids, names, the flip, the `Model` marks and the tiers are
104
115
  all fixed the moment the plan is written and are already in the file, so typing
105
116
  them again is transcription with a chance of error in it.
@@ -234,6 +245,33 @@ start, that a run not being continued was left open and wants `close`. A tree
234
245
  with no run prints nothing. The reading-back rule above still stands; this is
235
246
  the harness doing it at the one moment memory has just been cut.
236
247
 
248
+ ## On stopping
249
+
250
+ `scripts/stop-guard.sh` is the Stop hook a global install wires. When the model
251
+ tries to end its turn it reads the run in the session's own tree, the git
252
+ top level of the session's working directory, and refuses, with a reason, when
253
+ a `deep` run there is past pre-flight, has tasks still `todo`, `active` or
254
+ `review`, and nothing is `blocked` or paused. The reason says what to do
255
+ instead: dispatch the next wave in the same message, mark the task that needs
256
+ your partner `blocked`, `pause --reason` and say so, or `close` a run that is
257
+ not this session's work. Every real stop is let through: no run in the
258
+ session's tree (the main-tree fallback other commands use is not taken here,
259
+ since a Stop in a runless worktree may be an unrelated session), a channel
260
+ other than `deep`, pre-flight not yet recorded, a blocked task, a paused run,
261
+ every task done, a run idle for a day, and a turn where the harness says a
262
+ stop hook already fired, which is what keeps it from looping. That last rule
263
+ means it refuses once per turn and lets the next attempt through: a nudge, not
264
+ a wall. The gate keys on the git top level of the session's working
265
+ directory and nothing else, so the run has to live in the tree the session
266
+ works in: open it after the worktree is cut, or `move` it there and then enter
267
+ that worktree, since `move` relocates the run and not the session, and a run
268
+ moved out from under a session still sitting in the main tree leaves that
269
+ session unguarded for the rest of the run.
270
+
271
+ `pause --reason <text>` records why a run is standing still; `show` and the
272
+ statusline carry it, and `resume` clears it. A pause with no reason is refused,
273
+ since the reason is the only thing that separates a pause from a stall.
274
+
237
275
  ## Statusline
238
276
 
239
277
  This is the part that makes a run visible without anyone asking. Give it rows of
@@ -263,11 +301,12 @@ then print it last, after whatever else the command emits:
263
301
  if [ -n "$sluice_line" ]; then printf '%s\n' "$sluice_line"; fi
264
302
  ```
265
303
 
266
- The gate is two tests because a linked worktree holds no state file of its own:
267
- there `.git` is a regular file naming the tree it was cut from, and the script
268
- resolves the run from it. In a tree with no run and no worktree behind it `.git`
269
- is a directory, so both tests fail and no process is spawned, which is the
270
- property the gate is for.
304
+ The gate is two tests because a linked worktree may hold no state file of its
305
+ own and still belong to a set with a run: there `.git` is a regular file naming
306
+ the tree it was cut from, and the script falls back to that tree. A worktree
307
+ with its own run passes the first test. In a tree with no run and no worktree
308
+ behind it `.git` is a directory, so both tests fail and no process is spawned,
309
+ which is the property the gate is for.
271
310
 
272
311
  `if` rather than `[ ... ] &&`: as the last command of a statusline script the
273
312
  short form makes it exit 1 on every render with no run live, which is the common
@@ -16,6 +16,9 @@
16
16
  # status.sh show [--json]
17
17
  # status.sh ready
18
18
  # status.sh final
19
+ # status.sh move --to <tree>
20
+ # status.sh pause --reason <text>
21
+ # status.sh resume
19
22
  # status.sh line [--full]
20
23
  # status.sh close
21
24
  #
@@ -24,7 +27,8 @@
24
27
  # directory ignores itself, so no project needs a .gitignore line for it.
25
28
  #
26
29
  # Exit: 0 ok, 1 the state could not be written, 2 no live run, 3 a run is
27
- # already live, 4 bad arguments, 5 jq missing, 6 the state file is unreadable.
30
+ # already live (here, or at move's destination), 4 bad arguments, 5 jq missing,
31
+ # 6 the state file is unreadable.
28
32
  # `line` is exempt and always exits 0 in silence, because a statusline renders
29
33
  # on every keystroke and has nowhere to put an error.
30
34
  #
@@ -40,7 +44,7 @@ err() { echo "status.sh: $*" >&2; }
40
44
 
41
45
  usage() {
42
46
  echo "usage:" >&2
43
- sed -n '/^# status.sh init/,/^# 5 jq missing/p' "$0" | sed 's/^# \{0,2\}//' >&2
47
+ sed -n '/^# status.sh init/,/unreadable\.$/p' "$0" | sed 's/^# \{0,2\}//' >&2
44
48
  }
45
49
 
46
50
  # A flag's value has to be checked before `shift 2`, not after. Bash refuses to
@@ -146,7 +150,8 @@ if [ "$SUB" = "line" ]; then
146
150
  (.channel // "?"),
147
151
  "\($done)/\(.tasks | length)",
148
152
  ([.tasks[]? | select(.status == "active") | "▸T\(.id)"] | first // empty),
149
- ([.tasks[]? | select(.status == "blocked") | "!T\(.id)"] | first // empty)
153
+ ([.tasks[]? | select(.status == "blocked") | "!T\(.id)"] | first // empty),
154
+ (if .paused then "paused" else empty end)
150
155
  ] | join(" ")
151
156
  ' "$STATE" 2>/dev/null || exit 0
152
157
  exit 0
@@ -229,6 +234,7 @@ if [ "$SUB" = "line" ]; then
229
234
  ] | join_parts)
230
235
  + (if $clock == "" then "" else " " + paint("2"; $clock) end)
231
236
  + (if $idle == "" then "" else " " + paint("2"; "·") + " " + paint("33"; $idle) end)
237
+ + (if .paused then " " + paint("2"; "·") + " " + paint("33"; "paused") else "" end)
232
238
  ),
233
239
  # The flip is drawn as a rule before its task: everything left of it is
234
240
  # inert and safe to leave landed, everything right of it is not. That is
@@ -269,9 +275,9 @@ require_readable() {
269
275
  # runs in having to add a line to its own .gitignore. `*` matches the .gitignore
270
276
  # file too, so the whole directory drops out of `git status`. An existing file is
271
277
  # left alone, and a tree that refuses the write still gets its run.
272
- mk_dir() { # <directory to create under .sluice>
278
+ mk_dir() { # <directory to create under .sluice> [<tree whose .sluice it is, default $DIR>]
273
279
  mkdir -p "$1" || { err "could not create $1"; exit 1; }
274
- local ignore="$DIR/.sluice/.gitignore"
280
+ local ignore="${2:-$DIR}/.sluice/.gitignore"
275
281
  [ -e "$ignore" ] || printf '*\n' >"$ignore" 2>/dev/null || true
276
282
  }
277
283
 
@@ -313,30 +319,31 @@ write_state() {
313
319
  # later one. A lock whose holder is gone is broken rather than waited out, and
314
320
  # one whose holder is alive is waited on for a bounded time and then reported,
315
321
  # because a command that hangs in a status bar is worse than one that fails.
316
- lock_taken=0
322
+ # `move` spans two trees and holds both locks, so the locks taken are a list.
323
+ LOCKS_TAKEN=()
317
324
  release_lock() {
318
- [ "$lock_taken" -eq 1 ] || return 0
319
- rm -rf "$LOCK"
320
- lock_taken=0
325
+ local l
326
+ for l in "${LOCKS_TAKEN[@]+"${LOCKS_TAKEN[@]}"}"; do rm -rf "$l"; done
327
+ LOCKS_TAKEN=()
321
328
  }
322
329
 
323
- take_lock() {
324
- local waited=0 holder
325
- while ! mkdir "$LOCK" 2>/dev/null; do
326
- holder="$(cat "$LOCK/pid" 2>/dev/null)"
330
+ take_lock() { # [<lock path>, default the run's own]
331
+ local lock="${1:-$LOCK}" waited=0 holder
332
+ while ! mkdir "$lock" 2>/dev/null; do
333
+ holder="$(cat "$lock/pid" 2>/dev/null)"
327
334
  if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then
328
- rm -rf "$LOCK"
335
+ rm -rf "$lock"
329
336
  continue
330
337
  fi
331
338
  if [ "$waited" -ge 100 ]; then
332
- err "another sluice command has held $LOCK for 10s; remove it if nothing is running"
339
+ err "another sluice command has held $lock for 10s; remove it if nothing is running"
333
340
  exit 1
334
341
  fi
335
342
  sleep 0.1
336
343
  waited=$((waited + 1))
337
344
  done
338
- printf '%s\n' "$$" >"$LOCK/pid" 2>/dev/null || true
339
- lock_taken=1
345
+ printf '%s\n' "$$" >"$lock/pid" 2>/dev/null || true
346
+ LOCKS_TAKEN+=("$lock")
340
347
  trap release_lock EXIT INT TERM
341
348
  }
342
349
 
@@ -361,10 +368,25 @@ case "$SUB" in
361
368
  take_lock
362
369
  if [ -f "$STATE" ] && [ "$FORCE" -eq 0 ]; then
363
370
  live="$(jq -r '.topic // "?"' "$STATE" 2>/dev/null || echo "?")"
364
- err "a run is already live (topic: $live); pass --force to replace it"
371
+ err "a run is already live (topic: $live). If it is yours and your work runs in a worktree, put it there: status.sh move --to <worktree>. If it is another session's, leave it and start yours from your own worktree. If it is finished, status.sh close; --force replaces it"
365
372
  exit 3
366
373
  fi
367
374
 
375
+ # Other trees in the set may hold runs of their own, legitimately or as
376
+ # one stranded before its session moved into a worktree. Said, never
377
+ # refused: the session starting here cannot tell which, and the one that
378
+ # can is the owner.
379
+ if [ "$(git -C "$DIR" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ]; then
380
+ here="$(cd "$DIR" && pwd -P)"
381
+ git -C "$DIR" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | while IFS= read -r tree; do
382
+ [ -d "$tree" ] || continue
383
+ [ "$(cd "$tree" && pwd -P)" != "$here" ] || continue
384
+ [ -f "$tree/.sluice/run.json" ] || continue
385
+ other="$(jq -r '.topic // "?"' "$tree/.sluice/run.json" 2>/dev/null || echo "?")"
386
+ err "note: another run is live in $tree (topic: $other); if it is this work stranded before a worktree was cut, move it there instead"
387
+ done
388
+ fi
389
+
368
390
  jq -n \
369
391
  --arg topic "$TOPIC" \
370
392
  --arg channel "$CHANNEL" \
@@ -563,6 +585,7 @@ case "$SUB" in
563
585
  else ["idle \($h / 24 | floor)d\($h % 24)h since the last write"]
564
586
  end
565
587
  end)
588
+ + (if .paused then ["paused \(.paused)"] else [] end)
566
589
  + (([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
567
590
  | if $debt == 0 then [] else ["unreviewed \($debt) done, owed a review the tier table promised"] end)
568
591
  + ["final review " + (if .final_review then "done" else "pending" end)]
@@ -664,6 +687,80 @@ case "$SUB" in
664
687
  jq --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.final_review = $now' "$STATE" | write_state
665
688
  ;;
666
689
 
690
+ pause)
691
+ REASON=""
692
+ while [ $# -gt 0 ]; do
693
+ case "$1" in
694
+ --reason) need_value --reason $# "${2-}"; REASON="$2"; shift 2 ;;
695
+ *) err "unknown flag: $1"; exit 4 ;;
696
+ esac
697
+ done
698
+ [ -n "$REASON" ] || { err "pause needs --reason <text>: a pause nobody can read the reason for is a stall"; exit 4; }
699
+ require_run
700
+ take_lock
701
+ require_readable
702
+
703
+ # A deliberate handback mid-run, which the stop guard otherwise refuses.
704
+ # The reason is the whole point: it is what the partner reads in `show`
705
+ # and what the next session reads to know why the run is standing still.
706
+ jq --arg reason "$REASON" '.paused = $reason' "$STATE" | write_state
707
+ ;;
708
+
709
+ resume)
710
+ [ $# -eq 0 ] || { err "resume takes no arguments"; exit 4; }
711
+ require_run
712
+ take_lock
713
+ require_readable
714
+ jq 'del(.paused)' "$STATE" | write_state
715
+ ;;
716
+
717
+ move)
718
+ TO=""
719
+ while [ $# -gt 0 ]; do
720
+ case "$1" in
721
+ --to) need_value --to $# "${2-}"; TO="$2"; shift 2 ;;
722
+ *) err "unknown flag: $1"; exit 4 ;;
723
+ esac
724
+ done
725
+ [ -n "$TO" ] || { err "move needs --to <tree>"; exit 4; }
726
+ [ -d "$TO" ] || { err "no such directory: $TO"; exit 4; }
727
+ TO="$(cd "$TO" && pwd -P)"
728
+ require_run
729
+
730
+ # The destination is taken as given rather than anchored: the whole point
731
+ # is to put the run in one particular tree, the controller's worktree,
732
+ # which anchoring would resolve straight back to the main tree it is
733
+ # leaving. It does have to be a work tree of the same set: a typo would
734
+ # otherwise strand the run at a path no command issued from the tree
735
+ # resolves, and the only way back is knowing where it went.
736
+ src_common="$(git -C "$DIR" rev-parse --git-common-dir 2>/dev/null)"
737
+ dst_common="$(git -C "$TO" rev-parse --git-common-dir 2>/dev/null)"
738
+ dst_top="$(git -C "$TO" rev-parse --show-toplevel 2>/dev/null)"
739
+ if [ -n "$src_common" ]; then
740
+ src_common="$(cd "$DIR" && cd "$src_common" 2>/dev/null && pwd -P)"
741
+ dst_common="$([ -n "$dst_common" ] && cd "$TO" && cd "$dst_common" 2>/dev/null && pwd -P)"
742
+ if [ -z "$dst_common" ] || [ "$dst_common" != "$src_common" ] || [ "$dst_top" != "$TO" ]; then
743
+ err "$TO is not a work tree of the same repository as $(cd "$DIR" && pwd -P); move only relocates a run between trees of one set"
744
+ exit 4
745
+ fi
746
+ fi
747
+ [ "$TO" != "$(cd "$DIR" && pwd -P)" ] || { err "the run is already in $TO"; exit 4; }
748
+
749
+ # Both trees' locks: a racing init in the destination takes that tree's
750
+ # lock, not this one's, and the mv would land on top of what it wrote.
751
+ take_lock
752
+ mk_dir "$TO/.sluice" "$TO"
753
+ take_lock "$TO/.sluice/run.lock"
754
+ DEST_STATE="$TO/.sluice/run.json"
755
+ if [ -f "$DEST_STATE" ]; then
756
+ there="$(jq -r '.topic // "?"' "$DEST_STATE" 2>/dev/null || echo "?")"
757
+ err "a run is already live in $TO (topic: $there); close it there first"
758
+ exit 3
759
+ fi
760
+ mv "$STATE" "$DEST_STATE" || { err "could not move $STATE to $DEST_STATE"; exit 1; }
761
+ echo "moved $(jq -r '.topic // "run"' "$DEST_STATE" 2>/dev/null || echo run) to $TO"
762
+ ;;
763
+
667
764
  close)
668
765
  [ $# -eq 0 ] || { err "close takes no arguments"; exit 4; }
669
766
  require_run
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: refuse to end the turn in the middle of a deep run.
3
+ #
4
+ # A turn ends the moment a message carries no tool call, and a run handed back
5
+ # that way has nothing in it for the partner to decide: it just stands still
6
+ # until they notice, which overnight is the next morning. So when a deep run is
7
+ # past pre-flight, has tasks still to go, and nothing is marked blocked or
8
+ # paused, the stop is refused with a reason saying what to do instead.
9
+ #
10
+ # Every stop that is a real stop is let through: no run, a channel other than
11
+ # deep, pre-flight not yet answered (that stop is owed), a blocked task, a run
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:
16
+ # a nudge with the state in it rather than a wall.
17
+ #
18
+ # Reads the harness's stop JSON on stdin for `cwd` and `stop_hook_active`. To
19
+ # refuse, prints {"decision":"block","reason":...} on stdout. Always exits 0.
20
+
21
+ set -uo pipefail
22
+
23
+ here="$(cd "$(dirname "$0")" && pwd)"
24
+ STATUS="$here/status.sh"
25
+
26
+ # jq first: without it nothing below can run, and the stdin read that follows
27
+ # has a worst case worth not paying for nothing.
28
+ command -v jq >/dev/null 2>&1 || exit 0
29
+
30
+ # Byte-wise and bounded, for the reason session-start.sh gives: bash 3.2 drops
31
+ # a timed-out partial read, and an inherited open stdin must not hang the stop.
32
+ input=""
33
+ n=0
34
+ if [ ! -t 0 ]; then
35
+ while IFS= read -r -n 1 -t 2 c; do
36
+ if [ -z "$c" ]; then input="$input
37
+ "; else input="$input$c"; fi
38
+ # Appending a byte at a time is quadratic, and -t bounds each byte rather
39
+ # than the total. Real stop JSON is under a kilobyte; past this the input
40
+ # is not the harness's and is not worth reading on.
41
+ n=$((n + 1))
42
+ [ "$n" -lt 65536 ] || break
43
+ done
44
+ fi
45
+
46
+ cwd="$PWD"
47
+ active="false"
48
+ if [ -n "$input" ]; then
49
+ got="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
50
+ [ -n "$got" ] && cwd="$got"
51
+ active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false)"
52
+ fi
53
+ [ "$active" = "true" ] && exit 0
54
+ [ -d "$cwd" ] || exit 0
55
+ [ -f "$STATUS" ] || exit 0
56
+
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.
62
+ top="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)"
63
+ [ -n "$top" ] || top="$cwd"
64
+ [ -f "$top/.sluice/run.json" ] || exit 0
65
+
66
+ run="$(bash "$STATUS" show --json --dir "$top" 2>/dev/null)" || exit 0
67
+ [ -n "$run" ] || exit 0
68
+
69
+ # One JSON object out, read back with jq: the topic is user text, and word
70
+ # 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)" '
72
+ (.tasks // []) as $t
73
+ | ([$t[] | select(.status == "done")] | length) as $done
74
+ | ([$t[] | select(.status == "blocked")] | length) as $blocked
75
+ | ([$t[] | select(.status == "todo" or .status == "active" or .status == "review")] | length) as $open
76
+ | ((.updated // .started // "" | try fromdateiso8601 catch 0) as $u
77
+ | if $u == 0 then 0 else (($now - $u) / 3600 | floor) end) as $idle_h
78
+ | if (.channel // "") != "deep" then {block: false}
79
+ elif ((.preflight // {}) | length) == 0 then {block: false}
80
+ elif .paused then {block: false}
81
+ elif ($t | length) == 0 then {block: false}
82
+ elif $blocked > 0 then {block: false}
83
+ elif $open == 0 then {block: false}
84
+ # A run nobody has written to for a day is a stale run, not a live one;
85
+ # refusing its stop would press an abandoned plan on whoever opened here.
86
+ elif $idle_h >= 24 then {block: false}
87
+ else {block: true, progress: "\($done)/\($t | length)", topic: (.topic // "run")}
88
+ end
89
+ ' 2>/dev/null)" || exit 0
90
+
91
+ [ "$(printf '%s' "$verdict" | jq -r '.block' 2>/dev/null)" = "true" ] || exit 0
92
+
93
+ printf '%s' "$verdict" | jq 2>/dev/null '{
94
+ decision: "block",
95
+ 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.")
96
+ }'
97
+ exit 0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.16.1",
3
+ "version": "0.18.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",
@@ -29,6 +29,7 @@
29
29
  ],
30
30
  "default": "global",
31
31
  "claudeHookDirective": "Before acting on a request that changes code, pick a sluice channel and state which one.",
32
- "claudeHookScript": "scripts/session-start.sh"
32
+ "claudeHookScript": "scripts/session-start.sh",
33
+ "claudeStopScript": "scripts/stop-guard.sh"
33
34
  }
34
35
  }