@iceinvein/agent-skills 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
package/skills/index.json CHANGED
@@ -283,7 +283,7 @@
283
283
  "name": "sluice",
284
284
  "description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries seven rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment 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.10.0"
286
+ "version": "0.12.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -91,9 +91,11 @@ pre-flight settled. `scripts/status.sh` writes and reads it, and
91
91
  `references/status.md` carries the commands and the statusline segment that
92
92
  makes a run visible without anyone asking. Open it with `init` when you open the
93
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.
94
+ command per task, then flip each task as it moves. It carries the ids, the names,
95
+ the flip, the model marks and the tiers, the last of these floored off `Touches`
96
+ and the contract graph rather than guessed. Import is safe to re-run: a status, a
97
+ review mark or a ratified model already recorded is left alone and a tier is only
98
+ ever raised, so resuming after a compaction cannot rewind the run.
97
99
 
98
100
  The record is the other file, and it holds what a status cannot: the reason
99
101
  review went the way it did, the reason a task was downshifted, the reason the
@@ -234,14 +236,22 @@ about it.
234
236
  Read the plan as a graph before you read it as a list. `Needs` and `Offers`
235
237
  are dependency edges, not only blindness insurance: a task is ready when every
236
238
  `Needs` it names is offered by a task already done, and any two ready tasks
237
- with disjoint `Touches` can go at the same time. Do that read once, before
238
- Task 1. A plan run in the order it happened to be written is a plan whose
239
+ with disjoint `Touches` can go at the same time.
240
+
241
+ **`scripts/status.sh ready` does that read.** It prints the ready set, names which
242
+ of them share a path and so cannot go together, says which tasks are still waiting
243
+ on a contract and what for, and holds the flip out of every wave. Run it before
244
+ each wave rather than deriving it again by hand, and note that it also checks the
245
+ candidates against whatever is already `active` or in `review`: those hold their
246
+ paths too, and a wave checked only against itself reads as safe while colliding
247
+ with work in flight. A plan run in the order it happened to be written is a plan whose
239
248
  graph nobody looked at, and inert-first ordering tends to put the independent
240
249
  tasks at the front, so the opportunity is usually real.
241
250
 
242
251
  Derive the sets at dispatch rather than writing wave numbers into the plan. A
243
252
  declared schedule is wrong the moment one task lands late or comes back with a
244
- blocking finding. A derived one just recomputes.
253
+ blocking finding. A derived one just recomputes, which is the whole reason `ready`
254
+ reads the run state rather than the plan: it sees what has actually landed.
245
255
 
246
256
  - One row per task in `run.json`, flipped to `active` and then `done` as it
247
257
  moves. That state outlives compaction; your memory doesn't.
@@ -365,6 +375,14 @@ or four dispatches rather than nine. If most of your plan qualifies for a
365
375
  dispatch, the tasks are interleaved rather than ordered, and reordering them
366
376
  is cheaper than reviewing them.
367
377
 
378
+ **Mark each review with `status.sh task <id> --reviewed` when it comes back.**
379
+ What that buys is a count of what this table promised and nobody delivered: tasks
380
+ that are done, that qualified for a dispatch, and that carry no mark. `show` and
381
+ the statusline both carry it from the moment it exists, which is the whole point.
382
+ Unmarked, the count sits permanently non-zero and stops being a signal, and
383
+ "review outstanding" goes back to first appearing in the closing summary, at the
384
+ one moment your partner can no longer do anything about it.
385
+
368
386
  Reviews are reads, so they are always parallel. Every review a wave earned
369
387
  goes out in one message, and they run while the next wave's implementers work:
370
388
  a reviewer writes nothing, so it collides with nothing. The final review is
@@ -15,9 +15,12 @@ bash <skill-dir>/scripts/status.sh init --topic <t> --channel deep \
15
15
  bash <skill-dir>/scripts/status.sh task 3 --name "adapter seam" --tier 1 --model cheap
16
16
  bash <skill-dir>/scripts/status.sh task 3 --status active --base 75014c9
17
17
  bash <skill-dir>/scripts/status.sh task 3 --status done --commit 2c7f261
18
+ bash <skill-dir>/scripts/status.sh task 3 --reviewed
18
19
  bash <skill-dir>/scripts/status.sh preflight --review "tier 3 only" --model "6 of 9 cheap" \
19
20
  --workspace "one worktree per implementer"
20
21
  bash <skill-dir>/scripts/status.sh show
22
+ bash <skill-dir>/scripts/status.sh ready
23
+ bash <skill-dir>/scripts/status.sh line --full
21
24
  bash <skill-dir>/scripts/status.sh close
