@iceinvein/agent-skills 0.19.0 → 0.20.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 +1 -1
- package/skills/sluice/SKILL.md +4 -1
- package/skills/sluice/evals/README.md +32 -11
- package/skills/sluice/evals/deep-plan-across-subsystems/case.yaml +4 -0
- package/skills/sluice/evals/deep-plan-across-subsystems/fixture.sh +105 -0
- package/skills/sluice/evals/deep-plan-across-subsystems/graders/announces-deep-channel.md +5 -1
- package/skills/sluice/evals/deep-plan-across-subsystems/graders/no-implementation-yet.md +7 -3
- package/skills/sluice/evals/main-new-interface/graders/announces-main-channel.md +5 -1
- package/skills/sluice/evals/main-new-interface/graders/shape-agreed-before-building.md +10 -7
- package/skills/sluice/evals/main-new-interface/prompt.md +2 -2
- package/skills/sluice/references/deep-channel.md +17 -6
- package/skills/sluice/references/status.md +45 -11
- package/skills/sluice/scripts/session-start.sh +1 -1
- package/skills/sluice/scripts/status.sh +96 -4
- package/skills/sluice/scripts/statusline.sh +6 -1
- package/skills/sluice/scripts/stop-guard.sh +47 -16
- package/skills/sluice/skill.json +1 -1
package/package.json
CHANGED
package/skills/index.json
CHANGED
|
@@ -283,7 +283,7 @@
|
|
|
283
283
|
"name": "sluice",
|
|
284
284
|
"description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries seven rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Claude Code only; stands down where the superpowers pipeline governs the repo.",
|
|
285
285
|
"type": "prompt",
|
|
286
|
-
"version": "0.
|
|
286
|
+
"version": "0.21.0"
|
|
287
287
|
},
|
|
288
288
|
{
|
|
289
289
|
"name": "temporal-coupling-detector",
|
package/skills/sluice/SKILL.md
CHANGED
|
@@ -102,7 +102,10 @@ your partner states a preference. Get the design signed off before code.
|
|
|
102
102
|
Take the design stop through the harness's plan mode where there is one. Its
|
|
103
103
|
gate is enforced rather than requested and it holds edits shut while it is open,
|
|
104
104
|
so nothing gets built against a design nobody signed. It carries the first stop
|
|
105
|
-
only; pre-flight still wants answers, and an approval is not one.
|
|
105
|
+
only; pre-flight still wants answers, and an approval is not one. Where there is
|
|
106
|
+
no plan mode, nothing is holding the draft: write the design to its file before
|
|
107
|
+
you end the turn on it, because a design that lives only in the message you just
|
|
108
|
+
sent is gone at the next compaction.
|
|
106
109
|
|
|
107
110
|
The run's state goes in `.sluice/run.json`, written a command at a time by
|
|
108
111
|
`scripts/status.sh`. That is what makes the breakdown readable from outside the
|
|
@@ -23,19 +23,18 @@ Execution, where the run is already past both stops:
|
|
|
23
23
|
|
|
24
24
|
## Running
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Seven cases scaffold a small Node repo and then change it, so they need the
|
|
27
27
|
scaffold flag and a tool grant. From the repo root:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
claude plugin eval skills/sluice --scaffold --allow-tools Bash Write Edit
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
`--case` takes one glob and is not repeatable, so the
|
|
34
|
-
less
|
|
33
|
+
`--case` takes one glob and is not repeatable, so the one case that needs
|
|
34
|
+
less runs on its own:
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
claude plugin eval skills/sluice --case 'bypass-*'
|
|
38
|
-
claude plugin eval skills/sluice --case 'superpowers-*' --scaffold
|
|
39
38
|
```
|
|
40
39
|
|
|
41
40
|
Useful while iterating on graders: `--ablation none` drops the no-plugin arm
|
|
@@ -70,10 +69,32 @@ only from the case's own directory.
|
|
|
70
69
|
|
|
71
70
|
## Verification status
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
Every case has been run end to end at least once and scored 1.00. The two that
|
|
73
|
+
needed the least (`bypass-question-stays-silent`, `superpowers-conflict-stands-down`)
|
|
74
|
+
were run at `--runs 1 --ablation none`; the other six were run the same way, and
|
|
75
|
+
`deep-plan-across-subsystems` and `main-new-interface` twice each after the
|
|
76
|
+
fixes below.
|
|
77
|
+
|
|
78
|
+
Three defects the first full pass turned up, all in the suite rather than in
|
|
79
|
+
sluice:
|
|
80
|
+
|
|
81
|
+
- `announces-<channel>-channel` matched `<channel> channel` anywhere in the
|
|
82
|
+
trace, and the trace carries SKILL.md's routing table, which names all four.
|
|
83
|
+
Those graders passed whenever the skill loaded. They now anchor on the
|
|
84
|
+
announcement opening an assistant message, the same anchor `run-stats.sh`
|
|
85
|
+
meters by. `fast-flag-on-existing-command` and
|
|
86
|
+
`explicit-instruction-collapses-to-fast` still carry the old pattern.
|
|
87
|
+
- `deep-plan-across-subsystems` shipped no `fixture.sh`, so the run landed in an
|
|
88
|
+
empty tree and the case flipped between designing against the prompt alone and
|
|
89
|
+
stopping to ask where the repo was. It has a fixture now: three callers
|
|
90
|
+
through one upstream client. Its `no-implementation-yet` grader went with it,
|
|
91
|
+
because `file_exists: 'src/**', exists: false` reported absent against a tree
|
|
92
|
+
holding four source files.
|
|
93
|
+
- `shape-agreed-before-building` was an `llm` grader over the trace, and the
|
|
94
|
+
judge is given a head-and-tail window of it. In a run this long the shape
|
|
95
|
+
statement lands in the dropped middle, so the judge voted FAIL six times out
|
|
96
|
+
of six on runs that had stated the shape plainly. `focus` accepts only
|
|
97
|
+
`last_message`, `trace` or a file, and `main` agrees in a message rather than
|
|
98
|
+
a file, so there was no slice to point it at. It is a regex over the
|
|
99
|
+
chronological trace now, which pins the order but not whether a
|
|
100
|
+
recommendation came with the shape.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Smallest repo that makes the rate limiter a real three-subsystem change: a
|
|
3
|
+
# CLI, a webhook handler and a worker, each calling the same upstream client,
|
|
4
|
+
# and nothing between them and the API.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
mkdir -p src/cli src/webhook src/worker src/upstream tests
|
|
8
|
+
|
|
9
|
+
cat > package.json <<'JSON'
|
|
10
|
+
{
|
|
11
|
+
"name": "relay",
|
|
12
|
+
"version": "0.4.0",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test \"tests/*.test.js\""
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
JSON
|
|
19
|
+
|
|
20
|
+
cat > src/upstream/client.js <<'JS'
|
|
21
|
+
const BASE = "https://api.upstream.example";
|
|
22
|
+
|
|
23
|
+
export async function call(path, body, fetchImpl = fetch) {
|
|
24
|
+
const response = await fetchImpl(`${BASE}${path}`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`upstream ${response.status} on ${path}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response.json();
|
|
35
|
+
}
|
|
36
|
+
JS
|
|
37
|
+
|
|
38
|
+
cat > src/cli/push.js <<'JS'
|
|
39
|
+
import { call } from "../upstream/client.js";
|
|
40
|
+
|
|
41
|
+
export async function push(records, log = console.log) {
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
await call("/v1/records", record);
|
|
44
|
+
log(`pushed ${record.id}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
JS
|
|
48
|
+
|
|
49
|
+
cat > src/webhook/handler.js <<'JS'
|
|
50
|
+
import { call } from "../upstream/client.js";
|
|
51
|
+
|
|
52
|
+
export async function handle(event) {
|
|
53
|
+
const result = await call("/v1/events", { type: event.type, payload: event.payload });
|
|
54
|
+
return { status: 202, id: result.id };
|
|
55
|
+
}
|
|
56
|
+
JS
|
|
57
|
+
|
|
58
|
+
cat > src/worker/backfill.js <<'JS'
|
|
59
|
+
import { call } from "../upstream/client.js";
|
|
60
|
+
|
|
61
|
+
export async function backfill(queue) {
|
|
62
|
+
let sent = 0;
|
|
63
|
+
|
|
64
|
+
while (queue.length > 0) {
|
|
65
|
+
await call("/v1/records", queue.shift());
|
|
66
|
+
sent += 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return sent;
|
|
70
|
+
}
|
|
71
|
+
JS
|
|
72
|
+
|
|
73
|
+
cat > tests/upstream.test.js <<'JS'
|
|
74
|
+
import assert from "node:assert/strict";
|
|
75
|
+
import { test } from "node:test";
|
|
76
|
+
import { call } from "../src/upstream/client.js";
|
|
77
|
+
|
|
78
|
+
function fakeFetch(ok, body) {
|
|
79
|
+
return async () => ({ ok, status: ok ? 200 : 429, json: async () => body });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("a successful call returns the parsed body", async () => {
|
|
83
|
+
const result = await call("/v1/records", { id: "a" }, fakeFetch(true, { id: "a" }));
|
|
84
|
+
assert.deepEqual(result, { id: "a" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a rejected call reports the status and the path", async () => {
|
|
88
|
+
await assert.rejects(
|
|
89
|
+
() => call("/v1/records", { id: "a" }, fakeFetch(false)),
|
|
90
|
+
/upstream 429 on \/v1\/records/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
JS
|
|
94
|
+
|
|
95
|
+
cat > README.md <<'MD'
|
|
96
|
+
# relay
|
|
97
|
+
|
|
98
|
+
Three callers, one upstream API: `src/cli/push.js`, `src/webhook/handler.js`
|
|
99
|
+
and `src/worker/backfill.js` all go through `src/upstream/client.js`.
|
|
100
|
+
`npm test` runs the suite.
|
|
101
|
+
MD
|
|
102
|
+
|
|
103
|
+
git init --quiet
|
|
104
|
+
git add -A
|
|
105
|
+
git -c user.email=fixture@example.com -c user.name=fixture commit --quiet -m "relay 0.4.0"
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: regex
|
|
3
|
-
|
|
3
|
+
# The trace carries SKILL.md's own routing table, which names every channel, so
|
|
4
|
+
# `contains` passes whenever the skill loads. Anchor on how the agent says it
|
|
5
|
+
# instead, the same anchor run-stats.sh meters: the words open an assistant
|
|
6
|
+
# message, or follow a label such as "Sluice:" on the same line.
|
|
7
|
+
pattern: '"text":"(?:[^.!?\n"]{0,100}[:=]\s*)?[\s*_#>\\]*deep channel'
|
|
4
8
|
flags: i
|
|
5
9
|
target: trace
|
|
6
10
|
weight: 2
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
type:
|
|
3
|
-
path:
|
|
4
|
-
exists: false
|
|
2
|
+
type: llm
|
|
3
|
+
focus: { source: file, path: src/upstream/client.js }
|
|
5
4
|
weight: 2
|
|
6
5
|
---
|
|
6
|
+
|
|
7
|
+
This is the upstream client after a turn that was asked to plan a shared rate limiter. Every caller reaches the upstream API through it, so it is the file a limiter has to land in.
|
|
8
|
+
|
|
9
|
+
PASS if it is still the fixture's client and nothing else: a `call()` that posts the body, throws on a non-ok response, and returns the parsed JSON.
|
|
10
|
+
FAIL if any limiting has been built into it: a token bucket, a counter, a budget check, a store or Redis client, a sleep, a queue, or a wrapper that decides whether the call may proceed.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: regex
|
|
3
|
-
|
|
3
|
+
# The trace carries SKILL.md's own routing table, which names every channel, so
|
|
4
|
+
# `contains` passes whenever the skill loads. Anchor on how the agent says it
|
|
5
|
+
# instead, the same anchor run-stats.sh meters: the words open an assistant
|
|
6
|
+
# message, or follow a label such as "Sluice:" on the same line.
|
|
7
|
+
pattern: '"text":"(?:[^.!?\n"]{0,100}[:=]\s*)?[\s*_#>\\]*main channel'
|
|
4
8
|
flags: i
|
|
5
9
|
target: trace
|
|
6
10
|
weight: 2
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
-
type:
|
|
3
|
-
|
|
2
|
+
type: regex
|
|
3
|
+
# The judge only ever sees a head-and-tail window of the trace, and in a run
|
|
4
|
+
# this long the shape statement falls in the dropped middle, so an llm grader
|
|
5
|
+
# here votes on evidence that cannot contain the thing it is asked about. The
|
|
6
|
+
# trace is chronological, so pin the order directly: an assistant message that
|
|
7
|
+
# names all three step operations and a path under src/, and only then a write
|
|
8
|
+
# into src/. What this cannot see is whether a recommendation came with it,
|
|
9
|
+
# which the rubric it replaces did ask for.
|
|
10
|
+
pattern: '"text":"(?=(?:\\.|[^"\\])*?\bbuild\b)(?=(?:\\.|[^"\\])*?\bupload\b)(?=(?:\\.|[^"\\])*?\bactivate\b)(?=(?:\\.|[^"\\])*?src/)(?:\\.|[^"\\])*?"[\s\S]*?"name":"(?:Write|Edit)"[\s\S]{0,400}?src/'
|
|
11
|
+
target: trace
|
|
4
12
|
weight: 2
|
|
5
13
|
---
|
|
6
|
-
|
|
7
|
-
The agent was asked to put deploy behind a pluggable target seam, which is a new interface the repo does not have.
|
|
8
|
-
|
|
9
|
-
PASS if, before writing the implementation, the agent stated the shape it intended to build: it named the seam and the operations on it, and where it recommended landing it. A single recommended approach counts; so does naming two options with one recommended.
|
|
10
|
-
FAIL if it started editing files with no statement of the shape first, or if it listed options with no recommendation and ended its turn waiting for an answer.
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
name: main-new-interface
|
|
3
3
|
description: Adding a port the repo does not have is the main channel. Pins the announcement and agreeing the shape before building.
|
|
4
4
|
tags: [routing, main, scaffold, write]
|
|
5
|
-
max_turns:
|
|
6
|
-
timeout_seconds:
|
|
5
|
+
max_turns: 80
|
|
6
|
+
timeout_seconds: 2400
|
|
7
7
|
allowed_tools: [Read, Glob, Grep, Skill, Write, Edit, Bash]
|
|
8
8
|
expected_outcome: Announces the main channel, states the port's shape and a recommendation before implementing, then builds it test-first with the suite green.
|
|
9
9
|
---
|
|
@@ -156,9 +156,13 @@ the run record section gives. Those are the durable files, the ones a session
|
|
|
156
156
|
resuming next week reads, and none of them is the one you drafted in.
|
|
157
157
|
|
|
158
158
|
Where plan mode is unavailable, the prose stop is what you have and it is the
|
|
159
|
-
same stop
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
same stop, with one thing you now do yourself: nothing is holding the draft, so
|
|
160
|
+
write the design to `docs/specs/YYYY-MM-DD-<topic>.md` before you end the turn
|
|
161
|
+
on it. A design that exists only in the message you just sent is gone at the
|
|
162
|
+
next compaction, which is what the durable files are for. Write it, end the turn
|
|
163
|
+
on it, and let the next instruction start the plan. Nothing else about this
|
|
164
|
+
section changes, because the obligation was never the mode's, only the
|
|
165
|
+
enforcement was.
|
|
162
166
|
|
|
163
167
|
## Pre-flight
|
|
164
168
|
|
|
@@ -230,7 +234,9 @@ down to a single question is still a stop.
|
|
|
230
234
|
|
|
231
235
|
**Write the answers down before Task 1's first edit, in the tree the work runs
|
|
232
236
|
in.** The order on the instruction that follows the stop: cut the worktree
|
|
233
|
-
first, when that was the answer, through the harness's worktree tool
|
|
237
|
+
first, when that was the answer, through the harness's worktree tool, which
|
|
238
|
+
puts this session inside it: everything below is issued from there, and issued
|
|
239
|
+
from the main tree instead it lands in the tree the work is not in. A fresh
|
|
234
240
|
worktree branches from the remote's default branch, so nothing uncommitted or
|
|
235
241
|
unpushed in the main tree comes across, and `docs/` may not exist there yet.
|
|
236
242
|
Move the design and plan into it yourself, each into its own directory since
|
|
@@ -341,13 +347,18 @@ reads the run state rather than the plan: it sees what has actually landed.
|
|
|
341
347
|
`Flips`. The invariant it establishes is what later tasks are checked
|
|
342
348
|
against, and whatever landed beside it was checked against nothing.
|
|
343
349
|
- Isolate the workspace before a multi-task plan: the harness's worktree
|
|
344
|
-
tool, not `git worktree` yourself.
|
|
350
|
+
tool, not `git worktree` yourself. That tool moves this session into the
|
|
351
|
+
worktree, which `git worktree add` does not: cut by hand, the files are
|
|
352
|
+
isolated and the session is still in the tree they came from, so every bare
|
|
353
|
+
`git`, build and test command you run lands in the wrong one. Enter it by
|
|
354
|
+
path if it already exists. Implementing straight onto main or
|
|
345
355
|
master needs your partner's say-so, which pre-flight is where you got, and
|
|
346
356
|
it forecloses concurrent implementers for the whole run. Cut it before the
|
|
347
357
|
run opens, so the state lives in it. A worktree cut after `init` still reads
|
|
348
358
|
the main tree's run, so nothing breaks for you, but the run stays in the main
|
|
349
359
|
tree where the next session to start a `deep` run finds it blocking `init`;
|
|
350
|
-
`status.sh move --to <worktree>` puts it where it belongs
|
|
360
|
+
`status.sh move --to <worktree>` puts it where it belongs, and the session
|
|
361
|
+
has to follow it there.
|
|
351
362
|
- **The agent that built the task commits it**, once its own tests pass, and
|
|
352
363
|
only the paths in its `Touches`. Never `git add -A`: the tree is shared, and
|
|
353
364
|
on a branch you did not isolate it holds work that is not this task's. The
|
|
@@ -30,9 +30,13 @@ bash <skill-dir>/scripts/status.sh close
|
|
|
30
30
|
|
|
31
31
|
`--dir <path>` reads another tree, which is what the statusline uses. A tree
|
|
32
32
|
with a run of its own is read as itself; one with none resolves to the main
|
|
33
|
-
worktree of its set,
|
|
34
|
-
|
|
35
|
-
worktree>` finds
|
|
33
|
+
worktree of its set, and from there to whatever tree the run has since moved
|
|
34
|
+
into. So any tree in the set reads the one run, wherever in the set it lives,
|
|
35
|
+
and `--dir <implementer worktree>` finds it too. `show` prints a `tree` row
|
|
36
|
+
naming where the run is whenever that is not the tree it was pointed at, since
|
|
37
|
+
a run read from a tree it does not live in otherwise answers "where is this"
|
|
38
|
+
with the tree the reader is already in. `show --json` stays the state file
|
|
39
|
+
verbatim, and the row is not in it. Statuses
|
|
36
40
|
are `todo`, `active`, `review`, `done` and `blocked`. A new id needs `--name`;
|
|
37
41
|
after that every call is a bare flip, so keeping it current costs one command
|
|
38
42
|
per transition rather than a paragraph. `close` archives the run under
|
|
@@ -56,9 +60,10 @@ is pointed at, `--dir` if given and the current tree otherwise, once; a base
|
|
|
56
60
|
already on the row is kept. Issued from the controller's tree that is the
|
|
57
61
|
controller's HEAD, which is what an implementer worktree cut from that branch
|
|
58
62
|
starts at, so the default is right at dispatch. Where the implementer's tree
|
|
59
|
-
has moved on,
|
|
60
|
-
|
|
61
|
-
pointed at the
|
|
63
|
+
has moved on, point the command at that tree: `--dir <implementer worktree>`
|
|
64
|
+
resolves the run through the set and takes the base from the tree it was
|
|
65
|
+
pointed at, which is the one about to be built in. `--base $(git -C
|
|
66
|
+
<implementer worktree> rev-parse --short HEAD)` says the same thing outright.
|
|
62
67
|
|
|
63
68
|
`init` reports any other run live in a tree of the same set, without refusing:
|
|
64
69
|
two sessions in two worktrees is legal, and a run stranded in the main tree
|
|
@@ -103,6 +108,24 @@ that already holds a run or that is not a work tree of the same repository. A
|
|
|
103
108
|
submodule anchors on its own checkout, not the superproject's, and a directory
|
|
104
109
|
that is no git work tree keeps its run exactly where it sits.
|
|
105
110
|
|
|
111
|
+
`move` is half a step, and the half it cannot take is the session. The harness
|
|
112
|
+
holds one working directory and no command here reaches it, so the controller
|
|
113
|
+
goes on asking about the tree it is still sitting in: its statusline draws for
|
|
114
|
+
that tree, so does the SessionStart hook, and so does every bare `git`, build
|
|
115
|
+
and test command it runs. **Move the session into the tree the run went to**,
|
|
116
|
+
with the harness's worktree tool where there is one, entering the worktree by
|
|
117
|
+
path when it was cut by hand. `move` says so on the way out, because that is
|
|
118
|
+
the moment it is still cheap.
|
|
119
|
+
|
|
120
|
+
Until the session moves, the tree it came from reads the run rather than
|
|
121
|
+
denying it: `move` leaves `.sluice/run.at` at the main worktree, one line
|
|
122
|
+
naming the tree the run is in, and a tree with no run of its own follows it.
|
|
123
|
+
The note lives at the main worktree and nowhere else, so a run moved twice
|
|
124
|
+
forwards once rather than down a chain of trees, and it is followed only while
|
|
125
|
+
the run it names is really there. `close` removes a note naming its own tree,
|
|
126
|
+
because a note that outlived its run would hand the next run opened in that
|
|
127
|
+
tree to whoever reads the main tree.
|
|
128
|
+
|
|
106
129
|
One file for several writers is one file to contend on, so `init`, `task`,
|
|
107
130
|
`preflight`, `final`, `pause`, `resume`, `close` and `move` take a lock first, `move` taking the
|
|
108
131
|
destination tree's as well as its own: two flips issued at the same moment
|
|
@@ -290,11 +313,22 @@ every task done, a run idle for a day, and a turn where the harness says a
|
|
|
290
313
|
stop hook already fired, which is what keeps it from looping. That last rule
|
|
291
314
|
means it refuses once per turn and lets the next attempt through: a nudge, not
|
|
292
315
|
a wall. The gate keys on the git top level of the session's working
|
|
293
|
-
directory
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
316
|
+
directory: the run this tree answers for is the state beside it, or the state
|
|
317
|
+
it forwarded into a worktree when `move` sent the run on without the session.
|
|
318
|
+
Both are this tree's, and a controller that moved its run out and stayed put is
|
|
319
|
+
guarded for the rest of the run rather than quietly let go at the moment it
|
|
320
|
+
moved. The main-worktree fallback stays untaken, so a session in a worktree
|
|
321
|
+
that merely reads the set's run is let stop as before.
|
|
322
|
+
|
|
323
|
+
Once the run is in another tree the remedy changes with it. The refusal names
|
|
324
|
+
that tree and says to move the session into it, and it stops offering `close`:
|
|
325
|
+
from here that would archive a run live somewhere else, which may be another
|
|
326
|
+
session's, and talking anyone into that is the one thing this hook must not do.
|
|
327
|
+
The offer comes back when the run is in the tree the stop came from. None of
|
|
328
|
+
this makes staying put correct: open the run after the worktree is cut, or
|
|
329
|
+
`move` it and enter that worktree, since `move` relocates the run and not the
|
|
330
|
+
session, and every bare `git`, build and test command meanwhile still lands in
|
|
331
|
+
the tree the work left.
|
|
298
332
|
|
|
299
333
|
`pause --reason <text>` records why a run is standing still; `show` and the
|
|
300
334
|
statusline carry it, and `resume` clears it. A pause with no reason is refused,
|
|
@@ -98,7 +98,7 @@ stamp_baseline "$sid" "$source"
|
|
|
98
98
|
shown="$(bash "$STATUS" show --dir "$cwd" 2>/dev/null)" || exit 0
|
|
99
99
|
[ -n "$shown" ] || exit 0
|
|
100
100
|
|
|
101
|
-
echo "A sluice run is live
|
|
101
|
+
echo "A sluice run is live. Its state, from .sluice/run.json:"
|
|
102
102
|
echo
|
|
103
103
|
echo "$shown"
|
|
104
104
|
echo
|
|
@@ -23,8 +23,11 @@
|
|
|
23
23
|
# status.sh close
|
|
24
24
|
#
|
|
25
25
|
# --dir <path> selects the tree to read (default: $PWD). State lives at
|
|
26
|
-
# <dir>/.sluice/run.json and closed runs at <dir>/.sluice/archive/.
|
|
27
|
-
#
|
|
26
|
+
# <dir>/.sluice/run.json and closed runs at <dir>/.sluice/archive/. A tree the
|
|
27
|
+
# run has moved out of keeps <dir>/.sluice/run.at, one line naming the tree it
|
|
28
|
+
# went to, so a session still sitting there resolves the run rather than reading
|
|
29
|
+
# it as gone. The directory ignores itself, so no project needs a .gitignore
|
|
30
|
+
# line for it.
|
|
28
31
|
#
|
|
29
32
|
# Exit: 0 ok, 1 the state could not be written, 2 no live run, 3 a run is
|
|
30
33
|
# already live (here, or at move's destination), 4 bad arguments, 5 jq missing,
|
|
@@ -107,6 +110,15 @@ if [ -z "$SUB" ]; then
|
|
|
107
110
|
exit 4
|
|
108
111
|
fi
|
|
109
112
|
|
|
113
|
+
# One function rather than the same pipeline at three call sites, because they
|
|
114
|
+
# have to agree: the tree resolution anchors on and the tree `move` and `close`
|
|
115
|
+
# leave their forwarding note in are the same tree by definition, and a note
|
|
116
|
+
# left anywhere else is a note nothing reads. Empty for a directory that is no
|
|
117
|
+
# git work tree.
|
|
118
|
+
main_tree() { # <dir>
|
|
119
|
+
git -C "$1" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p'
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
# A tree's own run comes first, and only a tree with none reads the set's. Two
|
|
111
123
|
# layouts share this script and pull opposite ways. A deep run plans in the main
|
|
112
124
|
# tree and cuts implementer worktrees after the plan: the run directory ignores
|
|
@@ -130,9 +142,29 @@ fi
|
|
|
130
142
|
ORIG_DIR="$DIR"
|
|
131
143
|
if [ "$SUB" != "init" ] && [ ! -f "$DIR/.sluice/run.json" ] \
|
|
132
144
|
&& [ "$(git -C "$DIR" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ]; then
|
|
133
|
-
MAIN_TREE="$(
|
|
145
|
+
MAIN_TREE="$(main_tree "$DIR")"
|
|
134
146
|
if [ -n "${MAIN_TREE:-}" ] && [ -d "$MAIN_TREE" ]; then
|
|
135
147
|
DIR="$MAIN_TREE"
|
|
148
|
+
|
|
149
|
+
# Then forward, where the main tree's run has moved on into a worktree.
|
|
150
|
+
# A `move` is half a step: it relocates the state and cannot relocate the
|
|
151
|
+
# session, the harness holding one working directory that no command here
|
|
152
|
+
# reaches. So the controller's session goes on asking about the tree it
|
|
153
|
+
# still sits in, and per-tree resolution answers that the run is gone --
|
|
154
|
+
# to its statusline, to the SessionStart hook and to a bare `show`, all
|
|
155
|
+
# on a run that is live two directories away.
|
|
156
|
+
#
|
|
157
|
+
# The note lives at the main tree and nowhere else, so a run moved twice
|
|
158
|
+
# forwards once rather than down a chain, and so the tree every other
|
|
159
|
+
# tree in the set already resolves to is the tree that knows. It is
|
|
160
|
+
# followed only while the run it names is really there: a note outliving
|
|
161
|
+
# its run is stale, not a second answer.
|
|
162
|
+
if [ ! -f "$DIR/.sluice/run.json" ] && [ -f "$DIR/.sluice/run.at" ]; then
|
|
163
|
+
# `read`, not `cat`: a builtin, and the first line is the whole note.
|
|
164
|
+
AT=""
|
|
165
|
+
IFS= read -r AT <"$DIR/.sluice/run.at" 2>/dev/null || true
|
|
166
|
+
[ -n "$AT" ] && [ -f "$AT/.sluice/run.json" ] && DIR="$AT"
|
|
167
|
+
fi
|
|
136
168
|
fi
|
|
137
169
|
fi
|
|
138
170
|
|
|
@@ -306,6 +338,16 @@ mk_dir() { # <directory to create under .sluice> [<tree whose .sluice it is, def
|
|
|
306
338
|
[ -e "$ignore" ] || printf '*\n' >"$ignore" 2>/dev/null || true
|
|
307
339
|
}
|
|
308
340
|
|
|
341
|
+
# Where the set's run went, written at the main tree for resolution to follow.
|
|
342
|
+
# Not `mk_dir`, which exits: this runs after the state has already arrived in
|
|
343
|
+
# the destination, and a note that could not be written is a blank statusline
|
|
344
|
+
# in one tree rather than a move that failed. The caller reports it instead.
|
|
345
|
+
leave_note() { # <main tree> <tree the run is now in>
|
|
346
|
+
mkdir -p "$1/.sluice" 2>/dev/null || return 1
|
|
347
|
+
[ -e "$1/.sluice/.gitignore" ] || printf '*\n' >"$1/.sluice/.gitignore" 2>/dev/null || true
|
|
348
|
+
printf '%s\n' "$2" >"$1/.sluice/run.at" 2>/dev/null
|
|
349
|
+
}
|
|
350
|
+
|
|
309
351
|
# Written through a temporary file so an interrupted write cannot leave the
|
|
310
352
|
# run state half-serialised, which would read as a corrupted run rather than
|
|
311
353
|
# as a failed command.
|
|
@@ -582,10 +624,28 @@ case "$SUB" in
|
|
|
582
624
|
exit 0
|
|
583
625
|
fi
|
|
584
626
|
|
|
627
|
+
# Where the run is, said only when that is not the tree the command was
|
|
628
|
+
# pointed at. A run read from a tree it does not live in answers "where
|
|
629
|
+
# is this" silently wrong otherwise: the reader takes the tree they are
|
|
630
|
+
# in, which after a `move` is the one tree the run is not in.
|
|
631
|
+
#
|
|
632
|
+
# A directory inside the tree holding the run is that tree, one level
|
|
633
|
+
# down, not somewhere else -- without that, the row would fire on every
|
|
634
|
+
# session that works from a subdirectory.
|
|
635
|
+
ELSEWHERE=""
|
|
636
|
+
run_tree="$(cd "$DIR" 2>/dev/null && pwd -P)"
|
|
637
|
+
asked="$(cd "$ORIG_DIR" 2>/dev/null && pwd -P)"
|
|
638
|
+
if [ -n "$run_tree" ] && [ -n "$asked" ]; then
|
|
639
|
+
case "$asked" in
|
|
640
|
+
"$run_tree" | "$run_tree"/*) ;;
|
|
641
|
+
*) ELSEWHERE="$run_tree" ;;
|
|
642
|
+
esac
|
|
643
|
+
fi
|
|
644
|
+
|
|
585
645
|
# Header and rows are laid out from the same widths, so the two cannot
|
|
586
646
|
# drift apart, and an over-long value is clipped with a marker rather
|
|
587
647
|
# than silently reading as the whole value.
|
|
588
|
-
jq -r --argjson now "$(date -u +%s)" '
|
|
648
|
+
jq -r --argjson now "$(date -u +%s)" --arg elsewhere "$ELSEWHERE" '
|
|
589
649
|
def dash: if . == null or . == "" then "-" else . end;
|
|
590
650
|
# Same reason as the statusline render: state written before the check
|
|
591
651
|
# on the way in, or edited by hand, holds bytes a terminal would act on
|
|
@@ -601,6 +661,7 @@ case "$SUB" in
|
|
|
601
661
|
$c[6]] | join(" "));
|
|
602
662
|
([.tasks[]? | select(.status == "done")] | length) as $done
|
|
603
663
|
| ["sluice \(.channel | clean) · \(.topic | clean) · \($done)/\(.tasks | length) done"]
|
|
664
|
+
+ (if $elsewhere == "" then [] else ["tree \($elsewhere | clean)"] end)
|
|
604
665
|
+ ["plan \(.plan | dash | clean)"]
|
|
605
666
|
+ ["record \(.record | dash | clean)"]
|
|
606
667
|
# Past a day since the last write the run is idle, and that is said
|
|
@@ -797,7 +858,26 @@ case "$SUB" in
|
|
|
797
858
|
exit 3
|
|
798
859
|
fi
|
|
799
860
|
mv "$STATE" "$DEST_STATE" || { err "could not move $STATE to $DEST_STATE"; exit 1; }
|
|
861
|
+
|
|
862
|
+
# After the state has arrived, never before: a note pointing at a run
|
|
863
|
+
# that never got there is worse than no note, being indistinguishable
|
|
864
|
+
# from one pointing at a run that did.
|
|
865
|
+
NOTE_TREE="$(main_tree "$TO")"
|
|
866
|
+
if [ -n "$NOTE_TREE" ] && [ -d "$NOTE_TREE" ]; then
|
|
867
|
+
if [ "$NOTE_TREE" = "$TO" ]; then
|
|
868
|
+
# The run is back where resolution already looks, so a note would
|
|
869
|
+
# only point the main tree at itself.
|
|
870
|
+
rm -f "$NOTE_TREE/.sluice/run.at"
|
|
871
|
+
elif ! leave_note "$NOTE_TREE" "$TO"; then
|
|
872
|
+
err "note: the run moved, but $NOTE_TREE/.sluice/run.at could not be written, so a session in $NOTE_TREE will read no run until it moves to $TO"
|
|
873
|
+
fi
|
|
874
|
+
fi
|
|
875
|
+
|
|
800
876
|
echo "moved $(jq -r '.topic // "run"' "$DEST_STATE" 2>/dev/null || echo run) to $TO"
|
|
877
|
+
# The session is the half of the move no command can make. Left where it
|
|
878
|
+
# was, every bare git, build and test command it runs still lands in the
|
|
879
|
+
# tree the run just left.
|
|
880
|
+
echo "move this session there too, with the harness's worktree tool where it has one; every status.sh call from elsewhere needs --dir $TO"
|
|
801
881
|
;;
|
|
802
882
|
|
|
803
883
|
close)
|
|
@@ -844,6 +924,18 @@ case "$SUB" in
|
|
|
844
924
|
n=$((n + 1))
|
|
845
925
|
done
|
|
846
926
|
mv "$STATE" "$dest" || { err "could not archive $STATE"; exit 1; }
|
|
927
|
+
|
|
928
|
+
# A note naming this tree has outlived the run it forwarded to. Left
|
|
929
|
+
# behind, it does not go quiet: the next run opened in this tree inherits
|
|
930
|
+
# the forward and reads as the main tree's, though nobody there opened
|
|
931
|
+
# it. Only a note naming this tree is ours to remove -- one naming
|
|
932
|
+
# another tree belongs to a run this close knows nothing about.
|
|
933
|
+
NOTE_TREE="$(main_tree "$DIR")"
|
|
934
|
+
if [ -n "$NOTE_TREE" ] && [ -f "$NOTE_TREE/.sluice/run.at" ]; then
|
|
935
|
+
AT=""
|
|
936
|
+
IFS= read -r AT <"$NOTE_TREE/.sluice/run.at" 2>/dev/null || true
|
|
937
|
+
[ "$AT" = "$(cd "$DIR" && pwd -P)" ] && rm -f "$NOTE_TREE/.sluice/run.at"
|
|
938
|
+
fi
|
|
847
939
|
# The summary needs parseable state and close is the one command that
|
|
848
940
|
# does not, so an unreadable run still gets a line naming where it went.
|
|
849
941
|
[ -n "$summary" ] || summary="closed $(basename "$dest"): state was unreadable, no summary"
|
|
@@ -65,7 +65,11 @@ done
|
|
|
65
65
|
d="$(cd "$DIR" 2>/dev/null && pwd -P)" || exit 0
|
|
66
66
|
[ -n "$d" ] || exit 0
|
|
67
67
|
|
|
68
|
-
#
|
|
68
|
+
# Five answers end the walk. A state file is a run, wherever it was found. So is
|
|
69
|
+
# a forwarding note beside where one used to be: the run moved out into a
|
|
70
|
+
# worktree, this tree is where the session that moved it is still sitting, and
|
|
71
|
+
# testing only the state file blanked its bar on a live run -- ahead of the
|
|
72
|
+
# `.git` directory below, which would otherwise answer for this tree first. A
|
|
69
73
|
# `.git` that is a regular file is a linked worktree or a submodule, which may
|
|
70
74
|
# hold no state of its own and still belong to a set that does, so it is a maybe
|
|
71
75
|
# and status.sh resolves it. A `.git` that is a directory is the top of an
|
|
@@ -78,6 +82,7 @@ d="$(cd "$DIR" 2>/dev/null && pwd -P)" || exit 0
|
|
|
78
82
|
# would put the gate's cost in the same range as the render it is avoiding.
|
|
79
83
|
while :; do
|
|
80
84
|
[ -f "$d/.sluice/run.json" ] && break
|
|
85
|
+
[ -f "$d/.sluice/run.at" ] && break
|
|
81
86
|
[ -f "$d/.git" ] && break
|
|
82
87
|
[ -d "$d/.git" ] && exit 0
|
|
83
88
|
[ "$d" = "${HOME:-}" ] && exit 0
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
# Every stop that is a real stop is let through: no run, a channel other than
|
|
11
11
|
# deep, pre-flight not yet answered (that stop is owed), a blocked task, a run
|
|
12
12
|
# paused on purpose with `status.sh pause --reason`, every task done (the
|
|
13
|
-
# handback), a run idle for a day, a run
|
|
14
|
-
#
|
|
15
|
-
# this turn, which is what keeps this from looping. One refusal per turn, then:
|
|
13
|
+
# handback), a run idle for a day, a run another tree owns rather than one this
|
|
14
|
+
# tree moved out, and any attempt where the harness says a stop hook already
|
|
15
|
+
# fired this turn, which is what keeps this from looping. One refusal per turn, then:
|
|
16
16
|
# a nudge with the state in it rather than a wall.
|
|
17
17
|
#
|
|
18
18
|
# Reads the harness's stop JSON on stdin for `cwd` and `stop_hook_active`. To
|
|
@@ -155,21 +155,37 @@ fi
|
|
|
155
155
|
|
|
156
156
|
[ -f "$STATUS" ] || exit 0
|
|
157
157
|
|
|
158
|
-
# The session's own tree, and only that
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
158
|
+
# The run the session's own tree answers for, and only that: the state beside
|
|
159
|
+
# it, or the state it forwarded into a worktree when `move` sent the run on
|
|
160
|
+
# without the session. status.sh also lets a tree with no run of its own read
|
|
161
|
+
# the main worktree's, and that one is not taken here: a Stop in such a tree may
|
|
162
|
+
# be an unrelated session, and a remedy printed to it would reach into somebody
|
|
163
|
+
# else's run.
|
|
164
|
+
#
|
|
165
|
+
# The forward is read here rather than left to status.sh's resolution because
|
|
166
|
+
# the two questions differ. Resolution answers "which run can this tree read",
|
|
167
|
+
# which is the right question for a render and the wrong one for a refusal. The
|
|
168
|
+
# tree named in the note is then passed as `--dir`, so what follows asks about
|
|
169
|
+
# one named tree and carries no layout knowledge of its own.
|
|
163
170
|
top="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)"
|
|
164
171
|
[ -n "$top" ] || top="$cwd"
|
|
165
|
-
[ -f "$top/.sluice/run.json" ]
|
|
172
|
+
if [ -f "$top/.sluice/run.json" ]; then
|
|
173
|
+
tree="$top"
|
|
174
|
+
elif [ -f "$top/.sluice/run.at" ]; then
|
|
175
|
+
tree=""
|
|
176
|
+
IFS= read -r tree <"$top/.sluice/run.at" 2>/dev/null || exit 0
|
|
177
|
+
# A note outliving the run it named is stale, not a run to refuse a stop over.
|
|
178
|
+
[ -n "$tree" ] && [ -f "$tree/.sluice/run.json" ] || exit 0
|
|
179
|
+
else
|
|
180
|
+
exit 0
|
|
181
|
+
fi
|
|
166
182
|
|
|
167
|
-
run="$(bash "$STATUS" show --json --dir "$
|
|
183
|
+
run="$(bash "$STATUS" show --json --dir "$tree" 2>/dev/null)" || exit 0
|
|
168
184
|
[ -n "$run" ] || exit 0
|
|
169
185
|
|
|
170
186
|
# One JSON object out, read back with jq: the topic is user text, and word
|
|
171
187
|
# splitting it would truncate at the first space and glob on the rest.
|
|
172
|
-
verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" '
|
|
188
|
+
verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" --arg tree "$tree" --arg here "$top" '
|
|
173
189
|
(.tasks // []) as $t
|
|
174
190
|
| ([$t[] | select(.status == "done")] | length) as $done
|
|
175
191
|
| ([$t[] | select(.status == "blocked")] | length) as $blocked
|
|
@@ -185,18 +201,33 @@ verdict="$(printf '%s' "$run" | jq -c --argjson now "$(date -u +%s)" '
|
|
|
185
201
|
# A run nobody has written to for a day is a stale run, not a live one;
|
|
186
202
|
# refusing its stop would press an abandoned plan on whoever opened here.
|
|
187
203
|
elif $idle_h >= 24 then {block: false}
|
|
188
|
-
# The topic
|
|
189
|
-
# would be acted on by the terminal rather than read. State
|
|
190
|
-
# status.sh refused those, or edited by hand, can still hold
|
|
204
|
+
# The topic and the tree land in a reason the harness prints, so a control
|
|
205
|
+
# byte in either would be acted on by the terminal rather than read. State
|
|
206
|
+
# written before status.sh refused those, or edited by hand, can still hold
|
|
207
|
+
# one, and so can a path.
|
|
191
208
|
else {block: true, progress: "\($done)/\($t | length)",
|
|
192
|
-
topic: (.topic // "run" | gsub("[\u0000-\u001f\u007f]"; ""))
|
|
209
|
+
topic: (.topic // "run" | gsub("[\u0000-\u001f\u007f]"; "")),
|
|
210
|
+
tree: ($tree | gsub("[\u0000-\u001f\u007f]"; "")),
|
|
211
|
+
elsewhere: ($tree != $here)}
|
|
193
212
|
end
|
|
194
213
|
' 2>/dev/null)" || exit 0
|
|
195
214
|
|
|
196
215
|
[ "$(printf '%s' "$verdict" | jq -r '.block' 2>/dev/null)" = "true" ] || exit 0
|
|
197
216
|
|
|
217
|
+
# Two remedies, because the last line of the local one is wrong once the run
|
|
218
|
+
# has moved: `close` from here would archive a run that is live in another tree
|
|
219
|
+
# and may be another session's, which is the one thing this hook must never talk
|
|
220
|
+
# anyone into. What replaces it is the step `move` could not take -- the session
|
|
221
|
+
# following the run -- because a controller guarded here is a controller sitting
|
|
222
|
+
# in the tree its own run left.
|
|
198
223
|
printf '%s' "$verdict" | jq 2>/dev/null '{
|
|
199
224
|
decision: "block",
|
|
200
|
-
reason: ("sluice: the deep run \(.topic) is \(.progress) done with tasks still to go and nothing marked blocked or paused, so ending the turn here hands a live run back with nothing for your partner to decide.
|
|
225
|
+
reason: ("sluice: the deep run \(.topic) is \(.progress) done with tasks still to go and nothing marked blocked or paused, so ending the turn here hands a live run back with nothing for your partner to decide."
|
|
226
|
+
+ (if .elsewhere then " The run lives in \(.tree), not in this tree: `move` relocated the run and not this session. If it is yours, move this session into that tree -- the worktree tool in your harness enters one that already exists -- and go on from there." else "" end)
|
|
227
|
+
+ " Continue: run status.sh ready and dispatch the next wave in this same message. If a task genuinely needs them, mark it: status.sh task <id> --status blocked. If the run has to stand still for a reason, record it: status.sh pause --reason \"<why>\", then say so and stop."
|
|
228
|
+
+ (if .elsewhere
|
|
229
|
+
then " If the run is not yours, it belongs to the session working in \(.tree): say so and stop, rather than closing it from here."
|
|
230
|
+
else " If this run is not the work you were asked to do, it was left open: status.sh close."
|
|
231
|
+
end))
|
|
201
232
|
}'
|
|
202
233
|
exit 0
|
package/skills/sluice/skill.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sluice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries seven rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Claude Code only; stands down where the superpowers pipeline governs the repo.",
|
|
5
5
|
"author": "iceinvein",
|
|
6
6
|
"type": "prompt",
|