@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 +1 -1
- package/skills/index.json +2 -2
- package/skills/sluice/SKILL.md +15 -4
- package/skills/sluice/references/deep-channel.md +85 -21
- package/skills/sluice/references/status.md +110 -0
- package/skills/sluice/scripts/plan.sh +399 -0
- package/skills/sluice/scripts/status.sh +365 -0
- package/skills/sluice/skill.json +2 -2
package/package.json
CHANGED
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.
|
|
286
|
+
"version": "0.10.0"
|
|
287
287
|
},
|
|
288
288
|
{
|
|
289
289
|
"name": "temporal-coupling-detector",
|
package/skills/sluice/SKILL.md
CHANGED
|
@@ -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
|
|
145
|
-
the reason
|
|
146
|
-
approval: one reply arrives for several
|
|
147
|
-
behind it signed off the plan and nothing
|
|
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.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
92
|
-
dispatch whenever this session has been
|
|
93
|
-
what you remember, including where the
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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
|
|
250
|
-
|
|
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
|
|
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
|
package/skills/sluice/skill.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sluice",
|
|
3
|
-
"version": "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": [
|