@iceinvein/agent-skills 0.16.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 +129 -2
- package/package.json +1 -1
- package/skills/index.json +1 -1
- package/skills/sluice/SKILL.md +6 -1
- package/skills/sluice/references/deep-channel.md +16 -0
- package/skills/sluice/references/review.md +2 -1
- package/skills/sluice/references/show-or-say.md +2 -1
- package/skills/sluice/references/status.md +106 -25
- package/skills/sluice/scripts/postinstall.sh +60 -0
- package/skills/sluice/scripts/status.sh +62 -9
- package/skills/sluice/scripts/statusline-command.sh +42 -0
- package/skills/sluice/scripts/statusline.sh +100 -0
- package/skills/sluice/scripts/stop-guard.sh +101 -0
- package/skills/sluice/skill.json +6 -3
package/dist/cli/index.js
CHANGED
|
@@ -299,6 +299,23 @@ 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
|
+
}
|
|
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
|
+
}
|
|
302
319
|
}
|
|
303
320
|
return { ok: true, manifest: d };
|
|
304
321
|
}
|
|
@@ -522,6 +539,94 @@ async function unwireSessionStartHook(settingsPath, skillName, directive) {
|
|
|
522
539
|
await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
523
540
|
`);
|
|
524
541
|
}
|
|
542
|
+
async function wireStopHook(settingsPath, skillName, scriptPath) {
|
|
543
|
+
let settings = {};
|
|
544
|
+
if (existsSync2(settingsPath)) {
|
|
545
|
+
settings = await Bun.file(settingsPath).json();
|
|
546
|
+
}
|
|
547
|
+
if (!settings.hooks)
|
|
548
|
+
settings.hooks = {};
|
|
549
|
+
if (!settings.hooks.Stop)
|
|
550
|
+
settings.hooks.Stop = [];
|
|
551
|
+
const command = `if [ -f ${shq(scriptPath)} ]; then bash ${shq(scriptPath)}; fi`;
|
|
552
|
+
let found = false;
|
|
553
|
+
for (const group of settings.hooks.Stop) {
|
|
554
|
+
for (const hook of group.hooks ?? []) {
|
|
555
|
+
if (hook.skill !== skillName)
|
|
556
|
+
continue;
|
|
557
|
+
found = true;
|
|
558
|
+
hook.command = command;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (!found) {
|
|
562
|
+
settings.hooks.Stop.push({ hooks: [{ type: "command", command, skill: skillName }] });
|
|
563
|
+
}
|
|
564
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
565
|
+
await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
566
|
+
`);
|
|
567
|
+
}
|
|
568
|
+
async function unwireStopHook(settingsPath, skillName) {
|
|
569
|
+
if (!existsSync2(settingsPath))
|
|
570
|
+
return;
|
|
571
|
+
const settings = await Bun.file(settingsPath).json();
|
|
572
|
+
const stop = settings.hooks?.Stop;
|
|
573
|
+
if (!stop)
|
|
574
|
+
return;
|
|
575
|
+
const filtered = stop.map((group) => ({ hooks: (group.hooks ?? []).filter((h) => h.skill !== skillName) })).filter((group) => group.hooks.length > 0);
|
|
576
|
+
if (filtered.length === 0) {
|
|
577
|
+
delete settings.hooks.Stop;
|
|
578
|
+
} else {
|
|
579
|
+
settings.hooks.Stop = filtered;
|
|
580
|
+
}
|
|
581
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
582
|
+
delete settings.hooks;
|
|
583
|
+
}
|
|
584
|
+
await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
585
|
+
`);
|
|
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
|
+
}
|
|
525
630
|
var claudeAdapter = {
|
|
526
631
|
name: "claude",
|
|
527
632
|
async install(cwd, manifest, files, activation) {
|
|
@@ -584,6 +689,20 @@ var claudeAdapter = {
|
|
|
584
689
|
installed.push(".claude/settings.json");
|
|
585
690
|
}
|
|
586
691
|
}
|
|
692
|
+
if (activation === "global" && manifest.activation?.claudeStopScript && config.bundleRoot) {
|
|
693
|
+
const settingsPath = join2(cwd, ".claude/settings.json");
|
|
694
|
+
await wireStopHook(settingsPath, manifest.name, join2(cwd, config.bundleRoot, manifest.activation.claudeStopScript));
|
|
695
|
+
if (!installed.includes(".claude/settings.json")) {
|
|
696
|
+
installed.push(".claude/settings.json");
|
|
697
|
+
}
|
|
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
|
+
}
|
|
587
706
|
if (config.postinstall && config.bundleRoot) {
|
|
588
707
|
const scriptPath = join2(cwd, config.bundleRoot, config.postinstall);
|
|
589
708
|
const result = runScript(scriptPath, join2(cwd, config.bundleRoot));
|
|
@@ -646,6 +765,12 @@ var claudeAdapter = {
|
|
|
646
765
|
const settingsPath = join2(cwd, ".claude/settings.json");
|
|
647
766
|
await unwireSessionStartHook(settingsPath, manifest.name, manifest.activation.claudeHookDirective);
|
|
648
767
|
}
|
|
768
|
+
if (manifest.activation?.claudeStopScript) {
|
|
769
|
+
await unwireStopHook(join2(cwd, ".claude/settings.json"), manifest.name);
|
|
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
|
+
}
|
|
649
774
|
}
|
|
650
775
|
};
|
|
651
776
|
|
|
@@ -1085,8 +1210,9 @@ async function removeSkill(cwd, skillName) {
|
|
|
1085
1210
|
const fullPath = join7(cwd, file);
|
|
1086
1211
|
if (existsSync6(fullPath)) {
|
|
1087
1212
|
await unwireSessionStartHook(fullPath, skillName);
|
|
1213
|
+
await unwireStatusLine(fullPath, skillName);
|
|
1088
1214
|
}
|
|
1089
|
-
warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place,
|
|
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.`);
|
|
1090
1216
|
continue;
|
|
1091
1217
|
}
|
|
1092
1218
|
const fullPath = join7(cwd, file);
|
|
@@ -1178,8 +1304,9 @@ async function removeSkill2(cwd, skillName) {
|
|
|
1178
1304
|
const fullPath = join8(cwd, file);
|
|
1179
1305
|
if (existsSync7(fullPath)) {
|
|
1180
1306
|
await unwireSessionStartHook(fullPath, skillName);
|
|
1307
|
+
await unwireStatusLine(fullPath, skillName);
|
|
1181
1308
|
}
|
|
1182
|
-
warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place,
|
|
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.`);
|
|
1183
1310
|
continue;
|
|
1184
1311
|
}
|
|
1185
1312
|
const fullPath = join8(cwd, file);
|
package/package.json
CHANGED
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.
|
|
286
|
+
"version": "0.19.0"
|
|
287
287
|
},
|
|
288
288
|
{
|
|
289
289
|
"name": "temporal-coupling-detector",
|
package/skills/sluice/SKILL.md
CHANGED
|
@@ -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
|
|
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,51 @@ 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
|
+
|
|
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
|
+
|
|
246
293
|
## Statusline
|
|
247
294
|
|
|
248
295
|
This is the part that makes a run visible without anyone asking. Give it rows of
|
|
@@ -250,20 +297,11 @@ its own rather than a segment among the badges: it then costs nothing when no ru
|
|
|
250
297
|
is live and contends with nothing for width when one is, which is what lets the
|
|
251
298
|
bar be wide and the task carry its name rather than only its number.
|
|
252
299
|
|
|
253
|
-
Capture it wherever the statusline command builds its other lines,
|
|
254
|
-
|
|
300
|
+
Capture it wherever the statusline command builds its other lines, passing the
|
|
301
|
+
directory the session is in and nothing else:
|
|
255
302
|
|
|
256
303
|
```bash
|
|
257
|
-
sluice_line
|
|
258
|
-
if [ -n "$cwd" ] && { [ -f "$cwd/.sluice/run.json" ] || [ -f "$cwd/.git" ]; }; then
|
|
259
|
-
for sluice_sh in "$cwd/.claude/skills/sluice/scripts/status.sh" \
|
|
260
|
-
"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/sluice/scripts/status.sh" \
|
|
261
|
-
"$HOME/.claude/skills/sluice/scripts/status.sh"; do
|
|
262
|
-
[ -f "$sluice_sh" ] || continue
|
|
263
|
-
sluice_line=$(bash "$sluice_sh" line --full --dir "$cwd" 2>/dev/null)
|
|
264
|
-
break
|
|
265
|
-
done
|
|
266
|
-
fi
|
|
304
|
+
sluice_line=$(bash "$HOME/.claude/skills/sluice/scripts/statusline.sh" --dir "$cwd" 2>/dev/null)
|
|
267
305
|
```
|
|
268
306
|
|
|
269
307
|
then print it last, after whatever else the command emits:
|
|
@@ -272,22 +310,46 @@ then print it last, after whatever else the command emits:
|
|
|
272
310
|
if [ -n "$sluice_line" ]; then printf '%s\n' "$sluice_line"; fi
|
|
273
311
|
```
|
|
274
312
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
281
348
|
|
|
282
349
|
`if` rather than `[ ... ] &&`: as the last command of a statusline script the
|
|
283
350
|
short form makes it exit 1 on every render with no run live, which is the common
|
|
284
351
|
case. `%s` rather than `%b`: the render already carries real escape bytes, and
|
|
285
|
-
`%b` would reinterpret a backslash inside a task name.
|
|
286
|
-
`workspace.current_dir` from the JSON the harness sends on stdin. The configured
|
|
287
|
-
config dir is read before the default because a session started with
|
|
288
|
-
`CLAUDE_CONFIG_DIR` set installs the skill there, which is the one place a
|
|
289
|
-
`$HOME/.claude` lookup will not find it; the two paths collapse to one when the
|
|
290
|
-
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:
|
|
291
353
|
|
|
292
354
|
```
|
|
293
355
|
⧗ deep · sluice-cross-harness ◷ 38m
|
|
@@ -295,6 +357,25 @@ variable is unset, at the price of a second `[ -f ]`. It renders as:
|
|
|
295
357
|
2/9 done · !T6 model tiers rather than model names +1 · ⟲ 1 unreviewed
|
|
296
358
|
```
|
|
297
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
|
+
|
|
298
379
|
The colour comes out of the script rather than being applied by the caller,
|
|
299
380
|
because the mapping from state to colour belongs next to the state. A caller that
|
|
300
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
|
|
@@ -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
|
#
|
|
@@ -58,6 +60,16 @@ need_value() { # <flag> <remaining $#> <candidate>
|
|
|
58
60
|
case "$3" in
|
|
59
61
|
--*) err "$1 needs a value, but the next argument is the flag $3"; exit 4 ;;
|
|
60
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
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
# A word from a space-separated set. Keeps validation in one place so every
|
|
@@ -148,7 +160,8 @@ if [ "$SUB" = "line" ]; then
|
|
|
148
160
|
(.channel // "?"),
|
|
149
161
|
"\($done)/\(.tasks | length)",
|
|
150
162
|
([.tasks[]? | select(.status == "active") | "▸T\(.id)"] | first // empty),
|
|
151
|
-
([.tasks[]? | select(.status == "blocked") | "!T\(.id)"] | first // empty)
|
|
163
|
+
([.tasks[]? | select(.status == "blocked") | "!T\(.id)"] | first // empty),
|
|
164
|
+
(if .paused then "paused" else empty end)
|
|
152
165
|
] | join(" ")
|
|
153
166
|
' "$STATE" 2>/dev/null || exit 0
|
|
154
167
|
exit 0
|
|
@@ -168,6 +181,12 @@ if [ "$SUB" = "line" ]; then
|
|
|
168
181
|
jq -r \
|
|
169
182
|
--argjson now "$(date -u +%s)" \
|
|
170
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;
|
|
171
190
|
def paint($c; $t): "\($esc)[\($c)m\($t)\($esc)[0m";
|
|
172
191
|
def join_parts: map(select(. != null and . != "")) | join(" \($esc)[2m·\($esc)[0m ");
|
|
173
192
|
# Done splits in two. A task that is done and was owed a review nobody has
|
|
@@ -227,10 +246,11 @@ if [ "$SUB" = "line" ]; then
|
|
|
227
246
|
end) as $idle
|
|
228
247
|
| ( paint("1;96"; "⧗") + " "
|
|
229
248
|
+ ([ paint("1;96"; (.channel // "?")),
|
|
230
|
-
paint("2"; (.topic // ""))
|
|
249
|
+
paint("2"; (.topic // "" | clean))
|
|
231
250
|
] | join_parts)
|
|
232
251
|
+ (if $clock == "" then "" else " " + paint("2"; $clock) end)
|
|
233
252
|
+ (if $idle == "" then "" else " " + paint("2"; "·") + " " + paint("33"; $idle) end)
|
|
253
|
+
+ (if .paused then " " + paint("2"; "·") + " " + paint("33"; "paused") else "" end)
|
|
234
254
|
),
|
|
235
255
|
# The flip is drawn as a rule before its task: everything left of it is
|
|
236
256
|
# inert and safe to leave landed, everything right of it is not. That is
|
|
@@ -240,8 +260,8 @@ if [ "$SUB" = "line" ]; then
|
|
|
240
260
|
+ cellgroup($w)
|
|
241
261
|
] | join($gap)) ),
|
|
242
262
|
( " " + ([ paint("1"; "\($done)/\(.tasks | length)") + " done",
|
|
243
|
-
(if $blocked then paint("1;91"; "!T\($blocked.id) \($blocked.name // "")")
|
|
244
|
-
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)
|
|
245
265
|
else "" end)
|
|
246
266
|
+ (if $attn > 1 then paint("2"; " +\($attn - 1)") else "" end),
|
|
247
267
|
(if $debt > 0 then paint("33"; "⟲ \($debt) unreviewed") else "" end)
|
|
@@ -558,7 +578,12 @@ case "$SUB" in
|
|
|
558
578
|
# than silently reading as the whole value.
|
|
559
579
|
jq -r --argjson now "$(date -u +%s)" '
|
|
560
580
|
def dash: if . == null or . == "" then "-" else . end;
|
|
561
|
-
|
|
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
|
|
562
587
|
| if length > $w then .[0:$w - 1] + "…"
|
|
563
588
|
else . + (" " * ($w - length))
|
|
564
589
|
end;
|
|
@@ -566,9 +591,9 @@ case "$SUB" in
|
|
|
566
591
|
($c[3] | cell(9)), ($c[4] | cell(9)), ($c[5] | cell(4)),
|
|
567
592
|
$c[6]] | join(" "));
|
|
568
593
|
([.tasks[]? | select(.status == "done")] | length) as $done
|
|
569
|
-
| ["sluice \(.channel) · \(.topic) · \($done)/\(.tasks | length) done"]
|
|
570
|
-
+ ["plan \(.plan | dash)"]
|
|
571
|
-
+ ["record \(.record | dash)"]
|
|
594
|
+
| ["sluice \(.channel | clean) · \(.topic | clean) · \($done)/\(.tasks | length) done"]
|
|
595
|
+
+ ["plan \(.plan | dash | clean)"]
|
|
596
|
+
+ ["record \(.record | dash | clean)"]
|
|
572
597
|
# Past a day since the last write the run is idle, and that is said
|
|
573
598
|
# here because a stale run blocks the next init and nothing else
|
|
574
599
|
# would name it.
|
|
@@ -581,12 +606,13 @@ case "$SUB" in
|
|
|
581
606
|
else ["idle \($h / 24 | floor)d\($h % 24)h since the last write"]
|
|
582
607
|
end
|
|
583
608
|
end)
|
|
609
|
+
+ (if .paused then ["paused \(.paused | clean)"] else [] end)
|
|
584
610
|
+ (([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
|
|
585
611
|
| if $debt == 0 then [] else ["unreviewed \($debt) done, owed a review the tier table promised"] end)
|
|
586
612
|
+ ["final review " + (if .final_review then "done" else "pending" end)]
|
|
587
613
|
+ ["pre-flight " + (
|
|
588
614
|
if (.preflight // {} | length) == 0 then "not recorded"
|
|
589
|
-
else [(.preflight | to_entries[] | "\(.key)=\(.value)")] | join("; ")
|
|
615
|
+
else [(.preflight | to_entries[] | "\(.key | clean)=\(.value | clean)")] | join("; ")
|
|
590
616
|
end)]
|
|
591
617
|
+ [""]
|
|
592
618
|
+ [row(["id", "status", "task", "base", "commit", "tier", "model"])]
|
|
@@ -682,6 +708,33 @@ case "$SUB" in
|
|
|
682
708
|
jq --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.final_review = $now' "$STATE" | write_state
|
|
683
709
|
;;
|
|
684
710
|
|
|
711
|
+
pause)
|
|
712
|
+
REASON=""
|
|
713
|
+
while [ $# -gt 0 ]; do
|
|
714
|
+
case "$1" in
|
|
715
|
+
--reason) need_value --reason $# "${2-}"; REASON="$2"; shift 2 ;;
|
|
716
|
+
*) err "unknown flag: $1"; exit 4 ;;
|
|
717
|
+
esac
|
|
718
|
+
done
|
|
719
|
+
[ -n "$REASON" ] || { err "pause needs --reason <text>: a pause nobody can read the reason for is a stall"; exit 4; }
|
|
720
|
+
require_run
|
|
721
|
+
take_lock
|
|
722
|
+
require_readable
|
|
723
|
+
|
|
724
|
+
# A deliberate handback mid-run, which the stop guard otherwise refuses.
|
|
725
|
+
# The reason is the whole point: it is what the partner reads in `show`
|
|
726
|
+
# and what the next session reads to know why the run is standing still.
|
|
727
|
+
jq --arg reason "$REASON" '.paused = $reason' "$STATE" | write_state
|
|
728
|
+
;;
|
|
729
|
+
|
|
730
|
+
resume)
|
|
731
|
+
[ $# -eq 0 ] || { err "resume takes no arguments"; exit 4; }
|
|
732
|
+
require_run
|
|
733
|
+
take_lock
|
|
734
|
+
require_readable
|
|
735
|
+
jq 'del(.paused)' "$STATE" | write_state
|
|
736
|
+
;;
|
|
737
|
+
|
|
685
738
|
move)
|
|
686
739
|
TO=""
|
|
687
740
|
while [ $# -gt 0 ]; do
|
|
@@ -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"
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
# 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]"; ""))}
|
|
92
|
+
end
|
|
93
|
+
' 2>/dev/null)" || exit 0
|
|
94
|
+
|
|
95
|
+
[ "$(printf '%s' "$verdict" | jq -r '.block' 2>/dev/null)" = "true" ] || exit 0
|
|
96
|
+
|
|
97
|
+
printf '%s' "$verdict" | jq 2>/dev/null '{
|
|
98
|
+
decision: "block",
|
|
99
|
+
reason: ("sluice: the deep run \(.topic) is \(.progress) done with tasks still to go and nothing marked blocked or paused, so ending the turn here hands a live run back with nothing for your partner to decide. Continue: run status.sh ready and dispatch the next wave in this same message. If a task genuinely needs them, mark it: status.sh task <id> --status blocked. If the run has to stand still for a reason, record it: status.sh pause --reason \"<why>\", then say so and stop. If this run is not the work you were asked to do, it was left open: status.sh close.")
|
|
100
|
+
}'
|
|
101
|
+
exit 0
|
package/skills/sluice/skill.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sluice",
|
|
3
|
-
"version": "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": {
|
|
@@ -29,6 +30,8 @@
|
|
|
29
30
|
],
|
|
30
31
|
"default": "global",
|
|
31
32
|
"claudeHookDirective": "Before acting on a request that changes code, pick a sluice channel and state which one.",
|
|
32
|
-
"claudeHookScript": "scripts/session-start.sh"
|
|
33
|
+
"claudeHookScript": "scripts/session-start.sh",
|
|
34
|
+
"claudeStopScript": "scripts/stop-guard.sh",
|
|
35
|
+
"claudeStatuslineScript": "scripts/statusline-command.sh"
|
|
33
36
|
}
|
|
34
37
|
}
|