@iceinvein/agent-skills 0.17.0 → 0.18.1
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 +71 -2
- package/package.json +1 -1
- package/skills/index.json +1 -1
- package/skills/sluice/references/deep-channel.md +31 -15
- package/skills/sluice/references/finish.md +8 -4
- package/skills/sluice/references/status.md +121 -41
- package/skills/sluice/scripts/postinstall.sh +60 -0
- package/skills/sluice/scripts/status.sh +67 -24
- package/skills/sluice/scripts/statusline-command.sh +42 -0
- package/skills/sluice/scripts/statusline.sh +100 -0
- package/skills/sluice/scripts/stop-guard.sh +5 -1
- package/skills/sluice/skill.json +5 -3
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,
|
|
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,
|
|
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
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.1"
|
|
287
287
|
},
|
|
288
288
|
{
|
|
289
289
|
"name": "temporal-coupling-detector",
|
|
@@ -180,8 +180,15 @@ your sign-off and Task 1 begins.
|
|
|
180
180
|
**Review.** Name the tasks the table below sends to a reviewer, each with the
|
|
181
181
|
trigger that qualified it, and say how many of the rest skip with a ledger
|
|
182
182
|
line. Then offer the choice: dispatch a reviewer at each of them, dispatch
|
|
183
|
-
only at tier 3, or hand back with
|
|
184
|
-
|
|
183
|
+
only at tier 3, or hand back with the whole tier table skipped. The options are
|
|
184
|
+
what makes the cost legible.
|
|
185
|
+
|
|
186
|
+
Whichever comes back, record it with `status.sh preflight --review`. That row is
|
|
187
|
+
what turns the tasks it skips into a coverage level rather than a debt: the count
|
|
188
|
+
stays, because the code is the same either way, but `show`, the bar and `close`
|
|
189
|
+
then name it as the level your partner chose. Unrecorded,
|
|
190
|
+
the same tasks read as unreviewed to the end of the run, which is a nag about a
|
|
191
|
+
decision that was already made. `references/status.md` has the two readings.
|
|
185
192
|
"Four of nine need a reviewer" is a decision your partner can price; "I will
|
|
186
193
|
review where appropriate" is not.
|
|
187
194
|
|
|
@@ -251,9 +258,10 @@ intending to, and the questions are still cheap here and unaskable an hour from
|
|
|
251
258
|
now.
|
|
252
259
|
|
|
253
260
|
A session that forbids subagents does not skip this; it changes what the
|
|
254
|
-
review options are. Skipping it is how
|
|
261
|
+
review options are. Skipping it is how the unreviewed count first appears in
|
|
255
262
|
the closing summary, at the one moment your partner can no longer do anything
|
|
256
|
-
about it
|
|
263
|
+
about it, and with no answer on file it stays worded as a debt rather than as
|
|
264
|
+
the level anyone chose.
|
|
257
265
|
|
|
258
266
|
## Dispatch rules
|
|
259
267
|
|
|
@@ -285,8 +293,10 @@ reads the run state rather than the plan: it sees what has actually landed.
|
|
|
285
293
|
implementer back into them. `done --commit <sha> --reviewed` when the review
|
|
286
294
|
clears, or `done --commit <sha>` alone for a tier 0 task, which was owed a
|
|
287
295
|
stat read and no dispatch, and for a task whose dispatch pre-flight declined;
|
|
288
|
-
the
|
|
289
|
-
|
|
296
|
+
the count counts the second kind and not the first. A declined dispatch is
|
|
297
|
+
meant to show up there, because the count describes the artifact rather than
|
|
298
|
+
the decision, and the recorded pre-flight answer is what makes it read as
|
|
299
|
+
coverage rather than debt. That state outlives compaction; your memory doesn't.
|
|
290
300
|
- Each task goes to a fresh agent carrying the brief below and nothing this
|
|
291
301
|
session accumulated. What you hold is yours to hold, not theirs.
|
|
292
302
|
- **The run never ends a turn between pre-flight and the handback.** A
|
|
@@ -405,9 +415,10 @@ One thing does not change: the work still owes a review. Reading your own diff
|
|
|
405
415
|
is not one, and the table below still names which tasks needed the stronger
|
|
406
416
|
tier. Name those tasks at pre-flight, not at handback, so the choice of what
|
|
407
417
|
to do about them is still open: shrink the plan, take the flip on its own, or
|
|
408
|
-
accept the gap knowingly.
|
|
409
|
-
|
|
410
|
-
too
|
|
418
|
+
accept the gap knowingly. Record the answer with `preflight --review` and say at
|
|
419
|
+
the handback what was covered and how, rather than claiming those tasks passed a
|
|
420
|
+
review; twice is the cap on saying so here too, and `references/review.md` has
|
|
421
|
+
that rule. A `deep` run that ships with nobody
|
|
411
422
|
having read it has become a `fast` run with a design document attached, and
|
|
412
423
|
your partner is entitled to know that while it can still change the plan.
|
|
413
424
|
|
|
@@ -471,12 +482,17 @@ dispatch, the tasks are interleaved rather than ordered, and reordering them
|
|
|
471
482
|
is cheaper than reviewing them.
|
|
472
483
|
|
|
473
484
|
**Mark each review with `status.sh task <id> --reviewed` when it comes back.**
|
|
474
|
-
What that buys is a count of
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
Unmarked, the count sits permanently non-zero and stops being a signal, and
|
|
478
|
-
|
|
479
|
-
|
|
485
|
+
What that buys is a count of the tasks this table sent to a reviewer and nobody
|
|
486
|
+
marked: done, qualified for a dispatch, and carrying no mark. `show` and the
|
|
487
|
+
statusline both carry it from the moment it exists, which is the whole point.
|
|
488
|
+
Unmarked, the count sits permanently non-zero and stops being a signal, and it
|
|
489
|
+
goes back to first appearing in the closing summary, at the one moment your
|
|
490
|
+
partner can no longer do anything about it.
|
|
491
|
+
|
|
492
|
+
With the pre-flight answer on file the same count is the coverage this run
|
|
493
|
+
bought, worded as such and drawn dim. Without it the count is a debt and stays in
|
|
494
|
+
the warning colour. Neither wording shrinks it: what changes is whether the
|
|
495
|
+
number reads as spent or as owed.
|
|
480
496
|
|
|
481
497
|
Reviews are reads, so they are always parallel. Every review a wave earned
|
|
482
498
|
goes out in one message, and they run while the next wave's implementers work:
|
|
@@ -43,12 +43,16 @@ the one list of them. In order:
|
|
|
43
43
|
2. The ledger, `scripts/run-stats.sh --tests "<that result>"`, pasted
|
|
44
44
|
unedited. `references/meter.md`
|
|
45
45
|
3. In `deep`, `status.sh show`, pasted, so "four of nine, task five blocked,
|
|
46
|
-
two
|
|
47
|
-
account of it. `references/status.md`
|
|
46
|
+
two done at tier 1+ with no dispatch, final review pending" is on the page
|
|
47
|
+
rather than in your account of it. `references/status.md`
|
|
48
48
|
4. One clause on review: dispatched and clear, dispatched with findings still
|
|
49
49
|
open, or not dispatched and why. In `deep` that clause covers the per-task
|
|
50
|
-
tiers and the final review separately, because the
|
|
51
|
-
|
|
50
|
+
tiers and the final review separately, because the count carries one and not
|
|
51
|
+
the other. Where pre-flight priced the level, say what was covered and how,
|
|
52
|
+
not that review is outstanding: "reviewed at the level you chose, a
|
|
53
|
+
controller stat read plus the final whole-plan pass rather than a per-task
|
|
54
|
+
dispatch" is the accurate line, and it is a coverage level rather than a
|
|
55
|
+
debt. Where nothing was priced, it is a debt and says so.
|
|
52
56
|
5. The three options, and nothing after them.
|
|
53
57
|
|
|
54
58
|
A `deep` run closes when the work stops being yours to act on: after a local
|
|
@@ -36,16 +36,20 @@ worktree>` finds nothing. Statuses
|
|
|
36
36
|
are `todo`, `active`, `review`, `done` and `blocked`. A new id needs `--name`;
|
|
37
37
|
after that every call is a bare flip, so keeping it current costs one command
|
|
38
38
|
per transition rather than a paragraph. `close` archives the run under
|
|
39
|
-
`.sluice/archive/`, prints one line saying what it archived, progress,
|
|
40
|
-
|
|
39
|
+
`.sluice/archive/`, prints one line saying what it archived, progress, how many
|
|
40
|
+
tasks shipped without a dispatch and whether the final review landed, and frees
|
|
41
|
+
the tree for the next one.
|
|
41
42
|
|
|
42
43
|
The controller writes every row. An implementer reports its SHA in its reply
|
|
43
44
|
and touches nothing under `.sluice/`; the brief in `references/deep-channel.md`
|
|
44
45
|
says so to it. `active` is the dispatch, `review` is the commit in and a
|
|
45
46
|
reviewer out, with the task's paths still held because a finding may send the
|
|
46
47
|
implementer back into them, and `done` is the end: with `--reviewed` when a
|
|
47
|
-
review cleared it, without when the tier
|
|
48
|
-
which is the row the
|
|
48
|
+
review cleared it, without when the tier qualified for one and pre-flight
|
|
49
|
+
declined it, which is the row the no-dispatch count counts. Declining is meant
|
|
50
|
+
to show up there: the count describes the artifact, not the decision, and a
|
|
51
|
+
chosen skip that erased itself would leave a number that only ever recorded
|
|
52
|
+
accidents.
|
|
49
53
|
|
|
50
54
|
A task going `active` with no `--base` takes the HEAD of the tree the command
|
|
51
55
|
is pointed at, `--dir` if given and the current tree otherwise, once; a base
|
|
@@ -148,8 +152,8 @@ have gone that way. Those are different claims.
|
|
|
148
152
|
## Reading it back
|
|
149
153
|
|
|
150
154
|
`show` prints the whole run: channel, topic, how many tasks are done, the plan
|
|
151
|
-
and record paths, how long it has sat idle once that passes a day, the
|
|
152
|
-
|
|
155
|
+
and record paths, how long it has sat idle once that passes a day, the tasks
|
|
156
|
+
that shipped without a dispatch, the final review, the pre-flight answers, and a row per task with its
|
|
153
157
|
base, commit, tier and model. Run it after compaction instead of reconstructing the
|
|
154
158
|
run from what you remember, and run it in the message that hands the work back,
|
|
155
159
|
where "four of nine, task five blocked" is a fact your partner can act on.
|
|
@@ -170,9 +174,9 @@ from what the whole bar would occupy, gaps included, rather than from the task
|
|
|
170
174
|
count: keyed off the count alone the schedule was not monotonic, and thirty tasks
|
|
171
175
|
at two cells each ran wider than twelve at three.
|
|
172
176
|
|
|
173
|
-
**A done task
|
|
174
|
-
`▰▰▰`.
|
|
175
|
-
row, which is the difference between knowing how much there is and knowing where.
|
|
177
|
+
**A done task that got no dispatch trails the review glyph**, `▰▰▨` against
|
|
178
|
+
`▰▰▰`. The gap then reads in position rather than only as a count at the end of
|
|
179
|
+
the row, which is the difference between knowing how much there is and knowing where.
|
|
176
180
|
Tier 0 was never owed a dispatch, so it reads as plainly done. On a plan long
|
|
177
181
|
enough to narrow cells to one, there is no trailing cell to give up and the
|
|
178
182
|
positional reading stops: the count in the third row is then the only carrier,
|
|
@@ -186,16 +190,40 @@ flips and `import` clears a stale one, so the bar is only ever asked to draw the
|
|
|
186
190
|
single legal case.
|
|
187
191
|
|
|
188
192
|
The third row carries the progress count, whichever task wants attention, and the
|
|
189
|
-
|
|
190
|
-
two worth interrupting for, and a `+n` follows when more than one task shares
|
|
191
|
-
state, since a plan running four wide has four actives by design.
|
|
193
|
+
no-dispatch count. A blocked task displaces the active one there, being the one of
|
|
194
|
+
the two worth interrupting for, and a `+n` follows when more than one task shares
|
|
195
|
+
that state, since a plan running four wide has four actives by design.
|
|
192
196
|
|
|
193
197
|
Mark a review with `task <id> --reviewed` when a reviewer comes back. What that
|
|
194
|
-
buys is the
|
|
195
|
-
and that nobody marked. Tier 0 is excluded, having only ever been owed
|
|
196
|
-
read. Without it
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
buys is the count: a task that is done, that the tier table qualified for a
|
|
199
|
+
dispatch, and that nobody marked. Tier 0 is excluded, having only ever been owed
|
|
200
|
+
a stat read. Without it that count first appears in the closing summary, at the
|
|
201
|
+
one moment your partner can no longer do anything about it, and `show` and the
|
|
202
|
+
statusline both carry it from the moment it exists.
|
|
203
|
+
|
|
204
|
+
## Coverage against debt
|
|
205
|
+
|
|
206
|
+
The same count reads two ways, and which one it gets turns on whether
|
|
207
|
+
`preflight --review` has an answer on file.
|
|
208
|
+
|
|
209
|
+
With an answer, the level was priced at the stop and the tasks it skipped were
|
|
210
|
+
spent rather than forgotten. `show` calls it `coverage`, with the answer itself
|
|
211
|
+
on the `pre-flight` row three lines below rather than repeated beside the count;
|
|
212
|
+
the bar says `⟲ 9 at the chosen level` dim rather than in the warning colour, and
|
|
213
|
+
`close` says `9 at the chosen review level`. That is a fact about how
|
|
214
|
+
far review reached, not a thing still to do, and a run that keeps calling it
|
|
215
|
+
outstanding is nagging your partner about a decision they already made.
|
|
216
|
+
|
|
217
|
+
With no answer on file, nobody priced anything. `show` calls it `unreviewed`, the
|
|
218
|
+
bar and `close` say the same, and the warning colour stays: the dispatches the
|
|
219
|
+
tier table promised are genuinely owed, and the absence of a pre-flight answer is
|
|
220
|
+
the evidence that nobody weighed them.
|
|
221
|
+
|
|
222
|
+
What never changes is the number. Nine tasks that shipped without a second pair
|
|
223
|
+
of eyes carry the same risk whether the skip was chosen or forgotten, and the
|
|
224
|
+
count is there to say how far coverage reached. Erasing a chosen skip would turn
|
|
225
|
+
it into a measure of diligence, which is not what anyone reads it for. So the
|
|
226
|
+
word moves and the number does not.
|
|
199
227
|
|
|
200
228
|
## The next wave
|
|
201
229
|
|
|
@@ -272,6 +300,24 @@ session unguarded for the rest of the run.
|
|
|
272
300
|
statusline carry it, and `resume` clears it. A pause with no reason is refused,
|
|
273
301
|
since the reason is the only thing that separates a pause from a stall.
|
|
274
302
|
|
|
303
|
+
## Free text
|
|
304
|
+
|
|
305
|
+
Every value a command stores is later drawn onto a terminal: by the statusline,
|
|
306
|
+
by `show`, by the SessionStart hook and by the stop guard's refusal. A control
|
|
307
|
+
character in one is not text there, it is an instruction the terminal obeys, and
|
|
308
|
+
task names are model-written and routinely pasted out of issue titles. `ESC[2J`
|
|
309
|
+
in a name clears the screen on every render for as long as the run is open; a
|
|
310
|
+
carriage return walks the cursor back over the row just drawn.
|
|
311
|
+
|
|
312
|
+
So every flag value is refused if it carries one, with exit 4 naming the flag,
|
|
313
|
+
rather than being quietly stripped: a name that is not the name the caller
|
|
314
|
+
passed is its own surprise, and no legitimate value has ever needed a control
|
|
315
|
+
byte. `plan.sh import` writes task names through the same door and inherits it.
|
|
316
|
+
|
|
317
|
+
The renders drop them as well. State written before the check existed, or edited
|
|
318
|
+
by hand, is an input like any other, and the only control bytes a render should
|
|
319
|
+
emit are the colour sequences it writes itself.
|
|
320
|
+
|
|
275
321
|
## Statusline
|
|
276
322
|
|
|
277
323
|
This is the part that makes a run visible without anyone asking. Give it rows of
|
|
@@ -279,20 +325,11 @@ its own rather than a segment among the badges: it then costs nothing when no ru
|
|
|
279
325
|
is live and contends with nothing for width when one is, which is what lets the
|
|
280
326
|
bar be wide and the task carry its name rather than only its number.
|
|
281
327
|
|
|
282
|
-
Capture it wherever the statusline command builds its other lines,
|
|
283
|
-
|
|
328
|
+
Capture it wherever the statusline command builds its other lines, passing the
|
|
329
|
+
directory the session is in and nothing else:
|
|
284
330
|
|
|
285
331
|
```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
|
|
332
|
+
sluice_line=$(bash "$HOME/.claude/skills/sluice/scripts/statusline.sh" --dir "$cwd" 2>/dev/null)
|
|
296
333
|
```
|
|
297
334
|
|
|
298
335
|
then print it last, after whatever else the command emits:
|
|
@@ -301,22 +338,46 @@ then print it last, after whatever else the command emits:
|
|
|
301
338
|
if [ -n "$sluice_line" ]; then printf '%s\n' "$sluice_line"; fi
|
|
302
339
|
```
|
|
303
340
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
341
|
+
`$cwd` is `workspace.current_dir` from the JSON the harness sends on stdin.
|
|
342
|
+
Substitute the install path: `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` where a
|
|
343
|
+
session may set one, or `$cwd/.claude/skills/sluice/scripts/statusline.sh` for a
|
|
344
|
+
project-local install.
|
|
345
|
+
|
|
346
|
+
Those two lines are the whole contract, and they are deliberately empty of
|
|
347
|
+
judgement. Whether a run is visible from a given directory is a question about
|
|
348
|
+
this skill's layout, and the answer has moved twice: once when the run anchored
|
|
349
|
+
on the worktree set, once when a deep run began opening inside the implementer
|
|
350
|
+
worktree. Both times a caller carrying the test went stale and stopped drawing
|
|
351
|
+
without saying so, which is indistinguishable from no run being live. A caller
|
|
352
|
+
that contributes only a path cannot go stale, and every install brings
|
|
353
|
+
`statusline.sh` up to date behind it.
|
|
354
|
+
|
|
355
|
+
`scripts/statusline.sh` holds what used to sit in the caller: it resolves the
|
|
356
|
+
directory it was given, walks up looking for a state file or a worktree marker,
|
|
357
|
+
stops at an ordinary tree root or at `$HOME` with neither, and dispatches to
|
|
358
|
+
`status.sh line --full` only when there is something to draw. The walk is what
|
|
359
|
+
lets a session sitting in a subdirectory see the run in its tree, which testing
|
|
360
|
+
the session's own directory alone never did, and it resolves symlinks first
|
|
361
|
+
because a linked directory's lexical parents lead away from the tree rather than
|
|
362
|
+
up it.
|
|
363
|
+
|
|
364
|
+
It answers only whether a run might be visible from here, never where one is.
|
|
365
|
+
`status.sh` stays the single authority on that, and is handed the directory the
|
|
366
|
+
caller passed rather than the one the walk stopped at. A gate that were ever
|
|
367
|
+
narrower than the resolution behind it would blank the bar on a run that
|
|
368
|
+
resolves perfectly well, which is the failure this whole arrangement exists to
|
|
369
|
+
end, so it errs permissive: a wasted spawn is the acceptable direction.
|
|
370
|
+
|
|
371
|
+
The gate is kept rather than dropped because it costs about 8ms against the 33ms
|
|
372
|
+
an ungated `status.sh` pays to work out there is no run, and the common case on
|
|
373
|
+
any machine is a tree with no run at all. Depth barely moves it, 7.7ms stopping
|
|
374
|
+
at a `.git` directory against 8.4ms walking to the root, because 5.4ms of that
|
|
375
|
+
is the bash spawn and the walk itself forks nothing.
|
|
310
376
|
|
|
311
377
|
`if` rather than `[ ... ] &&`: as the last command of a statusline script the
|
|
312
378
|
short form makes it exit 1 on every render with no run live, which is the common
|
|
313
379
|
case. `%s` rather than `%b`: the render already carries real escape bytes, and
|
|
314
|
-
`%b` would reinterpret a backslash inside a task name.
|
|
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:
|
|
380
|
+
`%b` would reinterpret a backslash inside a task name. It renders as:
|
|
320
381
|
|
|
321
382
|
```
|
|
322
383
|
⧗ deep · sluice-cross-harness ◷ 38m
|
|
@@ -324,6 +385,25 @@ variable is unset, at the price of a second `[ -f ]`. It renders as:
|
|
|
324
385
|
2/9 done · !T6 model tiers rather than model names +1 · ⟲ 1 unreviewed
|
|
325
386
|
```
|
|
326
387
|
|
|
388
|
+
On a machine with no status line at all there is nothing to paste into, so the
|
|
389
|
+
install claims the empty `statusLine` slot and points it at
|
|
390
|
+
`scripts/statusline-command.sh`, a complete command that reads the harness JSON
|
|
391
|
+
on stdin and draws the run and nothing else. A slot already holding someone
|
|
392
|
+
else's command is never touched, on install or on removal, and the one the
|
|
393
|
+
install claimed is given back when the skill is removed, including on the
|
|
394
|
+
removal path that has no manifest to read.
|
|
395
|
+
|
|
396
|
+
Three things make claiming a single shared slot safe. The command is written
|
|
397
|
+
guarded by its own script's existence, as the hook commands are, so a bundle
|
|
398
|
+
that moved leaves the bar silent rather than running a path that is gone on
|
|
399
|
+
every keystroke. Ownership is matched on the whole command and never as a
|
|
400
|
+
substring, so a command with ours composed into it belongs to whoever composed
|
|
401
|
+
it and is left whole. And the path it matches carries the skill's own directory,
|
|
402
|
+
so the second skill to declare a statusline cannot take the first one's slot on
|
|
403
|
+
install or delete it on removal. That is why the bundled
|
|
404
|
+
command draws no prompt of its own: it exists for the empty slot, not to compete
|
|
405
|
+
for a full one, so with no run live it prints nothing rather than an empty row.
|
|
406
|
+
|
|
327
407
|
The colour comes out of the script rather than being applied by the caller,
|
|
328
408
|
because the mapping from state to colour belongs next to the state. A caller that
|
|
329
409
|
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,24 +181,31 @@ 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
|
-
# Done splits in two. A task that is done and
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
192
|
+
# Done splits in two. A task that is done and got no dispatch keeps the done
|
|
193
|
+
# shape but trails the review glyph, so where the gap is reads in position
|
|
194
|
+
# rather than only as a count. Tier 0 was never owed a dispatch, so it is
|
|
195
|
+
# plainly done. $mark is the marker colour: a level pre-flight chose is a
|
|
196
|
+
# fact about coverage and draws dim, one nobody priced is a warning.
|
|
197
|
+
def cellgroup($w; $mark):
|
|
181
198
|
(.status == "done"
|
|
182
199
|
and (.tier // 0) >= 1
|
|
183
|
-
and (.reviewed // false) == false) as $
|
|
200
|
+
and (.reviewed // false) == false) as $nodispatch
|
|
184
201
|
| (if .status == "done" then ["32", "▰"]
|
|
185
202
|
elif .status == "active" then ["96", "◈"]
|
|
186
203
|
elif .status == "review" then ["33", "▨"]
|
|
187
204
|
elif .status == "blocked" then ["91", "▮"]
|
|
188
205
|
else ["2", "▱"]
|
|
189
206
|
end) as $s
|
|
190
|
-
| if $
|
|
191
|
-
then paint($s[0]; ($s[1] * ($w - 1))) + paint(
|
|
207
|
+
| if $nodispatch and $w > 1
|
|
208
|
+
then paint($s[0]; ($s[1] * ($w - 1))) + paint($mark; "▨")
|
|
192
209
|
else paint($s[0]; ($s[1] * $w))
|
|
193
210
|
end;
|
|
194
211
|
|
|
@@ -203,7 +220,12 @@ if [ "$SUB" = "line" ]; then
|
|
|
203
220
|
else 1 end) as $w
|
|
204
221
|
| (if $w > 1 then " " else "" end) as $gap
|
|
205
222
|
| ([.tasks[]? | select(.status == "done")] | length) as $done
|
|
206
|
-
| ([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $
|
|
223
|
+
| ([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $nodispatch
|
|
224
|
+
# A pre-flight review answer on file means the stop happened and the level
|
|
225
|
+
# was priced, so the tasks it skipped were spent rather than forgotten. The
|
|
226
|
+
# count is the same either way, because the gap in the code is the same; the
|
|
227
|
+
# word is not, and "unreviewed" on a level someone chose reads as a nag.
|
|
228
|
+
| (((.preflight.review // "") | length) > 0) as $priced
|
|
207
229
|
| [.tasks[]? | select(.status == "blocked")] as $blockedAll
|
|
208
230
|
| [.tasks[]? | select(.status == "active")] as $activeAll
|
|
209
231
|
| ($blockedAll | first) as $blocked
|
|
@@ -230,7 +252,7 @@ if [ "$SUB" = "line" ]; then
|
|
|
230
252
|
end) as $idle
|
|
231
253
|
| ( paint("1;96"; "⧗") + " "
|
|
232
254
|
+ ([ paint("1;96"; (.channel // "?")),
|
|
233
|
-
paint("2"; (.topic // ""))
|
|
255
|
+
paint("2"; (.topic // "" | clean))
|
|
234
256
|
] | join_parts)
|
|
235
257
|
+ (if $clock == "" then "" else " " + paint("2"; $clock) end)
|
|
236
258
|
+ (if $idle == "" then "" else " " + paint("2"; "·") + " " + paint("33"; $idle) end)
|
|
@@ -241,14 +263,17 @@ if [ "$SUB" = "line" ]; then
|
|
|
241
263
|
# what the flip means, and a name in the header could not say it.
|
|
242
264
|
( " " + ([ .tasks[]?
|
|
243
265
|
| (if .flips then paint("95"; "┃") + $gap else "" end)
|
|
244
|
-
+ cellgroup($w)
|
|
266
|
+
+ cellgroup($w; (if $priced then "2" else "33" end))
|
|
245
267
|
] | join($gap)) ),
|
|
246
268
|
( " " + ([ 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 // "")
|
|
269
|
+
(if $blocked then paint("1;91"; "!T\($blocked.id) \($blocked.name // "" | clean)")
|
|
270
|
+
elif $active then paint("96"; "▸T\($active.id)") + " " + ($active.name // "" | clean)
|
|
249
271
|
else "" end)
|
|
250
272
|
+ (if $attn > 1 then paint("2"; " +\($attn - 1)") else "" end),
|
|
251
|
-
(if $
|
|
273
|
+
(if $nodispatch == 0 then ""
|
|
274
|
+
elif $priced then paint("2"; "⟲ \($nodispatch) at the chosen level")
|
|
275
|
+
else paint("33"; "⟲ \($nodispatch) unreviewed")
|
|
276
|
+
end)
|
|
252
277
|
] | join_parts)
|
|
253
278
|
)
|
|
254
279
|
' "$STATE" 2>/dev/null || exit 0
|
|
@@ -562,7 +587,12 @@ case "$SUB" in
|
|
|
562
587
|
# than silently reading as the whole value.
|
|
563
588
|
jq -r --argjson now "$(date -u +%s)" '
|
|
564
589
|
def dash: if . == null or . == "" then "-" else . end;
|
|
565
|
-
|
|
590
|
+
# Same reason as the statusline render: state written before the check
|
|
591
|
+
# on the way in, or edited by hand, holds bytes a terminal would act on
|
|
592
|
+
# rather than print. Every table cell passes through `cell`, so the
|
|
593
|
+
# table is covered there; the lines built outside it clean their own.
|
|
594
|
+
def clean: if type == "string" then gsub("[\u0000-\u001f\u007f]"; "") else . end;
|
|
595
|
+
def cell($w): tostring | clean
|
|
566
596
|
| if length > $w then .[0:$w - 1] + "…"
|
|
567
597
|
else . + (" " * ($w - length))
|
|
568
598
|
end;
|
|
@@ -570,9 +600,9 @@ case "$SUB" in
|
|
|
570
600
|
($c[3] | cell(9)), ($c[4] | cell(9)), ($c[5] | cell(4)),
|
|
571
601
|
$c[6]] | join(" "));
|
|
572
602
|
([.tasks[]? | select(.status == "done")] | length) as $done
|
|
573
|
-
| ["sluice \(.channel) · \(.topic) · \($done)/\(.tasks | length) done"]
|
|
574
|
-
+ ["plan \(.plan | dash)"]
|
|
575
|
-
+ ["record \(.record | dash)"]
|
|
603
|
+
| ["sluice \(.channel | clean) · \(.topic | clean) · \($done)/\(.tasks | length) done"]
|
|
604
|
+
+ ["plan \(.plan | dash | clean)"]
|
|
605
|
+
+ ["record \(.record | dash | clean)"]
|
|
576
606
|
# Past a day since the last write the run is idle, and that is said
|
|
577
607
|
# here because a stale run blocks the next init and nothing else
|
|
578
608
|
# would name it.
|
|
@@ -585,13 +615,22 @@ case "$SUB" in
|
|
|
585
615
|
else ["idle \($h / 24 | floor)d\($h % 24)h since the last write"]
|
|
586
616
|
end
|
|
587
617
|
end)
|
|
588
|
-
+ (if .paused then ["paused \(.paused)"] else [] end)
|
|
589
|
-
|
|
590
|
-
|
|
618
|
+
+ (if .paused then ["paused \(.paused | clean)"] else [] end)
|
|
619
|
+
# Same count, two readings. A pre-flight review answer on file says the
|
|
620
|
+
# level was priced at the stop, so what it skipped is the coverage of this
|
|
621
|
+
# run. With no answer on file nobody priced anything and the dispatches in
|
|
622
|
+
# the tier table are still owed. The answer itself is not repeated here:
|
|
623
|
+
# the pre-flight row below carries it, three lines down.
|
|
624
|
+
+ (([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $nodispatch
|
|
625
|
+
| if $nodispatch == 0 then []
|
|
626
|
+
elif ((.preflight.review // "") | length) > 0
|
|
627
|
+
then ["coverage \($nodispatch) done at tier 1+, no dispatch"]
|
|
628
|
+
else ["unreviewed \($nodispatch) done at tier 1+, owed a review and no pre-flight answer"]
|
|
629
|
+
end)
|
|
591
630
|
+ ["final review " + (if .final_review then "done" else "pending" end)]
|
|
592
631
|
+ ["pre-flight " + (
|
|
593
632
|
if (.preflight // {} | length) == 0 then "not recorded"
|
|
594
|
-
else [(.preflight | to_entries[] | "\(.key)=\(.value)")] | join("; ")
|
|
633
|
+
else [(.preflight | to_entries[] | "\(.key | clean)=\(.value | clean)")] | join("; ")
|
|
595
634
|
end)]
|
|
596
635
|
+ [""]
|
|
597
636
|
+ [row(["id", "status", "task", "base", "commit", "tier", "model"])]
|
|
@@ -772,9 +811,13 @@ case "$SUB" in
|
|
|
772
811
|
# allowed to stop the archive.
|
|
773
812
|
summary="$(jq -r '
|
|
774
813
|
([.tasks[]? | select(.status == "done")] | length) as $done
|
|
775
|
-
| ([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $
|
|
814
|
+
| ([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $nodispatch
|
|
815
|
+
| (((.preflight.review // "") | length) > 0) as $priced
|
|
776
816
|
| [ "closed \(.topic // "run"): \($done)/\(.tasks | length) done",
|
|
777
|
-
(if
|
|
817
|
+
(if $nodispatch == 0 then empty
|
|
818
|
+
elif $priced then "\($nodispatch) at the chosen review level"
|
|
819
|
+
else "\($nodispatch) unreviewed"
|
|
820
|
+
end),
|
|
778
821
|
"final review \(if .final_review then "done" else "pending" end)"
|
|
779
822
|
] | join(" · ")
|
|
780
823
|
' "$STATE" 2>/dev/null || true)"
|
|
@@ -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
|
-
|
|
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
|
|
package/skills/sluice/skill.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sluice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
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
|
}
|