@iceinvein/agent-skills 0.17.0 → 0.18.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
@@ -302,6 +302,20 @@ function validateManifest(data) {
302
302
  if (a.claudeStopScript !== undefined && typeof a.claudeStopScript !== "string") {
303
303
  return { ok: false, error: "'activation.claudeStopScript' must be a string" };
304
304
  }
305
+ if (a.claudeStatuslineScript !== undefined && typeof a.claudeStatuslineScript !== "string") {
306
+ return { ok: false, error: "'activation.claudeStatuslineScript' must be a string" };
307
+ }
308
+ const claudeBundleRoot = d.install.claude?.bundleRoot;
309
+ if (claudeBundleRoot === undefined) {
310
+ for (const field of ["claudeHookScript", "claudeStopScript", "claudeStatuslineScript"]) {
311
+ if (a[field] !== undefined) {
312
+ return {
313
+ ok: false,
314
+ error: `'activation.${field}' is resolved against 'install.claude.bundleRoot', which is not set`
315
+ };
316
+ }
317
+ }
318
+ }
305
319
  }
306
320
  return { ok: true, manifest: d };
307
321
  }
@@ -570,6 +584,49 @@ async function unwireStopHook(settingsPath, skillName) {
570
584
  await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
571
585
  `);
572
586
  }
587
+ function statusLineCommand(scriptPath) {
588
+ return `if [ -f ${shq(scriptPath)} ]; then bash ${shq(scriptPath)}; fi`;
589
+ }
590
+ function ownsStatusLine(command, skillName, scriptPath) {
591
+ if (typeof command !== "string")
592
+ return false;
593
+ if (scriptPath !== undefined && command === statusLineCommand(scriptPath))
594
+ return true;
595
+ const shape = /^if \[ -f '(.*)' \]; then bash '(.*)'; fi$/.exec(command);
596
+ if (shape === null || shape[1] !== shape[2])
597
+ return false;
598
+ return shape[1].includes(`/skills/${skillName}/`);
599
+ }
600
+ async function wireStatusLine(settingsPath, skillName, scriptPath) {
601
+ let settings = {};
602
+ if (existsSync2(settingsPath)) {
603
+ settings = await Bun.file(settingsPath).json();
604
+ }
605
+ const existing = settings.statusLine;
606
+ if (existing !== undefined && existing !== null) {
607
+ if (!ownsStatusLine(existing?.command, skillName, scriptPath))
608
+ return;
609
+ }
610
+ const command = statusLineCommand(scriptPath);
611
+ if (existing?.type === "command" && existing?.command === command)
612
+ return;
613
+ settings.statusLine = { type: "command", command };
614
+ mkdirSync(dirname(settingsPath), { recursive: true });
615
+ await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
616
+ `);
617
+ }
618
+ async function unwireStatusLine(settingsPath, skillName, scriptPath) {
619
+ if (!existsSync2(settingsPath))
620
+ return;
621
+ const settings = await Bun.file(settingsPath).json();
622
+ if (settings.statusLine === undefined || settings.statusLine === null)
623
+ return;
624
+ if (!ownsStatusLine(settings.statusLine?.command, skillName, scriptPath))
625
+ return;
626
+ delete settings.statusLine;
627
+ await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
628
+ `);
629
+ }
573
630
  var claudeAdapter = {
574
631
  name: "claude",
575
632
  async install(cwd, manifest, files, activation) {
@@ -639,6 +696,13 @@ var claudeAdapter = {
639
696
  installed.push(".claude/settings.json");
640
697
  }
641
698
  }
699
+ if (activation === "global" && manifest.activation?.claudeStatuslineScript && config.bundleRoot) {
700
+ const settingsPath = join2(cwd, ".claude/settings.json");
701
+ await wireStatusLine(settingsPath, manifest.name, join2(cwd, config.bundleRoot, manifest.activation.claudeStatuslineScript));
702
+ if (!installed.includes(".claude/settings.json")) {
703
+ installed.push(".claude/settings.json");
704
+ }
705
+ }
642
706
  if (config.postinstall && config.bundleRoot) {
643
707
  const scriptPath = join2(cwd, config.bundleRoot, config.postinstall);
644
708
  const result = runScript(scriptPath, join2(cwd, config.bundleRoot));
@@ -704,6 +768,9 @@ var claudeAdapter = {
704
768
  if (manifest.activation?.claudeStopScript) {
705
769
  await unwireStopHook(join2(cwd, ".claude/settings.json"), manifest.name);
706
770
  }
771
+ if (manifest.activation?.claudeStatuslineScript && config.bundleRoot) {
772
+ await unwireStatusLine(join2(cwd, ".claude/settings.json"), manifest.name, join2(cwd, config.bundleRoot, manifest.activation.claudeStatuslineScript));
773
+ }
707
774
  }
708
775
  };
709
776
 
@@ -1143,8 +1210,9 @@ async function removeSkill(cwd, skillName) {
1143
1210
  const fullPath = join7(cwd, file);
1144
1211
  if (existsSync6(fullPath)) {
1145
1212
  await unwireSessionStartHook(fullPath, skillName);
1213
+ await unwireStatusLine(fullPath, skillName);
1146
1214
  }
1147
- warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place, only removing '${skillName}'s SessionStart hook from it; any MCP server entries it owns were not touched.`);
1215
+ warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place, removing '${skillName}'s SessionStart hook and status line from it; any MCP server entries it owns were not touched.`);
1148
1216
  continue;
1149
1217
  }
1150
1218
  const fullPath = join7(cwd, file);
@@ -1236,8 +1304,9 @@ async function removeSkill2(cwd, skillName) {
1236
1304
  const fullPath = join8(cwd, file);
1237
1305
  if (existsSync7(fullPath)) {
1238
1306
  await unwireSessionStartHook(fullPath, skillName);
1307
+ await unwireStatusLine(fullPath, skillName);
1239
1308
  }
1240
- warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place, only removing '${skillName}'s SessionStart hook from it; any MCP server entries it owns were not touched.`);
1309
+ warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place, removing '${skillName}'s SessionStart hook and status line from it; any MCP server entries it owns were not touched.`);
1241
1310
  continue;
1242
1311
  }
1243
1312
  const fullPath = join8(cwd, file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.18.0"
286
+ "version": "0.19.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -272,6 +272,24 @@ session unguarded for the rest of the run.
272
272
  statusline carry it, and `resume` clears it. A pause with no reason is refused,
273
273
  since the reason is the only thing that separates a pause from a stall.
274
274
 
275
+ ## Free text
276
+
277
+ Every value a command stores is later drawn onto a terminal: by the statusline,
278
+ by `show`, by the SessionStart hook and by the stop guard's refusal. A control
279
+ character in one is not text there, it is an instruction the terminal obeys, and
280
+ task names are model-written and routinely pasted out of issue titles. `ESC[2J`
281
+ in a name clears the screen on every render for as long as the run is open; a
282
+ carriage return walks the cursor back over the row just drawn.
283
+
284
+ So every flag value is refused if it carries one, with exit 4 naming the flag,
285
+ rather than being quietly stripped: a name that is not the name the caller
286
+ passed is its own surprise, and no legitimate value has ever needed a control
287
+ byte. `plan.sh import` writes task names through the same door and inherits it.
288
+
289
+ The renders drop them as well. State written before the check existed, or edited
290
+ by hand, is an input like any other, and the only control bytes a render should
291
+ emit are the colour sequences it writes itself.
292
+
275
293
  ## Statusline
276
294
 
277
295
  This is the part that makes a run visible without anyone asking. Give it rows of
@@ -279,20 +297,11 @@ its own rather than a segment among the badges: it then costs nothing when no ru
279
297
  is live and contends with nothing for width when one is, which is what lets the
280
298
  bar be wide and the task carry its name rather than only its number.
281
299
 
282
- Capture it wherever the statusline command builds its other lines, keyed off the
283
- state file existing so a session with no run spawns no process at all:
300
+ Capture it wherever the statusline command builds its other lines, passing the
301
+ directory the session is in and nothing else:
284
302
 
285
303
  ```bash
286
- sluice_line=""
287
- if [ -n "$cwd" ] && { [ -f "$cwd/.sluice/run.json" ] || [ -f "$cwd/.git" ]; }; then
288
- for sluice_sh in "$cwd/.claude/skills/sluice/scripts/status.sh" \
289
- "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/sluice/scripts/status.sh" \
290
- "$HOME/.claude/skills/sluice/scripts/status.sh"; do
291
- [ -f "$sluice_sh" ] || continue
292
- sluice_line=$(bash "$sluice_sh" line --full --dir "$cwd" 2>/dev/null)
293
- break
294
- done
295
- fi
304
+ sluice_line=$(bash "$HOME/.claude/skills/sluice/scripts/statusline.sh" --dir "$cwd" 2>/dev/null)
296
305
  ```
297
306
 
298
307
  then print it last, after whatever else the command emits:
@@ -301,22 +310,46 @@ then print it last, after whatever else the command emits:
301
310
  if [ -n "$sluice_line" ]; then printf '%s\n' "$sluice_line"; fi
302
311
  ```
303
312
 
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.
313
+ `$cwd` is `workspace.current_dir` from the JSON the harness sends on stdin.
314
+ Substitute the install path: `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` where a
315
+ session may set one, or `$cwd/.claude/skills/sluice/scripts/statusline.sh` for a
316
+ project-local install.
317
+
318
+ Those two lines are the whole contract, and they are deliberately empty of
319
+ judgement. Whether a run is visible from a given directory is a question about
320
+ this skill's layout, and the answer has moved twice: once when the run anchored
321
+ on the worktree set, once when a deep run began opening inside the implementer
322
+ worktree. Both times a caller carrying the test went stale and stopped drawing
323
+ without saying so, which is indistinguishable from no run being live. A caller
324
+ that contributes only a path cannot go stale, and every install brings
325
+ `statusline.sh` up to date behind it.
326
+
327
+ `scripts/statusline.sh` holds what used to sit in the caller: it resolves the
328
+ directory it was given, walks up looking for a state file or a worktree marker,
329
+ stops at an ordinary tree root or at `$HOME` with neither, and dispatches to
330
+ `status.sh line --full` only when there is something to draw. The walk is what
331
+ lets a session sitting in a subdirectory see the run in its tree, which testing
332
+ the session's own directory alone never did, and it resolves symlinks first
333
+ because a linked directory's lexical parents lead away from the tree rather than
334
+ up it.
335
+
336
+ It answers only whether a run might be visible from here, never where one is.
337
+ `status.sh` stays the single authority on that, and is handed the directory the
338
+ caller passed rather than the one the walk stopped at. A gate that were ever
339
+ narrower than the resolution behind it would blank the bar on a run that
340
+ resolves perfectly well, which is the failure this whole arrangement exists to
341
+ end, so it errs permissive: a wasted spawn is the acceptable direction.
342
+
343
+ The gate is kept rather than dropped because it costs about 8ms against the 33ms
344
+ an ungated `status.sh` pays to work out there is no run, and the common case on
345
+ any machine is a tree with no run at all. Depth barely moves it, 7.7ms stopping
346
+ at a `.git` directory against 8.4ms walking to the root, because 5.4ms of that
347
+ is the bash spawn and the walk itself forks nothing.
310
348
 
311
349
  `if` rather than `[ ... ] &&`: as the last command of a statusline script the
312
350
  short form makes it exit 1 on every render with no run live, which is the common
313
351
  case. `%s` rather than `%b`: the render already carries real escape bytes, and
314
- `%b` would reinterpret a backslash inside a task name. `$cwd` is
315
- `workspace.current_dir` from the JSON the harness sends on stdin. The configured
316
- config dir is read before the default because a session started with
317
- `CLAUDE_CONFIG_DIR` set installs the skill there, which is the one place a
318
- `$HOME/.claude` lookup will not find it; the two paths collapse to one when the
319
- variable is unset, at the price of a second `[ -f ]`. It renders as:
352
+ `%b` would reinterpret a backslash inside a task name. It renders as:
320
353
 
321
354
  ```
322
355
  ⧗ deep · sluice-cross-harness ◷ 38m
@@ -324,6 +357,25 @@ variable is unset, at the price of a second `[ -f ]`. It renders as:
324
357
  2/9 done · !T6 model tiers rather than model names +1 · ⟲ 1 unreviewed
325
358
  ```
326
359
 
360
+ On a machine with no status line at all there is nothing to paste into, so the
361
+ install claims the empty `statusLine` slot and points it at
362
+ `scripts/statusline-command.sh`, a complete command that reads the harness JSON
363
+ on stdin and draws the run and nothing else. A slot already holding someone
364
+ else's command is never touched, on install or on removal, and the one the
365
+ install claimed is given back when the skill is removed, including on the
366
+ removal path that has no manifest to read.
367
+
368
+ Three things make claiming a single shared slot safe. The command is written
369
+ guarded by its own script's existence, as the hook commands are, so a bundle
370
+ that moved leaves the bar silent rather than running a path that is gone on
371
+ every keystroke. Ownership is matched on the whole command and never as a
372
+ substring, so a command with ours composed into it belongs to whoever composed
373
+ it and is left whole. And the path it matches carries the skill's own directory,
374
+ so the second skill to declare a statusline cannot take the first one's slot on
375
+ install or delete it on removal. That is why the bundled
376
+ command draws no prompt of its own: it exists for the empty slot, not to compete
377
+ for a full one, so with no run live it prints nothing rather than an empty row.
378
+
327
379
  The colour comes out of the script rather than being applied by the caller,
328
380
  because the mapping from state to colour belongs next to the state. A caller that
329
381
  coloured the line itself would have to re-derive each cell's meaning from its
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # Postinstall: tell whoever just installed the skill how to put the run on their
3
+ # status bar, and touch nothing.
4
+ #
5
+ # Wiring it automatically would mean finding their statusline script, and the
6
+ # only thing settings.json holds is the shell command that runs it
7
+ # (`bash "$HOME/.claude/statusline-command.sh"`). Recovering a path from that is
8
+ # parsing a shell string and hoping, on a file the installer never created. So
9
+ # this prints the two lines and leaves the editing to a person.
10
+ #
11
+ # Runs with the bundle root as its working directory, which puts settings.json
12
+ # two levels up whether the install went to $HOME/.claude or to a
13
+ # $CLAUDE_CONFIG_DIR somewhere else. Always exits 0: a postinstall that fails
14
+ # reports an error against an install that in fact succeeded.
15
+
16
+ set -uo pipefail
17
+
18
+ BUNDLE="$PWD"
19
+ SETTINGS="$BUNDLE/../../settings.json"
20
+ RENDER="$BUNDLE/scripts/statusline.sh"
21
+
22
+ [ -f "$SETTINGS" ] || exit 0
23
+ [ -f "$RENDER" ] || exit 0
24
+ command -v jq >/dev/null 2>&1 || exit 0
25
+
26
+ # Nothing is said to someone with no statusline configured. Either the install
27
+ # claimed the empty slot, in which case the branch below reports it, or this is
28
+ # a session-mode install that wires nothing, in which case there is no file to
29
+ # paste into and no instruction worth printing.
30
+ CONFIGURED="$(jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true)"
31
+ [ -n "$CONFIGURED" ] || exit 0
32
+
33
+ RENDER="$(cd "$(dirname "$RENDER")" && pwd)/$(basename "$RENDER")"
34
+
35
+ # The slot points at the bundled command: the install claimed it and there is
36
+ # nothing for anyone to paste. Say so rather than print instructions that would
37
+ # have them edit a file they do not have.
38
+ case "$CONFIGURED" in
39
+ *skills/sluice/scripts/statusline-command.sh*)
40
+ echo
41
+ echo "sluice: your status line now shows a live run. It draws nothing until one"
42
+ echo "is open, and uninstalling gives the setting back."
43
+ exit 0
44
+ ;;
45
+ esac
46
+
47
+ cat <<EOF
48
+
49
+ sluice: to show a live run on your status line, add these to your statusline
50
+ command. The first goes wherever it builds its other lines, the second last of
51
+ all, after everything else it prints:
52
+
53
+ sluice_line=\$(bash "$RENDER" --dir "\$cwd" 2>/dev/null)
54
+
55
+ if [ -n "\$sluice_line" ]; then printf '%s\\n' "\$sluice_line"; fi
56
+
57
+ \$cwd is workspace.current_dir from the JSON the harness sends on stdin. Those
58
+ two lines carry no knowledge of where a run lives, so they stay correct as this
59
+ skill changes; every install updates the script behind them.
60
+ EOF
@@ -60,6 +60,16 @@ need_value() { # <flag> <remaining $#> <candidate>
60
60
  case "$3" in
61
61
  --*) err "$1 needs a value, but the next argument is the flag $3"; exit 4 ;;
