v4.5.2: semble timeout always bounded, optional coreutils install step

This commit is contained in:
kochetkov-ma
2026-08-02 21:17:46 +01:00
parent e5749a7e4a
commit 55b81cfd35
21 changed files with 614 additions and 64 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "brewcode",
"version": "4.5.1",
"version": "4.5.2",
"description": "Brewcode - full-featured development platform for Claude Code: infinite focus tasks, prompt optimization, skill/agent creation, quorum reviews, rules management",
"author": {
"name": "Maksim Kochetkov",
+1 -1
View File
@@ -4,7 +4,7 @@
| Field | Value |
|-------|-------|
| Version | 4.5.1 |
| Version | 4.5.2 |
| Skills | 9 |
| Agents | 10 |
| Hooks | 2 |
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "claude-plugin-brewcode",
"version": "4.5.1",
"version": "4.5.2",
"description": "Infinite task execution with automatic handoff for Claude Code",
"keywords": [
"claude-code",
@@ -36,6 +36,6 @@
},
"claude-plugin": {
"name": "brewcode",
"version": "4.5.1"
"version": "4.5.2"
}
}
+11 -2
View File
@@ -31,7 +31,7 @@ Free text in Russian or English routes to one mode. The full table and the resol
| Mode | Effect | Mutates |
|------|--------|---------|
| `status` | full report: prereqs, MCP, cache, guidance, agents, coverage, state | no |
| `setup` | install `uv`, register `semble_code` at user scope, checkpoint for reload | yes |
| `setup` | install `uv` (and, if you accept, `coreutils`), register `semble_code` at user scope, checkpoint for reload | yes |
| `resume` | after the reload: smoke query, rule + hooks + permissions, agent migration | yes |
| `enable` | back on: verify, warm, `phase=ready` | yes |
| `disable` | `enabled=false` — hooks go silent, nothing is deleted | yes |
@@ -60,6 +60,15 @@ Free text in Russian or English routes to one mode. The full table and the resol
Installation is **uvx-ephemeral** by default: no `semble` on `PATH`. That is deliberate — any unrecognized argv makes `semble` start a *blocking* stdio server, so a stray bare invocation would hang. `uv tool install` is opt-in.
### Prerequisites — one required, one optional
| Package | Gate | If you decline or it is impossible |
|---------|------|------------------------------------|
| `uv` / `uvx` (`brew install uv`) | **required** — setup asks first and stops if you decline; manual fallback `curl -LsSf https://astral.sh/uv/install.sh \| sh` is printed, never run | setup cannot continue |
| `coreutils` (`brew install coreutils` -> `gtimeout`) | **optional** — offered only when no `timeout`/`gtimeout` exists and `brew` does | nothing breaks: the scripts' `sc_timeout` falls back to a pure-bash watchdog, so every shell-out stays time-bounded either way |
`coreutils` is never a hard requirement: no brew, a failed install, `SEMBLE_NO_NETWORK=1` or a declined offer all leave the run at exit `0` with a note. Setup never blocks on it.
## Limits — read before trusting it
| Fact | Consequence |
@@ -91,7 +100,7 @@ Installation is **uvx-ephemeral** by default: no `semble` on `PATH`. That is del
|------|------|
| `SKILL.md` | the router: status first, mode selection, delegation |
| `scripts/semble-status.sh` | read-only full report (`--json`, `--section`, `--strict`) |
| `scripts/semble-install.sh` | `uv` via brew, pin priming through `uvx` |
| `scripts/semble-install.sh` | `check` \| `uv` \| `coreutils` \| `semble` \| `all``uv` via brew, optional `coreutils`, pin priming through `uvx` |
| `scripts/semble-mcp.sh` | detect / add / repair / remove / checkpoint |
| `scripts/semble-cache.sh` | resolve, inspect, reserve the docs root, guarded purge |
| `scripts/semble-state.sh` | `.claude/semble/state.json` read-modify-write |
+59 -8
View File
@@ -34,6 +34,7 @@ Both take a **required `repo`** parameter — the absolute project root, or an e
| `semble install` writes an **unpinned** server named `semble` into `~/.claude.json` | This skill never runs it. An existing `semble` server is *detected* and reported as a conflict, never auto-removed. |
| `semble clear index` wipes **every** index under the cache root | Per-repo rebuild has no CLI. `reindex` deletes exactly one resolved `<code root>/<64-hex>` dir, guarded and confirmed. |
| Windows is unsupported by this skill | On a non-macOS/Linux platform: print `⚠️ Windows is unsupported by this skill` and refuse every mutation. |
| Stock macOS ships no `timeout` binary | Every shell-out is bounded regardless: `sc_timeout` uses `timeout`/`gtimeout` when one exists and a pure-bash watchdog when none does — `.timeout.bounded` is always `true`. `coreutils` (for `gtimeout`) is an **optional** upgrade, never a requirement, and never a reason to fail a run. |
<instructions>
@@ -113,7 +114,7 @@ Read `references/intent-routing.md` and apply its 5-step algorithm to `$ARGUMENT
2. `phase == "awaiting_reload"` and no mode named -> **`resume`** (checked before scoring).
3. Highest count of distinct matched keywords wins.
4. Ties: destructive involved -> ask; `status` involved -> `status`; two mutating modes -> first keyword in the prompt; all zero -> run `status` and offer two plausible modes in **Next Step**.
5. `AskUserQuestion` at most **once** per invocation, only for a destructive tie, the removal flavour, a scope conflict, the reindex deletion confirmation, or the `setup` install gate (Step 3.1 — a machine-level `brew install`).
5. `AskUserQuestion` at most **once** per invocation, only for a destructive tie, the removal flavour, a scope conflict, the reindex deletion confirmation, or the `setup` install gate (Step 3.1 — a machine-level `brew install`). The `uv` gate (3.1b) and the `coreutils` offer (3.1d) are **mutually exclusive**: 3.1b's question already covers both installs, so 3.1d only runs when 3.1b did not.
State the resolved mode **and its reason** to the user before acting. Anything else -> decide, do not ask.
@@ -126,7 +127,7 @@ If the resolved mode is `status`, or if everything is already `ready` and the in
| Mode | Route |
|------|-------|
| `status` | `semble-status.sh --section all --json` (Step 1 output; nothing further) |
| `setup` | Step 3 chain: `semble-install.sh all --json` (dry, exit 4 = confirm) -> confirm -> `semble-install.sh all --yes --json` -> `semble-cache.sh reserve-docs` -> `semble-mcp.sh detect`/`add`/`repair` -> **reload checkpoint** |
| `setup` | Step 3 chain: `semble-install.sh all --json` (probe: `check -> uv -> coreutils -> semble`; exit 4 = confirm) -> confirm -> `semble-install.sh all --yes --json`, or on exit 0 the report-driven `semble-install.sh coreutils --yes --json` offer -> `semble-cache.sh reserve-docs` -> `semble-mcp.sh detect`/`add`/`repair` -> **reload checkpoint** |
| `resume` | Step 4: `semble-project.sh smoke` -> `semble-guidance.sh install` -> `semble-agents.sh apply` -> `semble-state.sh phase ready` |
| `enable` | `semble-project.sh enable --yes --json` |
| `disable` | `semble-project.sh disable --yes --json` |
@@ -144,9 +145,29 @@ Script exit codes are uniform: `0` ok · `1` hard failure, nothing written · `2
State the concrete plan first — exact commands, exact paths — then run the blocks in order. Stop at the first ❌.
### 3.1 Prerequisites — a hard confirmation gate, never `--yes` on the first run
### 3.1 Prerequisites — one hard gate (`uv`), one soft gate (`coreutils`)
Only `brew` is used to obtain `uv`; the pin itself comes from `uvx`. `brew install uv` is a machine-level mutation, so it uses the same exit-4 gate as `reindex`, `purge` and `agents apply`: **probe first without `--yes`, ask, then apply**. Narrating a plan is not a gate. Default mode is **uvx-ephemeral** — no `uv tool install`, because a `semble` on `PATH` is a hazard (a bare invocation blocks).
`semble-install.sh all` runs `check -> uv -> coreutils -> semble`. Two machine-level `brew install`s can appear in it and they are gated **differently** — never collapse them into one rule:
| Step | Gate | Effect on the run's exit code |
|------|------|-------------------------------|
| `brew install uv` | **hard** — exit `4`, nothing runs without an explicit confirmation (same gate as `reindex`, `purge`, `agents apply`) | drives it: `4` confirm · `3` no brew · `1` failed |
| `brew install coreutils` -> `gtimeout` | **soft** — inside `all` a missing `--yes`, a missing `brew`, a failed brew, `SEMBLE_NO_NETWORK` and `SEMBLE_DRY_RUN` all stay a *note* | none. It can never make `all` non-zero |
Only `brew` is used to obtain `uv`; the pin itself comes from `uvx`. Narrating a plan is not a gate. Default mode is **uvx-ephemeral** — no `uv tool install`, because a `semble` on `PATH` is a hazard (a bare invocation blocks).
Why the soft step still gets offered: `sc_timeout` is bounded **either way** (`.timeout.bounded` is always `true` — a real binary when one exists, the pure-bash watchdog when it does not), so a missing `gtimeout` is a degradation, not a failure. But on a machine that already has `uv`, `all` exits `0` and the note would scroll past unseen. **Therefore the coreutils decision is read from the report, never from the exit code.**
Keys to read from the probe JSON — decide on these, not on `RC` alone:
| Key | Meaning |
|-----|---------|
| `.timeout.backend` | `timeout` / `gtimeout` = a binary backs `sc_timeout`; `none` = the bash watchdog does |
| `.timeout.bounded` | always `true` — the invariant. Never report semble's shell-outs as unbounded |
| `.timeout.coreutils.status` | `present` · `installed` · `needs_confirmation` · `skipped` · `failed` |
| `.timeout.coreutils.reason` | the one clause to quote when the step did not run |
| `.brew.present` | whether `brew install coreutils` is even possible |
| `.commands` | the exact command list — print it verbatim, never retyped |
**3.1a — probe. `--yes` is absent, so nothing can be installed.**
@@ -155,22 +176,25 @@ Only `brew` is used to obtain `uv`; the pin itself comes from `uvx`. `brew insta
```bash
SD="${CLAUDE_SKILL_DIR:-$(ls -d "$HOME"/.claude/plugins/cache/claude-brewcode/brewcode/*/skills/semble 2>/dev/null | sort -V | tail -1)}"
bash "$SD/scripts/semble-install.sh" all --json; RC=$?
echo "RC=$RC # 0 = uv already present, nothing to install | 3 = precondition | 4 = confirmation required, nothing installed"
echo "RC=$RC # 0 = uv already present | 3 = precondition | 4 = confirmation required, nothing installed"
{ [ "$RC" -eq 0 ] || [ "$RC" -eq 3 ] || [ "$RC" -eq 4 ]; } && echo "✅" || echo "❌ FAILED"
```
> **STOP if ❌** — exit 1/2 means nothing was installed; report the raw output.
> `RC=3` is not a failure: a precondition is unmet (no `brew`) — print the manual fallback `curl -LsSf https://astral.sh/uv/install.sh | sh` **without running it**, and stop.
> `RC=0` means `uv`/`uvx` are already on `PATH` and the pin resolved: **nothing to confirm, skip 3.1b and 3.1c** and go to 3.2.
> `RC=0` means `uv`/`uvx` are already on `PATH` and the pin resolved: skip 3.1b/3.1c and go to **3.1d** — `RC=0` does *not* mean there is nothing to confirm.
**3.1b — ask, only on `RC=4`.** Print the `.commands` array from the probe JSON verbatim — that is the exact list that would run, typically:
**3.1b — ask, only on `RC=4`.** Print the `.commands` array from the probe JSON **verbatim** — that is the exact list that would run. On a machine with neither `uv` nor a timeout binary it is all three lines; the coreutils line is absent when a binary already backs `sc_timeout`:
```text
brew install uv
brew install coreutils
uvx --from 'semble[mcp]==0.5.2' semble --help
```
Then one `AskUserQuestion`: *"Install `uv` via Homebrew now?"* — options `Install` (runs exactly the commands printed above) / `Cancel` (nothing runs; setup stops and the manual fallback `curl -LsSf https://astral.sh/uv/install.sh | sh` is printed, not run). Say plainly that `brew install uv` writes to the machine, outside this project. On `Cancel`: emit the report with `Actions -> skipped: brew install uv (declined)` and end the invocation.
Then one `AskUserQuestion`: *"Run these Homebrew installs now?"* — options `Install` (runs exactly the commands printed above) / `Cancel` (nothing runs; setup stops and the manual fallback `curl -LsSf https://astral.sh/uv/install.sh | sh` is printed, not run). Say plainly that `brew install` writes to the machine, outside this project, and that `coreutils` is the optional half: it only upgrades `sc_timeout` from its bash watchdog to `gtimeout`. On `Cancel`: emit the report with `Actions -> skipped: brew install uv (declined)` and end the invocation.
> This single question covers both installs, so 3.1d is **skipped** after 3.1c — one `AskUserQuestion` per invocation, never two.
**3.1c — apply, only after an explicit `Install`.**
@@ -184,6 +208,33 @@ echo "RC=$RC"
```
> **STOP if ❌** — exit 1/2 means the install failed; report the raw output. `RC=3` is still not a failure: `brew` is missing — print the manual fallback `curl -LsSf https://astral.sh/uv/install.sh | sh` **without running it**, and stop.
> Read `.timeout.coreutils.status` from this run too and put it in `Actions` (`installed` -> changed, anything else -> skipped with its `reason`). It never changes the verdict. Then go to 3.2 — 3.1d does not run on this path.
**3.1d — the coreutils offer, only when 3.1b did not fire (`RC=0`).** Take the FIRST matching row and act; there is no fallthrough:
| # | Condition (from the 3.1a JSON) | Action |
|---|--------------------------------|--------|
| 1 | `.timeout.backend != "none"` | Nothing to install. One line: `timeout: <backend> <path> — sc_timeout is bound by a binary.` -> 3.2 |
| 2 | `.timeout.backend == "none"` **and** `.timeout.coreutils.status == "needs_confirmation"` **and** `.brew.present == true` | **Ask** — the one `AskUserQuestion` of this invocation. -> 3.1e |
| 3 | `.timeout.backend == "none"` **and** `.brew.present == false` | One line: `no brew — gtimeout cannot be installed; sc_timeout keeps its bash watchdog (still bounded).` -> 3.2 |
| 4 | anything else (`skipped` / `failed` / dry / no-network) | One line quoting `.timeout.coreutils.reason`. -> 3.2 |
Row 2 prints the `brew install coreutils` line from `.commands` verbatim and asks: *"Install `coreutils` for `gtimeout`? Optional — `sc_timeout` is already bounded by a bash watchdog; `gtimeout` just makes the bound a real binary."* — options `Install` / `Skip`. `Skip` is a first-class answer: record `Actions -> skipped: brew install coreutils (declined)` and go to 3.2.
Rows 1, 3 and 4 **never ask and never block**. This step cannot fail the setup: whatever happens, `sc_timeout` stays bounded.
**3.1e — apply coreutils, only after an explicit `Install`.** Soft by construction — with `--yes` this subcommand exits `0` on every path, including a failed `brew`.
**EXECUTE** using Bash tool:
```bash
SD="${CLAUDE_SKILL_DIR:-$(ls -d "$HOME"/.claude/plugins/cache/claude-brewcode/brewcode/*/skills/semble 2>/dev/null | sort -V | tail -1)}"
bash "$SD/scripts/semble-install.sh" coreutils --yes --json; RC=$?
echo "RC=$RC"
[ "$RC" -eq 0 ] && echo "✅" || echo "❌ FAILED"
```
> **Not a stop — continue to 3.2 either way.** `.timeout.coreutils.status` is the outcome: `installed` -> `Actions -> changed`, `failed`/`skipped` -> `Actions -> skipped` with its `reason`. A ❌ here is reported and nothing else; the setup chain carries on and the verdict is unaffected.
### 3.2 Reserve the docs cache root
@@ -34,7 +34,7 @@ This file is normative. The router in `SKILL.md` follows it literally.
- Tie between two non-destructive modes where one is `status` -> pick `status` (safe, read-only).
- Tie between two non-destructive *mutating* modes (e.g. `setup` vs `update`) -> pick the one whose keyword appeared **first** in the prompt.
- Score 0 for every mode (no keyword matched) -> run `status` and, in the report's **Next Step**, offer the two most plausible modes. Do not ask.
5. `AskUserQuestion` is used at most **once** per invocation, and only for: (a) a destructive tie, (b) an explicit removal request where the four removal flavours are distinguishable, (c) a scope conflict (MCP present in more than one scope), (d) confirming `reindex` deletion of a resolved cache dir, (e) the `setup` install gate — `brew install uv` after `semble-install.sh all --json` exits `4`. Nothing else.
5. `AskUserQuestion` is used at most **once** per invocation, and only for: (a) a destructive tie, (b) an explicit removal request where the four removal flavours are distinguishable, (c) a scope conflict (MCP present in more than one scope), (d) confirming `reindex` deletion of a resolved cache dir, (e) the `setup` install gate — **one** question covering the brew installs the probe reported: `brew install uv` when `semble-install.sh all --json` exits `4` (that same question also carries the optional `brew install coreutils` line when the probe listed it), or, when the probe exits `0`, the standalone `coreutils` offer of SKILL.md 3.1d — raised on `.timeout.coreutils.status == "needs_confirmation"` with `.brew.present == true`, and silently skipped otherwise. The two are mutually exclusive. Nothing else.
> Step 2 is checked **before** scoring. A checkpointed setup that is waiting for a new session
> takes precedence over a vague prompt — that is how the interrupted flow resumes by itself.
@@ -75,6 +75,7 @@ Checkpoint: <abs>/.claude/semble/state.json
| `staleness` | One of `absent | incomplete | mismatch | stale | fresh | unknown`. `stale` is reported as **likely stale** — the check approximates semble's own validation. |
| `smoke` | `skipped (<reason>)` when `SEMBLE_NO_NETWORK=1`, when the MCP is not yet live, or when the mode never warms. Reasons are concrete, never "n/a". |
| `uncovered` | Printed on every invocation, verbatim as in the template. It is a standing limit of the corpus, not a per-run finding. |
| the `coreutils` step | `semble-install.sh`'s `.timeout.coreutils.status`: `installed` -> `Actions -> changed`, everything else (`present`, `skipped`, `failed`, declined) -> `Actions -> skipped` with its `.reason` verbatim. It never reaches `failed:` and never changes the verdict — it is an optional upgrade, not a prerequisite. |
| `Next Step` | Exactly one concrete action, or the literal `none`. Never a list, never a suggestion the user cannot act on immediately. |
| Verdict domain | `ready | reload_required | verifying | partial | disabled | not_installed | error` (rendered in the human form as `reload required` / `not installed`). |
@@ -90,6 +91,7 @@ Checkpoint: <abs>/.claude/semble/state.json
| `stale` as a certainty | The check approximates `get_validated_cache`; say `likely stale` and offer `reindex` rather than acting. |
| a result field named `line` | Results carry `file_path`, `start_line`, `end_line`, `score` and optional `content`. |
| a tool call without `repo` | `repo` is a REQUIRED absolute path (or `https://` git URL) on **both** tools. |
| that a shell-out ran unbounded, or that `coreutils`/`gtimeout` is missing/required | `sc_timeout` bounds every shell-out — with `timeout`/`gtimeout` when one exists, with a pure-bash watchdog when none does. `.timeout.bounded` is always `true`; `gtimeout` is an optional upgrade of *how* the bound is enforced. |
---
@@ -102,22 +102,76 @@ sc_semble_tool_version() {
{ uv tool list 2>/dev/null | awk '$1=="semble"{print $2; exit}' | tr -d 'v'; } || true
}
# sc_timeout SECONDS CMD [ARG...] — run CMD with an upper bound.
# GNU `timeout` (Linux) or `gtimeout` (coreutils on macOS) when available; macOS
# ships neither by default, so the fallback runs CMD unbounded rather than
# failing — a missing bound must never turn into a missing feature. Every
# argument is passed through as its own argv element, so 'semble[mcp]==X.Y.Z'
# stays one word and is never re-parsed by a shell.
# Which binary backs sc_timeout: `timeout` (GNU/Linux) | `gtimeout` (coreutils
# on macOS) | `none`. `none` is NOT unbounded — sc_timeout_watch takes over.
# SEMBLE_TIMEOUT_BIN overrides detection: `none` forces the pure-bash watchdog,
# any other value is used as the binary. Production leaves it unset.
sc_timeout_backend() {
local ovr="${SEMBLE_TIMEOUT_BIN:-}"
if [ -n "$ovr" ]; then
if [ "$ovr" != "none" ] && sc_have "$ovr"; then printf '%s\n' "${ovr##*/}"; else printf 'none\n'; fi
return 0
fi
if sc_have timeout; then printf 'timeout\n'
elif sc_have gtimeout; then printf 'gtimeout\n'
else printf 'none\n'; fi
}
# Absolute path of the backing binary, empty when the watchdog is in use.
sc_timeout_path() {
local b; b="$(sc_timeout_backend)"
[ "$b" = "none" ] && return 0
sc_bin "${SEMBLE_TIMEOUT_BIN:-$b}"
}
# sc_timeout_watch SECONDS CMD [ARG...] — dependency-free stand-in for GNU
# `timeout`, used on a stock macOS that has neither binary. `set -m` puts the
# child in its own process group so the whole tree dies, not just the wrapper;
# the poll starts at 10 ms and backs off to 250 ms, so a fast command pays ~10 ms
# and a 60 s probe costs a few hundred sleeps. Job-control chatter ("Terminated")
# is muted by swapping the SHELL's fd 2 — the child already holds the real one.
# 124 is reported only when the deadline passed AND the child died of a signal,
# so an unreaped child that finished on its own still yields its own status.
sc_timeout_watch() {
local secs="${1:?sc_timeout needs seconds}"; shift
local had_m=0; case "$-" in *m*) had_m=1 ;; esac
set -m
"$@" &
local pid=$!
[ "$had_m" = "1" ] || set +m
local slept=0 limit=$(( secs * 100 )) step=1 timedout=0 rc=0
while kill -0 "$pid" 2>/dev/null; do
if [ "$slept" -ge "$limit" ]; then timedout=1; break; fi
sleep "$(printf '0.%02d' "$step")" 2>/dev/null || sleep 1
slept=$(( slept + step ))
if [ "$slept" -ge 100 ]; then step=25
elif [ "$slept" -ge 10 ]; then step=5
fi
done
exec 3>&2 2>/dev/null
if [ "$timedout" = "1" ]; then
kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
sleep 0.1
kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
fi
wait "$pid" 2>/dev/null || rc=$?
exec 2>&3 3>&-
if [ "$timedout" = "1" ] && [ "$rc" -ge 128 ]; then rc=124; fi
return "$rc"
}
# sc_timeout SECONDS CMD [ARG...] — run CMD with an upper bound. The bound is
# always enforced: a real `timeout`/`gtimeout` when one exists, the pure-bash
# watchdog otherwise. Every argument is passed through as its own argv element,
# so 'semble[mcp]==X.Y.Z' stays one word and is never re-parsed by a shell.
# Exit: 124 = timed out (GNU convention), else CMD's own status.
sc_timeout() {
local secs="${1:?sc_timeout needs seconds}"; shift
if sc_have timeout; then
timeout "$secs" "$@"
elif sc_have gtimeout; then
gtimeout "$secs" "$@"
else
"$@"
fi
local backend; backend="$(sc_timeout_backend)"
case "$backend" in
none) sc_timeout_watch "$secs" "$@" ;;
*) "${SEMBLE_TIMEOUT_BIN:-$backend}" "$secs" "$@" ;;
esac
}
# Why the probe failed, set by every sc_semble_probe call:
@@ -177,7 +177,7 @@ emit_report() {
const split=v=>(v||"").split("\n").map(x=>x.trim()).filter(Boolean);
process.stdout.write(JSON.stringify({schema:1,mode:process.env.SG_MODE,part:process.env.SG_PART,
changed:split(process.env.SG_CHANGED),unchanged:split(process.env.SG_UNCHANGED),
skipped:split(process.env.SG_SKIPPED),failed:split(process.env.SG_FAILED)}));'
skipped:split(process.env.SG_SKIPPED),failed:split(process.env.SG_FAILED)})+"\n");'
else
printf '%s' "$CHANGED" | while IFS= read -r l; do [ -n "$l" ] && sc_ok "$l"; done
printf '%s' "$UNCHANGED" | while IFS= read -r l; do [ -n "$l" ] && sc_skip "$l"; done
+118 -17
View File
@@ -1,15 +1,20 @@
#!/usr/bin/env bash
# semble-install.sh - prerequisite installer for the brewcode:semble skill.
#
# Usage: semble-install.sh <check|uv|semble|all> [--yes] [--json] [--tool]
# Usage: semble-install.sh <check|uv|coreutils|semble|all> [--yes] [--json] [--tool]
#
# check nothing mutating; prints the prereq object. Exit 3 if uv/uvx absent.
# uv `brew install uv`. Needs --yes (else exit 4). No brew -> exit 3 and
# the manual instruction is PRINTED, never executed.
# semble primes the pinned uvx environment with
# `uvx --from 'semble[mcp]==0.5.2' semble --help`.
# --tool additionally runs `uv tool install 'semble[mcp]==0.5.2'`.
# all check -> uv -> semble.
# check nothing mutating; prints the prereq object. Exit 3 if uv/uvx absent.
# uv `brew install uv`. Needs --yes (else exit 4). No brew -> exit 3 and
# the manual instruction is PRINTED, never executed.
# coreutils `brew install coreutils` -> gtimeout, so sc_timeout bounds its
# shell-outs with a real binary instead of the bash watchdog. Soft:
# a missing brew or a failed install is a warning, never a failure,
# and never changes `all`'s exit code. Skipped when a timeout binary
# already exists. Needs --yes when invoked directly.
# semble primes the pinned uvx environment with
# `uvx --from 'semble[mcp]==0.5.2' semble --help`.
# --tool additionally runs `uv tool install 'semble[mcp]==0.5.2'`.
# all check -> uv -> coreutils -> semble.
#
# Default mode is uvx-ephemeral: no `uv tool install`, so no `semble` lands on
# PATH (a bare `semble` invocation starts a blocking MCP server).
@@ -24,12 +29,13 @@ SC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
cat <<'EOF'
semble-install.sh <check|uv|semble|all> [--yes] [--json] [--tool]
semble-install.sh <check|uv|coreutils|semble|all> [--yes] [--json] [--tool]
check read-only prereq report (exit 3 when uv/uvx missing)
uv brew install uv (requires --yes)
semble prime the pinned uvx env (--tool also does uv tool install)
all check -> uv -> semble
check read-only prereq report (exit 3 when uv/uvx missing)
uv brew install uv (requires --yes)
coreutils brew install coreutils -> gtimeout (requires --yes; never fatal)
semble prime the pinned uvx env (--tool also does uv tool install)
all check -> uv -> coreutils -> semble
--yes confirm the mutating steps
--json emit a single JSON object (schema: DESIGN 9.2)
@@ -48,7 +54,7 @@ TOOL=0
while [ $# -gt 0 ]; do
case "$1" in
check|uv|semble|all)
check|uv|coreutils|semble|all)
if [ -n "$MODE" ]; then sc_err "only one subcommand is allowed" >&2; exit 2; fi
MODE="$1" ;;
--yes|-y) YES=1 ;;
@@ -76,6 +82,11 @@ UV_CHANGED="false"
SEMBLE_RESOLVABLE="false"
SEMBLE_TOOL_INSTALLED="false"
SEMBLE_CHANGED="false"
# coreutils step: present | installed | needs_confirmation | skipped | failed.
# `present` is the no-op case (a timeout binary already backs sc_timeout).
COREUTILS_STATUS="present"
COREUTILS_REASON=""
COREUTILS_CHANGED="false"
record() { COMMANDS="${COMMANDS}${COMMANDS:+$'\n'}$1"; }
@@ -141,6 +152,64 @@ do_uv() {
fi
}
# ── coreutils ───────────────────────────────────────────────────────────────
# `ok` never outranks anything, so this records a note without escalating.
note() { raise ok "$1"; }
# $1 = "soft" inside `all`: even the missing --yes stays a note there, because a
# degraded bound is still a bound (sc_timeout falls back to its bash watchdog)
# and must not cost `all` its exit 0.
do_coreutils() {
local soft="${1:-}" brew_path
if [ "$(sc_timeout_backend)" != "none" ]; then
COREUTILS_STATUS="present"
COREUTILS_REASON="$(sc_timeout_backend) already backs sc_timeout"
return 0
fi
record "brew install coreutils"
if [ "$YES" != "1" ]; then
COREUTILS_STATUS="needs_confirmation"
COREUTILS_REASON="brew install coreutils needs --yes"
if [ "$soft" = "soft" ]; then note "$COREUTILS_REASON (optional)"
else raise needs_confirmation "$COREUTILS_REASON"; fi
return 0
fi
brew_path="$(sc_brew_path)"
if [ -z "$brew_path" ]; then
COREUTILS_STATUS="skipped"
COREUTILS_REASON="brew is not installed - sc_timeout keeps using its bash watchdog"
note "coreutils skipped: $COREUTILS_REASON"
return 0
fi
if [ "$DRY" = "1" ]; then
sc_dry "brew install coreutils" >&2
COREUTILS_STATUS="skipped"
COREUTILS_REASON="SEMBLE_DRY_RUN=1"
return 0
fi
if [ "$NONET" = "1" ]; then
COREUTILS_STATUS="skipped"
COREUTILS_REASON="SEMBLE_NO_NETWORK=1 - brew install coreutils was not run"
note "coreutils skipped: $COREUTILS_REASON"
return 0
fi
if "$brew_path" install coreutils >&2; then
if [ "$(sc_timeout_backend)" != "none" ]; then
COREUTILS_STATUS="installed"
COREUTILS_CHANGED="true"
COREUTILS_REASON=""
else
COREUTILS_STATUS="skipped"
COREUTILS_REASON="brew install coreutils succeeded but no gtimeout landed on PATH"
note "coreutils: $COREUTILS_REASON"
fi
else
COREUTILS_STATUS="failed"
COREUTILS_REASON="brew install coreutils failed - sc_timeout keeps using its bash watchdog"
note "coreutils: $COREUTILS_REASON"
fi
}
# ── semble ──────────────────────────────────────────────────────────────────
do_semble() {
local before_tool
@@ -186,12 +255,14 @@ do_semble() {
}
case "$MODE" in
check) do_check ;;
uv) do_uv ;;
semble) do_semble ;;
check) do_check ;;
uv) do_uv ;;
coreutils) do_coreutils ;;
semble) do_semble ;;
all)
do_check
do_uv
do_coreutils soft
# A missing confirmation or a failed uv step makes the semble step
# pointless - report what we have instead of chasing a broken PATH.
if [ "$STATUS" = "ok" ] || [ "$STATUS" = "precondition" ]; then do_semble; fi
@@ -214,6 +285,18 @@ if [ -z "$UV_AFTER" ]; then UV_AFTER="$(sc_uv_version)"; fi
if [ -n "$UV_AFTER" ] && [ "$UV_AFTER" != "$UV_BEFORE" ]; then UV_CHANGED="true"; fi
BREW_PATH="$(sc_brew_path)"
TIMEOUT_BACKEND="$(sc_timeout_backend)"
TIMEOUT_PATH="$(sc_timeout_path)"
# Modes that never run the step still report it honestly: `present` only when a
# binary really is there, otherwise `skipped` with the mode as the reason.
if [ "$MODE" != "coreutils" ] && [ "$MODE" != "all" ]; then
if [ "$TIMEOUT_BACKEND" = "none" ]; then
COREUTILS_STATUS="skipped"
COREUTILS_REASON="the coreutils step does not run in mode $MODE"
else
COREUTILS_REASON="$TIMEOUT_BACKEND already backs sc_timeout"
fi
fi
UVX_PATH="$(probe_uvx)"
UVX_VER="$(sc_uvx_version)"
SEMBLE_TOOLVER="$(sc_semble_tool_version)"
@@ -230,6 +313,18 @@ const report = {
brew: { present: !!E.SI_BREW_PATH, path: E.SI_BREW_PATH || "" },
uv: { before: E.SI_UV_BEFORE || "", after: E.SI_UV_AFTER || "", changed: E.SI_UV_CHANGED === "true" },
uvx: { present: !!E.SI_UVX_PATH, version: E.SI_UVX_VER || "" },
// `bounded` is the invariant callers care about: sc_timeout enforces the bound
// with a binary when there is one and with its bash watchdog when there is not.
timeout: {
backend: E.SI_TMO_BACKEND,
path: E.SI_TMO_PATH || "",
bounded: true,
coreutils: {
status: E.SI_CU_STATUS,
reason: E.SI_CU_REASON || "",
changed: E.SI_CU_CHANGED === "true",
},
},
semble: {
pin: E.SI_PIN,
spec: E.SI_SPEC,
@@ -252,6 +347,10 @@ if (E.SI_JSONMODE === "1") {
L.push("brew: " + (report.brew.present ? report.brew.path : "absent"));
L.push("uv: " + (report.uv.after || "absent") + (report.uv.changed ? " (installed now)" : ""));
L.push("uvx: " + (report.uvx.present ? (report.uvx.version || "present") : "absent"));
const t = report.timeout;
L.push("timeout: " + (t.backend === "none" ? "none (bash watchdog)" : t.backend + " " + t.path) +
" | bounded " + (t.bounded ? "yes" : "no") +
" | coreutils " + t.coreutils.status + (t.coreutils.reason ? " - " + t.coreutils.reason : ""));
L.push("semble: pin " + report.semble.pin +
" | resolvable " + report.semble.resolvable +
" | uv-tool " + (report.semble.toolInstalled ? "yes" : "no (uvx-ephemeral)"));
@@ -274,6 +373,8 @@ SI_COMMANDS="$COMMANDS" \
SI_BREW_PATH="$BREW_PATH" \
SI_UV_BEFORE="$UV_BEFORE" SI_UV_AFTER="$UV_AFTER" SI_UV_CHANGED="$UV_CHANGED" \
SI_UVX_PATH="$UVX_PATH" SI_UVX_VER="$UVX_VER" \
SI_TMO_BACKEND="$TIMEOUT_BACKEND" SI_TMO_PATH="$TIMEOUT_PATH" \
SI_CU_STATUS="$COREUTILS_STATUS" SI_CU_REASON="$COREUTILS_REASON" SI_CU_CHANGED="$COREUTILS_CHANGED" \
SI_PIN="$SEMBLE_PIN_VERSION" SI_SPEC="$SEMBLE_PIN_SPEC" \
SI_RESOLVABLE="$SEMBLE_RESOLVABLE" \
SI_TOOL_INSTALLED="$SEMBLE_TOOL_INSTALLED" \
@@ -7,7 +7,7 @@ SC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SC_DIR/lib/semble-common.sh"
SP_WARM_QUERY_DEFAULT="entry point main function"
SP_SEARCH_TIMEOUT=600
SP_SEARCH_TIMEOUT="${SP_SEARCH_TIMEOUT:-600}"
# Never-walked directory names (semble/index/file_walker.py:14-33).
SP_SKIP_DIRS=".git .hg .svn __pycache__ node_modules .venv venv .tox .mypy_cache .pytest_cache .ruff_cache .cache .semble .next dist build .eggs"
@@ -214,7 +214,7 @@ sp_search_cmd_str() {
# sp_run_search QUERY -> JSON {schema,query,command,exit,resultCount,firstResult,durationMs,status,reason}
sp_run_search() {
local query="$1" root cache cmd start finish out rc tmo
local query="$1" root cache cmd start finish out rc
root="$(sc_project_root)"; cache="$(sc_cache_root_code)"
cmd="$(sp_search_cmd_str "$query")"
@@ -232,13 +232,12 @@ sp_run_search() {
return 0
fi
tmo=""
if sc_have timeout; then tmo="timeout $SP_SEARCH_TIMEOUT"
elif sc_have gtimeout; then tmo="gtimeout $SP_SEARCH_TIMEOUT"; fi
# `env` carries SEMBLE_CACHE_LOCATION into the child because sc_timeout may be a
# shell function (bash watchdog), not a binary a var prefix would apply to.
start="$(date +%s)"
set +e
out="$(SEMBLE_CACHE_LOCATION="$cache" $tmo uvx --from "$SEMBLE_PIN_SPEC" semble search \
out="$(sc_timeout "$SP_SEARCH_TIMEOUT" env SEMBLE_CACHE_LOCATION="$cache" \
uvx --from "$SEMBLE_PIN_SPEC" semble search \
"$query" "$root" --content code config -k 5 --max-snippet-lines 10 2>/dev/null)"
rc=$?
set -e
@@ -633,6 +633,76 @@ writeClaudeJson(FIXTURES.wrongscope_stale);
check('deviation.precedence', sh('sc_mcp_state').out, 'wrong_scope',
'local scope + floating pin reports wrong_scope: precedence wins over the §6.2 definition');
// ── 19. sc_timeout: the bound is enforced with or without a timeout binary ──
// PATH cannot be trusted to lack `timeout` (every Linux has it) or to have it
// (stock macOS has neither), so the backend is pinned via SEMBLE_TIMEOUT_BIN.
const TBIN = join(BASE, 'tbin');
mkdirSync(TBIN, { recursive: true });
const TIMEOUT_STUB = join(TBIN, 'timeout');
const TIMEOUT_LOG = join(BASE, 'timeout-calls.log');
writeFileSync(TIMEOUT_STUB, `#!/usr/bin/env bash
printf '%s\\n' "$*" >> "${TIMEOUT_LOG}"
secs="$1"; shift
exec "$@"
`);
spawnSync('chmod', ['+x', TIMEOUT_STUB]);
writeFileSync(TIMEOUT_LOG, '');
check('timeout.backend.none', sh('sc_timeout_backend', { SEMBLE_TIMEOUT_BIN: 'none' }).out, 'none',
'SEMBLE_TIMEOUT_BIN=none pins the pure-bash watchdog');
check('timeout.backend.stub', sh('sc_timeout_backend', { SEMBLE_TIMEOUT_BIN: TIMEOUT_STUB }).out, 'timeout',
'an overridden binary is reported by its basename');
check('timeout.backend.missing', sh('sc_timeout_backend', { SEMBLE_TIMEOUT_BIN: '/no/such/gtimeout' }).out, 'none',
'an override that is not executable falls back to the watchdog, never to an unbounded run');
check('timeout.path.none', sh('sc_timeout_path', { SEMBLE_TIMEOUT_BIN: 'none' }).out, '',
'the watchdog has no backing binary path');
check('timeout.path.stub', sh('sc_timeout_path', { SEMBLE_TIMEOUT_BIN: TIMEOUT_STUB }).out, TIMEOUT_STUB,
'the backing binary is reported by absolute path');
// Delegation to a real binary keeps every argument its own argv element.
const delegated = sh(`sc_timeout 42 printf '%s|%s\\n' 'semble[mcp]==0.5.2' two`,
{ SEMBLE_TIMEOUT_BIN: TIMEOUT_STUB });
check('timeout.delegate.stdout', delegated.out, 'semble[mcp]==0.5.2|two',
'the wrapped command runs with its argv intact');
check('timeout.delegate.log', readFileSync(TIMEOUT_LOG, 'utf8'),
"42 printf %s|%s\\n semble[mcp]==0.5.2 two\n",
'the binary was invoked with the seconds first and the pin as one word');
{
const t0 = Date.now();
const r = sh('sc_timeout 1 sleep 5', { SEMBLE_TIMEOUT_BIN: 'none' });
const elapsed = Date.now() - t0;
check('timeout.watch.code', r.status, 124, 'the watchdog reports 124, the GNU timeout convention');
// Bash startup + a 100 ms TERM->KILL grace sit on top of the 1 s bound.
check('timeout.watch.elapsed', Math.abs(elapsed - 1350) <= 700, true,
'it returns at ~1.35 s (+/-0.7), not after the full 5 s sleep');
}
{
const t0 = Date.now();
const r = sh('sc_timeout 30 printf ok', { SEMBLE_TIMEOUT_BIN: 'none' });
const elapsed = Date.now() - t0;
check('timeout.watch.fast.out', r.out, 'ok', 'stdout passes through the watchdog unchanged');
check('timeout.watch.fast.code', r.status, 0, 'a command that finishes keeps its own exit code');
check('timeout.watch.fast.elapsed', Math.abs(elapsed - 0) <= 1500, true,
'a fast command is not slowed to anywhere near its 30 s bound');
}
check('timeout.watch.exitcode', sh('sc_timeout 30 sh -c "exit 7"', { SEMBLE_TIMEOUT_BIN: 'none' }).status, 7,
'a non-zero exit is passed through, not rewritten to 124');
check('timeout.watch.stderr',
sh('sc_timeout 30 sh -c "echo boom >&2" 2>&1', { SEMBLE_TIMEOUT_BIN: 'none' }).out, 'boom',
'stderr passes through and job-control chatter never joins it');
// The whole process group dies: a grandchild that would outlive the child must
// never get to write its marker.
{
const MARK = join(BASE, 'watchdog-grandchild.marker');
const r = sh(`sc_timeout 1 bash -c '(sleep 3; touch "${MARK}") & sleep 5'; sleep 4`,
{ SEMBLE_TIMEOUT_BIN: 'none' });
check('timeout.watch.group.code', r.status, 0, 'the trailing sleep makes the probe itself succeed');
check('timeout.watch.group.marker', existsSync(MARK), false,
'the grandchild died with the group instead of surviving the timeout');
}
// ── report ─────────────────────────────────────────────────────────────────
console.log('suite-core.mjs (unit B: lib + mcp + cache + state)');
for (const line of results) console.log(line);
+10 -1
View File
@@ -66,11 +66,15 @@ function check(name, actual, expected, message) {
}
}
function runNode(script, stdinStr) {
// Unparseable stdin makes a hook fall back to process.cwd(); running from the
// repo root let the developer machine's own .claude/semble/state.json decide the
// answer. cwd is pinned to a state-less temp dir so the fixture is the only input.
function runNode(script, stdinStr, cwd = BASE) {
const r = spawnSync(process.execPath, [script], {
input: stdinStr,
encoding: 'utf8',
env: { ...process.env },
cwd,
timeout: 15000,
});
return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status };
@@ -244,6 +248,11 @@ const REMIND_OK = (cwd) => ({
const p = freshProject({});
const r = guidance(p, ['install', '--part', 'all', '--json']);
check('A1.exit', r.status, 0, 'install --part all exits 0 on a fresh project');
// Every script's --json ends its single object with exactly one newline.
check('A1.trailingNewline', [r.stdout.endsWith('\n'), r.stdout.endsWith('\n\n')], [true, false],
'the report ends with exactly one trailing newline');
check('A1.statusTrailingNewline', guidance(p, ['status', '--json']).stdout.endsWith('\n'), true,
'status --json carries the same trailing newline');
const s = readSettings(p);
const { session, reminder: rem } = semblePaths(p);
check('A1.sessionEntry', s.hooks.SessionStart, [
@@ -257,6 +257,95 @@ check('smoke shape', Object.keys(smokeJson).sort(),
'smoke --json carries the §9.6 keys');
check('smoke query override', smokeJson.query, 'auth handler', '--query is honoured');
// ═══════════════════════════════════════════════════════════════════════════
// 2b. the real search path is bounded by sc_timeout, with or without a
// timeout binary. uvx is stubbed in a dedicated bin dir prepended to PATH so
// the rest of the suite still runs with no uvx at all; SP_SEARCH_TIMEOUT is
// dialled down from its production 600 s and the backend is pinned, because
// PATH cannot be trusted to have (macOS) or lack (Linux) `timeout`.
// ═══════════════════════════════════════════════════════════════════════════
const UVX_BIN = join(BASE, 'uvxbin');
const UVX_STUB = join(UVX_BIN, 'uvx');
const UVX_ENV_LOG = join(BASE, 'uvx-env.log');
const HANG_MARKER = join(BASE, 'uvx-outlived-the-bound.marker');
const TIMEOUT_STUB = join(UVX_BIN, 'timeout-stub');
const TIMEOUT_LOG = join(BASE, 'search-timeout-calls.log');
function writeExec(p, body) {
write(p, body);
chmodSync(p, 0o755);
return p;
}
// A uvx that records the injected cache location, then either answers or hangs.
writeExec(UVX_STUB, `#!/usr/bin/env bash
printf '%s\\n' "\${SEMBLE_CACHE_LOCATION:-<unset>}" >> "${UVX_ENV_LOG}"
if [ "\${UVX_STUB_MODE}" = "hang" ]; then
sleep 30
: > "${HANG_MARKER}"
exit 0
fi
printf '%s' '{"results":[{"file_path":"src/main.py","start_line":3,"end_line":9,"score":0.75,"content":"x"}]}'
`);
writeExec(TIMEOUT_STUB, `#!/usr/bin/env bash
printf '%s\\n' "$*" >> "${TIMEOUT_LOG}"
secs="$1"; shift
exec "$@"
`);
writeFileSync(UVX_ENV_LOG, '');
writeFileSync(TIMEOUT_LOG, '');
const SEARCH_ENV = {
SEMBLE_PROJECT_ROOT: COV,
SEMBLE_NO_NETWORK: '',
PATH: `${UVX_BIN}:${BIN}:${process.env.PATH}`,
};
// WHEN: the stubbed uvx answers — the wrapper must not disturb the happy path.
const okRun = run(PROJECT_SH, ['smoke', '--query', 'bounded ok', '--json'],
{ ...SEARCH_ENV, SEMBLE_TIMEOUT_BIN: 'none', SP_SEARCH_TIMEOUT: '30' });
const okJson = safeParse(okRun.stdout);
check('search.ok.exit', okRun.status, 0, 'a search that answers exits 0');
check('search.ok.status', okJson.status, 'ok', 'the stubbed results parse as ok');
check('search.ok.count', okJson.resultCount, 1, 'the single stubbed result is counted');
check('search.ok.first', okJson.firstResult,
{ file_path: 'src/main.py', start_line: 3, end_line: 9, score: 0.75 },
'the first result survives the sc_timeout wrapper intact');
check('search.ok.cache-env', readFileSync(UVX_ENV_LOG, 'utf8'), `${CACHE_CODE}\n`,
'SEMBLE_CACHE_LOCATION reaches the child even though sc_timeout is a shell function');
// WHEN: a real timeout binary backs sc_timeout — same shape, one argv element per arg.
writeFileSync(UVX_ENV_LOG, '');
const okBin = run(PROJECT_SH, ['smoke', '--query', 'bounded ok', '--json'],
{ ...SEARCH_ENV, SEMBLE_TIMEOUT_BIN: TIMEOUT_STUB, SP_SEARCH_TIMEOUT: '30' });
check('search.bin.status', safeParse(okBin.stdout).status, 'ok',
'the binary backend runs the same search');
check('search.bin.log', readFileSync(TIMEOUT_LOG, 'utf8'),
`30 env SEMBLE_CACHE_LOCATION=${CACHE_CODE} uvx --from ${PIN_SPEC} semble search `
+ `bounded ok ${COV} --content code config -k 5 --max-snippet-lines 10\n`,
'the bound goes first and the env prefix is passed as argv, not as a shell string');
check('search.bin.cache-env', readFileSync(UVX_ENV_LOG, 'utf8'), `${CACHE_CODE}\n`,
'the binary backend injects the cache location too');
// WHEN: uvx wedges and NO timeout binary exists — the bound must still fire.
{
const t0 = Date.now();
const hung = run(PROJECT_SH, ['warm', '--json'],
{ ...SEARCH_ENV, SEMBLE_TIMEOUT_BIN: 'none', SP_SEARCH_TIMEOUT: '2', UVX_STUB_MODE: 'hang' });
const elapsed = Date.now() - t0;
const hungJson = safeParse(hung.stdout);
check('search.hang.exit', hung.status, 3, 'warm reports 3 when the search does not succeed');
check('search.hang.code', hungJson.exit, 124, 'the watchdog 124 is carried into the report');
check('search.hang.status', hungJson.status, 'failed', 'a timed-out search is a failure, not a skip');
check('search.hang.reason', hungJson.reason, 'semble search exited 124',
'the exit code is named in the reason');
// 2 s bound + bash/node startup + the 100 ms TERM->KILL grace.
check('search.hang.elapsed', Math.abs(elapsed - 2600) <= 1400, true,
'it returns at ~2.6 s (+/-1.4), not after the stub 30 s sleep');
check('search.hang.marker', existsSync(HANG_MARKER), false,
'the wedged uvx was killed instead of outliving its bound');
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. reindex — guards, --yes, blast radius
// ═══════════════════════════════════════════════════════════════════════════
+134 -1
View File
@@ -86,11 +86,14 @@ const PROJECT = join(BASE, 'project');
const CACHE_CODE = join(BASE, 'cache', 'semble-code');
const CACHE_DOCS = join(BASE, 'cache', 'semble-docs');
const STAGE = join(BASE, 'scripts');
const BIN3 = join(BASE, 'bin3');
const CLAUDE_LOG = join(BIN, 'claude-calls.log');
const BREW_LOG = join(BIN2, 'brew-calls.log');
const BREW_FAIL_LOG = join(BIN3, 'brew-fail-calls.log');
const TIMEOUT_STUB = join(BIN3, 'timeout');
const DETECT_FILE = join(BIN, 'detect.json');
for (const d of [BIN, BIN2, HOME_DIR, join(HOME_DIR, '.claude'), PROJECT,
for (const d of [BIN, BIN2, BIN3, HOME_DIR, join(HOME_DIR, '.claude'), PROJECT,
join(PROJECT, '.claude'), CACHE_CODE, CACHE_DOCS, STAGE, join(STAGE, 'lib')]) {
mkdirSync(d, { recursive: true });
}
@@ -112,8 +115,23 @@ printf 'brew %s\\n' "$*" >> "${BREW_LOG}"
exit 0
`);
chmodSync(join(BIN2, 'brew'), 0o755);
// A brew that fails: `brew install coreutils` blowing up is a warning, never a
// failure, so the failing path needs its own stub to be provable.
writeFileSync(join(BIN3, 'brew'), `#!/usr/bin/env bash
printf 'brew %s\\n' "$*" >> "${BREW_FAIL_LOG}"
echo "stub brew: boom" >&2
exit 1
`);
chmodSync(join(BIN3, 'brew'), 0o755);
writeFileSync(TIMEOUT_STUB, `#!/usr/bin/env bash
secs="$1"; shift
exec "$@"
`);
chmodSync(TIMEOUT_STUB, 0o755);
writeFileSync(CLAUDE_LOG, '');
writeFileSync(BREW_LOG, '');
writeFileSync(BREW_FAIL_LOG, '');
// ── stage the two scripts under test + the shared library ───────────────────
copyFileSync(join(SRC_SCRIPTS, 'lib', 'semble-common.sh'), join(STAGE, 'lib', 'semble-common.sh'));
@@ -627,6 +645,121 @@ writeFileSync(BREW_LOG, '');
check('91-human-json', human.stdout.trim().startsWith('{'), false, 'the human form must not emit JSON');
}
// ═══════════════════════════════════════════════════════════════════════════
// 9b. the coreutils step and the timeout backend report
// ═══════════════════════════════════════════════════════════════════════════
// PATH decides nothing here: every Linux has `timeout` in /usr/bin and stock
// macOS has neither binary, so the backend is pinned with SEMBLE_TIMEOUT_BIN.
{
const NOTMO = { SEMBLE_TIMEOUT_BIN: 'none' };
const HASTMO = { SEMBLE_TIMEOUT_BIN: TIMEOUT_STUB };
const brewEnv = { PATH: `${BIN2}:${BIN}:/usr/bin:/bin` };
const brewFailEnv = { PATH: `${BIN3}:${BIN}:/usr/bin:/bin` };
writeFileSync(BREW_LOG, '');
writeFileSync(BREW_FAIL_LOG, '');
const chkNo = safeParse(runInstall(['check', '--json'], NOTMO).stdout);
check('92-check-timeout-none', chkNo.timeout,
{ backend: 'none', path: '', bounded: true,
coreutils: { status: 'skipped', reason: 'the coreutils step does not run in mode check', changed: false } },
'check reports the backend, and bounded stays true because the bash watchdog takes over');
const chkYes = safeParse(runInstall(['check', '--json'], HASTMO).stdout);
check('92-check-timeout-binary', chkYes.timeout,
{ backend: 'timeout', path: TIMEOUT_STUB, bounded: true,
coreutils: { status: 'present', reason: 'timeout already backs sc_timeout', changed: false } },
'a real binary is named with its absolute path');
const chkHuman = runInstall(['check'], NOTMO);
check('92-check-timeout-human',
chkHuman.stdout.split('\n').filter((l) => l.startsWith('timeout:')),
['timeout: none (bash watchdog) | bounded yes | coreutils skipped - '
+ 'the coreutils step does not run in mode check'],
'the human summary carries exactly one timeout line');
const cuNoYes = runInstall(['coreutils', '--json'], NOTMO);
const cuj = safeParse(cuNoYes.stdout);
check('93-coreutils-noyes-exit', cuNoYes.status, 4, '`coreutils` without --yes must exit exactly 4');
check('93-coreutils-noyes-status', cuj.status, 'needs_confirmation', 'status must be needs_confirmation');
check('93-coreutils-noyes-commands', cuj.commands, ['brew install coreutils'],
'the exact command it would have run');
check('93-coreutils-noyes-step', cuj.timeout.coreutils,
{ status: 'needs_confirmation', reason: 'brew install coreutils needs --yes', changed: false },
'the step reports its own outcome');
check('93-coreutils-noyes-brewlog', readFileSync(BREW_LOG, 'utf8'), '',
'nothing may be installed without --yes');
const cuPresent = runInstall(['coreutils', '--yes', '--json'], { ...brewEnv, ...HASTMO });
const cupj = safeParse(cuPresent.stdout);
check('94a-coreutils-present-exit', cuPresent.status, 0, 'an existing timeout binary short-circuits the step');
check('94a-coreutils-present-commands', cupj.commands, [],
'a skipped step records no command at all');
check('94a-coreutils-present-brewlog', readFileSync(BREW_LOG, 'utf8'), '',
'brew is never consulted when timeout is already there');
const cuDry = runInstall(['coreutils', '--yes', '--json'], { ...brewEnv, ...NOTMO, SEMBLE_DRY_RUN: '1' });
const cudj = safeParse(cuDry.stdout);
check('94b-coreutils-dry-exit', cuDry.status, 0, 'SEMBLE_DRY_RUN=1 changes nothing and exits 0');
check('94b-coreutils-dry-step', cudj.timeout.coreutils,
{ status: 'skipped', reason: 'SEMBLE_DRY_RUN=1', changed: false }, 'the dry run is reported as a skip');
check('94b-coreutils-dry-commands', cudj.commands, ['brew install coreutils'],
'the command it would have run is still printed');
check('94b-coreutils-dry-brewlog', readFileSync(BREW_LOG, 'utf8'), '', 'a dry run must not execute brew');
// SEMBLE_NO_NETWORK=1 comes from ENV: the uv step calls that a precondition,
// the coreutils step may not - a missing bound is degraded, never fatal.
const cuNoNet = runInstall(['coreutils', '--yes', '--json'], { ...brewEnv, ...NOTMO });
const cunj = safeParse(cuNoNet.stdout);
check('94c-coreutils-nonet-exit', cuNoNet.status, 0, 'no network must not turn coreutils into exit 3');
check('94c-coreutils-nonet-status', cunj.status, 'ok', 'the overall status stays ok');
check('94c-coreutils-nonet-step', cunj.timeout.coreutils.status, 'skipped', 'the step itself reports skipped');
check('94c-coreutils-nonet-brewlog', readFileSync(BREW_LOG, 'utf8'), '', 'no network means brew is never called');
const cuNoBrew = runInstall(['coreutils', '--yes', '--json'], NOTMO);
const cubj = safeParse(cuNoBrew.stdout);
check('94d-coreutils-nobrew-exit', cuNoBrew.status, 0, 'a missing brew must not raise precondition here');
check('94d-coreutils-nobrew-status', cubj.status, 'ok', 'the overall status stays ok');
check('94d-coreutils-nobrew-step', cubj.timeout.coreutils,
{ status: 'skipped',
reason: 'brew is not installed - sc_timeout keeps using its bash watchdog',
changed: false },
'the reason names the fallback the user is left with');
const cuFail = runInstall(['coreutils', '--yes', '--json'], { ...brewFailEnv, ...NOTMO, SEMBLE_NO_NETWORK: '' });
const cufj = safeParse(cuFail.stdout);
check('94e-coreutils-fail-exit', cuFail.status, 0, 'a failed brew install coreutils still exits 0');
check('94e-coreutils-fail-status', cufj.status, 'ok', 'it never escalates the overall status');
check('94e-coreutils-fail-step', cufj.timeout.coreutils,
{ status: 'failed',
reason: 'brew install coreutils failed - sc_timeout keeps using its bash watchdog',
changed: false },
'the failure is reported as a warning-level step outcome');
check('94e-coreutils-fail-brewlog', readFileSync(BREW_FAIL_LOG, 'utf8'), 'brew install coreutils\n',
'brew was called exactly once, with exactly that argv');
writeFileSync(BREW_LOG, '');
const allDry = runInstall(['all', '--yes', '--json'], { ...brewEnv, ...NOTMO, SEMBLE_DRY_RUN: '1' });
const adj = safeParse(allDry.stdout);
// uv/uvx stay absent under this PATH and a dry run installs nothing, so the
// pin cannot resolve: exit 3 is the uv/uvx precondition, unchanged by the new
// step - the note must not mention coreutils at all.
check('94f-all-dry-exit', allDry.status, 3, '`all` keeps the pre-existing uv/uvx precondition');
check('94f-all-dry-note', adj.note, 'uv/uvx not on PATH; uvx is not on PATH - install uv first',
'the coreutils step adds nothing to the note when it is only skipped');
check('94f-all-dry-order', adj.commands,
['brew install uv', 'brew install coreutils', "uvx --from 'semble[mcp]==0.5.2' semble --help"],
'all runs check -> uv -> coreutils -> semble, in that order');
check('94f-all-dry-step', adj.timeout.coreutils.status, 'skipped', 'the coreutils step is reported in `all`');
check('94f-all-dry-brewlog', readFileSync(BREW_LOG, 'utf8'), '', 'a dry `all` installs nothing');
const allSoft = runInstall(['all', '--json'], { ...brewEnv, ...NOTMO });
const asj = safeParse(allSoft.stdout);
check('94g-all-soft-status', asj.status, 'needs_confirmation',
'the uv gate still owns the status; coreutils never adds one of its own');
check('94g-all-soft-note', String(asj.note).includes('brew install coreutils needs --yes (optional)'), true,
'inside `all` the coreutils confirmation is a note, not an escalation');
}
// ═══════════════════════════════════════════════════════════════════════════
// 10. Fixture tree integrity
// ═══════════════════════════════════════════════════════════════════════════