@iceinvein/agent-skills 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -296,6 +296,9 @@ function validateManifest(data) {
296
296
  if (a.claudeHookDirective !== undefined && typeof a.claudeHookDirective !== "string") {
297
297
  return { ok: false, error: "'activation.claudeHookDirective' must be a string" };
298
298
  }
299
+ if (a.claudeHookScript !== undefined && typeof a.claudeHookScript !== "string") {
300
+ return { ok: false, error: "'activation.claudeHookScript' must be a string" };
301
+ }
299
302
  }
300
303
  return { ok: true, manifest: d };
301
304
  }
@@ -425,12 +428,17 @@ function runScript(scriptPath, cwd) {
425
428
  });
426
429
  return { ok: result.exitCode === 0, code: result.exitCode ?? 1 };
427
430
  }
428
- function matchesSkillDirective(hook, skillName) {
431
+ function shq(s) {
432
+ return "'" + s.replace(/'/g, "'\\''") + "'";
433
+ }
434
+ function matchesSkillDirective(hook, skillName, directive) {
429
435
  if (hook.skill !== undefined)
430
436
  return hook.skill === skillName;
437
+ if (directive !== undefined && hook.command.includes(`echo ${shq(directive)}`))
438
+ return true;
431
439
  return hook.command.includes(`Activate ${skillName} skill`);
432
440
  }
433
- async function wireSessionStartHook(settingsPath, skillName, directive) {
441
+ async function wireSessionStartHook(settingsPath, skillName, directive, scriptPath) {
434
442
  let settings = {};
435
443
  if (existsSync2(settingsPath)) {
436
444
  settings = await Bun.file(settingsPath).json();
@@ -439,12 +447,50 @@ async function wireSessionStartHook(settingsPath, skillName, directive) {
439
447
  settings.hooks = {};
440
448
  if (!settings.hooks.SessionStart)
441
449
  settings.hooks.SessionStart = [];
442
- const command = `echo '${directive}'`;
450
+ const command = scriptPath ? `echo ${shq(directive)}; if [ -f ${shq(scriptPath)} ]; then bash ${shq(scriptPath)}; fi` : `echo ${shq(directive)}`;
451
+ const legacy = `echo ${shq(directive)}`;
452
+ let adopted = false;
453
+ let custom = false;
454
+ let changed = false;
443
455
  for (const group of settings.hooks.SessionStart) {
456
+ const kept = [];
444
457
  for (const hook of group.hooks ?? []) {
445
- if (matchesSkillDirective(hook, skillName))
446
- return;
458
+ if (!matchesSkillDirective(hook, skillName, directive)) {
459
+ kept.push(hook);
460
+ continue;
461
+ }
462
+ const plain = hook.skill !== undefined || hook.command === legacy;
463
+ if (!plain) {
464
+ custom = true;
465
+ kept.push(hook);
466
+ continue;
467
+ }
468
+ if (adopted) {
469
+ changed = true;
470
+ continue;
471
+ }
472
+ adopted = true;
473
+ if (hook.command !== command || hook.skill !== skillName) {
474
+ hook.command = command;
475
+ hook.skill = skillName;
476
+ changed = true;
477
+ }
478
+ kept.push(hook);
479
+ }
480
+ if (kept.length !== (group.hooks ?? []).length)
481
+ group.hooks = kept;
482
+ }
483
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter((group) => !group.hooks || group.hooks.length > 0);
484
+ if (adopted || custom) {
485
+ if (custom && !adopted && scriptPath) {
486
+ console.warn(`${skillName}: a hand-edited SessionStart hook already carries its directive, so it was left as is and ${scriptPath} was not wired; add it to that entry yourself if you want it.`);
447
487
  }
488
+ if (changed) {
489
+ mkdirSync(dirname(settingsPath), { recursive: true });
490
+ await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
491
+ `);
492
+ }
493
+ return;
448
494
  }
449
495
  settings.hooks.SessionStart.push({
450
496
  hooks: [{ type: "command", command, skill: skillName }]
@@ -453,15 +499,17 @@ async function wireSessionStartHook(settingsPath, skillName, directive) {
453
499
  await Bun.write(settingsPath, JSON.stringify(settings, null, 2) + `
454
500
  `);
455
501
  }
456
- async function unwireSessionStartHook(settingsPath, skillName) {
502
+ async function unwireSessionStartHook(settingsPath, skillName, directive) {
457
503
  if (!existsSync2(settingsPath))
458
504
  return;
459
505
  const settings = await Bun.file(settingsPath).json();
460
506
  const sessionStart = settings.hooks?.SessionStart;
461
507
  if (!sessionStart)
462
508
  return;
509
+ const legacy = directive === undefined ? undefined : `echo ${shq(directive)}`;
510
+ const ours = (h) => matchesSkillDirective(h, skillName, directive) && (h.skill !== undefined || legacy === undefined || h.command === legacy);
463
511
  const filteredGroups = sessionStart.map((group) => ({
464
- hooks: (group.hooks ?? []).filter((h) => !matchesSkillDirective(h, skillName))
512
+ hooks: (group.hooks ?? []).filter((h) => !ours(h))
465
513
  })).filter((group) => group.hooks.length > 0);
466
514
  if (filteredGroups.length === 0) {
467
515
  delete settings.hooks.SessionStart;
@@ -529,7 +577,9 @@ var claudeAdapter = {
529
577
  }
530
578
  if (activation === "global" && manifest.activation?.claudeHookDirective) {
531
579
  const settingsPath = join2(cwd, ".claude/settings.json");
532
- await wireSessionStartHook(settingsPath, manifest.name, manifest.activation.claudeHookDirective);
580
+ const script = manifest.activation.claudeHookScript;
581
+ const scriptPath = script && config.bundleRoot ? join2(cwd, config.bundleRoot, script) : undefined;
582
+ await wireSessionStartHook(settingsPath, manifest.name, manifest.activation.claudeHookDirective, scriptPath);
533
583
  if (!installed.includes(".claude/settings.json")) {
534
584
  installed.push(".claude/settings.json");
535
585
  }
@@ -594,7 +644,7 @@ var claudeAdapter = {
594
644
  }
595
645
  if (manifest.activation?.claudeHookDirective) {
596
646
  const settingsPath = join2(cwd, ".claude/settings.json");
597
- await unwireSessionStartHook(settingsPath, manifest.name);
647
+ await unwireSessionStartHook(settingsPath, manifest.name, manifest.activation.claudeHookDirective);
598
648
  }
599
649
  }
600
650
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
package/skills/index.json CHANGED
@@ -281,9 +281,9 @@
281
281
  },
282
282
  {
283
283
  "name": "sluice",
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 and one status command can answer where the run is, 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.",
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.15.0"
286
+ "version": "0.16.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -30,8 +30,11 @@ back to you and the run never left your hands.
30
30
 
31
31
  Name the channel and the signal that actually routed you there. The strings above
32
32
  are examples, not fixed copy, and a channel with a two-part signal should say
33
- which part applied. `bypass` says nothing at all, because a question that gets
34
- announced stops being a question.
33
+ which part applied. One thing is fixed: the words `<channel> channel` open the
34
+ message or follow a label such as `Sluice:`, because the meter finds the run by
35
+ them and an announcement worded otherwise reports as `not announced`. `bypass`
36
+ says nothing at all, because a question that gets announced stops being a
37
+ question.
35
38
 
36
39
  **`root-cause`, `finish`, `meter` and `show-or-say` are not channel-assigned.**
37
40
  The code misbehaving triggers the first: a bug report, a red test, behaviour you
@@ -157,10 +160,16 @@ discharge pre-flight, not the approval: one reply arrives for several
157
160
  obligations, so a "yes" with no rows behind it signed off the plan and nothing
158
161
  else.
159
162
 
160
- Three from that file that catch people out: concurrent implementers need a
163
+ Four from that file that catch people out: concurrent implementers need a
161
164
  worktree each and the flip runs alone, review is tiered rather than automatic,
162
- and a `deep` run that cannot dispatch has to replace the review tier with
163
- something, not quietly ship without one.
165
+ a `deep` run that cannot dispatch has to replace the review tier with
166
+ something, not quietly ship without one, and the run ends: `status.sh final`
167
+ when the plan's own review clears, `status.sh close` once the work is no longer
168
+ yours to act on, after a local merge, when the branch is left as it stands, or
169
+ when an open PR lands, not when it opens. A run left open reads as live to the
170
+ statusline and blocks the next one. The SessionStart hook prints a live run at every session start,
171
+ compaction and resume included, so a run you did not start is one you were
172
+ shown, not one you have to remember.
164
173
 
165
174
  ## Conflicts
166
175
 
@@ -253,10 +253,18 @@ declared schedule is wrong the moment one task lands late or comes back with a
253
253
  blocking finding. A derived one just recomputes, which is the whole reason `ready`
254
254
  reads the run state rather than the plan: it sees what has actually landed.
255
255
 
256
- - One row per task in `run.json`, flipped to `active` and then `done` as it
257
- moves. That state outlives compaction; your memory doesn't.
258
- - Each task goes to a fresh agent with that task's text and nothing else.
259
- What this session accumulated is yours to hold, not theirs.
256
+ - One row per task in `run.json`, and you are the one who flips it; an
257
+ implementer reports, it does not write the run state. `active` at dispatch,
258
+ which records the base as the HEAD of the tree the command is pointed at
259
+ unless you pass `--base`. `review` when the task's commit is in and its
260
+ reviewer has gone out: the paths stay held, because a finding may send the
261
+ implementer back into them. `done --commit <sha> --reviewed` when the review
262
+ clears, or `done --commit <sha>` alone for a tier 0 task, which was owed a
263
+ stat read and no dispatch, and for a task whose dispatch pre-flight declined;
264
+ the debt count counts the second kind and not the first. That state outlives
265
+ compaction; your memory doesn't.
266
+ - Each task goes to a fresh agent carrying the brief below and nothing this
267
+ session accumulated. What you hold is yours to hold, not theirs.
260
268
  - **Label the dispatch `T<n>: <task name>`.** The harness lists running agents
261
269
  under whatever label the dispatch gave them, so labelled by task that list
262
270
  reads as the plan and labelled anything else it reads as a row of anonymous
@@ -307,6 +315,31 @@ commit only when asked is about that outward-facing act. The plan's sign-off
307
315
  is the asking. Nothing a task commits reaches anywhere your partner has not
308
316
  already agreed to, so the instruction is satisfied rather than excepted.
309
317
 
318
+ ## The dispatch brief
319
+
320
+ An implementer sees its task and nothing else, so anything it has to obey that
321
+ is not in the task text has to be in the brief. The rules it would otherwise
322
+ never meet are the ones this skill spends the most words on, and an agent that
323
+ never loaded the skill follows none of them by default. The brief carries, in
324
+ this order:
325
+
326
+ - **Ground Rules**, verbatim from the plan. They bind every task and are
327
+ repeated in none, so the brief is where they reach the implementer.
328
+ - **The task**, whole: heading, Contract, Touches, Flips, steps.
329
+ - **The implementer contract**, five lines that are the same for every task:
330
+ the test comes first and is watched failing before the code
331
+ (`references/test-first.md`, one paragraph of it, not a pointer the agent
332
+ cannot follow); nothing outside `Touches` is edited; the commit stages only
333
+ the paths in `Touches`, never `git add -A`; the reply reports the commit SHA
334
+ and names any behaviour left untested and why; and `.sluice/` is not written,
335
+ the run state being yours to flip.
336
+ - **The label**, `T<n>: <task name>`, on the dispatch itself.
337
+
338
+ Leave out how you got here. The design, the record, the other tasks and this
339
+ session's reasoning are what dispatch exists to keep out of the implementer's
340
+ context, and a brief that carries them has spent the fresh context it was
341
+ buying.
342
+
310
343
  ## When dispatch is unavailable
311
344
 
312
345
  Separate two cases first. A session where dispatch is off unless your partner
@@ -409,10 +442,12 @@ a reviewer writes nothing, so it collides with nothing. The final review is
409
442
  the only one that waits, because it is the only one that needs everything to
410
443
  have landed.
411
444
 
412
- Record the base against that task with `status.sh task <id> --base <sha>` when
413
- you dispatch, before the agent's first commit lands. Recovering it afterwards is archaeology, and the
414
- answer you will guess at is `HEAD~1`, which `references/review.md` already
415
- names as the standing mistake.
445
+ The base is recorded when the task goes `active`: the flip takes the HEAD of
446
+ the tree it is pointed at, `--dir` if you passed one and the tree you issued it
447
+ from otherwise, so point it at the tree the implementer is cut from, or pass
448
+ `--base <sha>` where that differs. Recovering it afterwards is
449
+ archaeology, and the answer you will guess at is `HEAD~1`, which
450
+ `references/review.md` already names as the standing mistake.
416
451
 
417
452
  **A finding surviving two rounds may be a defect in the criterion, not the
418
453
  work.** Before a third round, ask whether any output could satisfy it. A
@@ -452,6 +487,14 @@ absorb into the next task's brief.
452
487
 
453
488
  ## The final review
454
489
 
490
+ It runs once every task is `done` and every per-task review is back, and it
491
+ comes before `references/finish.md` starts: the suite run there is the last
492
+ check before the three options, and a review that lands after it would be
493
+ reviewing a tree the suite never saw. Mark it with `status.sh final` when it
494
+ clears, so `show` and `close` report it done rather than pending; the per-task
495
+ marks cannot stand in for it, because they count dispatches the tier table owed
496
+ and this one is owed by the plan as a whole.
497
+
455
498
  It covers cross-task integration and everything the record accumulated, not
456
499
  lines a per-task review already cleared. It is a dispatch, on this session's
457
500
  model and never a downshifted one, and it gets the whole-plan diff and the
@@ -5,6 +5,10 @@ in every channel. A commit made in passing during `fast` channel work is
5
5
  not one of those: the branch as a whole has to be about to leave your
6
6
  hands.
7
7
 
8
+ In `deep`, the final review comes before this file starts, and its verdict is
9
+ in hand or it is not: `references/deep-channel.md` owns it. Nothing here
10
+ dispatches a review; this is where what the reviews found gets said.
11
+
8
12
  Start here: run every test the project has, not a sample of them. A red
9
13
  result stops the process; there is no menu after a failure. A pass from
10
14
  earlier in the session doesn't count: the tree has changed since, and
@@ -30,6 +34,32 @@ After a local merge, run the whole suite again over the merged tree before
30
34
  deleting anything. A failure there stops the cleanup; you haven't pushed
31
35
  anything yet, so you can still walk it back.
32
36
 
37
+ ## The handback message
38
+
39
+ The parts are owned by three references and read as one message, so this is
40
+ the one list of them. In order:
41
+
42
+ 1. The suite's result, as it printed, with the command that produced it.
43
+ 2. The ledger, `scripts/run-stats.sh --tests "<that result>"`, pasted
44
+ unedited. `references/meter.md`
45
+ 3. In `deep`, `status.sh show`, pasted, so "four of nine, task five blocked,
46
+ two unreviewed, final review pending" is on the page rather than in your
47
+ account of it. `references/status.md`
48
+ 4. One clause on review: dispatched and clear, dispatched with findings still
49
+ open, or not dispatched and why. In `deep` that clause covers the per-task
50
+ tiers and the final review separately, because the debt count carries one
51
+ and not the other.
52
+ 5. The three options, and nothing after them.
53
+
54
+ A `deep` run closes when the work stops being yours to act on: after a local
55
+ merge and its re-run of the suite, or when your partner leaves the branch where
56
+ it stands. While a PR is open it stays live, because the review comments come
57
+ back as work on those same tasks and the rows are where that work is tracked;
58
+ it closes when the PR lands. `status.sh close` archives the state and prints
59
+ one line saying what it archived, and that line goes in your reply. A run left
60
+ open blocks the next `init` and renders a finished plan in the statusline as a
61
+ live one, which is how a run comes to sit at 0/9 for three weeks.
62
+
33
63
  While a PR is open, the workspace survives: it is where the review
34
64
  comments get answered, and tearing it down means rebuilding it the moment
35
65
  the first one arrives. Discarding needs an explicit, confirmed ask from
@@ -1,7 +1,8 @@
1
1
  # Meter the run
2
2
 
3
3
  Run `scripts/run-stats.sh` as part of the message that hands the work back,
4
- and paste its output into that message unedited:
4
+ and paste its output into that message unedited; `references/finish.md` says
5
+ where in that message it sits:
5
6
 
6
7
  ```
7
8
  bash <skill-dir>/scripts/run-stats.sh --tests "<what the suite actually printed>"
@@ -20,6 +20,7 @@ bash <skill-dir>/scripts/status.sh preflight --review "tier 3 only" --model "6 o
20
20
  --workspace "one worktree per implementer"
21
21
  bash <skill-dir>/scripts/status.sh show
22
22
  bash <skill-dir>/scripts/status.sh ready
23
+ bash <skill-dir>/scripts/status.sh final
23
24
  bash <skill-dir>/scripts/status.sh line --full
24
25
  bash <skill-dir>/scripts/status.sh close
25
26
  ```
@@ -30,7 +31,29 @@ run as the tree it was cut from. Statuses
30
31
  are `todo`, `active`, `review`, `done` and `blocked`. A new id needs `--name`;
31
32
  after that every call is a bare flip, so keeping it current costs one command
32
33
  per transition rather than a paragraph. `close` archives the run under
33
- `.sluice/archive/` and frees the tree for the next one.
34
+ `.sluice/archive/`, prints one line saying what it archived, progress, review
35
+ debt and whether the final review landed, and frees the tree for the next one.
36
+
37
+ The controller writes every row. An implementer reports its SHA in its reply
38
+ and touches nothing under `.sluice/`; the brief in `references/deep-channel.md`
39
+ says so to it. `active` is the dispatch, `review` is the commit in and a
40
+ reviewer out, with the task's paths still held because a finding may send the
41
+ implementer back into them, and `done` is the end: with `--reviewed` when a
42
+ review cleared it, without when the tier owed one and pre-flight declined it,
43
+ which is the row the debt count counts.
44
+
45
+ A task going `active` with no `--base` takes the HEAD of the tree the command
46
+ is pointed at, `--dir` if given and the current tree otherwise, once; a base
47
+ already on the row is kept. Pass `--base` when the implementer's tree is
48
+ neither.
49
+
50
+ Every write stamps `updated`. Past a day since the last one, `show` and the
51
+ statusline both say how long the run has sat idle, because a finished plan
52
+ whose run was never closed looks exactly like a live one otherwise, and it
53
+ blocks the next `init`.
54
+
55
+ `final` records that the plan's final review cleared. `show` reports it
56
+ pending until then, and `close` says which it was.
34
57
 
35
58
  A command that cannot finish leaves the state exactly as it found it, so a
36
59
  failed `task` never costs you the rows already in the file. Two argument rules
@@ -54,17 +77,23 @@ the plan seeded would be absent from every implementer, `init` there would
54
77
  start a second run nothing else reads, and the worktree would take that state
55
78
  with it when it went.
56
79
 
57
- So every command anchors on the main worktree of whatever tree it is pointed
58
- at, and one run covers the set. The statusline renders the same run in every
59
- window, a flip made by an implementer lands where the controller is watching,
60
- and `init` from a worktree reports the run that is already live rather than
61
- replacing it. A submodule anchors on its own checkout, not the superproject's,
62
- and a directory that is no git work tree keeps its run exactly where it sits.
80
+ So a tree with no run of its own anchors on the main worktree of its set, and
81
+ the set's run covers it: the statusline renders the controller's run in every
82
+ implementer's window, and a flip issued from any of those trees lands where
83
+ every other one is watching. A tree's own run comes first, though. `init`
84
+ always lands in the tree it is given, and every other command reads that
85
+ tree's state when it has one, so two sessions working independently in two
86
+ worktrees of one repo each keep their own run and neither is shown the
87
+ other's. Anchored unconditionally, as this once was, the first `init` in the
88
+ set took over every other session's statusline and refused every other `init`.
89
+ A submodule anchors on its own checkout, not the superproject's, and a
90
+ directory that is no git work tree keeps its run exactly where it sits.
63
91
 
64
92
  One file for several writers is one file to contend on, so `init`, `task`,
65
- `preflight` and `close` take a lock first: four implementers each flipping
66
- their own row would otherwise have the later write built on a snapshot taken
67
- before the earlier one landed, dropping that row without saying so. The lock
93
+ `preflight`, `final` and `close` take a lock first: two flips issued at the
94
+ same moment from different trees would otherwise have the later write built on
95
+ a snapshot taken before the earlier one landed, dropping that row without
96
+ saying so. The lock
68
97
  carries its holder's pid, so a killed run is broken through rather than waited
69
98
  out. Reads take nothing, state being installed through a rename, which is what
70
99
  keeps `line` cheap enough to render on.
@@ -108,8 +137,9 @@ have gone that way. Those are different claims.
108
137
  ## Reading it back
109
138
 
110
139
  `show` prints the whole run: channel, topic, how many tasks are done, the plan
111
- and record paths, the pre-flight answers, and a row per task with its base,
112
- commit, tier and model. Run it after compaction instead of reconstructing the
140
+ and record paths, how long it has sat idle once that passes a day, the review
141
+ debt, the final review, the pre-flight answers, and a row per task with its
142
+ base, commit, tier and model. Run it after compaction instead of reconstructing the
113
143
  run from what you remember, and run it in the message that hands the work back,
114
144
  where "four of nine, task five blocked" is a fact your partner can act on.
115
145
 
@@ -184,6 +214,26 @@ establishes is what the tasks after it are checked against.
184
214
  Derive the wave here rather than writing wave numbers into the plan. A declared
185
215
  schedule is wrong the moment one task lands late; this recomputes.
186
216
 
217
+ What a wave of several means is read off the pre-flight workspace answer: an
218
+ answer containing "per implementer", "per concurrent implementer", "each
219
+ implementer" or "worktree each" prints "a worktree each", any other recorded
220
+ answer prints "serial, one at a time in the shared tree", and no answer prints
221
+ neither. It is a match on words, not a reading of the sentence, so record the
222
+ answer in one of those phrases when worktrees were bought and in none of them
223
+ when they were not.
224
+
225
+ ## On session start
226
+
227
+ `scripts/session-start.sh` is the SessionStart hook a global install wires,
228
+ after the routing directive. On startup, resume, clear and compact it runs
229
+ `show` against the tree the session opened in, a subdirectory or a linked
230
+ worktree included since `show` anchors on the main worktree, and prints the
231
+ run when there is one, with one sentence more: after a compaction, resume or
232
+ clear, that the record is to be read before the next dispatch; on a fresh
233
+ start, that a run not being continued was left open and wants `close`. A tree
234
+ with no run prints nothing. The reading-back rule above still stands; this is
235
+ the harness doing it at the one moment memory has just been cut.
236
+
187
237
  ## Statusline
188
238
 
189
239
  This is the part that makes a run visible without anyone asking. Give it rows of
@@ -240,6 +290,9 @@ because the mapping from state to colour belongs next to the state. A caller tha
240
290
  coloured the line itself would have to re-derive each cell's meaning from its
241
291
  glyph, which is the same fact stored twice.
242
292
 
293
+ Past a day since the last write the first row gains `· idle 2d1h`, so a run
294
+ nobody closed reads as one.
295
+
243
296
  A run that is only visible to the session running it is a run your partner
244
297
  cannot redirect. That is the same argument the channel announcement makes, and
245
298
  the statusline is where it holds for the hour after the announcement scrolled
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+ # SessionStart hook: show a live sluice run to the session that just opened.
3
+ #
4
+ # A deep run outlives the context window that started it. The prose asks the
5
+ # model to run `status.sh show` after a compaction, and that is the one rule with
6
+ # nothing but memory behind it, in the one moment memory has just been cut. So
7
+ # the harness does the asking: on startup, resume, clear and compact this prints
8
+ # the run, and on a tree with no run it prints nothing.
9
+ #
10
+ # Reads the harness's session JSON on stdin for `cwd` and `source`, falling back
11
+ # to $PWD when there is none. Always exits 0: a hook that fails is noise in a
12
+ # session that has not started yet.
13
+
14
+ set -uo pipefail
15
+
16
+ here="$(cd "$(dirname "$0")" && pwd)"
17
+ STATUS="$here/status.sh"
18
+
19
+ # Bounded, and skipped on a terminal: the harness closes stdin after the JSON,
20
+ # but a hook that inherits an open, silent stdin would otherwise never return,
21
+ # and a hook that never returns holds the session start with it. Read a byte at
22
+ # a time: on a timeout bash 3.2, which is what macOS ships, discards whatever
23
+ # the interrupted read had gathered where bash 4 keeps it, and a byte read
24
+ # whole before the timeout is kept by both, trailing newline or not.
25
+ input=""
26
+ if [ ! -t 0 ]; then
27
+ while IFS= read -r -n 1 -t 2 c; do
28
+ if [ -z "$c" ]; then input="$input
29
+ "; else input="$input$c"; fi
30
+ done
31
+ fi
32
+ cwd="$PWD"
33
+ source=""
34
+ if [ -n "$input" ] && command -v jq >/dev/null 2>&1; then
35
+ got="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
36
+ [ -n "$got" ] && cwd="$got"
37
+ source="$(printf '%s' "$input" | jq -r '.source // empty' 2>/dev/null || true)"
38
+ fi
39
+
40
+ # No gate of its own beyond the directory existing: status.sh anchors on the
41
+ # main worktree of whatever tree it is given, which is what lets a session
42
+ # opened in a subdirectory or a linked worktree find the run, and on a tree
43
+ # with no run it exits 2 in silence. Once per session start, that is cheap.
44
+ [ -d "$cwd" ] || exit 0
45
+ [ -f "$STATUS" ] || exit 0
46
+
47
+ shown="$(bash "$STATUS" show --dir "$cwd" 2>/dev/null)" || exit 0
48
+ [ -n "$shown" ] || exit 0
49
+
50
+ echo "A sluice run is live in this tree. Its state, from .sluice/run.json:"
51
+ echo
52
+ echo "$shown"
53
+ echo
54
+ case "$source" in
55
+ compact | resume | clear)
56
+ echo "This session's context was summarised, resumed or cleared. Read the run record before the next dispatch and trust the two files over what you remember."
57
+ ;;
58
+ *)
59
+ echo "If this run is not the work you were asked to continue, it was left open: close it with status.sh close before starting another."
60
+ ;;
61
+ esac
62
+ exit 0
@@ -15,6 +15,7 @@
15
15
  # status.sh preflight [--review <t>] [--model <t>] [--workspace <t>]
16
16
  # status.sh show [--json]
17
17
  # status.sh ready
18
+ # status.sh final
18
19
  # status.sh line [--full]
19
20
  # status.sh close
20
21
  #
@@ -92,18 +93,29 @@ if [ -z "$SUB" ]; then
92
93
  exit 4
93
94
  fi
94
95
 
95
- # A linked worktree is another view of the same run, not a new one. The run
96
- # directory ignores itself, so `git worktree add` never carries it across: read
97
- # from the tree it was called in, the run a plan seeded is absent from every
98
- # implementer created after that plan, and an `init` there lands a rival state
99
- # file that dies with the worktree. Every tree in a set anchors on the main
100
- # worktree instead, which is the one path all of them agree on.
96
+ # A tree's own run comes first, and only a tree with none reads the set's. Two
97
+ # layouts share this script and pull opposite ways. A deep run plans in the main
98
+ # tree and cuts implementer worktrees after the plan: the run directory ignores
99
+ # itself, so `git worktree add` never carries it across, and read from the tree
100
+ # it was called in the run would be absent from every implementer. Those trees
101
+ # hold no run of their own, so they anchor on the main worktree, the one path
102
+ # the set agrees on. Independent sessions, one per worktree, each start a run
103
+ # where they sit: anchored unconditionally, the first `init` in the set took
104
+ # over every other session's statusline and refused every other `init`. So
105
+ # `init` always lands in the tree it was given, and every other command reads
106
+ # the tree's own state when it has one.
101
107
  #
102
108
  # `git worktree list` names the main worktree first. A submodule names its own
103
109
  # checkout there rather than the superproject's, which is what keeps a
104
110
  # submodule's run beside its own working tree, and a directory that is no git
105
111
  # work tree at all is left exactly as it was given.
106
- if [ "$(git -C "$DIR" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ]; then
112
+ #
113
+ # The tree the command was issued from, kept apart from the anchored one: a
114
+ # base defaulted at dispatch is the HEAD of the tree the implementer is about to
115
+ # be cut from, which in a worktree set is not always the main worktree's.
116
+ ORIG_DIR="$DIR"
117
+ if [ "$SUB" != "init" ] && [ ! -f "$DIR/.sluice/run.json" ] \
118
+ && [ "$(git -C "$DIR" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ]; then
107
119
  MAIN_TREE="$(git -C "$DIR" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')"
108
120
  if [ -n "${MAIN_TREE:-}" ] && [ -d "$MAIN_TREE" ]; then
109
121
  DIR="$MAIN_TREE"
@@ -200,11 +212,23 @@ if [ "$SUB" = "line" ]; then
200
212
  else "◷ \($m / 60 | floor)h\($m % 60)m"
201
213
  end
202
214
  end) as $clock
215
+ # Idle is the time since the last write, and it only shows once it passes
216
+ # a day: a run in progress is written every few minutes, so a day of
217
+ # silence is a run that was forgotten or finished without being closed. A
218
+ # run written before `updated` existed falls back to `started`.
219
+ | ((.updated // .started // "" | try fromdateiso8601 catch 0) as $u
220
+ | if $u == 0 then ""
221
+ else (($now - $u) / 3600 | floor) as $h
222
+ | if $h < 24 then ""
223
+ else "idle \($h / 24 | floor)d\($h % 24)h"
224
+ end
225
+ end) as $idle
203
226
  | ( paint("1;96"; "⧗") + " "
204
227
  + ([ paint("1;96"; (.channel // "?")),
205
228
  paint("2"; (.topic // ""))
206
229
  ] | join_parts)
207
230
  + (if $clock == "" then "" else " " + paint("2"; $clock) end)
231
+ + (if $idle == "" then "" else " " + paint("2"; "·") + " " + paint("33"; $idle) end)
208
232
  ),
209
233
  # The flip is drawn as a rule before its task: everything left of it is
210
234
  # inert and safe to leave landed, everything right of it is not. That is
@@ -260,15 +284,21 @@ mk_dir() { # <directory to create under .sluice>
260
284
  # stdin, so a jq that died upstream of this feeds it nothing, and installing
261
285
  # nothing atomically is still a wipe of the one file in the run that outlives
262
286
  # compaction. A command that cannot finish leaves the state as it found it.
287
+ #
288
+ # Every write stamps `updated`, which is what lets a reader tell a run that is
289
+ # moving from one that was left behind: `started` only says how old it is.
263
290
  write_state() {
264
- local tmp="$STATE.tmp.$$"
291
+ local tmp="$STATE.tmp.$$" stamped="$STATE.stamped.$$"
265
292
  cat >"$tmp"
266
- if [ ! -s "$tmp" ] || ! jq -e . "$tmp" >/dev/null 2>&1; then
267
- rm -f "$tmp"
293
+ # One jq does both the check and the stamp: it fails on malformed input and
294
+ # writes nothing on empty input, and either leaves the candidate unfit.
295
+ if ! jq --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 'if type != "object" then error("state is not an object") else .updated = $now end' "$tmp" >"$stamped" 2>/dev/null || [ ! -s "$stamped" ]; then
296
+ rm -f "$tmp" "$stamped"
268
297
  err "refusing to write $STATE: the update produced no valid state, so the existing state is unchanged"
269
298
  exit 1
270
299
  fi
271
- mv "$tmp" "$STATE" || { rm -f "$tmp"; err "could not replace $STATE"; exit 1; }
300
+ rm -f "$tmp"
301
+ mv "$stamped" "$STATE" || { rm -f "$stamped"; err "could not replace $STATE"; exit 1; }
272
302
  }
273
303
 
274
304
  # One state file now serves a whole worktree set, so two implementers can flip
@@ -410,6 +440,20 @@ case "$SUB" in
410
440
  exit 4
411
441
  fi
412
442
 
443
+ # The base is cheap to know at dispatch and archaeology afterwards, and
444
+ # the guess it gets recovered as is HEAD~1. So a task going active with
445
+ # no base takes the HEAD of the tree the command was issued from, once:
446
+ # a base already on the row was a decision and a second flip keeps it.
447
+ if [ "$STATUS" = "active" ] && [ -z "$BASE" ]; then
448
+ has_base="$(jq --argjson id "$ID" '[.tasks[]? | select(.id == $id and .base != null)] | length' "$STATE" 2>/dev/null)"
449
+ case "$has_base" in
450
+ '' | *[!0-9]*) err "could not read the task list from $STATE"; exit 6 ;;
451
+ esac
452
+ if [ "$has_base" = "0" ]; then
453
+ BASE="$(git -C "$ORIG_DIR" rev-parse --short HEAD 2>/dev/null || true)"
454
+ fi
455
+ fi
456
+
413
457
  patch="$(jq -n \
414
458
  --arg name "$NAME" --arg status "$STATUS" --arg base "$BASE" \
415
459
  --arg commit "$COMMIT" --arg tier "$TIER" --arg model "$MODEL" \
@@ -494,7 +538,7 @@ case "$SUB" in
494
538
  # Header and rows are laid out from the same widths, so the two cannot
495
539
  # drift apart, and an over-long value is clipped with a marker rather
496
540
  # than silently reading as the whole value.
497
- jq -r '
541
+ jq -r --argjson now "$(date -u +%s)" '
498
542
  def dash: if . == null or . == "" then "-" else . end;
499
543
  def cell($w): tostring
500
544
  | if length > $w then .[0:$w - 1] + "…"
@@ -505,11 +549,24 @@ case "$SUB" in
505
549
  $c[6]] | join(" "));
506
550
  ([.tasks[]? | select(.status == "done")] | length) as $done
507
551
  | ["sluice \(.channel) · \(.topic) · \($done)/\(.tasks | length) done"]
508
- + ["plan \(.plan | dash)"]
509
- + ["record \(.record | dash)"]
552
+ + ["plan \(.plan | dash)"]
553
+ + ["record \(.record | dash)"]
554
+ # Past a day since the last write the run is idle, and that is said
555
+ # here because a stale run blocks the next init and nothing else
556
+ # would name it.
557
+ # A run written before `updated` existed falls back to `started`,
558
+ # which is the case the line was added for.
559
+ + ((.updated // .started // "" | try fromdateiso8601 catch 0) as $u
560
+ | if $u == 0 then []
561
+ else (($now - $u) / 3600 | floor) as $h
562
+ | if $h < 24 then []
563
+ else ["idle \($h / 24 | floor)d\($h % 24)h since the last write"]
564
+ end
565
+ end)
510
566
  + (([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
511
- | if $debt == 0 then [] else ["unreviewed \($debt) done, owed a review the tier table promised"] end)
512
- + ["pre-flight " + (
567
+ | if $debt == 0 then [] else ["unreviewed \($debt) done, owed a review the tier table promised"] end)
568
+ + ["final review " + (if .final_review then "done" else "pending" end)]
569
+ + ["pre-flight " + (
513
570
  if (.preflight // {} | length) == 0 then "not recorded"
514
571
  else [(.preflight | to_entries[] | "\(.key)=\(.value)")] | join("; ")
515
572
  end)]
@@ -558,9 +615,18 @@ case "$SUB" in
558
615
  "no contract graph in the run state.",
559
616
  "re-run `plan.sh import <plan>` to record Needs, Offers and Touches."
560
617
  else
618
+ # What a wave of several means depends on what pre-flight bought:
619
+ # worktrees per implementer run it at once, anything else runs it
620
+ # one at a time, and an answer never recorded says neither.
561
621
  (
562
622
  "\($ready | length) ready now"
563
- + (if ($ready | length) > 1 then " · a worktree each" else "" end)
623
+ + (if ($ready | length) > 1 then
624
+ (.preflight.workspace // "") as $ws
625
+ | if $ws == "" then ""
626
+ elif ($ws | test("per (concurrent )?implementer|each implementer|worktree each"; "i")) then " · a worktree each"
627
+ else " · serial, one at a time in the shared tree"
628
+ end
629
+ else "" end)
564
630
  ),
565
631
  ($ready[] | " T\(.id) \(.name // "" | pad(38))\((.touches // []) | join(", "))"),
566
632
  # A shared path is what rules two ready tasks out of the same wave,
@@ -586,11 +652,36 @@ case "$SUB" in
586
652
  ' "$STATE"
587
653
  ;;
588
654
 
655
+ final)
656
+ [ $# -eq 0 ] || { err "final takes no arguments"; exit 4; }
657
+ require_run
658
+ take_lock
659
+ require_readable
660
+
661
+ # The per-task marks count dispatches the tier table owed. The final
662
+ # review is owed by the plan as a whole, so it is a fact about the run
663
+ # rather than a row, and `show` reports it pending until this lands.
664
+ jq --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.final_review = $now' "$STATE" | write_state
665
+ ;;
666
+
589
667
  close)
590
668
  [ $# -eq 0 ] || { err "close takes no arguments"; exit 4; }
591
669
  require_run
592
670
  take_lock
593
671
 
672
+ # Said once, as the run leaves: a close that archives six unreviewed
673
+ # tasks and no final review in silence lets those facts leave with it.
674
+ # Best effort on state nothing else will parse, which is why it is not
675
+ # allowed to stop the archive.
676
+ summary="$(jq -r '
677
+ ([.tasks[]? | select(.status == "done")] | length) as $done
678
+ | ([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
679
+ | [ "closed \(.topic // "run"): \($done)/\(.tasks | length) done",
680
+ (if $debt > 0 then "\($debt) unreviewed" else empty end),
681
+ "final review \(if .final_review then "done" else "pending" end)"
682
+ ] | join(" · ")
683
+ ' "$STATE" 2>/dev/null || true)"
684
+
594
685
  # Deliberately not `require_readable`. The parse error every other
595
686
  # subcommand raises names close as the way out, so close is the one
596
687
  # command that has to accept state nothing else will touch: it moves the
@@ -612,7 +703,11 @@ case "$SUB" in
612
703
  dest="$ARCHIVE/$stamp-$slug-$n.json"
613
704
  n=$((n + 1))
614
705
  done
615
- mv "$STATE" "$dest"
706
+ mv "$STATE" "$dest" || { err "could not archive $STATE"; exit 1; }
707
+ # The summary needs parseable state and close is the one command that
708
+ # does not, so an unreadable run still gets a line naming where it went.
709
+ [ -n "$summary" ] || summary="closed $(basename "$dest"): state was unreadable, no summary"
710
+ echo "$summary"
616
711
  ;;
617
712
 
618
713
  *)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.15.0",
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 and one status command can answer where the run is, 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.",
3
+ "version": "0.16.0",
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",
7
7
  "tools": [
@@ -28,6 +28,7 @@
28
28
  "global"
29
29
  ],
30
30
  "default": "global",
31
- "claudeHookDirective": "Before acting on a request that changes code, pick a sluice channel and state which one."
31
+ "claudeHookDirective": "Before acting on a request that changes code, pick a sluice channel and state which one.",
32
+ "claudeHookScript": "scripts/session-start.sh"
32
33
  }
33
34
  }