62
62
  esac
63
+ # Every value this script stores is later drawn onto a terminal, by the
64
+ # statusline, by `show` and by the SessionStart hook. A control byte in one is
65
+ # not text there, it is a command the terminal obeys: ESC[2J clears the screen
66
+ # on every render for as long as the run is open, and a carriage return walks
67
+ # the cursor back over the row just drawn. Refused rather than stripped,
68
+ # because a name that is not the name the caller passed is its own surprise,
69
+ # and no legitimate value has ever needed one.
70
+ case "$3" in
71
+ *[[:cntrl:]]*) err "$1 value contains a control character, which a terminal would act on rather than print"; exit 4 ;;
72
+ esac
63
73
  }
64
74
 
65
75
  # A word from a space-separated set. Keeps validation in one place so every
@@ -171,6 +181,12 @@ if [ "$SUB" = "line" ]; then
171
181
  jq -r \
172
182
  --argjson now "$(date -u +%s)" \
173
183
  --arg esc "$(printf '\033')" '
184
+ # The state file is an input like any other: it predates the check on the
185
+ # way in, or was hand-edited, so what it holds is not known to be drawable.
186
+ # Everything this render emits deliberately is an SGR colour sequence, so any
187
+ # other control byte reaching the terminal came from the state, and is dropped
188
+ # here rather than obeyed.
189
+ def clean: if type == "string" then gsub("[\u0000-\u001f\u007f]"; "") else . end;
174
190
  def paint($c; $t): "\($esc)[\($c)m\($t)\($esc)[0m";