22
25
  ```
23
26
 
@@ -39,13 +42,24 @@ which is the file that does get committed.
39
42
 
40
43
  Open it with `init` when you open the run record, at the same point and for the
41
44
  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
+ command per task. The ids, names, the flip, the `Model` marks and the tiers are
46
+ all fixed the moment the plan is written and are already in the file, so typing
47
+ them again is transcription with a chance of error in it.
48
+
49
+ The tier import writes is a floor read off `Touches`: an `(edit)` means existing
50
+ code changed, no `(test)` means nothing executable covers the task, and `Flips`
51
+ or a `Review` flag is tier 3 outright. Raise one by hand with `--tier` where the
52
+ work is more delicate than its paths suggest; nothing lowers it for you, because
53
+ the tier table takes the highest row a task matches.
45
54
 
46
55
  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.
56
+ names, the contract graph and the flip, moving the flip when the plan moved it,
57
+ and it raises a tier without ever lowering one. A status, a review mark or a model
58
+ ratified at pre-flight is left alone, so resuming after a compaction cannot rewind
59
+ the run. The one consequence worth knowing: adding a missing `(test)` to a plan
60
+ will not drop a task from tier 2 back to tier 1, because the tier table takes the
61
+ highest row a task matches and nothing here can tell a correction from a
62
+ regression. Lower it by hand with `--tier` if that is what you mean.
49
63
 
50
64
  ## What goes where
51
65
 
@@ -70,15 +84,86 @@ commit, tier and model. Run it after compaction instead of reconstructing the
70
84
  run from what you remember, and run it in the message that hands the work back,
71
85
  where "four of nine, task five blocked" is a fact your partner can act on.
72
86
 
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.
87
+ `show --json` is the same state for another reader. `line` is the compact
88
+ one-line form and `line --full` the wide one; both exit 0 in silence on a missing
89
+ run, unreadable state or a missing jq, because their caller is a status bar with
90
+ nowhere to put an error.
91
+
92
+ `line --full` renders three rows: the run and its clock, the bar alone, then the
93
+ detail. The bar gets a row to itself so it never competes with text for width,
94
+ which is what lets a cell be wide enough to read as a block rather than a tick.
95
+
96
+ A group of cells per task, one repeated glyph each: `▰` done, `◈` active, `▨` in
97
+ review, `▮` blocked, `▱` still to do. The glyphs are distinct before they are
98
+ coloured, so the rows survive having their colour stripped. The width is chosen
99
+ from what the whole bar would occupy, gaps included, rather than from the task
100
+ count: keyed off the count alone the schedule was not monotonic, and thirty tasks
101
+ at two cells each ran wider than twelve at three.
102
+
103
+ **A done task still owed a review trails the review glyph**, `▰▰▨` against
104
+ `▰▰▰`. Debt then reads in position rather than only as a count at the end of the
105
+ row, which is the difference between knowing how much there is and knowing where.
106
+ Tier 0 was never owed a dispatch, so it reads as plainly done. On a plan long
107
+ enough to narrow cells to one, there is no trailing cell to give up and the
108
+ positional reading stops: the count in the third row is then the only carrier,
109
+ which is why it is printed whether or not the bar could show the same thing.
110
+
111
+ **The flip draws as a rule, `┃`, before its task.** Everything left of it is inert
112
+ and safe to leave landed; everything right of it is not. That is what the flip
113
+ means, and it is a boundary between tasks rather than a property of one, so a
114
+ name in the header could not say it. `plan.sh validate` rejects a plan with two
115
+ flips and `import` clears a stale one, so the bar is only ever asked to draw the
116
+ single legal case.
117
+
118
+ The third row carries the progress count, whichever task wants attention, and the
119
+ review debt. A blocked task displaces the active one there, being the one of the
120
+ two worth interrupting for, and a `+n` follows when more than one task shares that
121
+ state, since a plan running four wide has four actives by design.
122
+
123
+ Mark a review with `task <id> --reviewed` when a reviewer comes back. What that
124
+ buys is the debt count: a task that is done, that the tier table owed a dispatch,
125
+ and that nobody marked. Tier 0 is excluded, having only ever been owed a stat
126
+ read. Without it "review outstanding" first appears in the closing summary, at
127
+ the one moment your partner can no longer do anything about it, and `show` and
128
+ the statusline both carry it from the moment it exists.
129
+
130
+ ## The next wave
131
+
132
+ `ready` answers the one question the other commands do not: not what the state is,
133
+ but what may go now. A task is ready when every symbol it `Needs` is offered by a
134
+ task already done, and two ready tasks are safe together when their `Touches` are
135
+ disjoint.
136
+
137
+ ```
138
+ 6 ready now · a worktree each
139
+ T1 extract the bundle writer src/cli/adapters/bundle.ts, …
140
+ T4 record what the harness offers skills/sluice/scripts/status.sh, …
141
+ T4 and T5 share skills/sluice/scripts/status.sh, so not together
142
+
143
+ 2 waiting on a contract
144
+ T2 cursor and gemini write bundles needs writeBundle
145
+
146
+ the flip runs alone
147
+ T8 sluice installs on four harnesses
148
+ ```
149
+
150
+ It reads the graph `plan.sh import` recorded, so a run seeded before that existed
151
+ says so and tells you to re-import rather than reporting everything ready. The
152
+ flip is held out of every wave whatever the graph says, because the invariant it
153
+ establishes is what the tasks after it are checked against.
154
+
155
+ Derive the wave here rather than writing wave numbers into the plan. A declared
156
+ schedule is wrong the moment one task lands late; this recomputes.
76
157
 
77
158
  ## Statusline
78
159
 
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:
160
+ This is the part that makes a run visible without anyone asking. Give it rows of
161
+ its own rather than a segment among the badges: it then costs nothing when no run
162
+ is live and contends with nothing for width when one is, which is what lets the
163
+ bar be wide and the task carry its name rather than only its number.
164
+
165
+ Capture it wherever the statusline command builds its other lines, keyed off the
166
+ state file existing so a session with no run spawns no process at all:
82
167
 
83
168
  ```bash
84
169
  sluice_line=""
@@ -86,16 +171,34 @@ if [ -n "$cwd" ] && [ -f "$cwd/.sluice/run.json" ]; then
86
171
  for sluice_sh in "$cwd/.claude/skills/sluice/scripts/status.sh" \
87
172
  "$HOME/.claude/skills/sluice/scripts/status.sh"; do
88
173
  [ -f "$sluice_sh" ] || continue
89
- sluice_line=$(bash "$sluice_sh" line --dir "$cwd" 2>/dev/null)
174
+ sluice_line=$(bash "$sluice_sh" line --full --dir "$cwd" 2>/dev/null)
90
175
  break
91
176
  done
92
177
  fi
