@iceinvein/agent-skills 0.8.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.8.5",
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.8.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.
@@ -141,10 +151,11 @@ this question the place to ask; genuine unavailability is the tool not being
141
151
  there at all, which `references/deep-channel.md` handles separately. Review that
142
152
  turns out to be missing is only actionable while the plan can still change.
143
153
 
144
- All three answers go into the run record before Task 1's first edit, each with
145
- the reason it went that way. Those rows are what discharge pre-flight, not the
146
- approval: one reply arrives for several obligations, so a "yes" with no rows
147
- behind 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.
148
159
 
149
160
  Three from that file that catch people out: concurrent implementers need a
150
161
  worktree each and the flip runs alone, review is tiered rather than automatic,
@@ -46,6 +46,16 @@
46
46
  the instruction out again in full. "TBD" is another. So is naming a type
47
47
  or a function that no task in the plan ever creates.
48
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
+
49
59
  ```
50
60
  # Plan: <topic>
51
61
  ## Ground Rules
@@ -72,14 +82,31 @@ Split it. A plan with none is not a `deep` plan: nothing in it does anything.
72
82
  ## The run record
73
83
 
74
84
  A `deep` run outlives its own context, so what it learns has to sit on disk
75
- rather than in the session. Open one file for the run before pre-flight and
76
- write it as you go. It holds what a stranger resuming tomorrow would need and
77
- you would otherwise be recalling: the base each task was dispatched from, each
78
- task with its status and its commits, the three answers pre-flight settled,
79
- review, model and workspace, with the reason each one went that way, and any finding
80
- belonging to a task other than the one that surfaced it. Those pre-flight rows
81
- come first and open the file, because they are also what says the stop happened
82
- 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.
83
110
 
84
111
  Where it goes follows the repo if the repo has a convention, and
85
112
  `docs/plans/YYYY-MM-DD-<topic>-record.md` if it does not. It belongs to you
@@ -88,11 +115,42 @@ task's `Touches` names it, and you commit it yourself alongside the plan.
88
115
  Assembling it at handback defeats it: a record written from memory is memory,
89
116
  which is the one thing the file exists to replace.
90
117
 
91
- A file only outlives compaction if you go back to it. Read it before the next
92
- dispatch whenever this session has been summarised, and treat what it says over
93
- what you remember, including where the two agree. Each task closes by writing
94
- its commit into the record, which means asking the implementer to report the
95
- 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.
96
154
 
97
155
  ## Pre-flight
98
156
 
@@ -155,9 +213,10 @@ is the declared schedule the dispatch rules reject.
155
213
  If one of the two has only one live answer, say which and ask the other. A stop
156
214
  down to a single question is still a stop.
157
215
 
158
- **Write the answers into the run record before Task 1's first edit.** Both of
159
- them, each with the reason it went that way. This is what discharges pre-flight,
160
- 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
161
220
  that carries the plan and pre-flight together has one reply for two obligations,
162
221
  so a bare "yes" satisfies the plan and leaves no trace either way of the
163
222
  questions. Rows in a file leave that trace. If Task 1 is about to open and the
@@ -184,10 +243,15 @@ Derive the sets at dispatch rather than writing wave numbers into the plan. A
184
243
  declared schedule is wrong the moment one task lands late or comes back with a
185
244
  blocking finding. A derived one just recomputes.
186
245
 
187
- - 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
188
247
  moves. That state outlives compaction; your memory doesn't.
189
248
  - Each task goes to a fresh agent with that task's text and nothing else.
190
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.
191
255
  - **Fan out wherever the graph allows.** Work that does not write is always
192
256
  safe and always parallel: investigations, searches and reviewers, one agent
193
257
  per question, all in one message so they run at once.
@@ -246,8 +310,8 @@ Three things change. The plan stops being a brief for strangers and becomes
246
310
  your own worklist, so its stop is no longer buying alignment with the agents
247
311
  who will carry it out, only your partner's read of work you will do yourself.
248
312
  Task isolation is gone, so
249
- the run record now carries all of the state that outlives compaction and
250
- 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
251
315
  review was buying.
252
316
 
253
317
  One thing does not change: the work still owes a review. Reading your own diff
@@ -307,8 +371,8 @@ a reviewer writes nothing, so it collides with nothing. The final review is
307
371
  the only one that waits, because it is the only one that needs everything to
308
372
  have landed.
309
373
 
310
- Record the base against that task in the run record when you dispatch, before
311
- 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
312
376
  answer you will guess at is `HEAD~1`, which `references/review.md` already
313
377
  names as the standing mistake.
314
378
 
@@ -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.8.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": [