175
191
  def join_parts: map(select(. != null and . != "")) | join(" \($esc)[2m·\($esc)[0m ");
176
192
  # Done splits in two. A task that is done and was owed a review nobody has
@@ -230,7 +246,7 @@ if [ "$SUB" = "line" ]; then
230
246
  end) as $idle
231
247
  | ( paint("1;96"; "⧗") + " "
232
248
  + ([ paint("1;96"; (.channel // "?")),
233
- paint("2"; (.topic // ""))
249
+ paint("2"; (.topic // "" | clean))
234
250
  ] | join_parts)
235
251
  + (if $clock == "" then "" else " " + paint("2"; $clock) end)
236
252
  + (if $idle == "" then "" else " " + paint("2"; "·") + " " + paint("33"; $idle) end)
@@ -244,8 +260,8 @@ if [ "$SUB" = "line" ]; then
244
260
  + cellgroup($w)
245
261
  ] | join($gap)) ),
246
262
  ( " " + ([ paint("1"; "\($done)/\(.tasks | length)") + " done",
247
- (if $blocked then paint("1;91"; "!T\($blocked.id) \($blocked.name // "")")
248
- elif $active then paint("96"; "▸T\($active.id)") + " " + ($active.name // "")
263
+ (if $blocked then paint("1;91"; "!T\($blocked.id) \($blocked.name // "" | clean)")
264
+ elif $active then paint("96"; "▸T\($active.id)") + " " + ($active.name // "" | clean)
249
265
  else "" end)
250
266
  + (if $attn > 1 then paint("2"; " +\($attn - 1)") else "" end),
251
267
  (if $debt > 0 then paint("33"; "⟲ \($debt) unreviewed") else "" end)
@@ -562,7 +578,12 @@ case "$SUB" in
562
578
  # than silently reading as the whole value.
563
579
  jq -r --argjson now "$(date -u +%s)" '
564
580
  def dash: if . == null or . == "" then "-" else . end;
565
- def cell($w): tostring
581
+ # Same reason as the statusline render: state written before the check
582
+ # on the way in, or edited by hand, holds bytes a terminal would act on
583
+ # rather than print. Every table cell passes through `cell`, so the
584
+ # table is covered there; the lines built outside it clean their own.
585
+ def clean: if type == "string" then gsub("[\u0000-\u001f\u007f]"; "") else . end;
586
+ def cell($w): tostring | clean
566
587
  | if length > $w then .[0:$w - 1] + "…"
567
588
  else . + (" " * ($w - length))
568
589
  end;
@@ -570,9 +591,9 @@ case "$SUB" in
570
591
  ($c[3] | cell(9)), ($c[4] | cell(9)), ($c[5] | cell(4)),
571
592
  $c[6]] | join(" "));
572
593
  ([.tasks[]? | select(.status == "done")] | length) as $done
573
- | ["sluice \(.channel) · \(.topic) · \($done)/\(.tasks | length) done"]
574
- + ["plan \(.plan | dash)"]
575
- + ["record \(.record | dash)"]
594
+ | ["sluice \(.channel | clean) · \(.topic | clean) · \($done)/\(.tasks | length) done"]
595
+ + ["plan \(.plan | dash | clean)"]
596
+ + ["record \(.record | dash | clean)"]
576
597
  # Past a day since the last write the run is idle, and that is said
577
598
  # here because a stale run blocks the next init and nothing else
578
599
  # would name it.
@@ -585,13 +606,13 @@ case "$SUB" in
585
606
  else ["idle \($h / 24 | floor)d\($h % 24)h since the last write"]
586
607
  end
587
608
  end)
588
- + (if .paused then ["paused \(.paused)"] else [] end)
609
+ + (if .paused then ["paused \(.paused | clean)"] else [] end)
589
610
  + (([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
590
611
  | if $debt == 0 then [] else ["unreviewed \($debt) done, owed a review the tier table promised"] end)
591
612
  + ["final review " + (if .final_review then "done" else "pending" end)]
592
613
  + ["pre-flight " + (
593
614
  if (.preflight // {} | length) == 0 then "not recorded"
594
- else [(.preflight | to_entries[] | "\(.key)=\(.value)")] | join("; ")
615
+ else [(.preflight | to_entries[] | "\(.key | clean)=\(.value | clean)")] | join("; ")
595
616
  end)]
596
617
  + [""]
597
618
  + [row(["id", "status", "task", "base", "commit", "tier", "model"])]
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # A complete statusline command, for a machine that had none.
3
+ #
4
+ # `statusline.sh` takes a directory; the harness delivers one as JSON on stdin.
5
+ # This bridges the two, and it is what settings.json can be pointed at directly:
6
+ #
7
+ # "statusLine": { "type": "command", "command": "bash '<this file>'" }
8
+ #
9
+ # It draws the live run and nothing else. Anyone who wants a prompt as well
10
+ # already has a statusline of their own, and the install refuses to overwrite
11
+ # one: this file exists for the empty slot, not to compete for a full one. So
12
+ # with no run live it prints nothing at all rather than an empty row.
13
+ #
14
+ # Always exits 0 in silence on anything it cannot render, for the same reason
15
+ # statusline.sh does: a status bar draws on every keystroke and has nowhere to
16
+ # put an error.
17
+
18
+ set -uo pipefail
19
+
20
+ here="$(cd "$(dirname "$0")" && pwd)"
21
+ RENDER="$here/statusline.sh"
22
+
23
+ [ -f "$RENDER" ] || exit 0
24
+ command -v jq >/dev/null 2>&1 || exit 0
25
+
26
+ # Bounded, and skipped on a terminal. The harness closes stdin after the JSON,
27
+ # so a read to EOF returns, but nothing here enforces that: read from a pipe
28
+ # nobody closes and an unbounded read hangs, which on a status bar is a bar that
29
+ # never draws and on a hand-run check is a frozen terminal with no explanation.
30
+ # `read` is a builtin, so the bound costs no fork against the gate's budget.
31
+ [ -t 0 ] && exit 0
32
+ input=""
33
+ IFS= read -r -d "" -t 2 input || true
34
+ [ -n "$input" ] || exit 0
35
+
36
+ # `cwd` is the older spelling of the same field and both are still sent, so it
37
+ # is a fallback rather than a bug. Neither present is not an error worth a row:
38
+ # it means this is not the JSON we were written against.
39
+ dir="$(printf '%s' "$input" | jq -r '.workspace.current_dir // .cwd // empty' 2>/dev/null || true)"
40
+ [ -n "$dir" ] || exit 0
41
+
42
+ bash "$RENDER" --dir "$dir" 2>/dev/null || exit 0
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # Statusline render: the whole sluice contribution to a status bar, behind one
3
+ # call. The caller passes the directory the session is in and prints whatever
4
+ # comes back.
5
+ #
6
+ # statusline.sh [--dir <path>]
7
+ #
8
+ # --dir defaults to $PWD. Always exits 0 in silence on anything it cannot
9
+ # render: a status bar draws on every keystroke and has nowhere to put an error,
10
+ # so a message here would be permanent clutter rather than a report anyone acts
11
+ # on.
12
+ #
13
+ # Why this exists as its own script rather than as a snippet in the caller.
14
+ # Whether a run is visible from a given directory is a question about sluice's
15
+ # own layout, and the answer has changed twice: once when the run anchored on
16
+ # the worktree set, once when a deep run began opening inside the implementer
17
+ # worktree. Each time, a caller carrying the test went stale and simply stopped
18
+ # drawing, silently, because silence is also what it looks like when no run is
19
+ # live. Callers now contribute a path and nothing else, and every install brings
20
+ # this file up to date behind them.
21
+ #
22
+ # The gate is still here rather than dropped: measured on this machine, it costs
23
+ # about 8ms against the 33ms an ungated status.sh pays to work out there is no
24
+ # run, and the common case on any machine is a tree with no run at all. Depth
25
+ # barely moves it -- 7.7ms stopping at a .git directory, 8.4ms walking to the
26
+ # root -- because the bash spawn is 5.4ms of it and the walk forks nothing.
27
+ # It answers only "might a run be visible from here", never "where is it":
28
+ # status.sh stays the single authority on resolution, and this stays a
29
+ # deliberately over-permissive filter in front of it. A filter that were ever
30
+ # narrower than status.sh would blank the bar on a run the code behind it can
31
+ # render, which is the whole class of bug this file exists to end.
32
+
33
+ set -uo pipefail
34
+
35
+ here="$(cd "$(dirname "$0")" && pwd)"
36
+ STATUS="$here/status.sh"
37
+
38
+ DIR="$PWD"
39
+ while [ $# -gt 0 ]; do
40
+ case "$1" in
41
+ --dir)
42
+ # An omitted value would silently make the next flag the directory.
43
+ [ $# -ge 2 ] && [ "${2#--}" = "$2" ] || exit 0
44
+ DIR="$2"
45
+ shift 2
46
+ ;;
47
+ *) exit 0 ;;
48
+ esac
49
+ done
50
+
51
+ [ -d "$DIR" ] || exit 0
52
+ [ -f "$STATUS" ] || exit 0
53
+
54
+ # Is a run reachable from here at all? Walk up rather than test $DIR alone: a
55
+ # session sitting in a subdirectory of the tree is as entitled to the render as
56
+ # one sitting at its root, and testing only $DIR was a second way for the bar to
57
+ # go blank on a live run.
58
+ #
59
+ # Resolve physically first. A directory reached through a symlink has lexical
60
+ # parents that are not its real ones, and climbing those walks away from the
61
+ # repo instead of up it -- a blank bar on a run status.sh resolves perfectly
62
+ # well, which is this file's own failure mode turned on a new input. `cd`
63
+ # failing here means the path is gone or unreadable, and there is nothing to
64
+ # draw for it.
65
+ d="$(cd "$DIR" 2>/dev/null && pwd -P)" || exit 0
66
+ [ -n "$d" ] || exit 0
67
+
68
+ # Four answers end the walk. A state file is a run, wherever it was found. A
69
+ # `.git` that is a regular file is a linked worktree or a submodule, which may
70
+ # hold no state of its own and still belong to a set that does, so it is a maybe
71
+ # and status.sh resolves it. A `.git` that is a directory is the top of an
72
+ # ordinary tree with no state beside it, which is a no. So is reaching $HOME:
73
+ # without that stop, one forgotten ~/.sluice/run.json would put a status.sh
74
+ # spawn on every keystroke of every session outside a repo, for a run that
75
+ # resolves to nothing.
76
+ #
77
+ # `${d%/*}` rather than `dirname`, which is not a builtin: a fork per level
78
+ # would put the gate's cost in the same range as the render it is avoiding.
79
+ while :; do
80
+ [ -f "$d/.sluice/run.json" ] && break
81
+ [ -f "$d/.git" ] && break
82
+ [ -d "$d/.git" ] && exit 0
83
+ [ "$d" = "${HOME:-}" ] && exit 0
84
+ [ "$d" = "/" ] && exit 0
85
+ d="${d%/*}"
86
+ [ -n "$d" ] || d="/"
87
+ done
88
+
89
+ # $DIR, not the $d the walk stopped at: $d is where the filter gave up looking,
90
+ # which is not the same question as where the run is. status.sh anchors on the
91
+ # worktree set and is the only thing that decides that.
92
+ #
93
+ # `line --full` is silent on everything it cannot read, a missing jq and an
94
+ # unreadable state file included, and exits 0 either way. The render arrives
95
+ # already coloured: the mapping from state to colour belongs next to the state,
96
+ # and a caller that applied it would have to re-derive each cell's meaning from
97
+ # its glyph.
98
+ rendered="$(bash "$STATUS" line --full --dir "$DIR" 2>/dev/null)" || exit 0
99
+ [ -n "$rendered" ] || exit 0
100
+ printf '%s\n' "$rendered"
@@ -84,7 +84,11 @@ verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" '
84
84
  # A run nobody has written to for a day is a stale run, not a live one;
85
85
  # refusing its stop would press an abandoned plan on whoever opened here.
86
86
  elif $idle_h >= 24 then {block: false}
87
- else {block: true, progress: "\($done)/\($t | length)", topic: (.topic // "run")}
87
+ # The topic lands in a reason the harness prints, so a control byte in it
88
+ # would be acted on by the terminal rather than read. State written before
89
+ # status.sh refused those, or edited by hand, can still hold one.
90
+ else {block: true, progress: "\($done)/\($t | length)",
91
+ topic: (.topic // "run" | gsub("[\u0000-\u001f\u007f]"; ""))}
88
92
  end
89
93
  ' 2>/dev/null)" || exit 0
90
94
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -19,7 +19,8 @@
19
19
  "install": {
20
20
  "claude": {
21
21
  "prompt": ".claude/skills/sluice/SKILL.md",
22
- "bundleRoot": ".claude/skills/sluice"
22
+ "bundleRoot": ".claude/skills/sluice",
23
+ "postinstall": "scripts/postinstall.sh"
23
24
  }
24
25
  },
25
26
  "activation": {
@@ -30,6 +31,7 @@
30
31
  "default": "global",
31
32
  "claudeHookDirective": "Before acting on a request that changes code, pick a sluice channel and state which one.",
32
33
  "claudeHookScript": "scripts/session-start.sh",
33
- "claudeStopScript": "scripts/stop-guard.sh"
34
+ "claudeStopScript": "scripts/stop-guard.sh",
35
+ "claudeStatuslineScript": "scripts/statusline-command.sh"
34
36
  }
35
37
  }