93
- [ -n "$sluice_line" ] && printf ' %s' "$sluice_line"
94
178
  ```
95
179
 
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.
180
+ then print it last, after whatever else the command emits:
181
+
182
+ ```bash
183
+ if [ -n "$sluice_line" ]; then printf '%s\n' "$sluice_line"; fi
184
+ ```
185
+
186
+ `if` rather than `[ ... ] &&`: as the last command of a statusline script the
187
+ short form makes it exit 1 on every render with no run live, which is the common
188
+ case. `%s` rather than `%b`: the render already carries real escape bytes, and
189
+ `%b` would reinterpret a backslash inside a task name. `$cwd` is
190
+ `workspace.current_dir` from the JSON the harness sends on stdin. It renders as:
191
+
192
+ ```
193
+ ⧗ deep · sluice-cross-harness ◷ 38m
194
+ ▰▰▰ ▰▰▨ ▨▨▨ ◈◈◈ ▱▱▱ ▮▮▮ ▱▱▱ ┃ ▱▱▱ ▱▱▱
195
+ 2/9 done · !T6 model tiers rather than model names +1 · ⟲1 unreviewed
196
+ ```
197
+
198
+ The colour comes out of the script rather than being applied by the caller,
199
+ because the mapping from state to colour belongs next to the state. A caller that
200
+ coloured the line itself would have to re-derive each cell's meaning from its
201
+ glyph, which is the same fact stored twice.
99
202
 
100
203
  A run that is only visible to the session running it is a run your partner
101
204
  cannot redirect. That is the same argument the channel announcement makes, and
@@ -14,9 +14,9 @@
14
14
  # validate prints one line per finding. An error means the plan cannot be
15
15
  # dispatched as written; a warning is a judgement call left to its author.
16
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.
17
+ # flip, the model marks and the tiers come from the file rather than from a dozen
18
+ # hand-typed commands. It is safe to re-run: a status, a review mark or a
19
+ # ratified model already recorded is left alone, and a tier is only ever raised.
20
20
  #
21
21
  # Exit: 0 no errors, 2 errors found, 4 bad arguments, 5 jq missing (import only).
22
22
 
@@ -24,24 +24,40 @@ set -uo pipefail
24
24
 
25
25
  err() { echo "plan.sh: $*" >&2; }
26
26
 
27
+ # ASCII unit separator. Tab would be merged by `read`; see the parser comment.
28
+ SEP="$(printf '\037')"
29
+
27
30
  usage() {
28
31
  echo "usage:" >&2
29
32
  sed -n '/^# plan.sh validate/,/^# Exit:/p' "$0" | sed 's/^# \{0,2\}//' >&2
30
33
  }
31
34
 
32
- # The parser. Emits tab-separated rows on stdout:
35
+ # The parser. Emits rows on stdout separated by 0x1f, the ASCII unit separator,
36
+ # NOT by a tab. Tab is an IFS *whitespace* character, so `read` merges runs of it
37
+ # and drops empty fields: one task with an empty Needs then shifts every later
38
+ # field left, which is how the graph columns silently swapped. 0x1f is
39
+ # non-whitespace, so empty columns survive.
40
+ #
41
+ # Rows:
33
42
  # summary <task count> <flip task or 0>
34
43
  # error|warn <message>
35
- # task <id> <name> <tier 3 or -> <model 1|0> <flips 1|0>
44
+ # task <id> <name> <tier 0-3> <model 1|0> <flips 1|0> <needs> <offers> <touches>
45
+ #
46
+ # The last three are space-separated and are what answers "which tasks may go
47
+ # now": a task is ready when every Needs it names is offered by something already
48
+ # done, and two ready tasks are safe together when their Touches are disjoint.
36
49
  #
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.
50
+ # The tier is a floor derived from what the plan actually settles: an (edit) in
51
+ # Touches means existing code changed, no (test) means nothing executable covers
52
+ # the task, and Flips or a Review flag is tier 3 outright. A task matching more
53
+ # than one row takes the highest, per the tier table.
40
54
  #
41
55
  # One pass, line-oriented, because the plan format is a strict skeleton rather
42
56
  # than free markdown. Fenced blocks are skipped: a plan carries signatures and
43
57
  # commands in them, and a `- [ ]` inside a fence is an example, not a step.
44
58
  PARSER='
59
+ BEGIN { SEP = sprintf("%c", 31) }
60
+
45
61
  function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
46
62
 
47
63
  # Symbols on one side of a Contract line. The two sides are read differently
@@ -54,7 +70,16 @@ function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
54
70
  #
55
71
  # Demand (Needs) is strict, because an extra symbol here is a false error:
56
72
  # backticked names and signatures only, falling back to the leading identifier
57
- # of each comma-separated part when the half carries neither.
73
+ # of each comma-separated part when the half carries neither. Primitive type
74
+ # names are dropped from it: a return shape drags in whatever it is spelled with,
75
+ # and no task in any plan creates `string`.
76
+ function is_primitive(w) {
77
+ return (w == "string" || w == "number" || w == "boolean" || w == "bool" ||
78
+ w == "void" || w == "any" || w == "unknown" || w == "never" ||
79
+ w == "null" || w == "undefined" || w == "int" || w == "float" ||
80
+ w == "object" || w == "Promise" || w == "Array")
81
+ }
82
+
58
83
  function symbols(half, out, generous, count, rest, tok, lower, n, parts, i, chunk) {
59
84
  lower = half
60
85
  gsub(/`/, "", lower)
