@iceinvein/agent-skills 0.8.4 → 0.9.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
@@ -21,12 +21,14 @@ var __toESM = (mod, isNodeMode, target) => {
21
21
  }
22
22
  target = mod != null ? __create(__getProtoOf(mod)) : {};
23
23
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
24
- for (let key of __getOwnPropNames(mod))
25
- if (!__hasOwnProp.call(to, key))
26
- __defProp(to, key, {
27
- get: __accessProp.bind(mod, key),
28
- enumerable: true
29
- });
24
+ if (mod && typeof mod === "object" || typeof mod === "function") {
25
+ for (let key of __getOwnPropNames(mod))
26
+ if (!__hasOwnProp.call(to, key))
27
+ __defProp(to, key, {
28
+ get: __accessProp.bind(mod, key),
29
+ enumerable: true
30
+ });
31
+ }
30
32
  if (canCache)
31
33
  cache.set(mod, to);
32
34
  return to;
@@ -35,7 +37,7 @@ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports,
35
37
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
36
38
 
37
39
  // node_modules/cli-width/index.js
38
- var require_cli_width = __commonJS((exports, module) => {
40
+ var require_cli_width = __commonJS(function(exports, module) {
39
41
  module.exports = cliWidth;
40
42
  function normalizeOpts(options) {
41
43
  const defaultOpts = {
@@ -75,7 +77,7 @@ var require_cli_width = __commonJS((exports, module) => {
75
77
  });
76
78
 
77
79
  // node_modules/mute-stream/lib/index.js
78
- var require_lib = __commonJS((exports, module) => {
80
+ var require_lib = __commonJS(function(exports, module) {
79
81
  var Stream = __require("stream");
80
82
 
81
83
  class MuteStream extends Stream {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.8.4",
3
+ "version": "0.9.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, 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 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.",
285
285
  "type": "prompt",
286
- "version": "0.7.0"
286
+ "version": "0.10.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -90,6 +90,16 @@ Design to `docs/specs/YYYY-MM-DD-<topic>.md`, plan to
90
90
  `docs/plans/YYYY-MM-DD-<topic>.md`, unless the repo has a convention or
91
91
  your partner states a preference. Get the design signed off before code.
92
92
 
93
+ Take the design stop through the harness's plan mode where there is one. Its
94
+ gate is enforced rather than requested and it holds edits shut while it is open,
95
+ so nothing gets built against a design nobody signed. It carries the first stop
96
+ only; pre-flight still wants answers, and an approval is not one.
97
+
98
+ The run's state goes in `.sluice/run.json`, written a command at a time by
99
+ `scripts/status.sh`. That is what makes the breakdown readable from outside the
100
+ session running it: a statusline segment while it runs, `status.sh show` after a
101
+ compaction, and neither costing your partner a turn to ask. `references/status.md`
102
+
93
103
  **Read `references/deep-channel.md` before you write the plan.** It carries the
94
104
  plan format, the pre-flight questions, the run record, the dispatch rules and
95
105
  the review tiers, and none of those are derivable from what is on this page.
@@ -112,6 +122,7 @@ plan:
112
122
  **Touches:** <path> (new) | <path> (edit) | <path> (test)
113
123
  **Flips:** <what changes, from what, or omit>
114
124
  **Review:** <reason, or omit>
125
+ **Model:** <cheaper model, and why the work is mechanical, or omit>
115
126
  - [ ] <action> -> <proof>
116
127
  ```
117
128
 
@@ -130,19 +141,21 @@ task that turns the new behaviour on with `Flips`. Then a late re-baseline,
130
141
  re-blessed snapshots, regenerated fixtures, attributes to that one point
131
142
  instead of to the branch at large.
132
143
 
133
- Pre-flight rides in that same stop: which flagged tasks get a reviewer, and
134
- whether the work runs in a worktree. Ask them as choices with the counts in
135
- them, never as a paragraph, then end the turn. Task 1 opens on their next
144
+ Pre-flight rides in that same stop: which flagged tasks get a reviewer, which
145
+ mechanical ones the plan marked for a cheaper model, and whether the work runs
146
+ in a worktree. Ask them as choices with the counts in them, never as a
147
+ paragraph, then end the turn. Task 1 opens on their next
136
148
  instruction, and an answer that already carries one is that instruction. A
137
149
  session that dispatches only when asked has not ruled dispatch out, it has made
138
150
  this question the place to ask; genuine unavailability is the tool not being
139
151
  there at all, which `references/deep-channel.md` handles separately. Review that
140
152
  turns out to be missing is only actionable while the plan can still change.
141
153
 
142
- Both answers go into the run record before Task 1's first edit, each with the
143
- reason it went that way. Those rows are what discharge pre-flight, not the
144
- approval: one reply arrives for two obligations, so a "yes" with no rows behind
145
- it signed off the plan and nothing else.
154
+ All three answers go into `run.json` and the run record before Task 1's first
155
+ edit, the answer in the first and the reason in the second. Those rows are what
156
+ discharge pre-flight, not the approval: one reply arrives for several
157
+ obligations, so a "yes" with no rows behind it signed off the plan and nothing
158
+ else.
146
159
 
147
160
  Three from that file that catch people out: concurrent implementers need a
148
161
  worktree each and the flip runs alone, review is tiered rather than automatic,
@@ -20,6 +20,16 @@
20
20
  corrupted concurrent run.
21
21
  - A **Review** field may name why the task needs the stronger tier (auth,
22
22
  data, money, concurrency); the table below decides otherwise.
23
+ - A **Model** field may put the task on a cheaper model, and says why the work
24
+ is mechanical: the Contract is exact, the tests it has to satisfy already
25
+ exist, and nothing in it turns on judgment. Omit it and the task runs on this
26
+ session's model, so the marked tasks are the exceptions rather than the rule.
27
+ Anything the table below sends to tier 3 is disqualified, `Flips` with it:
28
+ those are the tasks where being wrong is expensive, and the saving is not
29
+ worth pricing against that. Mark it here rather than deciding at dispatch,
30
+ because whether a task is mechanical is fixed the moment its Contract and
31
+ Touches are written, and pre-flight is the last point your partner can price
32
+ it.
23
33
  - **Order the plan so the inert tasks come first.** A task is inert when it
24
34
  adds capability, config, a schema or a code path that nothing reads yet:
25
35
  landing it changes no observable behaviour, so it is safe to land alone and
@@ -36,6 +46,16 @@
36
46
  the instruction out again in full. "TBD" is another. So is naming a type
37
47
  or a function that no task in the plan ever creates.
38
48
 
49
+ **Run `scripts/plan.sh validate <plan>` on the finished plan, before pre-flight.**
50
+ Most of what this section asks for needs no judgement to check: a `Needs` no
51
+ task `Offers`, a "TBD", a step deferring to a neighbour, a missing `Contract`,
52
+ a plan with no flip or two of them, a `Model` mark on a task the tier table
53
+ sends to tier 3. Those come back as errors with the task number on them. The
54
+ warnings are the judgement calls left to you: a step with no proof, a `Needs`
55
+ satisfied only by a later task, two tasks whose `Touches` overlap. Reading the
56
+ plan yourself catches these on a good day, and the point of a check is the
57
+ other kind of day.
58
+
39
59
  ```
40
60
  # Plan: <topic>
41
61
  ## Ground Rules
@@ -45,6 +65,7 @@
45
65
  **Touches:** <path> (new) | <path> (edit) | <path> (test)
46
66
  **Flips:** <what changes, from what, or omit>
47
67
  **Review:** <reason, or omit>
68
+ **Model:** <cheaper model, and why the work is mechanical, or omit>
48
69
  - [ ] <action> -> <proof>
49
70
  ```
50
71
 
@@ -61,14 +82,31 @@ Split it. A plan with none is not a `deep` plan: nothing in it does anything.
61
82
  ## The run record
62
83
 
63
84
  A `deep` run outlives its own context, so what it learns has to sit on disk
64
- rather than in the session. Open one file for the run before pre-flight and
65
- write it as you go. It holds what a stranger resuming tomorrow would need and
66
- you would otherwise be recalling: the base each task was dispatched from, each
67
- task with its status and its commits, both answers pre-flight settled, review
68
- and workspace alike, with the reason each one went that way, and any finding
69
- belonging to a task other than the one that surfaced it. Those pre-flight rows
70
- come first and open the file, because they are also what says the stop happened
71
- at all.
85
+ rather than in the session. That lands in two files, and which one a thing goes
86
+ in follows from who has to read it.
87
+
88
+ `.sluice/run.json` holds the state that moves: each task with its status, base
89
+ and commit, its tier, its `Model` mark and its `Flips` line, and the answers
90
+ pre-flight settled. `scripts/status.sh` writes and reads it, and
91
+ `references/status.md` carries the commands and the statusline segment that
92
+ makes a run visible without anyone asking. Open it with `init` when you open the
93
+ record, seed the rows with `scripts/plan.sh import <plan>` rather than typing a
94
+ command per task, then flip each task as it moves. Import is safe to re-run: it
95
+ sets names, model marks and the flip, and leaves a status already recorded
96
+ alone, so resuming after a compaction cannot rewind the run.
97
+
98
+ The record is the other file, and it holds what a status cannot: the reason
99
+ review went the way it did, the reason a task was downshifted, the reason the
100
+ workspace answer went that way, any finding belonging to a task other than the
101
+ one that surfaced it, and whatever else a stranger resuming tomorrow would need
102
+ and could not derive. **It no longer carries task rows with statuses in them.**
103
+ Status written in both places drifts, and once it has there are two answers and
104
+ nothing to say which is stale.
105
+
106
+ Pre-flight lands in both, which is the one deliberate overlap: `run.json` holds
107
+ the answer, so the file can say whether the stop happened at all, and the record
108
+ holds the reason, so a reader can tell whether it should have gone that way.
109
+ Those rows come first and open the record for the same reason they always did.
72
110
 
73
111
  Where it goes follows the repo if the repo has a convention, and
74
112
  `docs/plans/YYYY-MM-DD-<topic>-record.md` if it does not. It belongs to you
@@ -77,16 +115,47 @@ task's `Touches` names it, and you commit it yourself alongside the plan.
77
115
  Assembling it at handback defeats it: a record written from memory is memory,
78
116
  which is the one thing the file exists to replace.
79
117
 
80
- A file only outlives compaction if you go back to it. Read it before the next
81
- dispatch whenever this session has been summarised, and treat what it says over
82
- what you remember, including where the two agree. Each task closes by writing
83
- its commit into the record, which means asking the implementer to report the
84
- SHA it committed and putting that in the row rather than deriving it later.
118
+ A file only outlives compaction if you go back to it. Run `status.sh show` and
119
+ read the record before the next dispatch whenever this session has been
120
+ summarised, and treat what they say over what you remember, including where the
121
+ two agree. Each task closes by writing its commit into `run.json`, which means
122
+ asking the implementer to report the SHA it committed and passing it to
123
+ `status.sh task <id> --status done --commit <sha>` rather than deriving it later.
124
+
125
+ ## The design stop and plan mode
126
+
127
+ The design stop is a stop because a plan written against the wrong design wastes
128
+ a plan's worth of work. Sluice enforced it with a sentence, "a stop ends your
129
+ turn", which is the weakest gate available in a harness that has a real one.
130
+
131
+ Claude Code's plan mode is the real one. `EnterPlanMode` needs your partner's
132
+ consent to enter, `ExitPlanMode` will not proceed without their approval, and
133
+ edits are held shut in between, so the design cannot be quietly built against
134
+ while it is still a draft. Take the design stop through it.
135
+
136
+ What that changes: the design gets drafted in the plan file the harness names,
137
+ and `ExitPlanMode` is the sign-off rather than a paragraph asking for one.
138
+
139
+ What it does not replace is pre-flight. That stop wants three answers, and an
140
+ approval is not an answer to any of them, so it stays where it is, after plan
141
+ mode has exited and the plan is written. One enforced gate does not collapse two
142
+ stops into one; it only makes the first of them hold.
143
+
144
+ **The harness's plan file is not the artifact.** It belongs to the mode and not
145
+ to the run. On approval, write the design to `docs/specs/YYYY-MM-DD-<topic>.md`,
146
+ the plan to `docs/plans/YYYY-MM-DD-<topic>.md`, and open the run record and
147
+ `run.json`. Those are the durable files, the ones a session resuming next week
148
+ reads, and none of them is the one you drafted in.
149
+
150
+ Where plan mode is unavailable, the prose stop is what you have and it is the
151
+ same stop: end the turn on the design and let the next instruction start the
152
+ plan. Nothing else about this section changes, because the obligation was never
153
+ the mode's, only the enforcement was.
85
154
 
86
155
  ## Pre-flight
87
156
 
88
157
  Design signed off, plan written, nothing built yet. Before Task 1, stop once
89
- and settle two things with your partner. Ask them as questions with options,
158
+ and settle three things with your partner. Ask them as questions with options,
90
159
  not as a paragraph they have to reply to in prose: what you are after is a
91
160
  decision, and a wall of considerations asks them to extract the decision from
92
161
  it first.
@@ -103,11 +172,18 @@ your sign-off and Task 1 begins.
103
172
  **Review.** Name the tasks the table below sends to a reviewer, each with the
104
173
  trigger that qualified it, and say how many of the rest skip with a ledger
105
174
  line. Then offer the choice: dispatch a reviewer at each of them, dispatch
106
- only at the stronger-model tier, or hand back with those tasks listed as
175
+ only at tier 3, or hand back with those tasks listed as
107
176
  review outstanding. The options are what makes the cost legible.
108
177
  "Four of nine need a reviewer" is a decision your partner can price; "I will
109
178
  review where appropriate" is not.
110
179
 
180
+ **Model.** The plan already marked which tasks are mechanical, so this is a
181
+ ratification rather than a fresh judgment, and it carries the count: six of
182
+ nine on the cheaper model and three on this session's, take it, put everything
183
+ on the session model, or name the exceptions. The count is what your partner
184
+ prices. "I will use the cheaper model where it fits" prices nothing, and it
185
+ also arrives after the tokens are spent.
186
+
111
187
  **Workspace, commits and concurrency.** One worktree for the plan, one per
112
188
  concurrent implementer, or straight onto the current branch; and with it who
113
189
  commits, each agent committing its own task as it finishes or agents leaving
@@ -137,9 +213,10 @@ is the declared schedule the dispatch rules reject.
137
213
  If one of the two has only one live answer, say which and ask the other. A stop
138
214
  down to a single question is still a stop.
139
215
 
140
- **Write the answers into the run record before Task 1's first edit.** Both of
141
- them, each with the reason it went that way. This is what discharges pre-flight,
142
- rather than the approval you got, and the distinction is the whole point: a stop
216
+ **Write the answers down before Task 1's first edit.** Both files:
217
+ `status.sh preflight` for the answers, the run record for the reason each one
218
+ went that way. That pair is what discharges pre-flight, rather than the
219
+ approval you got, and the distinction is the whole point: a stop
143
220
  that carries the plan and pre-flight together has one reply for two obligations,
144
221
  so a bare "yes" satisfies the plan and leaves no trace either way of the
145
222
  questions. Rows in a file leave that trace. If Task 1 is about to open and the
@@ -166,10 +243,15 @@ Derive the sets at dispatch rather than writing wave numbers into the plan. A
166
243
  declared schedule is wrong the moment one task lands late or comes back with a
167
244
  blocking finding. A derived one just recomputes.
168
245
 
169
- - One line per task in the run record, marked in progress then complete as it
246
+ - One row per task in `run.json`, flipped to `active` and then `done` as it
170
247
  moves. That state outlives compaction; your memory doesn't.
171
248
  - Each task goes to a fresh agent with that task's text and nothing else.
172
249
  What this session accumulated is yours to hold, not theirs.
250
+ - **Label the dispatch `T<n>: <task name>`.** The harness lists running agents
251
+ under whatever label the dispatch gave them, so labelled by task that list
252
+ reads as the plan and labelled anything else it reads as a row of anonymous
253
+ agents. It costs nothing and it is the only place a partner can see which
254
+ task is in flight without asking.
173
255
  - **Fan out wherever the graph allows.** Work that does not write is always
174
256
  safe and always parallel: investigations, searches and reviewers, one agent
175
257
  per question, all in one message so they run at once.
@@ -201,8 +283,11 @@ blocking finding. A derived one just recomputes.
201
283
  authoring a message for a diff you did not write means reading that diff,
202
284
  which is the context dispatch exists to keep out of this session. Pre-flight
203
285
  can overturn it for a given run.
204
- - Match model to task: cheap for mechanical work, stronger for judgment
205
- and final review.
286
+ - **The plan and pre-flight decide the model, not the moment of dispatch.** A
287
+ task with no `Model` line goes out on this session's, and a downshift that
288
+ was neither marked nor ratified is a saving your partner never agreed to.
289
+ Reviewers are the other half of that rule: a review never runs below the
290
+ model that built the task, so a downshifted task's reviewer comes back up.
206
291
 
207
292
  A per-task commit is not an integration event. `references/finish.md` owns
208
293
  push, PR and merge, none of which happen here, and a standing instruction to
@@ -225,8 +310,8 @@ Three things change. The plan stops being a brief for strangers and becomes
225
310
  your own worklist, so its stop is no longer buying alignment with the agents
226
311
  who will carry it out, only your partner's read of work you will do yourself.
227
312
  Task isolation is gone, so
228
- the run record now carries all of the state that outlives compaction and
229
- matters more, not less. And fresh context is unavailable, which was the entire thing
313
+ `run.json` and the record now carry all of the state that outlives compaction
314
+ and matter more, not less. And fresh context is unavailable, which was the entire thing
230
315
  review was buying.
231
316
 
232
317
  One thing does not change: the work still owes a review. Reading your own diff
@@ -259,8 +344,15 @@ and it fails by printing success rather than by erroring.
259
344
  | 0 | Created files only, executable tests exist and pass, Contract matches | No dispatch. Read the commit stat yourself. |
260
345
  | 1 | Modified existing code, or later tasks build on it | One reviewer dispatch |
261
346
  | 2 | No executable test covers it: prose, config, docs | One reviewer dispatch; a stat cannot confirm the words are right |
262
- | 3 | Auth, data, money, concurrency, or the plan flags it | One reviewer dispatch, stronger model |
263
- | 3 | Carries the `Flips` line | One reviewer dispatch, stronger model |
347
+ | 3 | Auth, data, money, concurrency, or the plan flags it | One reviewer dispatch; the task may not be downshifted |
348
+ | 3 | Carries the `Flips` line | One reviewer dispatch; the task may not be downshifted |
349
+
350
+ No tier buys a bigger model, because a plan that downshifted nothing has every
351
+ agent on the same one, and a tier promising something stronger would be
352
+ promising what is already in use. What a tier buys is a dispatch. The one model
353
+ rule that holds across all of them is that a review never runs below the model
354
+ that built the task, so tiers 1 and 2 over a downshifted task come back up to
355
+ this session's.
264
356
 
265
357
  A task matching more than one row takes the highest tier of them. Tier is the
266
358
  number, not the row order and not which shape sounds more serious. A task that
@@ -279,8 +371,8 @@ a reviewer writes nothing, so it collides with nothing. The final review is
279
371
  the only one that waits, because it is the only one that needs everything to
280
372
  have landed.
281
373
 
282
- Record the base against that task in the run record when you dispatch, before
283
- the agent's first commit lands. Recovering it afterwards is archaeology, and the
374
+ Record the base against that task with `status.sh task <id> --base <sha>` when
375
+ you dispatch, before the agent's first commit lands. Recovering it afterwards is archaeology, and the
284
376
  answer you will guess at is `HEAD~1`, which `references/review.md` already
285
377
  names as the standing mistake.
286
378
 
@@ -323,8 +415,9 @@ absorb into the next task's brief.
323
415
  ## The final review
324
416
 
325
417
  It covers cross-task integration and everything the record accumulated, not
326
- lines a per-task review already cleared. It is a dispatch, on the stronger
327
- model, and it gets the whole-plan diff and the deferred findings as a list.
418
+ lines a per-task review already cleared. It is a dispatch, on this session's
419
+ model and never a downshifted one, and it gets the whole-plan diff and the
420
+ deferred findings as a list.
328
421
 
329
422
  Size the brief to what it is actually carrying, and say which of two things it
330
423
  is. After nine per-task reviews cleared, it is an integration check. When
@@ -0,0 +1,110 @@
1
+ # Run state
2
+
3
+ The plan says what the tasks are. The run record says why each decision went
4
+ the way it did. Neither answers "where is this right now" to anything but a
5
+ model reading prose, which is why a `deep` run is invisible from outside the
6
+ session that is running it: your partner has to ask, and asking costs a turn
7
+ and gets an answer from memory.
8
+
9
+ `.sluice/run.json` is that answer in a form something else can read. One file
10
+ per tree, holding only what changes as the run moves.
11
+
12
+ ```
13
+ bash <skill-dir>/scripts/status.sh init --topic <t> --channel deep \
14
+ --plan docs/plans/<date>-<topic>.md --record docs/plans/<date>-<topic>-record.md
15
+ bash <skill-dir>/scripts/status.sh task 3 --name "adapter seam" --tier 1 --model cheap
16
+ bash <skill-dir>/scripts/status.sh task 3 --status active --base 75014c9
17
+ bash <skill-dir>/scripts/status.sh task 3 --status done --commit 2c7f261
18
+ bash <skill-dir>/scripts/status.sh preflight --review "tier 3 only" --model "6 of 9 cheap" \
19
+ --workspace "one worktree per implementer"
20
+ bash <skill-dir>/scripts/status.sh show
21
+ bash <skill-dir>/scripts/status.sh close
22
+ ```
23
+
24
+ `--dir <path>` reads another tree, which is what the statusline uses. Statuses
25
+ are `todo`, `active`, `review`, `done` and `blocked`. A new id needs `--name`;
26
+ after that every call is a bare flip, so keeping it current costs one command
27
+ per transition rather than a paragraph. `close` archives the run under
28
+ `.sluice/archive/` and frees the tree for the next one.
29
+
30
+ A command that cannot finish leaves the state exactly as it found it, so a
31
+ failed `task` never costs you the rows already in the file. Two argument rules
32
+ follow from that being worth guaranteeing: a value beginning with `--` is
33
+ rejected rather than accepted as a value, and a flag with no value at all is
34
+ rejected rather than taking the next flag as one.
35
+
36
+ Gitignore `.sluice/`. It is working state, and everything durable in it lands
37
+ somewhere else anyway: the commits are in git and the reasons are in the record,
38
+ which is the file that does get committed.
39
+
40
+ Open it with `init` when you open the run record, at the same point and for the
41
+ same reason, then seed the rows with `plan.sh import <plan>` rather than a
42
+ command per task. The ids, names, the flip, the `Model` marks and the one tier
43
+ the plan settles are all fixed the moment the plan is written and are already in
44
+ the file, so typing them again is transcription with a chance of error in it.
45
+
46
+ Re-importing is safe and is the right move after the plan changes. It refreshes
47
+ names, the flip and the tier, and it leaves a status or a ratified model already
48
+ recorded alone, so resuming after a compaction cannot rewind the run.
49
+
50
+ ## What goes where
51
+
52
+ **`run.json` owns status. The record owns why.** A status written into both
53
+ drifts, and the moment it does there are two answers and no way to tell which
54
+ is stale. So the record stops carrying task rows with statuses in them and
55
+ carries what a status cannot hold: the reason review went the way it did, the
56
+ reason a task was downshifted, a finding that belongs to a task other than the
57
+ one that surfaced it, and what a stranger resuming tomorrow would need and
58
+ could not derive.
59
+
60
+ Pre-flight answers land in both, and that is deliberate rather than an
61
+ exception: `run.json` holds the answer so the file can say whether the stop
62
+ happened, the record holds the reason so a reader can tell whether it should
63
+ have gone that way. Those are different claims.
64
+
65
+ ## Reading it back
66
+
67
+ `show` prints the whole run: channel, topic, how many tasks are done, the plan
68
+ and record paths, the pre-flight answers, and a row per task with its base,
69
+ commit, tier and model. Run it after compaction instead of reconstructing the
70
+ run from what you remember, and run it in the message that hands the work back,
71
+ where "four of nine, task five blocked" is a fact your partner can act on.
72
+
73
+ `show --json` is the same state for another reader. `line` is the one-line form,
74
+ and it exits 0 in silence on a missing run, unreadable state or a missing jq,
75
+ because its caller is a status bar with nowhere to put an error.
76
+
77
+ ## Statusline
78
+
79
+ This is the part that makes a run visible without anyone asking. The segment
80
+ goes in the Claude Code statusline command, keyed off the state file existing so
81
+ a session with no run in flight spawns nothing:
82
+
83
+ ```bash
84
+ sluice_line=""
85
+ if [ -n "$cwd" ] && [ -f "$cwd/.sluice/run.json" ]; then
86
+ for sluice_sh in "$cwd/.claude/skills/sluice/scripts/status.sh" \
87
+ "$HOME/.claude/skills/sluice/scripts/status.sh"; do
88
+ [ -f "$sluice_sh" ] || continue
89
+ sluice_line=$(bash "$sluice_sh" line --dir "$cwd" 2>/dev/null)
90
+ break
91
+ done
92
+ fi
93
+ [ -n "$sluice_line" ] && printf ' %s' "$sluice_line"
94
+ ```
95
+
96
+ `$cwd` is `workspace.current_dir` from the JSON the harness sends on stdin. It
97
+ renders as `sluice deep 4/9 ▸T5`, or `!T5` where a task is blocked, which is
98
+ the one state worth colouring as a warning rather than as progress.
99
+
100
+ A run that is only visible to the session running it is a run your partner
101
+ cannot redirect. That is the same argument the channel announcement makes, and
102
+ the statusline is where it holds for the hour after the announcement scrolled
103
+ away.
104
+
105
+ ## Friction
106
+
107
+ "I know which task I'm on." You do, until this session is summarised. The file
108
+ costs one command per transition and is the only thing in the run that survives
109
+ that, and a plan whose progress lives in one context window is a plan nobody
110
+ outside that window can read.
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env bash
2
+ # sluice plan tooling: the parts of the plan format that a reader can check
3
+ # without judgement.
4
+ #
5
+ # `references/deep-channel.md` is 21KB read once, at the point the plan is
6
+ # written, and relied on from memory for the rest of the run. Most of what it
7
+ # asks for is not a matter of taste: a Needs no task Offers, a "TBD", a step
8
+ # deferring to another task, a plan with no flip or two. Those are checkable, so
9
+ # they should be checked rather than remembered.
10
+ #
11
+ # plan.sh validate <plan.md>
12
+ # plan.sh import <plan.md> [--dir <path>] [--force]
13
+ #
14
+ # validate prints one line per finding. An error means the plan cannot be
15
+ # dispatched as written; a warning is a judgement call left to its author.
16
+ # import seeds the run state's task rows from the plan, so the ids, names, the
17
+ # flip, the model marks and the one tier the plan settles come from the file
18
+ # rather than from a dozen hand-typed commands. It is safe to re-run: a status or
19
+ # a ratified model already recorded is left alone.
20
+ #
21
+ # Exit: 0 no errors, 2 errors found, 4 bad arguments, 5 jq missing (import only).
22
+
23
+ set -uo pipefail
24
+
25
+ err() { echo "plan.sh: $*" >&2; }
26
+
27
+ usage() {
28
+ echo "usage:" >&2
29
+ sed -n '/^# plan.sh validate/,/^# Exit:/p' "$0" | sed 's/^# \{0,2\}//' >&2
30
+ }
31
+
32
+ # The parser. Emits tab-separated rows on stdout:
33
+ # summary <task count> <flip task or 0>
34
+ # error|warn <message>
35
+ # task <id> <name> <tier 3 or -> <model 1|0> <flips 1|0>
36
+ #
37
+ # Only tier 3 is derivable from the plan: Flips and a Review flag are the two
38
+ # triggers the plan itself settles. The rest turn on task shape and stay for the
39
+ # tier table to decide.
40
+ #
41
+ # One pass, line-oriented, because the plan format is a strict skeleton rather
42
+ # than free markdown. Fenced blocks are skipped: a plan carries signatures and
43
+ # commands in them, and a `- [ ]` inside a fence is an example, not a step.
44
+ PARSER='
45
+ function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
46
+
47
+ # Symbols on one side of a Contract line. The two sides are read differently
48
+ # because a mistake costs differently on each.
49
+ #
50
+ # Supply (Offers) is generous: every identifier in the half goes on offer, so a
51
+ # return shape spelled `parsePlan(path) -> `PlanDoc`` puts PlanDoc on offer as
52
+ # well as parsePlan, which is what the format asks for when it says both sides
53
+ # are spelled out whole. An extra symbol on offer costs nothing.
54
+ #
55
+ # Demand (Needs) is strict, because an extra symbol here is a false error:
56
+ # backticked names and signatures only, falling back to the leading identifier
57
+ # of each comma-separated part when the half carries neither.
58
+ function symbols(half, out, generous, count, rest, tok, lower, n, parts, i, chunk) {
59
+ lower = half
60
+ gsub(/`/, "", lower)
61
+ lower = tolower(trim(lower))
62
+ if (lower == "" || lower == "none" || lower == "-" || lower == "n/a") return 0
63
+ count = 0
64
+
65
+ if (generous) {
66
+ rest = half
67
+ gsub(/`/, " ", rest)
68
+ while (match(rest, /[A-Za-z_][A-Za-z0-9_]*/)) {
69
+ out[++count] = substr(rest, RSTART, RLENGTH)
70
+ rest = substr(rest, RSTART + RLENGTH)
71
+ }
72
+ return count
73
+ }
74
+
75
+ rest = half
76
+ while (match(rest, /`[^`]*`/)) {
77
+ chunk = substr(rest, RSTART + 1, RLENGTH - 2)
78
+ rest = substr(rest, RSTART + RLENGTH)
79
+ if (match(chunk, /[A-Za-z_][A-Za-z0-9_]*/))
80
+ out[++count] = substr(chunk, RSTART, RLENGTH)
81
+ }
82
+ rest = half
83
+ gsub(/`/, "", rest)
84
+ while (match(rest, /[A-Za-z_][A-Za-z0-9_]*\(/)) {
85
+ tok = substr(rest, RSTART, RLENGTH - 1)
86
+ out[++count] = tok
87
+ rest = substr(rest, RSTART + RLENGTH)
88
+ }
89
+ if (count == 0) {
90
+ rest = half
91
+ gsub(/`/, "", rest)
92
+ n = split(rest, parts, /[,;]/)
93
+ for (i = 1; i <= n; i++)
94
+ if (match(parts[i], /[A-Za-z_][A-Za-z0-9_]*/))
95
+ out[++count] = substr(parts[i], RSTART, RLENGTH)
96
+ }
97
+ return count
98
+ }
99
+
100
+ function finding(sev, msg) { out[++nout] = sev "\t" msg }
101
+
102
+ /^[ \t]*```/ { fenced = 1 - fenced; next }
103
+ fenced { next }
104
+
105
+ /^# Plan:/ { has_title = 1 }
106
+
107
+ /^## / {
108
+ in_rules = (tolower($0) ~ /^## ground rules/) ? 1 : 0
109
+ if (in_rules) has_rules_heading = 1
110
+ # A section after the last task belongs to the plan, not to that task. Left
111
+ # set, cur attributes its prose and any checkbox in it to a task that ended
112
+ # several headings ago.
113
+ cur = 0
114
+ next
115
+ }
116
+
117
+ /^### Task / {
118
+ in_rules = 0
119
+ s = substr($0, 10)
120
+ colon = index(s, ":")
121
+ if (colon == 0) { finding("error", "a task heading has no name: " $0); next }
122
+ id = trim(substr(s, 1, colon - 1))
123
+ if (id !~ /^[0-9]+$/) { finding("error", "a task heading is not numbered: " $0); next }
124
+ id = id + 0
125
+ if (id in seen) {
126
+ finding("error", "task " id " appears twice; a repeated number makes the dependency order unreadable")
127
+ }
128
+ seen[id] = 1
129
+ order[++ntasks] = id
130
+ name[id] = trim(substr(s, colon + 1))
131
+ cur = id
132
+ next
133
+ }
134
+
135
+ in_rules && /^- / { nrules++ }
136
+
137
+ # Everything below belongs to a task, so a field outside one is a field nobody
138
+ # reads.
139
+ !cur { next }
140
+
141
+ # Scanned over every line of a task body, and deliberately ahead of the field
142
+ # and step rules: those end in `next`, so a check placed after them never sees a
143
+ # step, which is exactly where a "TBD" turns up.
144
+ {
145
+ if ($0 ~ /TBD/)
146
+ finding("error", "task " cur ": \"TBD\" is not an instruction")
147
+ if (tolower($0) ~ /same as (task|step)/)
148
+ finding("error", "task " cur ": \"same as ...\" defers to a task its implementer never sees")
149
+ }
150
+
151
+ /^\*\*Contract:\*\*/ {
152
+ has_contract[cur] = 1
153
+ body = substr($0, length("**Contract:**") + 1)
154
+ oi = index(body, "Offers:")
155
+ if (oi > 0) { nh = substr(body, 1, oi - 1); oh = substr(body, oi + 7) }
156
+ else { nh = body; oh = "" }
157
+ ni = index(nh, "Needs:")
158
+ if (ni > 0) nh = substr(nh, ni + 6)
159
+ sub(/\|[ \t]*$/, "", nh)
160
+
161
+ delete syms
162
+ n = symbols(nh, syms, 0)
163
+ for (i = 1; i <= n; i++) needs[cur] = needs[cur] " " syms[i]
164
+
165
+ delete syms
166
+ n = symbols(oh, syms, 1)
167
+ for (i = 1; i <= n; i++) {
168
+ if (!(syms[i] in offered_by) || cur < offered_by[syms[i]]) offered_by[syms[i]] = cur
169
+ }
170
+ next
171
+ }
172
+
173
+ /^\*\*Touches:\*\*/ {
174
+ has_touches[cur] = 1
175
+ body = substr($0, length("**Touches:**") + 1)
176
+ n = split(body, parts, /\|/)
177
+ for (i = 1; i <= n; i++) {
178
+ p = parts[i]
179
+ sub(/\([^)]*\)[ \t]*$/, "", p)
180
+ p = trim(p)
181
+ if (p == "") continue
182
+ # Accumulated rather than assigned: with three tasks on one path,
183
+ # reporting a single pair leaves the reader serialising two of them and
184
+ # still running the third alongside.
185
+ if (index(" " owners[p] " ", " " cur " ") == 0)
186
+ owners[p] = owners[p] (owners[p] == "" ? "" : " ") cur
187
+ }
188
+ next
189
+ }
190
+
191
+ /^\*\*Flips:\*\*/ { has_flips[cur] = 1; nflips++; flips_list = flips_list (flips_list == "" ? "" : ", ") cur; next }
192
+ /^\*\*Review:\*\*/ { has_review[cur] = 1; next }
193
+ /^\*\*Model:\*\*/ { has_model[cur] = 1; next }
194
+
195
+ /^- \[[ xX]\]/ {
196
+ nsteps[cur]++
197
+ # Counted rather than reported here. One warning per step turns a plan
198
+ # written before the proof convention into forty identical lines, and the
199
+ # errors underneath them stop being read.
200
+ if (index($0, "->") == 0) noproof[cur]++
201
+ next
202
+ }
203
+
204
+ END {
205
+ if (!has_title) finding("warn", "no \"# Plan: <topic>\" heading, so the file does not say what it plans")
206
+
207
+ if (!has_rules_heading || nrules == 0)
208
+ finding("error", "no Ground Rules with entries; an implementer arrives with its own defaults and uses them on anything left unsaid")
209
+
210
+ if (ntasks == 0) {
211
+ finding("error", "no tasks, so there is nothing to dispatch")
212
+ } else {
213
+ for (i = 1; i <= ntasks; i++) {
214
+ id = order[i]
215
+ if (id != i)
216
+ finding("error", "tasks are not numbered 1.." ntasks " in sequence: task " id " sits where " i " should")
217
+ }
218
+ for (i = 1; i <= ntasks; i++) {
219
+ id = order[i]
220
+ if (!(id in has_contract))
221
+ finding("error", "task " id " has no Contract, so a symbol absent from it does not exist for whoever builds it")
222
+ if (!(id in has_touches))
223
+ finding("error", "task " id " has no Touches, and concurrency is derived from it")
224
+ if (!(id in nsteps))
225
+ finding("error", "task " id " has no steps")
226
+ else if (id in noproof)
227
+ finding("warn", "task " id ": " noproof[id] " of " nsteps[id] " steps have no proof after ->, so nothing says they worked")
228
+ if ((id in has_model) && (id in has_flips))
229
+ finding("error", "task " id " carries Flips and a Model mark; the flip is tier 3 and may not be downshifted")
230
+ if ((id in has_model) && (id in has_review))
231
+ finding("error", "task " id " is flagged for Review and carries a Model mark; a tier 3 task may not be downshifted")
232
+ }
233
+
234
+ if (nflips == 0)
235
+ finding("error", "no task carries Flips, so nothing in the plan turns anything on")
236
+ else if (nflips > 1)
237
+ finding("error", "two or more tasks carry Flips (" flips_list "); a plan with more than one holds that many branches of work")
238
+
239
+ for (i = 1; i <= ntasks; i++) {
240
+ id = order[i]
241
+ n = split(needs[id], want, " ")
242
+ for (j = 1; j <= n; j++) {
243
+ sym = want[j]
244
+ if (sym == "" || ((id "\t" sym) in reported)) continue
245
+ reported[id "\t" sym] = 1
246
+ if (!(sym in offered_by))
247
+ finding("error", "task " id " Needs " sym ", which no task Offers")
248
+ else if (offered_by[sym] > id)
249
+ finding("warn", "task " id " Needs " sym ", offered only by the later task " offered_by[sym])
250
+ }
251
+ }
252
+ }
253
+
254
+ for (p in owners) {
255
+ n = split(owners[p], who, " ")
256
+ if (n < 2) continue
257
+ list = who[1]
258
+ for (i = 2; i <= n; i++) list = list (i == n ? " and " : ", ") who[i]
259
+ finding("warn", "tasks " list " " (n == 2 ? "both" : "all") " touch " p ", so they cannot run at the same time")
260
+ }
261
+
262
+ flip = 0
263
+ for (i = 1; i <= ntasks; i++) if (order[i] in has_flips) { flip = order[i]; break }
264
+ print "summary\t" ntasks "\t" flip
265
+ for (i = 1; i <= nout; i++) print out[i]
266
+ for (i = 1; i <= ntasks; i++) {
267
+ id = order[i]
268
+ tier = ((id in has_flips) || (id in has_review)) ? "3" : "-"
269
+ print "task\t" id "\t" name[id] "\t" tier "\t" ((id in has_model) ? 1 : 0) "\t" ((id in has_flips) ? 1 : 0)
270
+ }
271
+ }
272
+ '
273
+
274
+ # Runs the parser and splits its output into the shell. Sets: NTASKS, FLIP,
275
+ # FINDINGS (newline-separated sev\tmsg), TASKROWS (newline-separated), NERR,
276
+ # NWARN.
277
+ parse_plan() {
278
+ local plan="$1" raw
279
+ raw="$(awk "$PARSER" "$plan")" || { err "could not read $plan"; exit 4; }
280
+
281
+ local summary
282
+ summary="$(printf '%s\n' "$raw" | grep '^summary ' | head -1)"
283
+ NTASKS="$(printf '%s' "$summary" | cut -f2)"
284
+ FLIP="$(printf '%s' "$summary" | cut -f3)"
285
+ FINDINGS="$(printf '%s\n' "$raw" | grep -E '^(error|warn) ' || true)"
286
+ TASKROWS="$(printf '%s\n' "$raw" | grep '^task ' || true)"
287
+ NERR="$(printf '%s\n' "$FINDINGS" | grep -c '^error ' || true)"
288
+ NWARN="$(printf '%s\n' "$FINDINGS" | grep -c '^warn ' || true)"
289
+ }
290
+
291
+ # "1 error" / "2 errors", so the header does not read as a template.
292
+ plural() { [ "$1" = "1" ] && echo "$1 $2" || echo "$1 $2s"; }
293
+
294
+ SUB="${1-}"
295
+ [ $# -gt 0 ] && shift
296
+ if [ -z "$SUB" ]; then
297
+ usage
298
+ exit 4
299
+ fi
300
+
301
+ case "$SUB" in
302
+ validate)
303
+ PLAN="${1-}"
304
+ [ -n "$PLAN" ] || { err "validate needs a plan path"; exit 4; }
305
+ [ -f "$PLAN" ] || { err "no such plan: $PLAN"; exit 4; }
306
+ shift
307
+ [ $# -eq 0 ] || { err "unknown argument: $1"; exit 4; }
308
+
309
+ parse_plan "$PLAN"
310
+
311
+ head="$(basename "$PLAN"): $(plural "$NTASKS" task)"
312
+ [ "$FLIP" != "0" ] && head="$head, flip at task $FLIP"
313
+ if [ "$NERR" = "0" ] && [ "$NWARN" = "0" ]; then
314
+ echo "$head, no errors"
315
+ exit 0
316
+ fi
317
+ [ "$NERR" != "0" ] && head="$head, $(plural "$NERR" error)"
318
+ [ "$NWARN" != "0" ] && head="$head, $(plural "$NWARN" warning)"
319
+ [ "$NERR" = "0" ] && head="$head, no errors"
320
+ echo "$head"
321
+ echo
322
+ # Errors before warnings. Within each, the parser's own order, which
323
+ # follows the file.
324
+ { printf '%s\n' "$FINDINGS" | grep '^error ' || true
325
+ printf '%s\n' "$FINDINGS" | grep '^warn ' || true
326
+ } | while IFS="$(printf '\t')" read -r sev msg; do
327
+ [ -n "$sev" ] || continue
328
+ printf ' %-5s %s\n' "$sev" "$msg"
329
+ done
330
+
331
+ [ "$NERR" = "0" ] || exit 2
332
+ ;;
333
+
334
+ import)
335
+ PLAN="${1-}"
336
+ [ -n "$PLAN" ] || { err "import needs a plan path"; exit 4; }
337
+ [ -f "$PLAN" ] || { err "no such plan: $PLAN"; exit 4; }
338
+ shift
339
+
340
+ DIR="$PWD" FORCE=""
341
+ while [ $# -gt 0 ]; do
342
+ case "$1" in
343
+ --dir)
344
+ if [ $# -lt 2 ] || [ -z "${2-}" ]; then err "--dir needs a path"; exit 4; fi
345
+ case "$2" in --*) err "--dir needs a path, but the next argument is the flag $2"; exit 4 ;; esac
346
+ DIR="$2"; shift 2 ;;
347
+ --force) FORCE=1; shift ;;
348
+ *) err "unknown flag: $1"; exit 4 ;;
349
+ esac
350
+ done
351
+
352
+ command -v jq >/dev/null 2>&1 || { err "jq is required"; exit 5; }
353
+ STATUS="$(dirname "$0")/status.sh"
354
+ [ -f "$STATUS" ] || { err "status.sh not found beside this script"; exit 4; }
355
+
356
+ parse_plan "$PLAN"
357
+
358
+ # A plan with errors in it is a plan nobody should be running yet, so
359
+ # importing one would seed a run against work that has to change first.
360
+ if [ "$NERR" != "0" ]; then
361
+ if [ -n "$FORCE" ]; then
362
+ err "$PLAN has $(plural "$NERR" error); importing anyway because --force was passed"
363
+ else
364
+ err "$PLAN has $(plural "$NERR" error); fix them or re-run with --force (plan.sh validate lists them)"
365
+ exit 2
366
+ fi
367
+ fi
368
+
369
+ [ "$NTASKS" != "0" ] || { err "$PLAN has no tasks to import"; exit 2; }
370
+
371
+ # The plan marks that a task is mechanical; pre-flight ratifies which model
372
+ # it actually runs on, and that answer lives in run.json. Re-import must
373
+ # not replace it with the placeholder, so the ids already carrying a model
374
+ # are read first and skipped.
375
+ HAS_MODEL=" $(bash "$STATUS" show --json --dir "$DIR" 2>/dev/null |
376
+ jq -r '[.tasks[]? | select(.model != null) | .id] | join(" ")' 2>/dev/null) "
377
+
378
+ printf '%s\n' "$TASKROWS" | while IFS="$(printf '\t')" read -r _ id name tier model flips; do
379
+ [ -n "${id:-}" ] || continue
380
+ set -- task "$id" --name "$name" --dir "$DIR"
381
+ [ "$flips" = "1" ] && set -- "$@" --flips
382
+ [ "$tier" != "-" ] && [ -n "$tier" ] && set -- "$@" --tier "$tier"
383
+ case "$HAS_MODEL" in
384
+ *" $id "*) ;;
385
+ *) [ "$model" = "1" ] && set -- "$@" --model cheap ;;
386
+ esac
387
+ bash "$STATUS" "$@" || exit 1
388
+ done || exit 1
389
+ imported="$NTASKS"
390
+
391
+ echo "imported $(plural "$imported" task) from $(basename "$PLAN")"
392
+ ;;
393
+
394
+ *)
395
+ err "unknown subcommand: $SUB"
396
+ usage
397
+ exit 4
398
+ ;;
399
+ esac
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env bash
2
+ # sluice run state: the task breakdown, in a form something other than a
3
+ # language model can read.
4
+ #
5
+ # The prose run record still holds the reasons and the cross-task findings.
6
+ # This file holds only what changes as the run moves, so a statusline, a
7
+ # resumed session or a reader who never saw the plan can answer "where is
8
+ # this" without parsing markdown.
9
+ #
10
+ # status.sh init --topic <t> --channel <c> [--plan <p>] [--record <p>] [--force]
11
+ # status.sh task <id> [--name <n>] [--status <s>] [--base <sha>]
12
+ # [--commit <sha>] [--tier 0-3] [--model <m>] [--flips]
13
+ # status.sh preflight [--review <t>] [--model <t>] [--workspace <t>]
14
+ # status.sh show [--json]
15
+ # status.sh line
16
+ # status.sh close
17
+ #
18
+ # --dir <path> selects the tree to read (default: $PWD). State lives at
19
+ # <dir>/.sluice/run.json and closed runs at <dir>/.sluice/archive/.
20
+ #
21
+ # Exit: 0 ok, 1 the state could not be written, 2 no live run, 3 a run is
22
+ # already live, 4 bad arguments, 5 jq missing, 6 the state file is unreadable.
23
+ # `line` is exempt and always exits 0 in silence, because a statusline renders
24
+ # on every keystroke and has nowhere to put an error.
25
+ #
26
+ # A flag value may not begin with `--`: unchecked, an omitted value silently
27
+ # becomes the next flag and the field holds a flag name.
28
+
29
+ set -uo pipefail
30
+
31
+ STATUSES="todo active review done blocked"
32
+ CHANNELS="bypass fast main deep"
33
+
34
+ err() { echo "status.sh: $*" >&2; }
35
+
36
+ usage() {
37
+ echo "usage:" >&2
38
+ sed -n '/^# status.sh init/,/^# 5 jq missing/p' "$0" | sed 's/^# \{0,2\}//' >&2
39
+ }
40
+
41
+ # A flag's value has to be checked before `shift 2`, not after. Bash refuses to
42
+ # shift when fewer than two arguments remain and returns non-zero instead, and
43
+ # with no `set -e` the flag loop then spins forever: $# stops decreasing and the
44
+ # loop condition stays true. Runs in the current shell rather than a subshell so
45
+ # its exit is the script's.
46
+ need_value() { # <flag> <remaining $#> <candidate>
47
+ if [ "$2" -lt 2 ]; then
48
+ err "$1 needs a value"
49
+ exit 4
50
+ fi
51
+ case "$3" in
52
+ --*) err "$1 needs a value, but the next argument is the flag $3"; exit 4 ;;
53
+ esac
54
+ }
55
+
56
+ # A word from a space-separated set. Keeps validation in one place so every
57
+ # rejection reads the same and names the offending value.
58
+ in_set() {
59
+ local needle="$1" hay="$2" w
60
+ for w in $hay; do [ "$w" = "$needle" ] && return 0; done
61
+ return 1
62
+ }
63
+
64
+ # --dir may arrive anywhere in the line, so it is stripped before the
65
+ # subcommand's own flag loop ever sees it.
66
+ DIR="$PWD"
67
+ ARGS=()
68
+ while [ $# -gt 0 ]; do
69
+ case "$1" in
70
+ --dir)
71
+ need_value --dir $# "${2-}"
72
+ [ -n "$2" ] || { err "--dir needs a path"; exit 4; }
73
+ DIR="$2"
74
+ shift 2
75
+ ;;
76
+ *)
77
+ ARGS+=("$1")
78
+ shift
79
+ ;;
80
+ esac
81
+ done
82
+ set -- "${ARGS[@]+"${ARGS[@]}"}"
83
+
84
+ SUB="${1-}"
85
+ [ $# -gt 0 ] && shift
86
+ if [ -z "$SUB" ]; then
87
+ usage
88
+ exit 4
89
+ fi
90
+
91
+ STATE="$DIR/.sluice/run.json"
92
+ ARCHIVE="$DIR/.sluice/archive"
93
+
94
+ # `line` swallows everything: a missing jq, unreadable state, no run at all.
95
+ # Any of those printing would put permanent clutter in the status bar.
96
+ if [ "$SUB" = "line" ]; then
97
+ [ $# -eq 0 ] || { err "line takes no arguments"; exit 4; }
98
+ command -v jq >/dev/null 2>&1 || exit 0
99
+ [ -f "$STATE" ] || exit 0
100
+ jq -r '
101
+ ([.tasks[]? | select(.status == "done")] | length) as $done
102
+ | [ "sluice",
103
+ (.channel // "?"),
104
+ "\($done)/\(.tasks | length)",
105
+ ([.tasks[]? | select(.status == "active") | "▸T\(.id)"] | first // empty),
106
+ ([.tasks[]? | select(.status == "blocked") | "!T\(.id)"] | first // empty)
107
+ ] | join(" ")
108
+ ' "$STATE" 2>/dev/null || exit 0
109
+ exit 0
110
+ fi
111
+
112
+ command -v jq >/dev/null 2>&1 || { err "jq is required"; exit 5; }
113
+
114
+ require_run() {
115
+ [ -f "$STATE" ] || { err "no run is live in $DIR (start one with: status.sh init)"; exit 2; }
116
+ }
117
+
118
+ # Distinct from a missing jq, which is exit 5: reported as that, a corrupt state
119
+ # file sends the reader off to install a tool they already have.
120
+ require_readable() {
121
+ jq -e . "$STATE" >/dev/null 2>&1 || {
122
+ err "cannot parse $STATE (repair it, or archive it with: status.sh close)"
123
+ exit 6
124
+ }
125
+ }
126
+
127
+ # Written through a temporary file so an interrupted write cannot leave the
128
+ # run state half-serialised, which would read as a corrupted run rather than
129
+ # as a failed command.
130
+ #
131
+ # The candidate is checked before it is installed, because the failure to guard
132
+ # against is an empty one rather than a malformed one: `cat` succeeds on empty
133
+ # stdin, so a jq that died upstream of this feeds it nothing, and installing
134
+ # nothing atomically is still a wipe of the one file in the run that outlives
135
+ # compaction. A command that cannot finish leaves the state as it found it.
136
+ write_state() {
137
+ local tmp="$STATE.tmp.$$"
138
+ cat >"$tmp"
139
+ if [ ! -s "$tmp" ] || ! jq -e . "$tmp" >/dev/null 2>&1; then
140
+ rm -f "$tmp"
141
+ err "refusing to write $STATE: the update produced no valid state, so the existing state is unchanged"
142
+ exit 1
143
+ fi
144
+ mv "$tmp" "$STATE" || { rm -f "$tmp"; err "could not replace $STATE"; exit 1; }
145
+ }
146
+
147
+ case "$SUB" in
148
+ init)
149
+ TOPIC="" CHANNEL="" PLAN="" RECORD="" FORCE=0
150
+ while [ $# -gt 0 ]; do
151
+ case "$1" in
152
+ --topic) need_value --topic $# "${2-}"; TOPIC="$2"; shift 2 ;;
153
+ --channel) need_value --channel $# "${2-}"; CHANNEL="$2"; shift 2 ;;
154
+ --plan) need_value --plan $# "${2-}"; PLAN="$2"; shift 2 ;;
155
+ --record) need_value --record $# "${2-}"; RECORD="$2"; shift 2 ;;
156
+ --force) FORCE=1; shift ;;
157
+ *) err "unknown flag: $1"; exit 4 ;;
158
+ esac
159
+ done
160
+ [ -n "$TOPIC" ] || { err "init needs --topic"; exit 4; }
161
+ [ -n "$CHANNEL" ] || { err "init needs --channel"; exit 4; }
162
+ in_set "$CHANNEL" "$CHANNELS" || { err "unknown channel: $CHANNEL (one of: $CHANNELS)"; exit 4; }
163
+
164
+ if [ -f "$STATE" ] && [ "$FORCE" -eq 0 ]; then
165
+ live="$(jq -r '.topic // "?"' "$STATE" 2>/dev/null || echo "?")"
166
+ err "a run is already live (topic: $live); pass --force to replace it"
167
+ exit 3
168
+ fi
169
+
170
+ mkdir -p "$DIR/.sluice"
171
+ jq -n \
172
+ --arg topic "$TOPIC" \
173
+ --arg channel "$CHANNEL" \
174
+ --arg started "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
175
+ --arg plan "$PLAN" \
176
+ --arg record "$RECORD" '
177
+ {schema: 1, topic: $topic, channel: $channel, started: $started}
178
+ + (if $plan == "" then {} else {plan: $plan} end)
179
+ + (if $record == "" then {} else {record: $record} end)
180
+ + {tasks: []}
181
+ ' | write_state
182
+ ;;
183
+
184
+ task)
185
+ ID="${1-}"
186
+ [ $# -gt 0 ] && shift
187
+ case "$ID" in
188
+ "" ) err "task needs an id"; exit 4 ;;
189
+ *[!0-9]* | 0 ) err "task id must be a positive integer, got: $ID"; exit 4 ;;
190
+ esac
191
+
192
+ NAME="" STATUS="" BASE="" COMMIT="" TIER="" MODEL="" FLIPS=false
193
+ while [ $# -gt 0 ]; do
194
+ case "$1" in
195
+ --name) need_value --name $# "${2-}"; NAME="$2"; shift 2 ;;
196
+ --status) need_value --status $# "${2-}"; STATUS="$2"; shift 2 ;;
197
+ --base) need_value --base $# "${2-}"; BASE="$2"; shift 2 ;;
198
+ --commit) need_value --commit $# "${2-}"; COMMIT="$2"; shift 2 ;;
199
+ --tier) need_value --tier $# "${2-}"; TIER="$2"; shift 2 ;;
200
+ --model) need_value --model $# "${2-}"; MODEL="$2"; shift 2 ;;
201
+ --flips) FLIPS=true; shift ;;
202
+ *) err "unknown flag: $1"; exit 4 ;;
203
+ esac
204
+ done
205
+
206
+ if [ -n "$STATUS" ]; then
207
+ in_set "$STATUS" "$STATUSES" || { err "unknown status: $STATUS (one of: $STATUSES)"; exit 4; }
208
+ fi
209
+ if [ -n "$TIER" ]; then
210
+ case "$TIER" in
211
+ 0|1|2|3) ;;
212
+ *) err "tier must be 0, 1, 2 or 3, got: $TIER"; exit 4 ;;
213
+ esac
214
+ fi
215
+
216
+ require_run
217
+ require_readable
218
+
219
+ # A row with no name is a number nobody can act on, so a new id has to
220
+ # bring one. An existing id does not, which is what makes every later
221
+ # call a bare status flip.
222
+ #
223
+ # A read that failed is not the same answer as "the id is known", so the
224
+ # count is checked rather than compared: unchecked, an empty result skips
225
+ # the guard the way a hit would.
226
+ known="$(jq --argjson id "$ID" '[.tasks[]? | select(.id == $id)] | length' "$STATE" 2>/dev/null)"
227
+ case "$known" in
228
+ '' | *[!0-9]*) err "could not read the task list from $STATE"; exit 6 ;;
229
+ esac
230
+ if [ "$known" = "0" ] && [ -z "$NAME" ]; then
231
+ err "task $ID is new here, so it needs --name"
232
+ exit 4
233
+ fi
234
+
235
+ patch="$(jq -n \
236
+ --arg name "$NAME" --arg status "$STATUS" --arg base "$BASE" \
237
+ --arg commit "$COMMIT" --arg tier "$TIER" --arg model "$MODEL" \
238
+ --argjson flips "$FLIPS" '
239
+ {}
240
+ + (if $name == "" then {} else {name: $name} end)
241
+ + (if $status == "" then {} else {status: $status} end)
242
+ + (if $base == "" then {} else {base: $base} end)
243
+ + (if $commit == "" then {} else {commit: $commit} end)
244
+ + (if $tier == "" then {} else {tier: ($tier | tonumber)} end)
245
+ + (if $model == "" then {} else {model: $model} end)
246
+ + (if $flips then {flips: true} else {} end)
247
+ ')"
248
+
249
+ jq --argjson id "$ID" --argjson patch "$patch" '
250
+ .tasks = (
251
+ if any(.tasks[]?; .id == $id)
252
+ then [.tasks[] | if .id == $id then . + $patch else . end]
253
+ else .tasks + [{id: $id, status: "todo"} + $patch]
254
+ end
255
+ )
256
+ | .tasks |= sort_by(.id)
257
+ ' "$STATE" | write_state
258
+ ;;
259
+
260
+ preflight)
261
+ REVIEW="" MODEL="" WORKSPACE=""
262
+ while [ $# -gt 0 ]; do
263
+ case "$1" in
264
+ --review) need_value --review $# "${2-}"; REVIEW="$2"; shift 2 ;;
265
+ --model) need_value --model $# "${2-}"; MODEL="$2"; shift 2 ;;
266
+ --workspace) need_value --workspace $# "${2-}"; WORKSPACE="$2"; shift 2 ;;
267
+ *) err "unknown flag: $1"; exit 4 ;;
268
+ esac
269
+ done
270
+ if [ -z "$REVIEW$MODEL$WORKSPACE" ]; then
271
+ err "preflight needs at least one of --review, --model, --workspace"
272
+ exit 4
273
+ fi
274
+ require_run
275
+ require_readable
276
+
277
+ jq --arg review "$REVIEW" --arg model "$MODEL" --arg workspace "$WORKSPACE" '
278
+ .preflight = ((.preflight // {})
279
+ + (if $review == "" then {} else {review: $review} end)
280
+ + (if $model == "" then {} else {model: $model} end)
281
+ + (if $workspace == "" then {} else {workspace: $workspace} end))
282
+ ' "$STATE" | write_state
283
+ ;;
284
+
285
+ show)
286
+ JSON=0
287
+ while [ $# -gt 0 ]; do
288
+ case "$1" in
289
+ --json) JSON=1; shift ;;
290
+ *) err "unknown flag: $1"; exit 4 ;;
291
+ esac
292
+ done
293
+ require_run
294
+ require_readable
295
+
296
+ if [ "$JSON" -eq 1 ]; then
297
+ jq . "$STATE"
298
+ exit 0
299
+ fi
300
+
301
+ # Header and rows are laid out from the same widths, so the two cannot
302
+ # drift apart, and an over-long value is clipped with a marker rather
303
+ # than silently reading as the whole value.
304
+ jq -r '
305
+ def dash: if . == null or . == "" then "-" else . end;
306
+ def cell($w): tostring
307
+ | if length > $w then .[0:$w - 1] + "…"
308
+ else . + (" " * ($w - length))
309
+ end;
310
+ def row($c): " " + ([($c[0] | cell(3)), ($c[1] | cell(8)), ($c[2] | cell(29)),
311
+ ($c[3] | cell(9)), ($c[4] | cell(9)), ($c[5] | cell(4)),
312
+ $c[6]] | join(" "));
313
+ ([.tasks[]? | select(.status == "done")] | length) as $done
314
+ | ["sluice \(.channel) · \(.topic) · \($done)/\(.tasks | length) done"]
315
+ + ["plan \(.plan | dash)"]
316
+ + ["record \(.record | dash)"]
317
+ + ["pre-flight " + (
318
+ if (.preflight // {} | length) == 0 then "not recorded"
319
+ else [(.preflight | to_entries[] | "\(.key)=\(.value)")] | join("; ")
320
+ end)]
321
+ + [""]
322
+ + [row(["id", "status", "task", "base", "commit", "tier", "model"])]
323
+ + [ .tasks[]?
324
+ | (if .flips then " FLIPS" else "" end) as $flips
325
+ | row([.id, .status, (.name | dash), (.base | dash),
326
+ (.commit | dash), (.tier | dash), (.model | dash)]) + $flips
327
+ ]
328
+ | .[]
329
+ ' "$STATE"
330
+ ;;
331
+
332
+ close)
333
+ [ $# -eq 0 ] || { err "close takes no arguments"; exit 4; }
334
+ require_run
335
+
336
+ # Deliberately not `require_readable`. The parse error every other
337
+ # subcommand raises names close as the way out, so close is the one
338
+ # command that has to accept state nothing else will touch: it moves the
339
+ # file aside intact rather than leaving it wedged in the tree.
340
+ started="$(jq -r '.started // empty' "$STATE" 2>/dev/null)"
341
+ topic="$(jq -r '.topic // empty' "$STATE" 2>/dev/null)"
342
+ stamp="$(printf '%s' "$started" | tr -cd '0-9TZ')"
343
+ slug="$(printf '%s' "${topic:-run}" | tr -cs 'A-Za-z0-9._-' '-')"
344
+ [ -n "$stamp" ] || stamp="unknown"
345
+ mkdir -p "$ARCHIVE"
346
+
347
+ # The archive holds the only copy of a closed run, and two runs sharing a
348
+ # start second and a topic name the same file. mv would leave one of them,
349
+ # so the name gets a suffix rather than the earlier run being silently
350
+ # dropped.
351
+ dest="$ARCHIVE/$stamp-$slug.json"
352
+ n=2
353
+ while [ -e "$dest" ]; do
354
+ dest="$ARCHIVE/$stamp-$slug-$n.json"
355
+ n=$((n + 1))
356
+ done
357
+ mv "$STATE" "$dest"
358
+ ;;
359
+
360
+ *)
361
+ err "unknown subcommand: $SUB"
362
+ usage
363
+ exit 4
364
+ ;;
365
+ esac
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.7.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, 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.10.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.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",
7
7
  "tools": [