@iceinvein/agent-skills 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -20,7 +20,7 @@ var __toESM = (mod, isNodeMode, target) => {
20
20
  return cached;
21
21
  }
22
22
  target = mod != null ? __create(__getProtoOf(mod)) : {};
23
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
23
+ const to = isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
24
24
  if (mod && typeof mod === "object" || typeof mod === "function") {
25
25
  for (let key of __getOwnPropNames(mod))
26
26
  if (!__hasOwnProp.call(to, key))
@@ -1032,9 +1032,9 @@ async function removeSkill(cwd, skillName) {
1032
1032
  const warnings = [];
1033
1033
  for (const file of entry.files) {
1034
1034
  if (isSharedConfigFile(file)) {
1035
- const fullPath2 = join7(cwd, file);
1036
- if (existsSync6(fullPath2)) {
1037
- await unwireSessionStartHook(fullPath2, skillName);
1035
+ const fullPath = join7(cwd, file);
1036
+ if (existsSync6(fullPath)) {
1037
+ await unwireSessionStartHook(fullPath, skillName);
1038
1038
  }
1039
1039
  warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place, only removing '${skillName}'s SessionStart hook from it; any MCP server entries it owns were not touched.`);
1040
1040
  continue;
@@ -1125,9 +1125,9 @@ async function removeSkill2(cwd, skillName) {
1125
1125
  const warnings = [];
1126
1126
  for (const file of entry.files) {
1127
1127
  if (isSharedConfigFile2(file)) {
1128
- const fullPath2 = join8(cwd, file);
1129
- if (existsSync7(fullPath2)) {
1130
- await unwireSessionStartHook(fullPath2, skillName);
1128
+ const fullPath = join8(cwd, file);
1129
+ if (existsSync7(fullPath)) {
1130
+ await unwireSessionStartHook(fullPath, skillName);
1131
1131
  }
1132
1132
  warnings.push(`Manifest unavailable (${manifestResult.error}). Left '${file}' in place, only removing '${skillName}'s SessionStart hook from it; any MCP server entries it owns were not touched.`);
1133
1133
  continue;
@@ -1518,7 +1518,7 @@ function isFactory(value) {
1518
1518
  }
1519
1519
  function useState(defaultValue) {
1520
1520
  return withPointer((pointer) => {
1521
- const setState = AsyncResource2.bind(function setState2(newValue) {
1521
+ const setState = AsyncResource2.bind(function setState(newValue) {
1522
1522
  if (pointer.get() !== newValue) {
1523
1523
  pointer.set(newValue);
1524
1524
  handleChange();
@@ -2387,7 +2387,7 @@ if (process.platform === "linux") {
2387
2387
  }
2388
2388
 
2389
2389
  // node_modules/signal-exit/dist/mjs/index.js
2390
- var processOk = (process3) => !!process3 && typeof process3 === "object" && typeof process3.removeListener === "function" && typeof process3.emit === "function" && typeof process3.reallyExit === "function" && typeof process3.listeners === "function" && typeof process3.kill === "function" && typeof process3.pid === "number" && typeof process3.on === "function";
2390
+ var processOk = (process2) => !!process2 && typeof process2 === "object" && typeof process2.removeListener === "function" && typeof process2.emit === "function" && typeof process2.reallyExit === "function" && typeof process2.listeners === "function" && typeof process2.kill === "function" && typeof process2.pid === "number" && typeof process2.on === "function";
2391
2391
  var kExitEmitter = Symbol.for("signal-exit emitter");
2392
2392
  var global = globalThis;
2393
2393
  var ObjectDefineProperty = Object.defineProperty.bind(Object);
@@ -2477,15 +2477,15 @@ class SignalExit extends SignalExitBase {
2477
2477
  #originalProcessReallyExit;
2478
2478
  #sigListeners = {};
2479
2479
  #loaded = false;
2480
- constructor(process3) {
2480
+ constructor(process2) {
2481
2481
  super();
2482
- this.#process = process3;
2482
+ this.#process = process2;
2483
2483
  this.#sigListeners = {};
2484
2484
  for (const sig of signals) {
2485
2485
  this.#sigListeners[sig] = () => {
2486
2486
  const listeners = this.#process.listeners(sig);
2487
2487
  let { count } = this.#emitter;
2488
- const p = process3;
2488
+ const p = process2;
2489
2489
  if (typeof p.__signal_exit_emitter__ === "object" && typeof p.__signal_exit_emitter__.count === "number") {
2490
2490
  count += p.__signal_exit_emitter__.count;
2491
2491
  }
@@ -2494,12 +2494,12 @@ class SignalExit extends SignalExitBase {
2494
2494
  const ret = this.#emitter.emit("exit", null, sig);
2495
2495
  const s = sig === "SIGHUP" ? this.#hupSig : sig;
2496
2496
  if (!ret)
2497
- process3.kill(process3.pid, s);
2497
+ process2.kill(process2.pid, s);
2498
2498
  }
2499
2499
  };
2500
2500
  }
2501
- this.#originalProcessReallyExit = process3.reallyExit;
2502
- this.#originalProcessEmit = process3.emit;
2501
+ this.#originalProcessReallyExit = process2.reallyExit;
2502
+ this.#originalProcessEmit = process2.emit;
2503
2503
  }
2504
2504
  onExit(cb, opts) {
2505
2505
  if (!processOk(this.#process)) {
@@ -2725,8 +2725,8 @@ function createPrompt(view) {
2725
2725
  signal.addEventListener("abort", abort);
2726
2726
  cleanups.add(() => signal.removeEventListener("abort", abort));
2727
2727
  }
2728
- cleanups.add(onExit((code, signal2) => {
2729
- reject(new ExitPromptError(`User force closed the prompt with ${code} ${signal2}`));
2728
+ cleanups.add(onExit((code, signal) => {
2729
+ reject(new ExitPromptError(`User force closed the prompt with ${code} ${signal}`));
2730
2730
  }));
2731
2731
  const sigint = () => reject(new ExitPromptError(`User force closed the prompt with SIGINT`));
2732
2732
  rl.on("SIGINT", sigint);
@@ -2847,12 +2847,12 @@ function normalizeChoices(choices) {
2847
2847
  if (Separator.isSeparator(choice))
2848
2848
  return choice;
2849
2849
  if (typeof choice !== "object" || choice === null || !("value" in choice)) {
2850
- const name2 = String(choice);
2850
+ const name = String(choice);
2851
2851
  return {
2852
2852
  value: choice,
2853
- name: name2,
2854
- short: name2,
2855
- checkedName: name2,
2853
+ name,
2854
+ short: name,
2855
+ checkedName: name,
2856
2856
  disabled: false,
2857
2857
  checked: false
2858
2858
  };
@@ -2957,8 +2957,8 @@ var dist_default4 = createPrompt((config, done) => {
2957
2957
  const cursor = isActive ? theme.icon.cursor : " ";
2958
2958
  if (item.disabled) {
2959
2959
  const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)";
2960
- const checkbox2 = item.checked ? theme.icon.disabledChecked : theme.icon.disabledUnchecked;
2961
- return theme.style.disabled(`${cursor}${checkbox2} ${item.name} ${disabledLabel}`);
2960
+ const checkbox = item.checked ? theme.icon.disabledChecked : theme.icon.disabledUnchecked;
2961
+ return theme.style.disabled(`${cursor}${checkbox} ${item.name} ${disabledLabel}`);
2962
2962
  }
2963
2963
  if (isActive) {
2964
2964
  description = item.description;
@@ -3021,11 +3021,11 @@ function normalizeChoices2(choices) {
3021
3021
  if (Separator.isSeparator(choice))
3022
3022
  return choice;
3023
3023
  if (typeof choice !== "object" || choice === null || !("value" in choice)) {
3024
- const name2 = String(choice);
3024
+ const name = String(choice);
3025
3025
  return {
3026
3026
  value: choice,
3027
- name: name2,
3028
- short: name2,
3027
+ name,
3028
+ short: name,
3029
3029
  disabled: false
3030
3030
  };
3031
3031
  }
@@ -3095,8 +3095,8 @@ var dist_default5 = createPrompt((config, done) => {
3095
3095
  } else if (isNumberKey(key) && !Number.isNaN(Number(rl.line))) {
3096
3096
  const selectedIndex = Number(rl.line) - 1;
3097
3097
  let selectableIndex = -1;
3098
- const position = items.findIndex((item2) => {
3099
- if (Separator.isSeparator(item2))
3098
+ const position = items.findIndex((item) => {
3099
+ if (Separator.isSeparator(item))
3100
3100
  return false;
3101
3101
  selectableIndex++;
3102
3102
  return selectableIndex === selectedIndex;
@@ -3477,10 +3477,10 @@ Installing ${names.length} skills...
3477
3477
  }
3478
3478
  const other = await otherScopeSkillCount(updateDir);
3479
3479
  if (other.count > 0) {
3480
- const command2 = other.scope === "global" ? "agent-skills update --all -g" : "agent-skills update --all";
3480
+ const command = other.scope === "global" ? "agent-skills update --all -g" : "agent-skills update --all";
3481
3481
  const noun = other.count === 1 ? "skill" : "skills";
3482
3482
  console.log(`
3483
- Note: ${other.count} ${noun} installed in the ${other.scope} scope. Run \`${command2}\` to update those too.`);
3483
+ Note: ${other.count} ${noun} installed in the ${other.scope} scope. Run \`${command}\` to update those too.`);
3484
3484
  }
3485
3485
  break;
3486
3486
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
package/skills/index.json CHANGED
@@ -283,7 +283,7 @@
283
283
  "name": "sluice",
284
284
  "description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries seven rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment and one status command can answer where the run is, and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Claude Code only; stands down where the superpowers pipeline governs the repo.",
285
285
  "type": "prompt",
286
- "version": "0.13.0"
286
+ "version": "0.15.0"
287
287
  },
288
288
  {
289
289
  "name": "temporal-coupling-detector",
@@ -56,8 +56,8 @@ yourself wanting to skip the rule, or arguing that this one is the exception.
56
56
  it down. `references/intent.md`
57
57
  - **Test first.** The test comes before the code; run it while it should
58
58
  still be failing, then write the least code that turns it green. Skip that
59
- watching step and a green result is only an unchecked guess.
60
- `references/test-first.md`
59
+ watching step and a green result is only an unchecked guess. A test no
60
+ plausible change could turn red is not one you owe. `references/test-first.md`
61
61
  - **Root cause** before fix. A fix that only hides the symptom has not fixed
62
62
  anything. Three failed fixes point at the design, not your guesswork.
63
63
  `references/root-cause.md`
@@ -285,7 +285,9 @@ reads the run state rather than the plan: it sees what has actually landed.
285
285
  - Isolate the workspace before a multi-task plan: the harness's worktree
286
286
  tool, not `git worktree` yourself. Implementing straight onto main or
287
287
  master needs your partner's say-so, which pre-flight is where you got, and
288
- it forecloses concurrent implementers for the whole run.
288
+ it forecloses concurrent implementers for the whole run. The run state
289
+ follows the set rather than the tree, so a worktree cut after the plan still
290
+ reads the rows the plan seeded and flips them where you are watching.
289
291
  - **The agent that built the task commits it**, once its own tests pass, and
290
292
  only the paths in its `Touches`. Never `git add -A`: the tree is shared, and
291
293
  on a branch you did not isolate it holds work that is not this task's. The
@@ -351,7 +353,7 @@ and it fails by printing success rather than by erroring.
351
353
 
352
354
  | Tier | Task shape | Review |
353
355
  |------|------------|--------|
354
- | 0 | Created files only, executable tests exist and pass, Contract matches | No dispatch. Read the commit stat yourself. |
356
+ | 0 | Created files only, tests that meet `references/test-first.md` exist and pass, Contract matches | No dispatch. Read the commit stat yourself. |
355
357
  | 1 | Modified existing code, or later tasks build on it | One reviewer dispatch |
356
358
  | 2 | No executable test covers it: prose, config, docs | One reviewer dispatch; a stat cannot confirm the words are right |
357
359
  | 3 | Auth, data, money, concurrency, or the plan flags it | One reviewer dispatch; the task may not be downshifted |
@@ -380,6 +382,13 @@ number, not the row order and not which shape sounds more serious. A task that
380
382
  only creates files but ships prose is tier 2, and a suite re-bless paired with
381
383
  an ADR is tier 2 rather than the free row it resembles.
382
384
 
385
+ **A test written to earn tier 0 does not earn it.** The row is a discount on
386
+ review, and a task whose only executable test fails
387
+ `references/test-first.md`'s question, that some plausible change to the code
388
+ turns it red, is a tier 2 task carrying a green line rather than a covered
389
+ one. Price it as tier 2. Buying this table's savings with the one kind of test the rest of the
390
+ skill argues against is the cheapest way to lose them.
391
+
383
392
  Ordering inert tasks first is what keeps this affordable. An inert task that
384
393
  only creates files takes tier 0, so a nine-task plan usually buys three
385
394
  or four dispatches rather than nine. If most of your plan qualifies for a
@@ -7,7 +7,7 @@ session that is running it: your partner has to ask, and asking costs a turn
7
7
  and gets an answer from memory.
8
8
 
9
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.
10
+ per worktree set, holding only what changes as the run moves.
11
11
 
12
12
  ```
13
13
  bash <skill-dir>/scripts/status.sh init --topic <t> --channel deep \
@@ -24,7 +24,9 @@ bash <skill-dir>/scripts/status.sh line --full
24
24
  bash <skill-dir>/scripts/status.sh close
25
25
  ```
26
26
 
27
- `--dir <path>` reads another tree, which is what the statusline uses. Statuses
27
+ `--dir <path>` reads another tree, which is what the statusline uses; it names
28
+ a tree in the set rather than a state file, so a worktree resolves to the same
29
+ run as the tree it was cut from. Statuses
28
30
  are `todo`, `active`, `review`, `done` and `blocked`. A new id needs `--name`;
29
31
  after that every call is a bare flip, so keeping it current costs one command
30
32
  per transition rather than a paragraph. `close` archives the run under
@@ -42,6 +44,31 @@ everything durable in it lands somewhere else anyway: the commits are in git and
42
44
  the reasons are in the record, which is the file that does get committed. Delete
43
45
  that `.gitignore` if you want a run tracked; it is only written when absent.
44
46
 
47
+ ## Worktrees
48
+
49
+ Ignoring itself is what makes the run invisible to a worktree unless something
50
+ is done about it, and a `deep` run makes worktrees after the plan is written:
51
+ `git worktree add` gives the implementer a clean checkout, and an ignored
52
+ directory is not in a checkout. Read from the tree it was called in, the run
53
+ the plan seeded would be absent from every implementer, `init` there would
54
+ start a second run nothing else reads, and the worktree would take that state
55
+ with it when it went.
56
+
57
+ So every command anchors on the main worktree of whatever tree it is pointed
58
+ at, and one run covers the set. The statusline renders the same run in every
59
+ window, a flip made by an implementer lands where the controller is watching,
60
+ and `init` from a worktree reports the run that is already live rather than
61
+ replacing it. A submodule anchors on its own checkout, not the superproject's,
62
+ and a directory that is no git work tree keeps its run exactly where it sits.
63
+
64
+ One file for several writers is one file to contend on, so `init`, `task`,
65
+ `preflight` and `close` take a lock first: four implementers each flipping
66
+ their own row would otherwise have the later write built on a snapshot taken
67
+ before the earlier one landed, dropping that row without saying so. The lock
68
+ carries its holder's pid, so a killed run is broken through rather than waited
69
+ out. Reads take nothing, state being installed through a rename, which is what
70
+ keeps `line` cheap enough to render on.
71
+
45
72
  Open it with `init` when you open the run record, at the same point and for the
46
73
  same reason, then seed the rows with `plan.sh import <plan>` rather than a
47
74
  command per task. The ids, names, the flip, the `Model` marks and the tiers are
@@ -169,7 +196,7 @@ state file existing so a session with no run spawns no process at all:
169
196
 
170
197
  ```bash
171
198
  sluice_line=""
172
- if [ -n "$cwd" ] && [ -f "$cwd/.sluice/run.json" ]; then
199
+ if [ -n "$cwd" ] && { [ -f "$cwd/.sluice/run.json" ] || [ -f "$cwd/.git" ]; }; then
173
200
  for sluice_sh in "$cwd/.claude/skills/sluice/scripts/status.sh" \
174
201
  "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/sluice/scripts/status.sh" \
175
202
  "$HOME/.claude/skills/sluice/scripts/status.sh"; do
@@ -186,6 +213,12 @@ then print it last, after whatever else the command emits:
186
213
  if [ -n "$sluice_line" ]; then printf '%s\n' "$sluice_line"; fi
187
214
  ```
188
215
 
216
+ The gate is two tests because a linked worktree holds no state file of its own:
217
+ there `.git` is a regular file naming the tree it was cut from, and the script
218
+ resolves the run from it. In a tree with no run and no worktree behind it `.git`
219
+ is a directory, so both tests fail and no process is spawned, which is the
220
+ property the gate is for.
221
+
189
222
  `if` rather than `[ ... ] &&`: as the last command of a statusline script the
190
223
  short form makes it exit 1 on every render with no run live, which is the common
191
224
  case. `%s` rather than `%b`: the render already carries real escape bytes, and
@@ -10,26 +10,64 @@ alone cannot tell you which. A test that passes the first time you run it is
10
10
  not testing your change, it is testing behaviour that already existed. When
11
11
  that happens, fix the test, not the code.
12
12
 
13
- A test is only a test if some change to the implementation would turn it red.
14
- Work out which change before you write the body; if there isn't one, what you
15
- have is an assertion that stays green whatever you build.
16
-
17
- Assertions belong on what the run actually produced: the value returned, the
18
- state left behind, the output written. Never point one at a mock: a green
19
- result then proves only that you called the mock the way you meant to, which
20
- you knew already. Before a mock stands in for a dependency, learn what the real
21
- one does on the paths your test never exercises; one that quietly drops a side
22
- effect will hold a broken change green.
23
-
24
- Keep helpers that exist only for tests inside the test files. If a production
25
- class grows a method whose only caller is a test reaching inside it, the test
26
- has started reshaping your design.
27
-
28
- Three cases are worth asking to skip: generated or scaffolded code, a config
29
- file with no logic in it, and a prototype headed for the bin before anyone
30
- sees it. Ask rather than assume, and treat that list as closed.
31
-
32
- The friction line: "this case is obvious enough to skip straight to the
33
- code." A test written once the code already works can only confirm what you
34
- already believe about it. By that point you have stopped hunting for the cases
35
- you never thought of, so the test does not find them either.
13
+ **Name the change that would turn it red.** A test is only a test if some
14
+ change to the implementation makes it fail, so work out which change before you
15
+ write the body, and hold the answer to being one a person would plausibly make
16
+ to this code later. An answer nobody would ever write, or one the type checker,
17
+ the linter or the framework already rejects on your behalf, means there is no
18
+ test to write here. Write nothing and move on. What you were reaching for is an
19
+ assertion that stays green whatever you build, and it bills a run on every
20
+ commit from now on to keep saying so.
21
+
22
+ **Some shapes never survive that question.** A getter, a constant, a
23
+ framework's own behaviour, a mock's call log. Coverage is a smoke detector,
24
+ not a target, and a test added to move the number is the purest case of the
25
+ thing this rule refuses.
26
+
27
+ **Skipping is a judgement, not a list.** Generated or scaffolded code, a config
28
+ file with no logic in it, and a prototype headed for the bin are the usual
29
+ cases that fail the question outright, but they are instances of the criterion
30
+ rather than the whole of it. What you owe in exchange for the judgement is one
31
+ sentence in your reply naming the behaviour you left untested and why: a skip
32
+ nobody hears about cannot be told apart from an oversight.
33
+
34
+ **Assertions belong on what the run actually produced**: the value returned,
35
+ the state left behind, the output written. Never point one at a mock, because
36
+ a green result then proves only that you called the mock the way you meant to,
37
+ which you knew already. Before a mock stands in for a dependency, learn what
38
+ the real one does on the paths your test never exercises; one that quietly
39
+ drops a side effect will hold a broken change green. Where the wiring is what
40
+ breaks, one real integration test is worth five mocked unit ones.
41
+
42
+ **Expected values are written by hand or taken from the spec.** Never produce
43
+ one by running the code under test and keeping what came back: a blessed
44
+ snapshot checks the implementation against itself, so it stays green whatever
45
+ that implementation does, which is the same as having no test at all. The
46
+ re-bless is where this goes wrong at scale, the diff too large to read and
47
+ every value in it exactly as authoritative as the bug you are freezing.
48
+
49
+ **One behaviour per test**, named for the behaviour it pins rather than for the
50
+ function it calls. Keep helpers that exist only for tests inside the test
51
+ files. If a production class grows a method whose only caller is a test
52
+ reaching inside it, the test has started reshaping your design.
53
+
54
+ **A test goes when the behaviour it pinned is gone.** Delete it alongside the
55
+ code, in the same commit, like anything else the change orphaned. Two tests
56
+ pinning one behaviour are a test and a maintenance bill, so keep the clearer.
57
+ None of that reaches a red test. A failure is the suite claiming your change
58
+ broke something, and deleting it, skipping it, loosening the assertion or
59
+ special-casing the input it feeds answers the claim by silencing the witness.
60
+ Believe the test itself is wrong and you say so and get agreement first.
61
+
62
+ Two friction lines, one in each direction:
63
+
64
+ "This case is obvious enough to skip straight to the code." A test written
65
+ once the code already works can only confirm what you already believe about
66
+ it. By that point you have stopped hunting for the cases you never thought of,
67
+ so the test does not find them either.
68
+
69
+ "Better add a test here to be safe." Safe against what? You are one sentence
70
+ away from the answer, so finish it and name the change this would catch. If
71
+ the sentence will not finish, what moved you was diligence rather than
72
+ evidence, and what it buys is a permanently green line nobody later dares
73
+ delete.
@@ -92,8 +92,27 @@ if [ -z "$SUB" ]; then
92
92
  exit 4
93
93
  fi
94
94
 
95
+ # A linked worktree is another view of the same run, not a new one. The run
96
+ # directory ignores itself, so `git worktree add` never carries it across: read
97
+ # from the tree it was called in, the run a plan seeded is absent from every
98
+ # implementer created after that plan, and an `init` there lands a rival state
99
+ # file that dies with the worktree. Every tree in a set anchors on the main
100
+ # worktree instead, which is the one path all of them agree on.
101
+ #
102
+ # `git worktree list` names the main worktree first. A submodule names its own
103
+ # checkout there rather than the superproject's, which is what keeps a
104
+ # submodule's run beside its own working tree, and a directory that is no git
105
+ # work tree at all is left exactly as it was given.
106
+ if [ "$(git -C "$DIR" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ]; then
107
+ MAIN_TREE="$(git -C "$DIR" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')"
108
+ if [ -n "${MAIN_TREE:-}" ] && [ -d "$MAIN_TREE" ]; then
109
+ DIR="$MAIN_TREE"
110
+ fi
111
+ fi
112
+
95
113
  STATE="$DIR/.sluice/run.json"
96
114
  ARCHIVE="$DIR/.sluice/archive"
115
+ LOCK="$DIR/.sluice/run.lock"
97
116
 
98
117
  # `line` swallows everything: a missing jq, unreadable state, no run at all.
99
118
  # Any of those printing would put permanent clutter in the status bar.
@@ -252,6 +271,45 @@ write_state() {
252
271
  mv "$tmp" "$STATE" || { rm -f "$tmp"; err "could not replace $STATE"; exit 1; }
253
272
  }
254
273
 
274
+ # One state file now serves a whole worktree set, so two implementers can flip
275
+ # their own task at the same moment. A flip reads the whole file, edits it with
276
+ # jq and writes it back, so unserialised the later write is built on a snapshot
277
+ # taken before the earlier one landed and drops that row without saying so.
278
+ # mkdir is the atomic primitive every platform this runs on has; flock is Linux
279
+ # only. Reads do not take it: write_state installs through a rename, so a reader
280
+ # sees either the whole old file or the whole new one.
281
+ #
282
+ # The holder's pid goes inside the directory so a killed run cannot wedge every
283
+ # later one. A lock whose holder is gone is broken rather than waited out, and
284
+ # one whose holder is alive is waited on for a bounded time and then reported,
285
+ # because a command that hangs in a status bar is worse than one that fails.
286
+ lock_taken=0
287
+ release_lock() {
288
+ [ "$lock_taken" -eq 1 ] || return 0
289
+ rm -rf "$LOCK"
290
+ lock_taken=0
291
+ }
292
+
293
+ take_lock() {
294
+ local waited=0 holder
295
+ while ! mkdir "$LOCK" 2>/dev/null; do
296
+ holder="$(cat "$LOCK/pid" 2>/dev/null)"
297
+ if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then
298
+ rm -rf "$LOCK"
299
+ continue
300
+ fi
301
+ if [ "$waited" -ge 100 ]; then
302
+ err "another sluice command has held $LOCK for 10s; remove it if nothing is running"
303
+ exit 1
304
+ fi
305
+ sleep 0.1
306
+ waited=$((waited + 1))
307
+ done
308
+ printf '%s\n' "$$" >"$LOCK/pid" 2>/dev/null || true
309
+ lock_taken=1
310
+ trap release_lock EXIT INT TERM
311
+ }
312
+
255
313
  case "$SUB" in
256
314
  init)
257
315
  TOPIC="" CHANNEL="" PLAN="" RECORD="" FORCE=0
@@ -269,13 +327,14 @@ case "$SUB" in
269
327
  [ -n "$CHANNEL" ] || { err "init needs --channel"; exit 4; }
270
328
  in_set "$CHANNEL" "$CHANNELS" || { err "unknown channel: $CHANNEL (one of: $CHANNELS)"; exit 4; }
271
329
 
330
+ mk_dir "$DIR/.sluice"
331
+ take_lock
272
332
  if [ -f "$STATE" ] && [ "$FORCE" -eq 0 ]; then
273
333
  live="$(jq -r '.topic // "?"' "$STATE" 2>/dev/null || echo "?")"
274
334
  err "a run is already live (topic: $live); pass --force to replace it"
275
335
  exit 3
276
336
  fi
277
337
 
278
- mk_dir "$DIR/.sluice"
279
338
  jq -n \
280
339
  --arg topic "$TOPIC" \
281
340
  --arg channel "$CHANNEL" \
@@ -332,6 +391,7 @@ case "$SUB" in
332
391
  fi
333
392
 
334
393
  require_run
394
+ take_lock
335
395
  require_readable
336
396
 
337
397
  # A row with no name is a number nobody can act on, so a new id has to
@@ -404,6 +464,7 @@ case "$SUB" in
404
464
  exit 4
405
465
  fi
406
466
  require_run
467
+ take_lock
407
468
  require_readable
408
469
 
409
470
  jq --arg review "$REVIEW" --arg model "$MODEL" --arg workspace "$WORKSPACE" '
@@ -528,6 +589,7 @@ case "$SUB" in
528
589
  close)
529
590
  [ $# -eq 0 ] || { err "close takes no arguments"; exit 4; }
530
591
  require_run
592
+ take_lock
531
593
 
532
594
  # Deliberately not `require_readable`. The parse error every other
533
595
  # subcommand raises names close as the way out, so close is the one
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries seven rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment and one status command can answer where the run is, and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Claude Code only; stands down where the superpowers pipeline governs the repo.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",