@@ -76,28 +101,43 @@ function symbols(half, out, generous, count, rest, tok, lower, n, parts, i, ch
76
101
  while (match(rest, /`[^`]*`/)) {
77
102
  chunk = substr(rest, RSTART + 1, RLENGTH - 2)
78
103
  rest = substr(rest, RSTART + RLENGTH)
79
- if (match(chunk, /[A-Za-z_][A-Za-z0-9_]*/))
80
- out[++count] = substr(chunk, RSTART, RLENGTH)
104
+ # The leading identifier of a backticked chunk, unless that is a wrapper
105
+ # like Promise<Config> or Array<TaskRow>: then the dependency is what it
106
+ # wraps, and taking the leading one both misses the real symbol and
107
+ # invents a false one.
108
+ while (match(chunk, /[A-Za-z_][A-Za-z0-9_]*/)) {
109
+ tok = substr(chunk, RSTART, RLENGTH)
110
+ chunk = substr(chunk, RSTART + RLENGTH)
111
+ if (!is_primitive(tok)) { out[++count] = tok; break }
112
+ }
81
113
  }
82
114
  rest = half
83
115
  gsub(/`/, "", rest)
84
116
  while (match(rest, /[A-Za-z_][A-Za-z0-9_]*\(/)) {
85
117
  tok = substr(rest, RSTART, RLENGTH - 1)
86
- out[++count] = tok
118
+ if (!is_primitive(tok)) out[++count] = tok
87
119
  rest = substr(rest, RSTART + RLENGTH)
88
120
  }
89
121
  if (count == 0) {
90
122
  rest = half
91
123
  gsub(/`/, "", rest)
92
124
  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)
125
+ for (i = 1; i <= n; i++) {
126
+ chunk = parts[i]
127
+ # Guarded like the strict rules above. Unguarded, a half whose every
128
+ # candidate was a primitive fell through to here and the filter was
129
+ # undone by the code meant to back it up.
130
+ while (match(chunk, /[A-Za-z_][A-Za-z0-9_]*/)) {
131
+ tok = substr(chunk, RSTART, RLENGTH)
132
+ chunk = substr(chunk, RSTART + RLENGTH)
133
+ if (!is_primitive(tok)) { out[++count] = tok; break }
134
+ }
135
+ }
96
136
  }
97
137
  return count
98
138
  }
99
139
 
100
- function finding(sev, msg) { out[++nout] = sev "\t" msg }
140
+ function finding(sev, msg) { out[++nout] = sev SEP msg }
101
141
 
102
142
  /^[ \t]*```/ { fenced = 1 - fenced; next }
103
143
  fenced { next }
@@ -166,6 +206,7 @@ in_rules && /^- / { nrules++ }
166
206
  n = symbols(oh, syms, 1)
167
207
  for (i = 1; i <= n; i++) {
168
208
  if (!(syms[i] in offered_by) || cur < offered_by[syms[i]]) offered_by[syms[i]] = cur
209
+ offers[cur] = offers[cur] " " syms[i]
169
210
  }
170
211
  next
171
212
  }
@@ -175,15 +216,34 @@ in_rules && /^- / { nrules++ }
175
216
  body = substr($0, length("**Touches:**") + 1)
176
217
  n = split(body, parts, /\|/)
177
218
  for (i = 1; i <= n; i++) {
219
+ # The path is everything before the first parenthesis. Stripping only a
220
+ # trailing annotation left the rest of a mid-field one in the path, and
221
+ # those fragments then read as real paths: two tasks with disjoint files
222
+ # came out sharing "(edit)" and were serialised for nothing.
178
223
  p = parts[i]
179
- sub(/\([^)]*\)[ \t]*$/, "", p)
224
+ if (index(p, "(") > 0) p = substr(p, 1, index(p, "(") - 1)
180
225
  p = trim(p)
181
226
  if (p == "") continue
227
+ # The annotation is what the tier table turns on, so it is kept rather
228
+ # than stripped and forgotten: an (edit) means existing code changed, and
229
+ # the absence of any (test) means nothing executable covers the task.
230
+ if (parts[i] ~ /\(edit\)/) has_edit[cur] = 1
231
+ if (parts[i] ~ /\(test\)/) has_test[cur] = 1
232
+ if (parts[i] ~ /\(new\)/) has_new[cur] = 1
233
+ # Under-tiering is the unsafe direction: an unrecognised annotation, or a
234
+ # missing one, reads as "nothing was edited here" and the task then owes
235
+ # no review. Say so rather than deriving from a spelling nobody checked.
236
+ if (parts[i] !~ /\((new|edit|test)\)/)
237
+ finding("warn", "task " cur " Touches " p " with no (new), (edit) or (test) annotation, so its tier is derived as if nothing was edited")
238
+ else if (parts[i] !~ /\((new|edit|test)\)[ \t]*$/)
239
+ finding("warn", "task " cur " Touches " p " with text after its annotation; the tier reads correctly but the line is not the format")
182
240
  # Accumulated rather than assigned: with three tasks on one path,
183
241
  # reporting a single pair leaves the reader serialising two of them and
184
242
  # still running the third alongside.
185
243
  if (index(" " owners[p] " ", " " cur " ") == 0)
186
244
  owners[p] = owners[p] (owners[p] == "" ? "" : " ") cur
245
+ if (index(" " paths[cur] " ", " " p " ") == 0)
246
+ paths[cur] = paths[cur] (paths[cur] == "" ? "" : " ") p
187
247
  }
188
248
  next
189
249
  }
@@ -259,14 +319,55 @@ END {
259
319
  finding("warn", "tasks " list " " (n == 2 ? "both" : "all") " touch " p ", so they cannot run at the same time")
260
320
  }
261
321
 
322
+ # A task matching more than one row takes the highest of them, which is what
323
+ # the tier table says and the reason this is a max rather than a chain of
324
+ # elses. Computed once, ahead of both the histogram and the task rows.
325
+ # The LAST task demanding each symbol, so "later tasks build on it" answers
326
+ # for any consumer rather than only the earliest. Kept as a minimum, one early
327
+ # consumer hid every later one and the producer three tasks were built blind
328
+ # against came out owing no review at all.
329
+ for (i = 1; i <= ntasks; i++) {
330
+ n = split(needs[order[i]], want, " ")
331
+ for (j = 1; j <= n; j++)
332
+ if (want[j] != "" && (!(want[j] in last_demand) || order[i] > last_demand[want[j]]))
333
+ last_demand[want[j]] = order[i]
334
+ }
335
+
336
+ for (i = 1; i <= ntasks; i++) {
337
+ id = order[i]
338
+ t = 0
339
+ if (id in has_edit) t = 1
340
+ # A task whose only path is a test is changing a suite that already
341
+ # exists: the format offers no "(test) (edit)" spelling, so the annotation
342
+ # cannot say so, and reading it as a creation leaves the task owing
343
+ # nothing. Under-tiering is the unsafe direction.
344
+ if ((id in has_test) && !(id in has_new) && !(id in has_edit)) t = 1
345
+ n = split(offers[id], mine, " ")
346
+ for (j = 1; j <= n; j++)
347
+ if (mine[j] != "" && (mine[j] in last_demand) && last_demand[mine[j]] > id) t = (t > 1 ? t : 1)
348
+ if (!(id in has_test)) t = (t > 2 ? t : 2)
349
+ if ((id in has_flips) || (id in has_review)) t = 3
350
+ tier_of[id] = t
351
+ }
352
+
262
353
  flip = 0
263
354
  for (i = 1; i <= ntasks; i++) if (order[i] in has_flips) { flip = order[i]; break }
264
- print "summary\t" ntasks "\t" flip
355
+ # The histogram is what prices the review question at pre-flight: "four of
356
+ # nine need a reviewer" is a decision a partner can weigh, and counting it
357
+ # by hand off a nine-task plan is how the count comes out wrong.
358
+ hist = ""
359
+ for (t = 0; t <= 3; t++) {
360
+ c = 0
361
+ for (i = 1; i <= ntasks; i++) if (tier_of[order[i]] == t) c++
362
+ if (c > 0) hist = hist (hist == "" ? "" : " ") t ":" c
363
+ }
364
+ print "summary" SEP ntasks SEP flip SEP hist
265
365
  for (i = 1; i <= nout; i++) print out[i]
266
366
  for (i = 1; i <= ntasks; i++) {
267
367
  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)
368
+ tier = tier_of[id]
369
+ print "task" SEP id SEP name[id] SEP tier SEP ((id in has_model) ? 1 : 0) SEP ((id in has_flips) ? 1 : 0) \
370
+ SEP trim(needs[id]) SEP trim(offers[id]) SEP trim(paths[id])
270
371
  }
