@iceinvein/agent-skills 0.16.0 → 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.16.0",
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.17.0"
286
+ "version": "0.18.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -171,7 +171,12 @@ yours to act on, after a local merge, when the branch is left as it stands, or
171
171
  when an open PR lands, not when it opens. A run left open reads as live to the
172
172
  statusline and blocks the next one. The SessionStart hook prints a live run at
173
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.
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.
175
180
 
176
181
  ## Conflicts
177
182
 
@@ -289,6 +289,22 @@ reads the run state rather than the plan: it sees what has actually landed.
289
289
  compaction; your memory doesn't.
290
290
  - Each task goes to a fresh agent carrying the brief below and nothing this
291
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.
292
308
  - **Label the dispatch `T<n>: <task name>`.** The harness lists running agents
293
309
  under whatever label the dispatch gave them, so labelled by task that list
294
310
  reads as the plan and labelled anything else it reads as a row of anonymous
@@ -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
@@ -22,6 +22,8 @@ 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
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
25
27
  bash <skill-dir>/scripts/status.sh line --full
26
28
  bash <skill-dir>/scripts/status.sh close
27
29
  ```
@@ -98,7 +100,7 @@ submodule anchors on its own checkout, not the superproject's, and a directory
98
100
  that is no git work tree keeps its run exactly where it sits.
99
101
 
100
102
  One file for several writers is one file to contend on, so `init`, `task`,
101
- `preflight`, `final`, `close` and `move` take a lock first, `move` taking the
103
+ `preflight`, `final`, `pause`, `resume`, `close` and `move` take a lock first, `move` taking the
102
104
  destination tree's as well as its own: two flips issued at the same moment
103
105
  from different trees would otherwise have the later write built on a snapshot
104
106
  taken before the earlier one landed, dropping that row without saying so. The lock
@@ -243,6 +245,33 @@ start, that a run not being continued was left open and wants `close`. A tree
243
245
  with no run prints nothing. The reading-back rule above still stands; this is
244
246
  the harness doing it at the one moment memory has just been cut.
245
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
+
246
275
  ## Statusline
247
276
 
248
277
  This is the part that makes a run visible without anyone asking. Give it rows of
@@ -17,6 +17,8 @@
17
17
  # status.sh ready
18
18
  # status.sh final
19
19
  # status.sh move --to <tree>
20
+ # status.sh pause --reason <text>
21
+ # status.sh resume
20
22
  # status.sh line [--full]
21
23
  # status.sh close
22
24
  #
@@ -148,7 +150,8 @@ if [ "$SUB" = "line" ]; then
148
150
  (.channel // "?"),
149
151
  "\($done)/\(.tasks | length)",
150
152
  ([.tasks[]? | select(.status == "active") | "▸T\(.id)"] | first // empty),
151
- ([.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)
152
155
  ] | join(" ")
153
156
  ' "$STATE" 2>/dev/null || exit 0
154
157
  exit 0
@@ -231,6 +234,7 @@ if [ "$SUB" = "line" ]; then
231
234
  ] | join_parts)
232
235
  + (if $clock == "" then "" else " " + paint("2"; $clock) end)
233
236
  + (if $idle == "" then "" else " " + paint("2"; "·") + " " + paint("33"; $idle) end)
237
+ + (if .paused then " " + paint("2"; "·") + " " + paint("33"; "paused") else "" end)
234
238
  ),
235
239
  # The flip is drawn as a rule before its task: everything left of it is
236
240
  # inert and safe to leave landed, everything right of it is not. That is
@@ -581,6 +585,7 @@ case "$SUB" in
581
585
  else ["idle \($h / 24 | floor)d\($h % 24)h since the last write"]
582
586
  end
583
587
  end)
588
+ + (if .paused then ["paused \(.paused)"] else [] end)
584
589
  + (([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
585
590
  | if $debt == 0 then [] else ["unreviewed \($debt) done, owed a review the tier table promised"] end)
586
591
  + ["final review " + (if .final_review then "done" else "pending" end)]
@@ -682,6 +687,33 @@ case "$SUB" in
682
687
  jq --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.final_review = $now' "$STATE" | write_state
683
688
  ;;
684
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
+
685
717
  move)
686
718
  TO=""
687
719
  while [ $# -gt 0 ]; do
@@ -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.17.0",
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
  }