271
372
  }
272
373
  '
@@ -279,13 +380,14 @@ parse_plan() {
279
380
  raw="$(awk "$PARSER" "$plan")" || { err "could not read $plan"; exit 4; }
280
381
 
281
382
  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)"
383
+ summary="$(printf '%s\n' "$raw" | grep "^summary$SEP" | head -1)"
384
+ NTASKS="$(printf '%s' "$summary" | cut -d"$SEP" -f2)"
385
+ FLIP="$(printf '%s' "$summary" | cut -d"$SEP" -f3)"
386
+ TIERS="$(printf '%s' "$summary" | cut -d"$SEP" -f4)"
387
+ FINDINGS="$(printf '%s\n' "$raw" | grep -E "^(error|warn)$SEP" || true)"
388
+ TASKROWS="$(printf '%s\n' "$raw" | grep "^task$SEP" || true)"
389
+ NERR="$(printf '%s\n' "$FINDINGS" | grep -c "^error$SEP" || true)"
390
+ NWARN="$(printf '%s\n' "$FINDINGS" | grep -c "^warn$SEP" || true)"
289
391
  }
290
392
 
291
393
  # "1 error" / "2 errors", so the header does not read as a template.
@@ -310,6 +412,7 @@ case "$SUB" in
310
412
 
311
413
  head="$(basename "$PLAN"): $(plural "$NTASKS" task)"
312
414
  [ "$FLIP" != "0" ] && head="$head, flip at task $FLIP"
415
+ [ -n "$TIERS" ] && head="$head, tiers $TIERS"
313
416
  if [ "$NERR" = "0" ] && [ "$NWARN" = "0" ]; then
314
417
  echo "$head, no errors"
315
418
  exit 0
@@ -321,9 +424,9 @@ case "$SUB" in
321
424
  echo
322
425
  # Errors before warnings. Within each, the parser's own order, which
323
426
  # 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
427
+ { printf '%s\n' "$FINDINGS" | grep "^error$SEP" || true
428
+ printf '%s\n' "$FINDINGS" | grep "^warn$SEP" || true
429
+ } | while IFS="$SEP" read -r sev msg; do
327
430
  [ -n "$sev" ] || continue
328
431
  printf ' %-5s %s\n' "$sev" "$msg"
329
432
  done
@@ -375,15 +478,37 @@ case "$SUB" in
375
478
  HAS_MODEL=" $(bash "$STATUS" show --json --dir "$DIR" 2>/dev/null |
376
479
  jq -r '[.tasks[]? | select(.model != null) | .id] | join(" ")' 2>/dev/null) "
377
480
 
378
- printf '%s\n' "$TASKROWS" | while IFS="$(printf '\t')" read -r _ id name tier model flips; do
481
+ # The tier table takes the highest row a task matches, so a tier raised by
482
+ # hand is a decision and re-import may only ever raise. Lowering it back
483
+ # silently un-decides it and drops the task out of the review debt it was
484
+ # owed, which is the opposite of what re-import is for.
485
+ RECORDED_TIERS="$(bash "$STATUS" show --json --dir "$DIR" 2>/dev/null |
486
+ jq -r '[.tasks[]? | select(.tier != null) | "\(.id):\(.tier)"] | join(" ")' 2>/dev/null)"
487
+
488
+ printf '%s\n' "$TASKROWS" | while IFS="$SEP" read -r _ id name tier model flips needs offers touches; do
379
489
  [ -n "${id:-}" ] || continue
380
490
  set -- task "$id" --name "$name" --dir "$DIR"
381
- [ "$flips" = "1" ] && set -- "$@" --flips
382
- [ "$tier" != "-" ] && [ -n "$tier" ] && set -- "$@" --tier "$tier"
491
+ # The flip is a plan fact rather than a run decision, so import is
492
+ # authoritative on it both ways. Add-only left a moved flip set on two
493
+ # tasks, and the render then named the wrong milestone.
494
+ if [ "$flips" = "1" ]; then set -- "$@" --flips; else set -- "$@" --no-flips; fi
495
+ if [ -n "$tier" ] && [ "$tier" != "-" ]; then
496
+ recorded=""
497
+ for pair in $RECORDED_TIERS; do
498
+ case "$pair" in "$id:"*) recorded="${pair#*:}" ;; esac
499
+ done
500
+ if [ -z "$recorded" ] || [ "$tier" -gt "$recorded" ]; then
501
+ set -- "$@" --tier "$tier"
502
+ fi
503
+ fi
383
504
  case "$HAS_MODEL" in
384
505
  *" $id "*) ;;
385
506
  *) [ "$model" = "1" ] && set -- "$@" --model cheap ;;
386
507
  esac
508
+ # Passed unconditionally, empty included: the graph is a plan fact like
509
+ # the flip, so an edge the plan dropped has to be cleared rather than
510
+ # left behind reporting a contract that no longer exists.
511
+ set -- "$@" --needs "${needs:-}" --offers "${offers:-}" --touches "${touches:-}"
387
512
  bash "$STATUS" "$@" || exit 1
388
513
  done || exit 1
389
514
  imported="$NTASKS"
@@ -9,10 +9,13 @@
9
9
  #
10
10
  # status.sh init --topic <t> --channel <c> [--plan <p>] [--record <p>] [--force]
11
11
  # status.sh task <id> [--name <n>] [--status <s>] [--base <sha>]
12
- # [--commit <sha>] [--tier 0-3] [--model <m>] [--flips]
12
+ # [--commit <sha>] [--tier 0-3] [--model <m>]
13
+ # [--flips | --no-flips] [--reviewed]
14
+ # [--needs <syms>] [--offers <syms>] [--touches <paths>]
13
15
  # status.sh preflight [--review <t>] [--model <t>] [--workspace <t>]
14
16
  # status.sh show [--json]
15
- # status.sh line
17
+ # status.sh ready
18
+ # status.sh line [--full]
16
19
  # status.sh close
17
20
  #
18
21
  # --dir <path> selects the tree to read (default: $PWD). State lives at
@@ -94,17 +97,110 @@ ARCHIVE="$DIR/.sluice/archive"
94
97
  # `line` swallows everything: a missing jq, unreadable state, no run at all.
95
98
  # Any of those printing would put permanent clutter in the status bar.
96
99
  if [ "$SUB" = "line" ]; then
97
- [ $# -eq 0 ] || { err "line takes no arguments"; exit 4; }
100
+ FULL=0
101
+ while [ $# -gt 0 ]; do
102
+ case "$1" in
103
+ --full) FULL=1; shift ;;
104
+ *) err "unknown flag: $1"; exit 4 ;;
105
+ esac
106
+ done
98
107
  command -v jq >/dev/null 2>&1 || exit 0
99
108
  [ -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(" ")
109
+
110
+ if [ "$FULL" -eq 0 ]; then
111
+ jq -r '
112
+ ([.tasks[]? | select(.status == "done")] | length) as $done
113
+ | [ "sluice",
114
+ (.channel // "?"),
115
+ "\($done)/\(.tasks | length)",
116
+ ([.tasks[]? | select(.status == "active") | "▸T\(.id)"] | first // empty),
117
+ ([.tasks[]? | select(.status == "blocked") | "!T\(.id)"] | first // empty)
118
+ ] | join(" ")
119
+ ' "$STATE" 2>/dev/null || exit 0
120
+ exit 0
121
+ fi
122
+
123
+ # The wide render, three rows: the run and its clock, the bar alone, then the
124
+ # detail. The bar gets a row to itself so it never competes with text for
125
+ # width, which is what lets a cell be wide enough to read as a block.
126
+ #
127
+ # The flip draws as a rule in the bar rather than a name in the first row: it
128
+ # is a boundary between tasks, not a property of one, and the first row cannot
129
+ # say how much of the plan is still reversible.
130
+ #
131
+ # Elapsed is passed in rather than computed here, jq having no clock, and
132
+ # fromdateiso8601 raises rather than returning null, so a bad start time must
133
+ # cost the clock cell and not the render.
134
+ jq -r \
135
+ --argjson now "$(date -u +%s)" \
136
+ --arg esc "$(printf '\033')" '
137
+ def paint($c; $t): "\($esc)[\($c)m\($t)\($esc)[0m";
138
+ def join_parts: map(select(. != null and . != "")) | join(" \($esc)[2m·\($esc)[0m ");
139
+ # Done splits in two. A task that is done and was owed a review nobody has
140
+ # marked keeps the done shape but trails the review glyph, so the debt reads
141
+ # in position rather than only as a count. Tier 0 was never owed a dispatch,
142
+ # so it is plainly done.
143
+ def cellgroup($w):
144
+ (.status == "done"
145
+ and (.tier // 0) >= 1
146
+ and (.reviewed // false) == false) as $owed
147
+ | (if .status == "done" then ["32", "▰"]
148
+ elif .status == "active" then ["96", "◈"]
149
+ elif .status == "review" then ["33", "▨"]
150
+ elif .status == "blocked" then ["91", "▮"]
151
+ else ["2", "▱"]
152
+ end) as $s
153
+ | if $owed and $w > 1
154
+ then paint($s[0]; ($s[1] * ($w - 1))) + paint("33"; "▨")
155
+ else paint($s[0]; ($s[1] * $w))
156
+ end;
157
+
158
+ # Three cells read as a block, one reads as a tick. The width is chosen from
159
+ # what the whole bar would occupy, gaps and the flip boundary included, so
160
+ # the schedule is monotonic in the task count. Keyed off the count alone it
161
+ # was not: thirty tasks at two wide ran wider than twelve at three.
162
+ (.tasks | length) as $n
163
+ | (if $n == 0 then 3
164
+ elif ($n * 4 + 2) <= 74 then 3
165
+ elif ($n * 3 + 2) <= 74 then 2
166
+ else 1 end) as $w
167
+ | (if $w > 1 then " " else "" end) as $gap
168
+ | ([.tasks[]? | select(.status == "done")] | length) as $done
169
+ | ([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
170
+ | [.tasks[]? | select(.status == "blocked")] as $blockedAll
171
+ | [.tasks[]? | select(.status == "active")] as $activeAll
172
+ | ($blockedAll | first) as $blocked
173
+ | ($activeAll | first) as $active
174
+ | (if ($blockedAll | length) > 0 then ($blockedAll | length) else ($activeAll | length) end) as $attn
175
+ | ((.started // "" | try fromdateiso8601 catch 0) as $t
176
+ | if $t == 0 then ""
177
+ else (($now - $t) / 60 | floor) as $m
178
+ | if $m < 1 then "◷ <1m"
179
+ elif $m < 60 then "◷ \($m)m"
180
+ else "◷ \($m / 60 | floor)h\($m % 60)m"
181
+ end
182
+ end) as $clock
183
+ | ( paint("1;96"; "⧗") + " "
184
+ + ([ paint("1;96"; (.channel // "?")),
185
+ paint("2"; (.topic // ""))
186
+ ] | join_parts)
187
+ + (if $clock == "" then "" else " " + paint("2"; $clock) end)
188
+ ),
189
+ # The flip is drawn as a rule before its task: everything left of it is
190
+ # inert and safe to leave landed, everything right of it is not. That is
191
+ # what the flip means, and a name in the header could not say it.
192
+ ( " " + ([ .tasks[]?
193
+ | (if .flips then paint("95"; "┃") + $gap else "" end)
194
+ + cellgroup($w)
195
+ ] | join($gap)) ),
196
+ ( " " + ([ paint("1"; "\($done)/\(.tasks | length)") + " done",
197
+ (if $blocked then paint("1;91"; "!T\($blocked.id) \($blocked.name // "")")
198
+ elif $active then paint("96"; "▸T\($active.id)") + " " + ($active.name // "")
199
+ else "" end)
200
+ + (if $attn > 1 then paint("2"; " +\($attn - 1)") else "" end),
201
+ (if $debt > 0 then paint("33"; "⟲\($debt) unreviewed") else "" end)
202
+ ] | join_parts)
203
+ )
108
204
  ' "$STATE" 2>/dev/null || exit 0
109
205
  exit 0
110
206
  fi
@@ -189,7 +285,8 @@ case "$SUB" in
189
285
  *[!0-9]* | 0 ) err "task id must be a positive integer, got: $ID"; exit 4 ;;
190
286
  esac
191
287
 
192
- NAME="" STATUS="" BASE="" COMMIT="" TIER="" MODEL="" FLIPS=false
288
+ NAME="" STATUS="" BASE="" COMMIT="" TIER="" MODEL="" FLIPS=false UNFLIP=false REVIEWED=false
289
+ NEEDS="" OFFERS="" TOUCHES="" GRAPH=0
193
290
  while [ $# -gt 0 ]; do
194
291
  case "$1" in
195
292
  --name) need_value --name $# "${2-}"; NAME="$2"; shift 2 ;;
@@ -199,10 +296,19 @@ case "$SUB" in
199
296
  --tier) need_value --tier $# "${2-}"; TIER="$2"; shift 2 ;;
200
297
  --model) need_value --model $# "${2-}"; MODEL="$2"; shift 2 ;;
201
298
  --flips) FLIPS=true; shift ;;
299
+ --no-flips) UNFLIP=true; shift ;;
300
+ --reviewed) REVIEWED=true; shift ;;
301
+ --needs) need_value --needs $# "${2-}"; NEEDS="$2"; GRAPH=1; shift 2 ;;
302
+ --offers) need_value --offers $# "${2-}"; OFFERS="$2"; GRAPH=1; shift 2 ;;
303
+ --touches) need_value --touches $# "${2-}"; TOUCHES="$2"; GRAPH=1; shift 2 ;;
202
304
  *) err "unknown flag: $1"; exit 4 ;;
203
305
  esac
204
306
  done
205
307
 
308
+ if [ "$FLIPS" = true ] && [ "$UNFLIP" = true ]; then
309
+ err "--flips and --no-flips were both given; they contradict"
310
+ exit 4
311
+ fi
206
312
  if [ -n "$STATUS" ]; then
207
313
  in_set "$STATUS" "$STATUSES" || { err "unknown status: $STATUS (one of: $STATUSES)"; exit 4; }
208
314
  fi
@@ -235,7 +341,13 @@ case "$SUB" in
235
341
  patch="$(jq -n \
236
342
  --arg name "$NAME" --arg status "$STATUS" --arg base "$BASE" \
237
343
  --arg commit "$COMMIT" --arg tier "$TIER" --arg model "$MODEL" \
238
- --argjson flips "$FLIPS" '
344
+ --argjson flips "$FLIPS" --argjson unflip "$UNFLIP" --argjson reviewed "$REVIEWED" \
345
+ --arg needs "$NEEDS" --arg offers "$OFFERS" --arg touches "$TOUCHES" \
346
+ --argjson graph "$GRAPH" '
347
+ def words: split(" ") | map(select(. != "")) | unique;
348
+ # A given-but-empty column clears the key: the caller passing the graph
349
+ # is authoritative on it, so a dropped edge does not survive.
350
+ def edge($v): if $v == "" then null else ($v | words) end;
239
351
  {}
240
352
  + (if $name == "" then {} else {name: $name} end)
241
353
  + (if $status == "" then {} else {status: $status} end)
@@ -244,15 +356,23 @@ case "$SUB" in
244
356
  + (if $tier == "" then {} else {tier: ($tier | tonumber)} end)
245
357
  + (if $model == "" then {} else {model: $model} end)
246
358
  + (if $flips then {flips: true} else {} end)
359
+ + (if $unflip then {flips: null} else {} end)
360
+ + (if $reviewed then {reviewed: true} else {} end)
361
+ + (if $graph == 0 then {}
362
+ else {needs: edge($needs), offers: edge($offers), touches: edge($touches)}
363
+ end)
247
364
  ')"
248
365
 
249
366
  jq --argjson id "$ID" --argjson patch "$patch" '
250
367
  .tasks = (
251
368
  if any(.tasks[]?; .id == $id)
252
- then [.tasks[] | if .id == $id then . + $patch else . end]
369
+ then [.tasks[] | if .id == $id then (. + $patch) else . end]
253
370
  else .tasks + [{id: $id, status: "todo"} + $patch]
254
371
  end
255
372
  )
373
+ # A null in the patch means clear, not store: leaving it would make
374
+ # every reader test for absent and for null.
375
+ | .tasks |= map(with_entries(select(.value != null)))
256
376
  | .tasks |= sort_by(.id)
257
377
  ' "$STATE" | write_state
258
378
  ;;
@@ -314,6 +434,8 @@ case "$SUB" in
314
434
  | ["sluice \(.channel) · \(.topic) · \($done)/\(.tasks | length) done"]
315
435
  + ["plan \(.plan | dash)"]
316
436
  + ["record \(.record | dash)"]
437
+ + (([.tasks[]? | select(.status == "done" and (.tier // 0) >= 1 and (.reviewed // false) == false)] | length) as $debt
438
+ | if $debt == 0 then [] else ["unreviewed \($debt) done, owed a review the tier table promised"] end)
317
439
  + ["pre-flight " + (
318
440
  if (.preflight // {} | length) == 0 then "not recorded"
319
441
  else [(.preflight | to_entries[] | "\(.key)=\(.value)")] | join("; ")
@@ -329,6 +451,68 @@ case "$SUB" in
329
451
  ' "$STATE"
330
452
  ;;
331
453
 
454
+ ready)
455
+ [ $# -eq 0 ] || { err "ready takes no arguments"; exit 4; }
456
+ require_run
457
+ require_readable
458
+
459
+ # The wave question, which is a graph query rather than a status display:
460
+ # a task is ready when every symbol it Needs is offered by something
461
+ # already done, and two ready tasks are safe together when their Touches
462
+ # are disjoint. Nothing goes concurrent with the flip whatever the graph
463
+ # says, because the invariant it establishes is what later tasks are
464
+ # checked against.
465
+ jq -r '
466
+ # Clipped with a marker, so a cut name does not read as the whole name,
467
+ # and always followed by a gap so it cannot run into the next column.
468
+ def pad($n):
469
+ if length > $n then .[0:$n - 1] + "… "
470
+ else . + (" " * ($n - length + 1))
471
+ end;
472
+ [.tasks[]? | select(.status == "done") | (.offers // [])[]] as $supplied
473
+ | [.tasks[]? | select(((.needs // []) | length) > 0 or ((.touches // []) | length) > 0)] as $withgraph
474
+ | [.tasks[]? | select(.status == "todo" and (.flips // false) == false)] as $pending
475
+ # Active and in-review tasks still hold their paths. Checked only
476
+ # against each other, a wave reads as safe while colliding with work
477
+ # already running, which is worse than not checking at all: the output
478
+ # says "a worktree each".
479
+ | [.tasks[]? | select(.status == "active" or .status == "review")] as $inflight
480
+ | [$pending[] | select([(.needs // [])[] | select(. as $s | $supplied | index($s) == null)] | length == 0)] as $ready
481
+ | [$pending[] | select([(.needs // [])[] | select(. as $s | $supplied | index($s) == null)] | length > 0)] as $waiting
482
+ | ([.tasks[]? | select(.flips)] | first) as $flip
483
+
484
+ | if ($withgraph | length) == 0 then
485
+ "no contract graph in the run state.",
486
+ "re-run `plan.sh import <plan>` to record Needs, Offers and Touches."
487
+ else
488
+ (
489
+ "\($ready | length) ready now"
490
+ + (if ($ready | length) > 1 then " · a worktree each" else "" end)
491
+ ),
492
+ ($ready[] | " T\(.id) \(.name // "" | pad(38))\((.touches // []) | join(", "))"),
493
+ # A shared path is what rules two ready tasks out of the same wave,
494
+ # so it is named rather than left to be noticed.
495
+ ( [ $ready[] as $a | ($ready + $inflight)[] as $b
496
+ | select($a.id != $b.id)
497
+ | select(($b.status != "todo") or ($a.id < $b.id))
498
+ | [(($a.touches // [])[] | select(. as $p | ($b.touches // []) | index($p)))] as $clash
499
+ | select(($clash | length) > 0)
500
+ | if $b.status == "todo"
501
+ then " T\($a.id) and T\($b.id) share \($clash | join(", ")), so not together"
502
+ else " T\($a.id) shares \($clash | join(", ")) with T\($b.id), already \($b.status)"
503
+ end
504
+ ] | unique | .[] ),
505
+ (if ($waiting | length) > 0 then
506
+ "", "\($waiting | length) waiting on a contract",
507
+ ($waiting[] | " T\(.id) \(.name // "" | pad(38))needs \([(.needs // [])[] | select(. as $s | $supplied | index($s) == null)] | join(", "))")
508
+ else empty end),
509
+ (if $flip != null and $flip.status != "done" then
510
+ "", "the flip runs alone", " T\($flip.id) \($flip.name // "")"
511
+ else empty end)
512
+ end
513
+ ' "$STATE"
514
+ ;;
515
+
332
516
  close)
333
517
  [ $# -eq 0 ] || { err "close takes no arguments"; exit 4; }
334
518
  require_run
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries seven rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment 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",