From 4ad84f1a54bd3ed39dc80bff081a31d75da857f0 Mon Sep 17 00:00:00 2001 From: Bayological <6872903+bayological@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:46:05 -0500 Subject: [PATCH 1/2] chore: skein setup (gate, plan, worker rules, coordinator runbook) Sets this repo up as the team's sandbox for running several coordinators at once with skein (https://github.com/bayological/skein): .skein/config.json, the vendored runtime at scripts/skein/, AGENTS.md worker rules (money: swap is dry-run only; no network in tests; addresses from the SDK; never publish; --json is a contract), docs/plan (runbook, coordinator prompt, onboarding, task DAG), CI running the gate on PRs, a pre-push hook refusing main, Superset lifecycle scripts. Plan: CLI-00 (test harness) briefed and dispatchable; CLI-01 (README) and CLI-02 (parseAmount validation) left unbriefed on purpose so a new coordinator's first act is a brief. The former AGENTS.md master-context note lives on as rule P3. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/skein-gate.yml | 30 +++ .gitignore | 1 + .skein/config.json | 42 ++++ .superset/config.json | 8 + .superset/setup.sh | 38 ++++ .superset/teardown.sh | 18 ++ AGENTS.md | 139 +++++++++++-- docs/adr/README.md | 6 + docs/plan/COORDINATOR-PROMPT.md | 24 +++ docs/plan/COORDINATOR.md | 117 +++++++++++ docs/plan/ONBOARDING.md | 60 ++++++ docs/plan/briefs/.gitkeep | 0 docs/plan/briefs/CLI-00.md | 92 +++++++++ docs/plan/wps.json | 68 ++++++ scripts/githooks/pre-push | 21 ++ scripts/skein/VERSION | 1 + scripts/skein/commands/brief.sh | 21 ++ scripts/skein/commands/dispatch.sh | 57 +++++ scripts/skein/commands/doctor.sh | 29 +++ scripts/skein/commands/gate.sh | 3 + scripts/skein/commands/merge.sh | 46 +++++ scripts/skein/commands/plan.sh | 3 + scripts/skein/commands/review.sh | 43 ++++ scripts/skein/commands/send.sh | 8 + scripts/skein/commands/status.sh | 41 ++++ scripts/skein/commands/watch.sh | 48 +++++ scripts/skein/lib/boards/github.sh | 92 +++++++++ scripts/skein/lib/boards/none.sh | 10 + scripts/skein/lib/boards/superset.sh | 52 +++++ scripts/skein/lib/caps.sh | 24 +++ scripts/skein/lib/common.sh | 78 +++++++ scripts/skein/lib/drivers/local.sh | 104 ++++++++++ scripts/skein/lib/drivers/superset.sh | 111 ++++++++++ scripts/skein/lib/gate.mjs | 286 ++++++++++++++++++++++++++ scripts/skein/lib/plan.mjs | 104 ++++++++++ scripts/skein/lib/render.mjs | 12 ++ scripts/skein/skein | 55 +++++ 37 files changed, 1877 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/skein-gate.yml create mode 100644 .skein/config.json create mode 100644 .superset/config.json create mode 100755 .superset/setup.sh create mode 100755 .superset/teardown.sh create mode 100644 docs/adr/README.md create mode 100644 docs/plan/COORDINATOR-PROMPT.md create mode 100644 docs/plan/COORDINATOR.md create mode 100644 docs/plan/ONBOARDING.md create mode 100644 docs/plan/briefs/.gitkeep create mode 100644 docs/plan/briefs/CLI-00.md create mode 100644 docs/plan/wps.json create mode 100755 scripts/githooks/pre-push create mode 100644 scripts/skein/VERSION create mode 100644 scripts/skein/commands/brief.sh create mode 100644 scripts/skein/commands/dispatch.sh create mode 100644 scripts/skein/commands/doctor.sh create mode 100644 scripts/skein/commands/gate.sh create mode 100644 scripts/skein/commands/merge.sh create mode 100644 scripts/skein/commands/plan.sh create mode 100644 scripts/skein/commands/review.sh create mode 100644 scripts/skein/commands/send.sh create mode 100644 scripts/skein/commands/status.sh create mode 100644 scripts/skein/commands/watch.sh create mode 100644 scripts/skein/lib/boards/github.sh create mode 100644 scripts/skein/lib/boards/none.sh create mode 100644 scripts/skein/lib/boards/superset.sh create mode 100644 scripts/skein/lib/caps.sh create mode 100644 scripts/skein/lib/common.sh create mode 100644 scripts/skein/lib/drivers/local.sh create mode 100644 scripts/skein/lib/drivers/superset.sh create mode 100644 scripts/skein/lib/gate.mjs create mode 100644 scripts/skein/lib/plan.mjs create mode 100644 scripts/skein/lib/render.mjs create mode 100755 scripts/skein/skein diff --git a/.github/workflows/skein-gate.yml b/.github/workflows/skein-gate.yml new file mode 100644 index 0000000..8933ac9 --- /dev/null +++ b/.github/workflows/skein-gate.yml @@ -0,0 +1,30 @@ +name: skein gate + +# The same gate the worker and the coordinator run, minus anything that needs secrets. The +# coordinator looks at this once per PR; nobody polls it. +on: + pull_request: + push: + branches: [main] + +concurrency: + group: skein-gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - run: npm ci --prefer-offline --no-audit --no-fund + - name: task gate (pull request) + if: github.event_name == 'pull_request' + run: scripts/skein/skein gate --from-branch --only brief,boundary,secrets,test-integrity,invariants,standard + - name: standard (main) + if: github.event_name == 'push' + run: scripts/skein/skein gate --standard-only diff --git a/.gitignore b/.gitignore index f246192..408460b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist .env *.tgz .reviews/ +.superset/config.local.json diff --git a/.skein/config.json b/.skein/config.json new file mode 100644 index 0000000..3db02ba --- /dev/null +++ b/.skein/config.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "name": "mento-cli", + "prefix": "CLI", + "envelope": "CLI_WORKER", + "coordinatorEnv": "CLI_COORDINATOR", + "plan": "docs/plan/wps.json", + "briefs": "docs/plan/briefs", + "vendor": "scripts/skein", + "gate": "scripts/skein/skein gate", + "baseBranch": "main", + "board": { + "type": "github", + "repo": "mento-protocol/cli" + }, + "driver": { + "type": "superset" + }, + "maxAgents": 6, + "install": "npm ci --prefer-offline --no-audit --no-fund", + "standard": [ + "npm run build" + ], + "e2e": [], + "invariants": [], + "monotonic": [], + "secretPatterns": [], + "secretAllow": [], + "worker": { + "agent": "claude", + "model": "claude-opus-5", + "effort": "high" + }, + "review": { + "agent": "codex", + "model": "gpt-6-astra", + "fallbackModel": "claude-fable-5-1" + }, + "readyMarker": "workspace '.*' ready", + "setupScript": ".superset/setup.sh", + "setupTimeout": 300 +} diff --git a/.superset/config.json b/.superset/config.json new file mode 100644 index 0000000..d497ef2 --- /dev/null +++ b/.superset/config.json @@ -0,0 +1,8 @@ +{ + "setup": [ + "./.superset/setup.sh" + ], + "teardown": [ + "./.superset/teardown.sh" + ] +} diff --git a/.superset/setup.sh b/.superset/setup.sh new file mode 100755 index 0000000..bdf91f4 --- /dev/null +++ b/.superset/setup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Workspace setup for mento-cli: runs once per new worktree, in the worktree root, under +# Superset or the skein local driver (both export SUPERSET_ROOT_PATH). Idempotent; keep it +# fast. Coordinator-owned (.superset/** in the plan). +set -euo pipefail + +ROOT="${SUPERSET_ROOT_PATH:-}" +WS_NAME="${SUPERSET_WORKSPACE_NAME:-workspace}" +[ -n "$ROOT" ] || { echo "SUPERSET_ROOT_PATH not set; run this via Superset or skein." >&2; exit 1; } + +# 1. Env files are gitignored: copy each from the main checkout, never overwriting one the +# workspace already has. Lines matching ENV_WITHHOLD are blanked so a worker never holds +# a production credential it does not need (edit the pattern for this project). +ENV_WITHHOLD='' +copy_env() { + local rel="$1" + if [ -f "$rel" ]; then echo "env: $rel already present" + elif [ -f "$ROOT/$rel" ]; then + mkdir -p "$(dirname "$rel")" + if [ -n "$ENV_WITHHOLD" ]; then sed -E "s|^($ENV_WITHHOLD)=.*|\1= # skein workspace: withheld|" "$ROOT/$rel" > "$rel"; else cp "$ROOT/$rel" "$rel"; fi + chmod 600 "$rel"; echo "env: copied $rel from main checkout" + elif [ -f "$rel.example" ]; then cp "$rel.example" "$rel"; echo "env: $rel created from $rel.example (fill in values)" + else echo "env: no $rel in main checkout; skipping"; fi +} +for f in .env; do copy_env "$f"; done + +# 2. The repo's git hooks: scripts/githooks/pre-push refuses a push to main. +git config core.hooksPath scripts/githooks + +# 3. Dependencies. +npm ci --prefer-offline --no-audit --no-fund + +# 4. Project-specific preparation (builds, generated code). Edit freely. +true + +# Readiness marker. skein dispatch waits for this exact line before launching an agent, so a +# worker never races the install. +echo "workspace '$WS_NAME' ready" diff --git a/.superset/teardown.sh b/.superset/teardown.sh new file mode 100755 index 0000000..b0cd5dd --- /dev/null +++ b/.superset/teardown.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Workspace teardown: stop any long-running process an agent left behind in this worktree +# (a dev server holding its port). Scoped by the process's working directory: never a +# broad pkill, which would take other workspaces' servers and the owner's with it. +ws=$(cd "${SUPERSET_WORKSPACE_PATH:-.}" 2>/dev/null && pwd -P) || exit 0 +[ -n "$ws" ] && [ "$ws" != / ] || exit 0 +cwd_of() { if [ -L "/proc/$1/cwd" ]; then readlink "/proc/$1/cwd"; else lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p'; fi; } +pids="" +for pid in $(pgrep -u "$(id -u)" -f 'node|bun|deno|python|cargo|next-server|vite' 2>/dev/null); do + [ "$pid" = "$$" ] && continue + case "$(cwd_of "$pid" 2>/dev/null)" in "$ws" | "$ws"/*) pids="$pids $pid" ;; esac +done +[ -n "$pids" ] || exit 0 +echo "Stopping processes running from $ws:$pids" +kill $pids 2>/dev/null +for _ in 1 2 3 4 5; do sleep 1; kill -0 $pids 2>/dev/null || exit 0; done +kill -9 $pids 2>/dev/null +exit 0 diff --git a/AGENTS.md b/AGENTS.md index 9c7c88a..296052d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,129 @@ -# Mento CLI Instructions +# AGENTS.md -For any protocol-level question that crosses beyond this CLI repo, first read -the private `mento-master-context` router when the checkout is available: +Rules for every agent working in this repository. Coordinator-owned: do not edit. +This repo is mento-cli. It is built by several agents in parallel, each in its own workspace +and branch, and every claim is verified by a gate before it merges. The rules below exist so +that parallel work merges cleanly and so that no mistake reaches production, a user, or a +credential. + +## Read before you write +1. Your brief: `docs/plan/briefs/.md`. It names the paths you own and how you will be + judged. +2. `docs/plan/wps.json`: which task owns which paths, and what is coordinator-owned. +3. The project rules below, and whatever the brief lists under "Read first". + +## The rules + +**1. Stay inside your paths.** Change only files matching your task's `owned` globs in +`docs/plan/wps.json`. The `alwaysAllowed` entries there (lockfile, `docs/adr/DRAFT-*.md`) are always +fine. Never edit anything under `coordinatorOwned`: `AGENTS.md`, `.skein/**`, +`scripts/skein/**`, `docs/plan/**`, `.github/**`, the hooks, and the project's frozen paths. If +a shared file seems to need a change, that is a blocker to report, not an edit to make. + +**2. Frozen paths are frozen.** The plan's `coordinatorOwned` list names the contract this +project builds against. If it lacks something you need, stop and report `BLOCKED` with the +exact change you propose. The coordinator applies it with a short ADR and tells running +workers to rebase. + +**3. Never weaken a test to get green.** No `.skip`, `.only` or `.todo`. No `@ts-nocheck`, +no file-wide lint disables, no widened tolerances. Shared regression suites only grow; the +gate counts them. A failing test is information: fix the code or report the problem. + +**4. Secrets.** Never commit `.env` or any credential. Never print, log, or put in an error +message a key, a token, a signing secret, or a user's personal data. Test data is invented. +The gate scans for committed keys; do not rely on it. + +**5. Dependencies.** Add one only if your brief's work needs it, and name it in your hand-off. +Prefer what is already here. No install scripts from packages you cannot vouch for. + +**6. Stay in scope.** Do what the brief says. Note improvements you spot in your hand-off +instead of making them. Do not refactor another task's code. + +**7. Skills and workflows.** The coordinator uses whatever skills it likes; workers do not. +Do not invoke `/ship`, `/review`, `/qa`, or any workflow that merges, bumps versions, or opens +PRs on your behalf. Your gate is `scripts/skein/skein gate ` and nothing else. + +**8. Do not poll GitHub.** Every agent shares API limits. No `gh pr checks --watch`, no loop +around `gh`. The local gate is your evidence; the coordinator watches CI. + +## Project rules +**P1. `mento swap` moves real money.** Never run it without `--dry-run`, by hand or in a test. +Never create, fund, request or import a wallet key; tests use throwaway keys from +`viem/accounts` and never a keyfile from disk. A private key or keyfile path never appears in a +commit, a fixture, a log, or a hand-off. + +**P2. Tests never depend on the network.** Every command talks to Celo through the SDK. A unit +test mocks the SDK (`@mento-protocol/mento-sdk`) or exercises the pure modules (`src/lib/format.ts`, +`src/lib/utils.ts`, `src/lib/errors.ts`, `src/lib/client.ts` resolution). A test that must reach +an RPC is an integration test: it skips itself with a printed reason when `MENTO_RPC_URL` is +unset, and CI never sets it. + +**P3. Addresses and ABIs come from the SDK, never from memory.** This repo is the source of +truth for CLI behaviour only. Deployment addresses, ABIs and live chain state resolve through +`@mento-protocol/mento-sdk` (and, for a person with the checkout, the private +`../mento-master-context/.agents/mento-context/README.md` router). A worker in a fresh worktree +does not have that checkout: the brief inlines what a task needs, and a worker that finds itself +guessing a contract address reports `BLOCKED`. + +**P4. Never publish.** `npm publish`, version bumps in `package.json`, and tags are +coordinator-only, through a PR. `prepack` runs `tsc`; do not make it do more. + +**P5. `--json` output is a contract.** With `--json`, stdout carries exactly one JSON document +and nothing else; progress and warnings go to `stderr`. Human-readable output may change freely; +JSON field names may not without the brief saying so. + + +## Workflow +1. Read the brief and everything it lists under "Read first". +2. Work in small conventional commits (`feat(scope): …`, `fix(scope): …`, `test(scope): …`) + on your branch only. +3. Before claiming done, run the gate and make it pass: + ```bash + scripts/skein/skein gate + ``` + It checks a clean tree, path ownership, committed secrets, test integrity, the project's + invariants, then the standard commands, the e2e suite if configured, and your task's + acceptance commands. The coordinator and CI run the same gate, so a claim it does not + support will be rejected. +4. Push your branch and open a **draft** pull request. Names say what the work is and carry + the task id: + - **your branch** is `/-`, made for you at dispatch; + - **the pull request title** is a conventional-commit subject with the id at the end: + `fix(api): JSON 404 for unknown paths (CLI-01)`. It becomes the squashed commit on + `main`, and the plan reads it to know the task merged. + + Never push to `main`, never merge, never force-push a branch that is not yours. +5. End your final message with exactly one envelope. + +## Completion envelope +```text +CLI_WORKER_DONE +task: +summary: +pr: +files: +checks: +handoff: +``` ```text -../mento-master-context/.agents/mento-context/README.md +CLI_WORKER_BLOCKED +task: +reason: +needs: ``` +Report `BLOCKED` early rather than working around a problem. A precise blocker is a good +outcome; a silent workaround is not. -This applies before broad repo searches for contracts, deployments, addresses, -ABIs, live on-chain state, stable supply, reserve data, monitoring/data -semantics, docs, the whitepaper, business model, or legal/risk framing. Load -only the relevant master-context card(s), then return to this repo for CLI -implementation details. - -This repo is source of truth for CLI behavior, not for published deployment -addresses, generated ABIs, or current chain state. Resolve those through -`deployments-v2` and live RPC as routed by master context. When answering, -mention which master-context card you used or state that the checkout was -unavailable. +## Ambiguity +If the architecture or the contract is ambiguous or wrong, do not guess silently. Write +`docs/adr/DRAFT--.md` stating the context, the decision you took and its +consequences, and mention it in your hand-off. The coordinator numbers it at merge. + +## Commands +```bash +npm ci --prefer-offline --no-audit --no-fund +# no standard commands detected: add typecheck/lint/test scripts +scripts/skein/skein gate +scripts/skein/skein gate --only boundary,secrets,test-integrity,invariants # fast static checks +``` diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..e989ee6 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,6 @@ +# Architecture decision records + +Numbered ADRs are coordinator-owned and record why the contract or the architecture is as it +is. A worker that hits an ambiguity writes `DRAFT--.md` here (always allowed by +the gate), states the context, the decision it took and its consequences, and mentions it in +its hand-off. The coordinator numbers it at merge. diff --git a/docs/plan/COORDINATOR-PROMPT.md b/docs/plan/COORDINATOR-PROMPT.md new file mode 100644 index 0000000..2976620 --- /dev/null +++ b/docs/plan/COORDINATOR-PROMPT.md @@ -0,0 +1,24 @@ +# Starting a coordinator + +Paste the block below into a fresh Claude Code session in your own checkout of this repo. +Any number of coordinators can run at once; the board keeps you apart. + +```text +You are a coordinator on mento-cli. Work is built by agents in parallel, each in its own +workspace and branch, and every claim is verified by a gate before anything merges. You +write no feature code yourself: you cut work into path-disjoint tasks, write briefs, +dispatch, verify, review, merge, and keep the record true. Other coordinators may be +working at the same time; the board is the lock, and you never touch a task someone else +holds. + +Read these before doing anything, and follow them over any habit of your own: +1. AGENTS.md — the rules every worker follows. +2. docs/plan/COORDINATOR.md — your runbook; every step is a `skein` command. +3. docs/plan/wps.json — which task owns which files, and what is coordinator-owned. +4. docs/plan/ONBOARDING.md — machine setup, if this is your first session here. + +Then check the machine: `skein doctor`, then `skein status` and `skein plan`. +The skein command is vendored at scripts/skein/skein; the /skein-* skills wrap it. + +Then ask me what to work on, or pick from what `skein plan` says is ready. +``` diff --git a/docs/plan/COORDINATOR.md b/docs/plan/COORDINATOR.md new file mode 100644 index 0000000..e34cd3c --- /dev/null +++ b/docs/plan/COORDINATOR.md @@ -0,0 +1,117 @@ +# Coordinator runbook + +For whichever agent is coordinating: a Claude Code session in a main checkout, or a person +by hand. There can be any number of coordinators at once; the board is what keeps them out +of each other's way. A coordinator writes no feature code. It cuts work into path-disjoint +tasks, writes briefs, dispatches workers, verifies every claim with the gate, gets a +cross-model review where money or personal data is involved, merges, and keeps the record +true. **All state is on disk or on the board**; a new coordinator needs only this file, the +plan, the briefs, and `skein status`. + +To start a coordinator: open a session in your own checkout and paste +`docs/plan/COORDINATOR-PROMPT.md`. + +## The commands +Every step below is a `skein` command, vendored at `scripts/skein/skein` so it is the same on +every machine and in CI: + +| Step | Command | +|---|---| +| see the DAG, what is ready, what overlaps | `skein plan` | +| validate or scaffold a brief | `skein brief --check` / `--new` | +| claim, create the workspace, launch the worker | `skein dispatch ` | +| wait for the envelope | `skein watch ` | +| everything running, board and local, both caps | `skein status` | +| talk to a worker | `skein send "message"` | +| the merge gate | `skein gate ` | +| cross-model review of the PR | `skein review ` | +| gate, squash-merge, close the claim, delete the workspace | `skein merge ` | + +## Cutting work into tasks +A flock only runs several agents at once when several tasks exist whose `owned` globs do not +overlap and whose `deps` are merged. That is the whole trick. When you add a task to +`docs/plan/wps.json`: +- Give it disjoint `owned` globs. `skein plan` prints overlaps among ready tasks. +- Give it `accept` commands the gate can run without anyone's secrets. +- Mark `money: true` or `pii: true` when it touches payments, ledgers, credentials, or personal + data. Both make a cross-model review mandatory, and `skein merge` refuses without one. +- Anything under `coordinatorOwned` is a coordinator change first (a branch, a short ADR under + `docs/adr/`, a PR), then a task. + +## Claims: N coordinators, one board +The board (github) holds one entry per task, titled `: `. **Assigning yourself +is the claim**; an unassigned entry is free, an assigned one is someone else's. `skein +dispatch` claims before it creates a workspace and refuses a task someone else holds. `skein +merge` refuses a task you do not hold. State labels (`ready`, `running`, `gating`, `blocked`) +move with the task and read the same in the board UI and in `skein status`. + +Two caps bound a dispatch: your machine's (`~/.skein/config.json` `maxAgents`, default 3) and +the repo's (`.skein/config.json` `maxAgents`), counted across all coordinators through the +board. The smaller wins. + +Single-writer, always: the files under `coordinatorOwned`. One coordinator changes them at a +time, through a PR the others can see. + +## Dispatch +A task is dispatchable when it has a valid brief, its deps are merged, and both the brief and +the plan are on `origin/main` (workspaces fork from origin, not from your local branch). +```bash +CLI_COORDINATOR=1 git push origin main # briefs and the plan must be on origin +skein dispatch <ID> # model from .skein/config.json worker policy +skein watch <ID> +``` +For a **resumed or follow-up** round, start the watcher **before** you send the prompt: +envelopes already on screen when it starts are ignored. + +## When a worker reports DONE: the merge gate +**A worker's claim is not evidence.** In its worktree: +1. `git diff --name-only origin/main...HEAD`: only the task's owned paths. +2. `skein gate <ID>`, run by you, must end `SKEIN_GATE_RESULT … status=PASS`. +3. **Read the diff for anything the gate cannot see**: a handler that logs a payload, a query + that reads-then-writes, a criterion vacuously satisfied. That is what the review is for. +4. **New dependency?** Verify it on the registry and its repository yourself. +5. **`skein review <ID>`** for any `money` or `pii` task: a different model family reviews in a + read-only worktree and posts one PR comment. Triage each finding on its merits, post rulings + on the PR, resume the author for fixes (`skein send`), then gate again. +6. **`skein merge <ID>`**: gate again, squash-merge, close the claim, delete the workspace, + mark the task done in the plan. +7. After a wave, in your checkout: pull, reinstall, restart anything you run locally. + +**Never merge without a human reading the diff** when a task touches production credentials, +live money, or infrastructure the team shares. Those are owner checkpoints whoever coordinates. + +## Naming +| What | Shape | Example | +|---|---|---| +| Branch | `<type>/<slug>-<task-id>` | `fix/api-json-404-cli-01` | +| Workspace | `<type>: <slug in words> (<TASK-ID>)` | `fix: api json 404 (CLI-01)` | +| PR title | `<type>(<scope>): <summary> (<TASK-ID>)` | `fix(api): JSON 404 for unknown paths (CLI-01)` | +| Brief, gate, envelope, board | the task id, unchanged | `CLI-01` | + +`skein dispatch` derives the branch and workspace name from the task's `type` and `slug`; the +gate's `--from-branch` resolves the task from the branch; `skein plan` reads the PR title on +`main` to know a task merged. + +## Pushing to main +`scripts/githooks/pre-push` refuses a push to `main` unless `CLI_COORDINATOR=1` is set. +Branch protection with the gate as a required check is the real guard where the plan allows +it; the hook stops the accident, not the decision. + +## Contract changes +Only a coordinator changes `coordinatorOwned` paths: a branch, a short ADR under `docs/adr/`, +the change, a PR the other coordinators can see, merge, then tell running workers to rebase. + +## Talking to workers +`skein send <ID> "one line"`. After a usage-limit stall, send `continue`. To pause a worker: +tell it to make a WIP commit and end its turn. + +## Hygiene, learned the expensive way +- **Workspaces fork from origin.** An unpushed brief, gate or lifecycle change is absent in the + worktree. `skein dispatch` refuses if the brief or plan differs from origin. +- **Nobody polls GitHub.** The local gate is the evidence; look at CI once per PR. +- **Never `pkill -f <agent>`**: it kills other coordinators' workers and your own dev server. + The lifecycle scripts scope by working directory or process group. +- Two tasks must never own the same file at once. Three tasks in one directory cost three + merge rounds. +- Chain shell steps with `&&` when a later step must not run after a failure. + diff --git a/docs/plan/ONBOARDING.md b/docs/plan/ONBOARDING.md new file mode 100644 index 0000000..bafe11c --- /dev/null +++ b/docs/plan/ONBOARDING.md @@ -0,0 +1,60 @@ +# Joining this project + +For a person joining mento-cli with their own machine and their own agents. Read this, then +`AGENTS.md`, then run `skein status`. + +## 1. Your machine +- The agent CLIs you use: Claude Code, and Codex if you run cross-model reviews. +- `gh`, authenticated as yourself. +- Superset with this repo added as a project, if you use the Superset driver. Without it, + set `"driver": {"type": "local"}` in your copy of the config or export `SKEIN_DRIVER=local`. +- The skein kit, for the skills and `skein init`/`upgrade`: + ```bash + git clone https://github.com/bayological/skein ~/.claude/skills/skein && ~/.claude/skills/skein/setup + ``` + Day to day you need only the vendored copy in this repo: `scripts/skein/skein`. +- Your caps: `~/.skein/config.json` with `{"maxAgents": 3}` (or whatever your machine and + seat can carry). + +Then: +```bash +git clone <this repo> && cd <repo> +git config core.hooksPath scripts/githooks +npm ci --prefer-offline --no-audit --no-fund +# no standard commands detected: add typecheck/lint/test scripts +skein doctor +``` + +## 2. How work is organised +- **`docs/plan/wps.json`** is the map: each task's `owned` globs are the only files it may change. Two + tasks never own one file at the same time. +- **`docs/plan/briefs/<task-id>.md`** is a task's specification: what to build, what to read first, + how it will be judged. +- **The board** (github) is where claims live. Assign yourself before you dispatch or brief. +- **`docs/plan/COORDINATOR.md`** is the runbook for whoever is coordinating. +- **The gate** is `skein gate <task-id>`. CI runs the same script. + +## 3. Your own coordinator +Paste `docs/plan/COORDINATOR-PROMPT.md` into a fresh session in your checkout. It spins up +agents, verifies them, reviews, merges, and keeps the record, the same as everyone else's. + +## 4. The rules that matter most +- Never commit `.env`; never print a key. +- Never weaken a test to get green. +- Never push to `main`; the coordinator merges through `skein merge`. +- Frozen paths (`coordinatorOwned` in the plan) change only through a coordinator PR with an ADR. + +## Mento specifics +- **This repo is the team's sandbox** for running several coordinators at once. Nothing here is + load-bearing; the point is to see the loop (brief, dispatch, gate, review, merge) with two or + more people coordinating on the same board. Break things; the gate will tell you. +- **Access:** write access to `mento-protocol/cli` (org membership is enough) and `gh auth login` + as yourself. No secrets exist for this repo. +- **Superset:** add the repo as a project in the desktop app; `skein` resolves the project by + path. Without Superset, `export SKEIN_DRIVER=local`: workers then run as Claude Code headless + in git worktrees under `~/.skein/worktrees/`, and `skein status` reads their logs. +- **Your first act as a coordinator:** `skein plan` shows CLI-00 ready and CLI-01, CLI-02 + unbriefed on purpose. Dispatch CLI-00 if nobody holds it (the board says), or brief CLI-01 + with `/skein-brief` while someone else runs CLI-00. Two coordinators, one board, no owner. +- **The master-context router** (`../mento-master-context`) is not available to workers. A brief + inlines what a task needs (AGENTS.md P3). diff --git a/docs/plan/briefs/.gitkeep b/docs/plan/briefs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/plan/briefs/CLI-00.md b/docs/plan/briefs/CLI-00.md new file mode 100644 index 0000000..e842e32 --- /dev/null +++ b/docs/plan/briefs/CLI-00.md @@ -0,0 +1,92 @@ +# CLI-00: Test harness, so the gate can judge work + +## Objective +Nothing in this repo can be verified yet. `package.json:8-12` has `build` (`tsc`), `dev` +(`tsx src/index.ts`) and `prepack`; there is no `typecheck`, no `test`, no test runner, and no +test file anywhere. `.skein/config.json` therefore runs only `npm run build` as its standard +check, which proves the code compiles and nothing more. Make the gate able to judge work: a +`typecheck` script, a real test runner with real tests over the pure modules, and the scripts +CI and every later task's `accept` will call. When this merges, the coordinator switches the +standard commands to `npm run typecheck` and `npm test`, and CLI-02 becomes dispatchable. + +This task adds no product behaviour. `mento tokens`, `mento quote` and every other command +behave exactly as they do today. + +## Read first +- `AGENTS.md`, all of it. P1 and P2 decide what a test here may do. +- `package.json` and `tsconfig.json` (`module: Node16`, `rootDir: ./src`, `include: src/**/*`). + Tests must not end up in `dist/` and must not break `npm run build`. +- `src/lib/format.ts`: `formatAddress` (`:8`), `parseAmount` (`:16`), `formatTokenAmount` + (`:26`), `output` (`:62`). Pure except `output`, which prints. +- `src/lib/client.ts:19-31`: `resolveChainId`, pure. `getMento` (`:37`) reaches the network: + do not call it in a unit test. +- `src/lib/errors.ts`: `handleError` maps messages and calls `process.exit(1)` on every path. + Testing it means stubbing `process.exit` and `console.error`. +- `src/lib/utils.ts`: `findToken` is not exported; `resolveTokenSync` reads the SDK's token + cache. Test through `resolveTokenSync` with `getCachedTokens` mocked, or leave it for a + later task and say so. +- `.github/workflows/skein-gate.yml`: CI runs `scripts/skein/skein gate --from-branch` on your + PR, which runs the `standard` commands and then your `accept` commands. + +## You own +- `package.json` (scripts and devDependencies only; `name`, `version`, `bin`, `files`, + `dependencies`, `prepack` unchanged) +- `tsconfig.json` +- `vitest.config.ts` (new) +- `tests/**` (new) +- `.gitignore` + +`package-lock.json` is always allowed. Nothing under `src/`: you test what is there and you +do not change it. If a test reveals a bug (there is at least one in `parseAmount`, see the +CLI-02 note in `docs/plan/wps.json`), let the test describe today's behaviour, leave the bug, +and name it in your hand-off. Fixing it is CLI-02. + +## Hard rules for this task +- **No network, no keys.** Every test runs offline. Nothing imports `getMento` or constructs a + wallet. (AGENTS.md P1, P2.) +- **No new runtime dependencies.** Everything you add is a `devDependency`: `vitest` and + nothing else unless you name why in the hand-off. +- **`npm run build` still produces the same `dist/`**: `tsconfig.json` may gain an `exclude` + for `tests`, but `rootDir`, `outDir` and `include` keep their meaning. Check `dist/` has no + `tests/` directory after a build. +- **CI passes with no secrets.** There are none to set; keep it that way. + +## Work +1. **Scripts.** In `package.json` add `"typecheck": "tsc --noEmit"` and `"test": "vitest run"`, + plus `"test:watch": "vitest"`. Add `vitest` (current 3.x) as a devDependency with + `npm install --save-dev`, so `package-lock.json` updates for that reason only. +2. **Config.** `vitest.config.ts`: `test.include: ["tests/**/*.test.ts"]`, node environment. + Because `tsconfig.json` has `include: ["src/**/*"]`, either add `tests` to `include` with + `rootDir` handling, or add a `tsconfig.test.json` that extends it; pick the one that keeps + `npm run build` unchanged and `npm run typecheck` covering the tests too. Say which in the + hand-off. +3. **Tests over the pure modules**, in `tests/`, one file per module, named by the module: + - `format.test.ts`: `formatAddress` (short address returned unchanged, long address + truncated with the default and a custom `chars`); `parseAmount` (`"1"`, `"1.5"`, + `"0.000001"` at 18 and 6 decimals, excess fractional digits truncated, and one test + documenting what `"abc"` does today); `formatTokenAmount` (`"0"`, values below one unit, + thousands separators, `displayDecimals` 0 and default, bigint and string input). + - `client.test.ts`: `resolveChainId` for `celo`, `CELO`, `celo-sepolia`, a numeric string, + and the error for an unknown name. + - `errors.test.ts`: `handleError` for at least four message classes (market closed, + insufficient, no route, network) and the default, asserting the message printed and that + `process.exit(1)` was called, with both stubbed via `vi.spyOn`. + Aim for roughly 25 tests. Clear over clever: these are the reference for what a test in + this repo looks like. +4. **Ignore.** `.gitignore` gains `coverage/` if you enable coverage; nothing else changes. +5. **Run the gate**: `scripts/skein/skein gate CLI-00`. Its `standard` step runs + `npm run build`; its `accept` step runs `npm run typecheck` and `npm test`. + +## What "done" looks like +`scripts/skein/skein gate CLI-00` ends `status=PASS`. `npm test` runs your tests offline in +under ten seconds. `npm run build` still succeeds and `dist/` contains no tests. A gate that +goes green because the suite is empty is a failed task, not a passed one: report how many +tests run, how long the suite takes, and anything in the existing code that looked wrong and +that you left alone. + +## Acceptance +`scripts/skein/skein gate CLI-00`, whose `accept` step runs `npm run typecheck` and `npm test`. +The coordinator reads the diff of `package.json` and `tsconfig.json`. + +Be direct and economical. Do not poll GitHub. Finish with the completion envelope from +`AGENTS.md`. diff --git a/docs/plan/wps.json b/docs/plan/wps.json new file mode 100644 index 0000000..f7bfd36 --- /dev/null +++ b/docs/plan/wps.json @@ -0,0 +1,68 @@ +{ + "version": 1, + "project": "mento-cli", + "notes": [ + "Source of truth for the task DAG. Coordinator-owned: no task edits this file.", + "A task may change only paths matching its `owned` globs, plus `alwaysAllowed`. `coordinatorOwned` always wins.", + "`brief: null` means NOT DISPATCHABLE. Write docs/plan/briefs/<id>.md first (the /skein-brief skill), then set the path here.", + "Keep `owned` globs disjoint between tasks that run at the same time; `skein plan` prints overlaps.", + "money: true and pii: true make a cross-model review mandatory before merge. `done: true` is set by skein merge.", + "This repo is the team's sandbox for running several coordinators at once. CLI-00 is briefed and dispatchable; CLI-01 and CLI-02 are deliberately unbriefed so a new coordinator's first act is to brief one." + ], + "modelPolicy": { + "principle": "The defects that matter are found by cross-model review, whichever model wrote the code. The reviewer is where model strength has the most leverage.", + "structure": "at most 6 agents on this repo across all coordinators (maxAgents in .skein/config.json); disjoint file ownership per brief; fix rounds resume the author session rather than starting a fresh agent" + }, + "coordinatorOwned": [ + "AGENTS.md", + ".skein/**", + "scripts/skein/**", + "docs/plan/**", + "scripts/githooks/**", + ".github/**", + ".superset/**", + "bin/**" + ], + "alwaysAllowed": ["package-lock.json", "docs/adr/DRAFT-*.md"], + "tasks": [ + { + "id": "CLI-00", + "title": "Test harness: typecheck and test scripts so the gate can judge work", + "type": "chore", + "slug": "test-harness", + "deps": [], + "tier": "default", + "owned": ["package.json", "tsconfig.json", "vitest.config.ts", "tests/**", ".gitignore"], + "accept": ["npm run typecheck", "npm test"], + "needs": [], + "brief": "docs/plan/briefs/CLI-00.md", + "notes": "Phase 0. package.json has only build/dev/prepack, so .skein/config.json standard is `npm run build` until this merges; the coordinator then sets standard to typecheck + test. No product behaviour changes." + }, + { + "id": "CLI-01", + "title": "README: correct the repository and install instructions", + "type": "docs", + "slug": "readme-install", + "deps": [], + "tier": "mechanical", + "owned": ["README.md"], + "accept": ["npm run build"], + "needs": [], + "brief": null, + "notes": "README.md still says `git clone .../mento-cli.git` and the package was renamed to @mento-protocol/cli in #6. Small, docs-only, no deps: a good first brief for a new coordinator. Check every command block against src/commands while there." + }, + { + "id": "CLI-02", + "title": "parseAmount rejects malformed amounts with a clear error", + "type": "fix", + "slug": "parse-amount-validation", + "deps": ["CLI-00"], + "tier": "default", + "owned": ["src/lib/format.ts", "tests/format.test.ts"], + "accept": ["npm test -- tests/format.test.ts"], + "needs": [], + "brief": null, + "notes": "src/lib/format.ts parseAmount splits on '.' and calls BigInt(): `1e3`, `abc`, `-1`, `1.2.3` and an empty string either throw a raw SyntaxError (handleError prints it verbatim) or silently produce a wrong bigint. Needs CLI-00's test runner. Pure module, no network: a good second brief." + } + ] +} diff --git a/scripts/githooks/pre-push b/scripts/githooks/pre-push new file mode 100755 index 0000000..d417886 --- /dev/null +++ b/scripts/githooks/pre-push @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Refuse a push to main. Kit-owned (installed by skein init; AGENTS.md workflow 4). +# +# Branch protection with the gate as a required check is the real guard where the repo's +# plan allows it. This hook stops the accident on every machine regardless: the lifecycle +# setup points core.hooksPath at this directory in every workspace. +# +# The coordinator, and only the coordinator, pushes the plan and merges to main: +# CLI_COORDINATOR=1 git push origin HEAD:main +# A guard, not a permission system: anyone can set the variable. +set -uo pipefail +while read -r _local_ref _local_sha remote_ref _remote_sha; do + [ -n "${remote_ref:-}" ] || continue + if [ "$remote_ref" = "refs/heads/main" ] && [ "${CLI_COORDINATOR:-}" != "1" ]; then + echo "pre-push: refusing to push to main." >&2 + echo " Work on your task's branch and open a draft pull request (AGENTS.md, workflow 4)." >&2 + echo " The coordinator merges with 'skein merge'. Coordinator pushes: CLI_COORDINATOR=1 git push origin HEAD:main" >&2 + exit 1 + fi +done +exit 0 diff --git a/scripts/skein/VERSION b/scripts/skein/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/scripts/skein/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/scripts/skein/commands/brief.sh b/scripts/skein/commands/brief.sh new file mode 100644 index 0000000..e7ff022 --- /dev/null +++ b/scripts/skein/commands/brief.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# skein brief <TASK> [--check | --new] +# --check validate the brief against its task in the plan (default) +# --new scaffold docs/plan/briefs/<TASK>.md from the template for the coordinator to fill +# The /skein-brief skill is what writes a brief; this command only validates or scaffolds. +. "$SKEIN_HOME/lib/common.sh" +require_config +TASK="${1:-}"; MODE="${2:---check}" +[ -n "$TASK" ] || die "usage: skein brief <TASK> [--check|--new]" +t="$(task_json "$TASK")"; ID="$(jq -r .id <<<"$t")" +case "$MODE" in + --check) exec node "$SKEIN_HOME/lib/plan.mjs" --check "$ID" ;; + --new) + out="$BRIEFS/$ID.md"; mkdir -p "$BRIEFS" + [ -e "$out" ] && die "$out already exists" + node "$SKEIN_HOME/lib/render.mjs" "$SKEIN_HOME/templates/brief.md.tmpl" "$out" \ + ID="$ID" TITLE="$(task_field "$t" title)" OWNED="$(jq -r '.owned[] | "- `\(.)`"' <<<"$t")" \ + ACCEPT="$(jq -r '.accept[] | "- `\(.)`"' <<<"$t")" GATE="$(cfg .gate 'scripts/skein/skein gate')" NAME="$(cfg .name)" + log "scaffolded $out; fill Objective, Read first, Hard rules and Work, then: skein brief $ID --check" ;; + *) die "unknown mode $MODE" ;; +esac diff --git a/scripts/skein/commands/dispatch.sh b/scripts/skein/commands/dispatch.sh new file mode 100644 index 0000000..b621d96 --- /dev/null +++ b/scripts/skein/commands/dispatch.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# skein dispatch <TASK> [--tag t] [--model m] [--agent a] [--effort e] +# Claim the task on the board, create the workspace, wait for setup, launch the worker. +# Prints one JSON line. Every refusal here has cost a rerun somewhere: a stale brief, a +# workspace forked from old main, a worker racing the install, a cap already spent. +. "$SKEIN_HOME/lib/common.sh"; require_config; load_board; load_driver; . "$SKEIN_HOME/lib/caps.sh" +need git jq node + +TASK=""; TAG=running; MODEL="-"; AGENT=""; EFFORT="" +while [ $# -gt 0 ]; do case "$1" in + --tag) TAG="$2"; shift 2 ;; --model) MODEL="$2"; shift 2 ;; --agent) AGENT="$2"; shift 2 ;; --effort) EFFORT="$2"; shift 2 ;; + -*) die "unknown flag $1" ;; *) TASK="$1"; shift ;; esac; done +[ -n "$TASK" ] || die "usage: skein dispatch <TASK> [--tag t] [--model m] [--agent a] [--effort e]" + +t="$(task_json "$TASK")"; ID="$(jq -r .id <<<"$t")"; TITLE="$(task_field "$t" title)" +BRANCH="$(task_branch "$t")"; WS_NAME="$(task_ws_name "$t")" +[ -z "$AGENT" ] && AGENT="$(cfg .worker.agent claude)" +[ "$MODEL" = "-" ] && MODEL="$(cfg .worker.model -)" +[ -z "$EFFORT" ] && EFFORT="$(cfg .worker.effort)" +GATE="$(cfg .gate 'scripts/skein/skein gate')" +BASE="$(cfg .baseBranch main)" + +fail() { emit --arg task "$ID" --arg error "$1" '{task:$task, ok:false, error:$error}'; exit 1; } + +# 1. Dispatchable: brief valid, deps merged, and both on origin (workspaces fork from origin). +node "$SKEIN_HOME/lib/plan.mjs" --check "$ID" >/dev/null 2>&1 || fail "$(node "$SKEIN_HOME/lib/plan.mjs" --check "$ID" 2>&1 | tr '\n' ' ')" +node "$SKEIN_HOME/lib/plan.mjs" --dispatchable | grep -qx "$ID" || fail "$ID is not dispatchable: a dependency is not merged (see skein plan)" +git -C "$ROOT" fetch -q origin 2>/dev/null +git -C "$ROOT" diff --quiet "origin/$BASE" -- "$(task_field "$t" brief)" "$PLAN" 2>/dev/null \ + || fail "brief or plan differs from origin/$BASE: push $BASE first (workspaces fork from origin)" + +# 2. Caps, then the claim. The claim is the lock between coordinators. +capmsg="$(check_caps)" || fail "$capmsg" +log "$capmsg" +BOARD_URL="$(board_claim "$ID" "$TITLE" "$BRANCH")" || exit 1 + +# 3. Workspace, setup, sanity. +created="$(driver_create "$WS_NAME" "$BRANCH" "$BASE" "$TAG")" || { board_release "$ID" ready; exit 1; } +WS="$(jq -r .ws <<<"$created")"; SETUP="$(jq -r .setup <<<"$created")" +driver_wait_setup "$WS" "$SETUP" "$(cfg .setupTimeout 300)" || { board_release "$ID" blocked; fail "setup failed in workspace $WS"; } +PATH_WS="$(driver_path "$WS")"; [ -d "$PATH_WS" ] || fail "cannot find the worktree for $WS_NAME" +[ -f "$PATH_WS/$(task_field "$t" brief)" ] || fail "brief missing in the worktree (is it on origin/$BASE?)" +git -C "$PATH_WS" fetch -q origin 2>/dev/null +HEAD="$(git -C "$PATH_WS" rev-parse --short HEAD)"; MAIN="$(git -C "$PATH_WS" rev-parse --short "origin/$BASE")" +[ "$HEAD" = "$MAIN" ] || fail "workspace HEAD $HEAD is not origin/$BASE $MAIN" + +# 4. The worker prompt: fixed, bounded, and the same for every task. +PROMPT="$(cfg .workerPrompt)" +[ -n "$PROMPT" ] || PROMPT="You are worker {ID} in a multi-agent build of {NAME}. Read AGENTS.md, then {BRIEF}, and follow the brief exactly. Work only in this workspace and on this branch. Your gate is \`{GATE} {ID}\`; do not use any other workflow or skill to test, review or ship. When the gate passes, push the branch, open a draft pull request, and end your final message with the completion envelope described in AGENTS.md. Never merge a pull request." +PROMPT="${PROMPT//\{ID\}/$ID}"; PROMPT="${PROMPT//\{NAME\}/$(cfg .name "$(basename "$ROOT")")}" +PROMPT="${PROMPT//\{BRIEF\}/$(task_field "$t" brief)}"; PROMPT="${PROMPT//\{GATE\}/$GATE}" + +TERM_ID="$(driver_launch "$WS" "$AGENT" "$MODEL" "$EFFORT" "$PROMPT")" || { board_release "$ID" blocked; exit 1; } +board_comment "$ID" "Dispatched by $(me): workspace \`$WS_NAME\`, branch \`$BRANCH\`, agent $AGENT${MODEL:+ ($MODEL)}, head $HEAD." +emit --arg task "$ID" --arg ws "$WS" --arg term "$TERM_ID" --arg branch "$BRANCH" --arg head "$HEAD" \ + --arg model "$AGENT:$MODEL${EFFORT:+:$EFFORT}" --arg path "$PATH_WS" --arg board "$BOARD_URL" \ + '{task:$task, ok:true, workspace:$ws, terminal:$term, branch:$branch, head:$head, model:$model, worktree:$path, board:$board}' diff --git a/scripts/skein/commands/doctor.sh b/scripts/skein/commands/doctor.sh new file mode 100644 index 0000000..d5d59a0 --- /dev/null +++ b/scripts/skein/commands/doctor.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# skein doctor — check this machine and this repo before coordinating. Read-only. +. "$SKEIN_HOME/lib/common.sh" +ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } +bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; FAILS=$((FAILS+1)); } +FAILS=0 +echo "skein doctor ($(cat "$SKEIN_HOME/VERSION" 2>/dev/null || echo dev)) in $ROOT" +for b in git node jq; do command -v "$b" >/dev/null && ok "$b $(command -v "$b")" || bad "$b missing"; done +have_config && ok ".skein/config.json ($(cfg .name), prefix $PREFIX, board $BOARD_TYPE, driver $DRIVER_TYPE)" || { bad "no .skein/config.json: run skein init"; exit 1; } +[ -f "$PLAN" ] && ok "plan $PLAN ($(jq '.tasks|length' "$PLAN") tasks)" || bad "plan missing: $PLAN" +[ -x "$ROOT/$(cfg .vendor scripts/skein)/skein" ] && ok "vendored skein $(cat "$ROOT/$(cfg .vendor scripts/skein)/VERSION" 2>/dev/null)" || bad "vendored scripts missing: run skein init" +[ "$(git config core.hooksPath)" = "scripts/githooks" ] && ok "hooks path set" || bad "git config core.hooksPath scripts/githooks (not set)" +git ls-remote origin >/dev/null 2>&1 && ok "origin reachable" || bad "origin not reachable" +case "$BOARD_TYPE" in + github) command -v gh >/dev/null && gh auth status >/dev/null 2>&1 && ok "gh authenticated as $(gh api user -q .login 2>/dev/null)" || bad "gh not authenticated (gh auth login)" ;; + superset) command -v superset >/dev/null && superset auth whoami --json >/dev/null 2>&1 && ok "superset authenticated" || bad "superset CLI not authenticated (superset auth login)" ;; + none) ok "board: none (single coordinator)" ;; +esac +case "$DRIVER_TYPE" in + superset) + if command -v superset >/dev/null && superset auth whoami --json >/dev/null 2>&1; then + load_driver; pid="$(driver_project_id 2>/dev/null)" && ok "superset project $pid" || bad "superset project for this repo not found (add it in the app, or SKEIN_PROJECT_ID)" + else bad "superset CLI not usable: is the desktop app running and logged in?"; fi ;; + local) command -v claude >/dev/null && ok "claude on PATH (local driver)" || bad "claude CLI missing" ;; +esac +command -v codex >/dev/null && ok "codex on PATH (cross-model review)" || printf ' \033[33m•\033[0m codex not installed: skein review falls back to claude %s\n' "$(cfg .review.fallbackModel)" +. "$SKEIN_HOME/lib/caps.sh"; ok "caps: machine $(machine_cap) ($USER_CONFIG), repo $(repo_cap) (.skein/config.json)" +[ "$(jq '.standard|length' "$CONFIG")" -gt 0 ] && ok "standard: $(jq -r '.standard|join(" && ")' "$CONFIG")" || bad "no standard commands: the gate cannot judge work" +[ "$FAILS" -eq 0 ] && echo "all good" || { echo "$FAILS problem(s)"; exit 1; } diff --git a/scripts/skein/commands/gate.sh b/scripts/skein/commands/gate.sh new file mode 100644 index 0000000..a58f9ab --- /dev/null +++ b/scripts/skein/commands/gate.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +. "$SKEIN_HOME/lib/common.sh" +exec node "$SKEIN_HOME/lib/gate.mjs" "$@" diff --git a/scripts/skein/commands/merge.sh b/scripts/skein/commands/merge.sh new file mode 100644 index 0000000..02c3774 --- /dev/null +++ b/scripts/skein/commands/merge.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# skein merge <TASK> [--no-gate] +# The coordinator's merge: rerun the gate in the worker's worktree, squash-merge the PR, +# close the claim, delete the workspace, mark the task done in the plan and log it. +# Refuses a task claimed by someone else, and never merges a money/pii task that has no +# review comment on its PR. +. "$SKEIN_HOME/lib/common.sh"; require_config; load_board; load_driver +need gh jq git node +TASK=""; NOGATE="" +while [ $# -gt 0 ]; do case "$1" in --no-gate) NOGATE=1; shift ;; -*) die "unknown flag $1" ;; *) TASK="$1"; shift ;; esac; done +[ -n "$TASK" ] || die "usage: skein merge <TASK> [--no-gate]" +t="$(task_json "$TASK")"; ID="$(jq -r .id <<<"$t")"; BRANCH="$(task_branch "$t")" +BASE="$(cfg .baseBranch main)"; GATE_ENTRY="$ROOT/$(cfg .vendor scripts/skein)/skein" + +owner="$(board_owner "$ID")"; me="$(me)" +[ -z "$owner" ] || [ "$owner" = "$me" ] || die "$ID is claimed by $owner, not you" + +PR="$(gh pr list --head "$BRANCH" --state open --json number,url,reviews,comments -q '.[0]' 2>/dev/null)" +[ -n "$PR" ] || die "no open PR for branch $BRANCH" +PRNUM="$(jq -r .number <<<"$PR")"; PRURL="$(jq -r .url <<<"$PR")" +if [ "$(task_field "$t" money)" = "true" ] || [ "$(task_field "$t" pii)" = "true" ]; then + n="$(gh pr view "$PRNUM" --json comments -q '[.comments[] | select(.body | test("review"; "i"))] | length' 2>/dev/null || echo 0)" + [ "$n" -gt 0 ] || die "$ID is money/pii and PR #$PRNUM has no review comment: run 'skein review $ID' first" +fi + +WS="$(driver_find "$ID")"; WPATH=""; [ -n "$WS" ] && WPATH="$(driver_path "$WS")" +if [ -z "$NOGATE" ]; then + [ -n "$WPATH" ] && [ -d "$WPATH" ] || die "no local worktree for $ID to run the gate in (use --no-gate only if CI ran it)" + git -C "$WPATH" fetch -q origin + [ -x "$WPATH/$(cfg .vendor scripts/skein)/skein" ] && GATE_ENTRY="$WPATH/$(cfg .vendor scripts/skein)/skein" + ( cd "$WPATH" && bash "$GATE_ENTRY" gate "$ID" ) || die "gate failed for $ID; not merging" +fi + +gh pr ready "$PRNUM" >/dev/null 2>&1 || true +gh pr merge "$PRNUM" --squash --delete-branch >/dev/null || die "merge failed for PR #$PRNUM" +log "merged $PRURL" +board_close "$ID" "$PRURL" +[ -n "$WS" ] && driver_delete "$WS" + +# Record: done in the plan, a line in the log, pushed by the coordinator. +( cd "$ROOT" && git pull -q --rebase origin "$BASE" 2>/dev/null + tmp="$(mktemp)"; jq --arg id "$ID" '(.tasks[] | select(.id==$id)).done = true' "$PLAN" > "$tmp" && mv "$tmp" "$PLAN" + mkdir -p "$(dirname "$PLAN")"; printf '%s %s merged %s by %s\n' "$(date -u +%Y-%m-%dT%H:%MZ)" "$ID" "$PRURL" "$me" >> "$(dirname "$PLAN")/LOG.md" + git add "$PLAN" "$(dirname "$PLAN")/LOG.md" && git commit -q -m "plan: $ID merged ($PRURL)" \ + && env "$COORD_ENV=1" git push -q origin "HEAD:$BASE" && log "plan updated on $BASE" ) || warn "could not record the merge in the plan; do it by hand" +emit --arg task "$ID" --arg pr "$PRURL" '{task:$task, ok:true, merged:$pr}' diff --git a/scripts/skein/commands/plan.sh b/scripts/skein/commands/plan.sh new file mode 100644 index 0000000..09bc3c6 --- /dev/null +++ b/scripts/skein/commands/plan.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +. "$SKEIN_HOME/lib/common.sh" +exec node "$SKEIN_HOME/lib/plan.mjs" "$@" diff --git a/scripts/skein/commands/review.sh b/scripts/skein/commands/review.sh new file mode 100644 index 0000000..69a127a --- /dev/null +++ b/scripts/skein/commands/review.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# skein review <TASK> [--agent codex|claude] [--model m] +# Cross-model review of the task's PR in a read-only worktree forked from the PR branch, +# headless. Posts the findings as one PR comment and prints the file. Mandatory for tasks +# marked money or pii; the coordinator triages each finding on its merits. +. "$SKEIN_HOME/lib/common.sh"; require_config; load_board; load_driver +need gh jq +TASK=""; AGENT=""; MODEL="" +while [ $# -gt 0 ]; do case "$1" in + --agent) AGENT="$2"; shift 2 ;; --model) MODEL="$2"; shift 2 ;; -*) die "unknown flag $1" ;; *) TASK="$1"; shift ;; esac; done +[ -n "$TASK" ] || die "usage: skein review <TASK> [--agent codex|claude] [--model m]" +t="$(task_json "$TASK")"; ID="$(jq -r .id <<<"$t")"; BRANCH="$(task_branch "$t")"; SLUG="$(task_field "$t" slug)" +[ -z "$AGENT" ] && AGENT="$(cfg .review.agent codex)" +[ -z "$MODEL" ] && MODEL="$(cfg .review.model)" +command -v "$AGENT" >/dev/null 2>&1 || { [ "$AGENT" = codex ] && { warn "codex not installed; falling back to claude"; AGENT=claude; MODEL="$(cfg .review.fallbackModel claude-fable-5-1)"; }; } + +PR="$(gh pr list --head "$BRANCH" --state open --json number,url -q '.[0]' 2>/dev/null)" +[ -n "$PR" ] || die "no open PR for branch $BRANCH" +PRNUM="$(jq -r .number <<<"$PR")"; PRURL="$(jq -r .url <<<"$PR")" + +created="$(driver_create "review: ${SLUG:-$ID} ($ID)" "review-${SLUG:-$(tr '[:upper:]' '[:lower:]' <<<"$ID")}-$(date +%s)" "$BRANCH" review)" || exit 1 +WS="$(jq -r .ws <<<"$created")"; RPATH="$(driver_path "$WS")" +driver_wait_setup "$WS" "$(jq -r .setup <<<"$created")" "$(cfg .setupTimeout 300)" || warn "review workspace setup did not finish cleanly; reviewing anyway" + +OUT="${TMPDIR:-/tmp}/skein-review-$ID-$(date +%s).md" +PROMPT="$(node "$SKEIN_HOME/lib/render.mjs" "$SKEIN_HOME/templates/review-prompt.md" /dev/stdout ID="$ID" PR="$PRURL" BRIEF="$(task_field "$t" brief)" BASE="origin/$(cfg .baseBranch main)" MONEY="$(task_field "$t" money)")" +log "reviewing $ID (PR #$PRNUM) with $AGENT${MODEL:+ $MODEL} in $RPATH" +case "$AGENT" in + codex) + # Headless on purpose: out of quota, Codex's interactive screen opens on an "Upgrade" + # menu that a pasted keystroke can confirm. codex exec just exits with the reset time. + ( cd "$RPATH" && codex exec ${MODEL:+-m "$MODEL"} -c model_reasoning_effort=high --dangerously-bypass-approvals-and-sandbox -o "$OUT" "$PROMPT" < /dev/null ) >/dev/null 2>&1 \ + || warn "codex exec exited non-zero; check $OUT" ;; + claude) + ( cd "$RPATH" && claude -p "$PROMPT" ${MODEL:+--model "$MODEL"} --dangerously-skip-permissions > "$OUT" 2>/dev/null ) \ + || warn "claude exited non-zero; check $OUT" ;; + *) die "unknown review agent $AGENT" ;; +esac +[ -s "$OUT" ] || die "review produced no output ($OUT)" +gh pr comment "$PRNUM" --body-file "$OUT" >/dev/null 2>&1 && log "posted review to $PRURL" || warn "could not post the PR comment; findings are in $OUT" +board_release "$ID" gating +driver_delete "$WS" +emit --arg task "$ID" --arg pr "$PRURL" --arg file "$OUT" --arg agent "$AGENT:$MODEL" '{task:$task, ok:true, pr:$pr, file:$file, reviewer:$agent}' diff --git a/scripts/skein/commands/send.sh b/scripts/skein/commands/send.sh new file mode 100644 index 0000000..d477a68 --- /dev/null +++ b/scripts/skein/commands/send.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# skein send <TASK> "message" — deliver one line to a running worker, quoting handled. +. "$SKEIN_HOME/lib/common.sh"; require_config; load_driver +TASK="${1:-}"; MSG="${2:-}"; [ -n "$TASK" ] && [ -n "$MSG" ] || die 'usage: skein send <TASK> "message"' +ID="$(task_id_norm "$TASK")" +WS="$(driver_find "$ID")"; [ -n "$WS" ] || die "no workspace found for $ID" +TERM_ID="$(driver_agent_terminal "$WS")"; [ -n "$TERM_ID" ] || die "no agent terminal in workspace $WS" +driver_send "$WS" "$TERM_ID" "$(tr '\n' ' ' <<<"$MSG")" && log "sent to $ID" diff --git a/scripts/skein/commands/status.sh b/scripts/skein/commands/status.sh new file mode 100644 index 0000000..55716d8 --- /dev/null +++ b/scripts/skein/commands/status.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# skein status — every task running anywhere (the board) and every worker running here +# (the driver), with both caps. Read-only: never sends anything to a worker. +. "$SKEIN_HOME/lib/common.sh"; require_config; load_board; load_driver; . "$SKEIN_HOME/lib/caps.sh" +B=$'\033[1m'; D=$'\033[2m'; R=$'\033[0m'; G=$'\033[32m'; Y=$'\033[33m'; RED=$'\033[31m'; C=$'\033[36m' +printf '%sskein status%s %s %s\n\n' "$B" "$R" "$(cfg .name "$(basename "$ROOT")")" "$(date '+%H:%M:%S')" +printf '%scaps%s machine %s/%s repo %s/%s (board: %s, driver: %s)\n\n' "$B" "$R" "$(driver_running_count)" "$(machine_cap)" "$(board_running_count)" "$(repo_cap)" "$BOARD_TYPE" "$DRIVER_TYPE" + +printf '%sboard%s (claims across all coordinators)\n' "$B" "$R" +board="$(board_list)" +if [ -z "$board" ]; then echo " no claimed tasks"; else printf '%s\n' "$board" | awk -F'\t' '{printf " %-10s %-9s %-14s %s\n", $1, $2, $3, $4}'; fi +echo + +printf '%sworkers on this machine%s\n' "$B" "$R" +workspaces="$(driver_list)" +if [ -z "$workspaces" ]; then echo " none"; fi +while IFS=$'\t' read -r ws name path tags; do + [ -n "$ws" ] || continue + id="$(grep -o -i -E "${PREFIX}-[a-z0-9]+" <<<"$name" | head -1 | tr '[:lower:]' '[:upper:]')" + term="$(driver_agent_terminal "$ws")" + state="${D}no agent${R}"; said="" + if [ -n "$term" ]; then + text="$(driver_read "$ws" "$term" 200)" + if grep -q "${ENVELOPE}_DONE" <<<"$text" && grep -q -i -E "task:[[:space:]]*${id}\b" <<<"$text"; then state="${G}DONE, awaiting gate${R}" + elif grep -q "${ENVELOPE}_BLOCKED" <<<"$text" && grep -q -i -E "task:[[:space:]]*${id}\b" <<<"$text"; then state="${RED}BLOCKED${R}" + elif grep -q -i -E "usage limit|limit reached|rate limit" <<<"$text"; then state="${RED}usage limit${R}" + elif grep -q -E "Waiting for API|will retry|API Error" <<<"$(tail -n 12 <<<"$text")"; then state="${Y}API retry${R}" + elif grep -q -E "… \([0-9]+[hms]|^● " <<<"$(tail -n 12 <<<"$text")"; then state="${G}working${R}" + else state="${Y}idle at prompt${R}"; fi + said="$(grep -E '^\s*●\s+[A-Z]' <<<"$text" | grep -v -E '●\s+(Bash|Read|Write|Edit|Grep|Glob|Agent|Task)\(' | tail -1 | sed 's/^\s*●\s*//' | cut -c1-160)" + fi + commits="-"; dirty="-" + if [ -n "$path" ] && [ -d "$path" ]; then + commits="$(git -C "$path" rev-list --count "origin/$(cfg .baseBranch main)..HEAD" 2>/dev/null || echo '?')" + dirty="$(git -C "$path" status --porcelain 2>/dev/null | wc -l | tr -d ' ')" + fi + printf ' %s%-40s%s %s %s%s%s\n' "$B" "$name" "$R" "$state" "$D" "${tags:+[$tags]}" "$R" + printf ' commits %-3s uncommitted %-3s %s%s%s\n' "$commits" "$dirty" "$C" "$path" "$R" + [ -n "$said" ] && printf ' last note %s\n' "$said" +done <<<"$workspaces" +printf '\n%sworking = spinner active · idle at prompt = finished a turn without an envelope · the gate, not this view, decides done%s\n' "$D" "$R" diff --git a/scripts/skein/commands/watch.sh b/scripts/skein/commands/watch.sh new file mode 100644 index 0000000..d7340f2 --- /dev/null +++ b/scripts/skein/commands/watch.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# skein watch <TASK> [ws] [term] [max-hours] [poll-seconds] [marker] [--resume] +# Poll one worker until it emits a completion envelope naming this task, vanishes, or +# times out. With --resume, envelopes already on screen when the watch starts are ignored +# (a resumed session still shows its last round's envelope): start it BEFORE sending the +# follow-up prompt. Prints SKEIN_WATCH task=<id> outcome=DONE|BLOCKED|… +. "$SKEIN_HOME/lib/common.sh"; require_config; load_driver +need jq node +RESUME=""; args=(); for a in "$@"; do [ "$a" = "--resume" ] && RESUME=1 || args+=("$a"); done; set -- "${args[@]}" +TASK="${1:-}"; [ -n "$TASK" ] || die "usage: skein watch <TASK> [ws] [term] [max-hours] [poll-s] [marker] [--resume]" +ID="$(task_id_norm "$TASK")" +WS="${2:-}"; { [ -z "$WS" ] || [ "$WS" = "-" ]; } && WS="$(driver_find "$ID")"; [ -n "$WS" ] || die "no workspace found for $ID" +TERM_ID="${3:-}"; { [ -z "$TERM_ID" ] || [ "$TERM_ID" = "-" ]; } && TERM_ID="$(driver_agent_terminal "$WS")"; [ -n "$TERM_ID" ] || die "no agent terminal in workspace $WS" +MAX_H="${4:-8}"; EVERY="${5:-120}"; MARKER="${6:-}" +DIR="${SKEIN_STATE_DIR:-${TMPDIR:-/tmp}/skein-$(basename "$ROOT")}"; mkdir -p "$DIR" +LOG="$DIR/watch-$ID.log"; SNAP="$DIR/watch-$ID.last.txt"; BASE="$DIR/watch-$ID.baseline.json"; rm -f "$BASE" +deadline=$(( $(date +%s) + MAX_H * 3600 )); misses=0 +echo "$(date -Is) watching $ID ws=$WS term=$TERM_ID every ${EVERY}s for up to ${MAX_H}h" >> "$LOG" + +while [ "$(date +%s)" -lt "$deadline" ]; do + text="$(driver_read "$WS" "$TERM_ID" 240)" + if [ -z "$text" ]; then + misses=$((misses+1)); echo "$(date -Is) empty read ($misses)" >> "$LOG" + [ "$misses" -ge 5 ] && { echo "SKEIN_WATCH task=$ID outcome=TERMINAL_UNREADABLE"; exit 2; } + else + misses=0; printf '%s\n' "$text" > "$SNAP" + if [ -n "$MARKER" ]; then text="$(MARKER="$MARKER" node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const i=s.lastIndexOf(process.env.MARKER);process.stdout.write(i<0?s:s.slice(i))})' <<<"$text")"; fi + mode=check; if [ ! -f "$BASE" ]; then if [ -n "$RESUME" ]; then mode=init; else echo "[]" > "$BASE"; fi; fi + verdict="$(printf '%s' "$text" | ENVELOPE="$ENVELOPE" node -e ' + const fs=require("fs");const [task,base,mode]=process.argv.slice(1);let s=""; + process.stdin.on("data",d=>s+=d).on("end",()=>{ + const re=new RegExp(process.env.ENVELOPE+"_(DONE|BLOCKED)[^\\n]*\\n(?:[^\\n]*\\n){0,2}?[^\\n]*task:\\s*"+task.replace(/[-.]/g,"\\$&")+"\\b[^\\n]*\\n[^\\n]*(?:summary|reason):[ \\t]*([^\\n]*)","gi"); + const found=[...s.matchAll(re)].filter(x=>!/[<>]/.test(x[2])&&x[2].trim().length>0).map(x=>({verdict:x[1].toUpperCase(),key:x[0].replace(/\s+/g," ").trim()})); + if(mode==="init"){fs.writeFileSync(base,JSON.stringify(found.map(f=>f.key)));return} + const old=new Set(JSON.parse(fs.readFileSync(base,"utf8")));const fresh=found.filter(f=>!old.has(f.key)); + if(fresh.length)process.stdout.write(fresh[fresh.length-1].verdict)})' "$ID" "$BASE" "$mode")" + [ "$mode" = init ] && echo "$(date -Is) baseline: $(jq length "$BASE") envelope(s) already on screen, ignored" >> "$LOG" + if [ -n "$verdict" ]; then + echo "$(date -Is) envelope: $verdict" >> "$LOG" + echo "SKEIN_WATCH task=$ID outcome=$verdict" + echo "----- last screen -----"; printf '%s\n' "$text" | grep -v '^\s*$' | tail -45 + exit 0 + fi + fi + sleep "$EVERY" +done +echo "SKEIN_WATCH task=$ID outcome=TIMEOUT after ${MAX_H}h"; echo "----- last screen -----"; tail -40 "$SNAP" 2>/dev/null +exit 1 diff --git a/scripts/skein/lib/boards/github.sh b/scripts/skein/lib/boards/github.sh new file mode 100644 index 0000000..bb3668b --- /dev/null +++ b/scripts/skein/lib/boards/github.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Board: GitHub issues. One issue per task, titled "<ID>: <title>". Assigning yourself is +# the claim; an unassigned issue is free. Labels carry the state so the board reads the +# same in the GitHub UI and in `skein status`. Works for teammates who never open Superset. +# +# Interface (every board implements these): +# board_claim <ID> <title> <branch> claim or fail; prints the issue url +# board_release <ID> <state> drop the running state (gating|blocked|ready) +# board_close <ID> <pr-url> close on merge +# board_owner <ID> login holding the claim, or empty +# board_running_count running claims across all coordinators +# board_list tsv: id state owner url +# board_comment <ID> <text> + +need gh jq +BOARD_REPO="$(cfg .board.repo "$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)")" +[ -n "$BOARD_REPO" ] || die "board.repo not set and gh cannot see a repo here" + +_gh_labels_ready="" +_ensure_labels() { + [ -n "$_gh_labels_ready" ] && return 0 + for l in "skein:#5319e7" "state:ready:#0e8a16" "state:running:#1d76db" "state:gating:#fbca04" "state:blocked:#d93f0b"; do + gh label create "${l%%:#*}" --color "${l##*#}" -R "$BOARD_REPO" >/dev/null 2>&1 || true + done + _gh_labels_ready=1 +} + +_issue_for() { # _issue_for <ID> -> json {number,url,assignees,labels} or empty + # Listed, not searched: GitHub's search index lags several seconds behind a create, + # which made a fresh claim look unowned on the very next call. + gh issue list -R "$BOARD_REPO" --state open --label skein --limit 200 \ + --json number,title,url,assignees,labels 2>/dev/null \ + | jq -c --arg id "$1" '[.[] | select(.title | ascii_downcase | startswith(($id|ascii_downcase) + ":"))] | .[0] // empty' +} + +board_claim() { + local id="$1" title="$2" branch="$3" me issue num owner + me="$(me)"; _ensure_labels + issue="$(_issue_for "$id")" + if [ -z "$issue" ]; then + gh issue create -R "$BOARD_REPO" --title "$id: $title" --label skein --label state:running --assignee "$me" \ + --body "Task \`$id\` in \`$(cfg .plan docs/plan/wps.json)\`. Branch \`$branch\`. Claimed by @$me via skein dispatch." 2>/dev/null \ + || die "could not create the board issue for $id" + return 0 + fi + num="$(jq -r .number <<<"$issue")" + owner="$(jq -r '.assignees[0].login // empty' <<<"$issue")" + if [ -n "$owner" ] && [ "$owner" != "$me" ]; then + die "$id is claimed by @$owner (issue #$num). An assigned issue is not yours to dispatch." + fi + if jq -e '.labels[] | select(.name=="state:running")' <<<"$issue" >/dev/null && [ "$owner" = "$me" ]; then + die "$id is already running under your claim (issue #$num). Watch it, or release it first." + fi + gh issue edit "$num" -R "$BOARD_REPO" --add-assignee "$me" --add-label state:running \ + --remove-label state:ready --remove-label state:gating --remove-label state:blocked >/dev/null 2>&1 \ + || die "could not claim issue #$num" + jq -r .url <<<"$issue" +} + +board_release() { + # ready = back on the shelf for anyone (assignee cleared); gating/blocked keep the claim. + local id="$1" state="${2:-gating}" issue num extra=() + issue="$(_issue_for "$id")"; [ -n "$issue" ] || return 0 + num="$(jq -r .number <<<"$issue")"; _ensure_labels + [ "$state" = ready ] && extra=(--remove-assignee "$(me)") + gh issue edit "$num" -R "$BOARD_REPO" --remove-label state:running --remove-label state:gating \ + --remove-label state:blocked --remove-label state:ready --add-label "state:$state" "${extra[@]}" >/dev/null 2>&1 || true +} + +board_close() { + local id="$1" pr="${2:-}" issue num + issue="$(_issue_for "$id")"; [ -n "$issue" ] || return 0 + num="$(jq -r .number <<<"$issue")" + gh issue close "$num" -R "$BOARD_REPO" --comment "Merged${pr:+: $pr}. Closed by skein merge." >/dev/null 2>&1 || true +} + +board_owner() { _issue_for "$1" | jq -r '.assignees[0].login // empty'; } + +board_running_count() { + gh issue list -R "$BOARD_REPO" --state open --label skein --label state:running --limit 200 --json number 2>/dev/null | jq 'length' +} + +board_list() { + gh issue list -R "$BOARD_REPO" --state open --label skein --limit 200 --json title,url,assignees,labels 2>/dev/null \ + | jq -r '.[] | [(.title|split(":")[0]), (([.labels[]?.name | select(startswith("state:")) | sub("state:";"")] | first) // "none"), (.assignees[0].login // "-"), .url] | @tsv' +} + +board_comment() { + local issue num; issue="$(_issue_for "$1")"; [ -n "$issue" ] || return 0 + num="$(jq -r .number <<<"$issue")" + gh issue comment "$num" -R "$BOARD_REPO" --body "$2" >/dev/null 2>&1 || true +} diff --git a/scripts/skein/lib/boards/none.sh b/scripts/skein/lib/boards/none.sh new file mode 100644 index 0000000..5495534 --- /dev/null +++ b/scripts/skein/lib/boards/none.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Board: none. A single coordinator with no shared claims. The repo cap then counts only +# what this machine runs. Same interface as boards/github.sh; everything is a no-op. +board_claim() { return 0; } +board_release() { return 0; } +board_close() { return 0; } +board_owner() { printf ''; } +board_running_count() { driver_running_count 2>/dev/null || echo 0; } +board_list() { return 0; } +board_comment() { return 0; } diff --git a/scripts/skein/lib/boards/superset.sh b/scripts/skein/lib/boards/superset.sh new file mode 100644 index 0000000..df59001 --- /dev/null +++ b/scripts/skein/lib/boards/superset.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Board: Superset's organization task queue. One task per skein task, titled "<ID>: <title>", +# assigned to the claiming user, with labels carrying the state. Every member sees it in +# their sidebar. Best-effort on field names: the CLI's task JSON has changed before. +# Same interface as boards/github.sh. + +need superset jq +_ss_me() { superset auth whoami --json 2>/dev/null | jq -r '.userId // empty'; } +_ss_task_for() { # -> json or empty + superset tasks list --json 2>/dev/null \ + | jq -c --arg id "$1" '(if type=="array" then . else (.tasks // .items // []) end) | map(select((.title // "") | ascii_downcase | startswith(($id|ascii_downcase) + ":"))) | .[0] // empty' +} +_ss_labels() { jq -r '(.labels // []) | if type=="array" then map(if type=="object" then .name else . end) else split(",") end | join(",")' <<<"$1"; } +_ss_set_state() { # <task-json> <state> + local id labels + id="$(jq -r '.id' <<<"$1")" + labels="$(_ss_labels "$1" | tr ',' '\n' | grep -v '^state:' | grep -v '^$' | paste -sd, -)" + superset tasks update "$id" --labels "${labels:+$labels,}skein,state:$2" --json >/dev/null 2>&1 || true +} + +board_claim() { + local id="$1" title="$2" branch="$3" me t owner + me="$(_ss_me)"; [ -n "$me" ] || die "superset auth whoami failed" + t="$(_ss_task_for "$id")" + if [ -z "$t" ]; then + superset tasks create --title "$id: $title" --labels "skein,state:running" --assignee "$me" \ + --description "Task $id. Branch $branch. Claimed via skein dispatch." --json >/dev/null 2>&1 \ + || die "could not create the Superset task for $id" + return 0 + fi + owner="$(jq -r '.assigneeId // .assignee.id // .assignee // empty' <<<"$t")" + if [ -n "$owner" ] && [ "$owner" != "$me" ]; then die "$id is claimed by another member on the Superset board"; fi + if _ss_labels "$t" | grep -q 'state:running' && [ "$owner" = "$me" ]; then die "$id is already running under your claim"; fi + superset tasks update "$(jq -r .id <<<"$t")" --assignee "$me" --json >/dev/null 2>&1 || die "could not claim $id" + _ss_set_state "$t" running +} +board_release() { local t; t="$(_ss_task_for "$1")"; [ -n "$t" ] && _ss_set_state "$t" "${2:-gating}"; return 0; } +board_close() { + local t id; t="$(_ss_task_for "$1")"; [ -n "$t" ] || return 0; id="$(jq -r .id <<<"$t")" + _ss_set_state "$t" done + [ -n "${2:-}" ] && superset tasks update "$id" --pr-url "$2" --json >/dev/null 2>&1 || true +} +board_owner() { _ss_task_for "$1" | jq -r '.assigneeId // .assignee.id // .assignee // empty'; } +board_running_count() { + superset tasks list --json 2>/dev/null \ + | jq '(if type=="array" then . else (.tasks // .items // []) end) | map(select(((.labels // []) | tostring) | test("state:running"))) | length' +} +board_list() { + superset tasks list --json 2>/dev/null \ + | jq -r '(if type=="array" then . else (.tasks // .items // []) end) | .[] | select(((.labels // [])|tostring)|test("skein")) | [(.title|split(":")[0]), (((.labels // [])|tostring|capture("state:(?<s>[a-z]+)").s) // "none"), (.assigneeId // .assignee // "-"), (.url // .id)] | @tsv' +} +board_comment() { return 0; } diff --git a/scripts/skein/lib/caps.sh b/scripts/skein/lib/caps.sh new file mode 100644 index 0000000..b5da6bc --- /dev/null +++ b/scripts/skein/lib/caps.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Two caps, two reasons. The per-machine cap (~/.skein/config.json maxAgents, or +# SKEIN_MAX_AGENTS) protects this laptop and this seat's usage. The per-repo cap +# (.skein/config.json maxAgents) protects the repo: merge rounds, review throughput, +# and how many disjoint tasks exist. A dispatch must fit under both. + +machine_cap() { printf '%s' "${SKEIN_MAX_AGENTS:-$(ucfg .maxAgents 3)}"; } +repo_cap() { printf '%s' "$(cfg .maxAgents 6)"; } + +# Counts come from the driver (local agents this machine runs for this repo) and the +# board (claims running anywhere). Both functions are provided by the loaded modules. +check_caps() { # exits non-zero with a reason on stdout when a dispatch must not happen + local mc rc local_n board_n + mc="$(machine_cap)"; rc="$(repo_cap)" + local_n="$(driver_running_count 2>/dev/null || echo 0)" + board_n="$(board_running_count 2>/dev/null || echo 0)" + if [ "$local_n" -ge "$mc" ]; then + printf 'machine cap reached: %s of %s agents running here (raise maxAgents in %s)\n' "$local_n" "$mc" "$USER_CONFIG"; return 1 + fi + if [ "$board_n" -ge "$rc" ]; then + printf 'repo cap reached: %s of %s tasks running across all coordinators (.skein/config.json maxAgents)\n' "$board_n" "$rc"; return 1 + fi + printf 'caps ok: machine %s/%s, repo %s/%s\n' "$local_n" "$mc" "$board_n" "$rc" +} diff --git a/scripts/skein/lib/common.sh b/scripts/skein/lib/common.sh new file mode 100644 index 0000000..a47bd49 --- /dev/null +++ b/scripts/skein/lib/common.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Shared helpers for every skein command. Sourced, never executed. +# Requires: git, node, jq. Commands that touch a board or a driver require gh or superset. + +set -uo pipefail + +: "${SKEIN_HOME:?SKEIN_HOME must be set by bin/skein}" + +log() { printf 'skein: %s\n' "$*" >&2; } +warn() { printf 'skein: warning: %s\n' "$*" >&2; } +die() { printf 'skein: %s\n' "$*" >&2; exit "${2:-1}"; } +need() { for b in "$@"; do command -v "$b" >/dev/null 2>&1 || die "needs '$b' on PATH"; done; } + +# Repo root and config. Every command runs from anywhere inside the repo. +repo_root() { git rev-parse --show-toplevel 2>/dev/null || die "not inside a git repository"; } +ROOT="$(repo_root)" +CONFIG="$ROOT/.skein/config.json" +USER_CONFIG="${SKEIN_USER_CONFIG:-$HOME/.skein/config.json}" + +have_config() { [ -f "$CONFIG" ]; } +require_config() { have_config || die "no .skein/config.json here: run 'skein init' first"; } + +# cfg <jq-path> [default] — read one value from the repo config. +cfg() { + local path="$1" def="${2:-}" + local v + v="$(jq -r "$path // empty" "$CONFIG" 2>/dev/null)" + [ -n "$v" ] && printf '%s' "$v" || printf '%s' "$def" +} +# ucfg <jq-path> [default] — read one value from the per-machine config. +ucfg() { + local path="$1" def="${2:-}" + local v="" + [ -f "$USER_CONFIG" ] && v="$(jq -r "$path // empty" "$USER_CONFIG" 2>/dev/null)" + [ -n "$v" ] && printf '%s' "$v" || printf '%s' "$def" +} + +PLAN="$ROOT/$(cfg .plan docs/plan/wps.json)" +BRIEFS="$ROOT/$(cfg .briefs docs/plan/briefs)" +PREFIX="$(cfg .prefix TASK)" +ENVELOPE="$(cfg .envelope "${PREFIX}_WORKER")" +BOARD_TYPE="${SKEIN_BOARD:-$(cfg .board.type github)}" +DRIVER_TYPE="${SKEIN_DRIVER:-$(cfg .driver.type superset)}" # SKEIN_DRIVER=local for a machine without Superset +COORD_ENV="$(cfg .coordinatorEnv "${PREFIX}_COORDINATOR")" + +# Task helpers over the plan file. +task_json() { # task_json <ID> -> the task object, or dies + local id="$1" + [ -f "$PLAN" ] || die "plan file not found: $PLAN" + local t + t="$(jq -c --arg id "$id" '.tasks[] | select((.id|ascii_downcase) == ($id|ascii_downcase))' "$PLAN")" + [ -n "$t" ] || die "task '$id' not found in $PLAN" + printf '%s' "$t" +} +task_field() { jq -r --arg k "$2" '.[$k] // empty' <<<"$1"; } # task_field <json> <key> +task_id_norm() { task_json "$1" | jq -r .id; } # canonical casing + +# Naming (COORDINATOR.md, "Naming"): branch <type>/<slug>-<id>, workspace "<type>: <slug words> (<ID>)". +task_branch() { + local t="$1" id type slug + id="$(jq -r '.id|ascii_downcase' <<<"$t")"; type="$(task_field "$t" type)"; slug="$(task_field "$t" slug)" + if [ -n "$type" ] && [ -n "$slug" ]; then printf '%s/%s-%s' "$type" "$slug" "$id"; else printf '%s' "$id"; fi +} +task_ws_name() { + local t="$1" id type slug + id="$(jq -r .id <<<"$t")"; type="$(task_field "$t" type)"; slug="$(task_field "$t" slug)" + if [ -n "$type" ] && [ -n "$slug" ]; then printf '%s: %s (%s)' "$type" "${slug//-/ }" "$id"; else printf '%s' "$(tr '[:upper:]' '[:lower:]' <<<"$id")"; fi +} + +# Load the board and driver implementations named in config. +load_board() { local f="$SKEIN_HOME/lib/boards/$BOARD_TYPE.sh"; [ -f "$f" ] || die "unknown board type '$BOARD_TYPE'"; . "$f"; } +load_driver() { local f="$SKEIN_HOME/lib/drivers/$DRIVER_TYPE.sh"; [ -f "$f" ] || die "unknown driver type '$DRIVER_TYPE'"; . "$f"; } + +# Who am I, for claims. GitHub login when gh is present, else the OS user. +me() { gh api user -q .login 2>/dev/null || id -un; } + +# One JSON line to stdout: the machine-readable result every running command ends with. +emit() { jq -cn "$@"; } diff --git a/scripts/skein/lib/drivers/local.sh b/scripts/skein/lib/drivers/local.sh new file mode 100644 index 0000000..aada04b --- /dev/null +++ b/scripts/skein/lib/drivers/local.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Driver: local. No Superset. A workspace is a git worktree under ~/.skein/worktrees, the +# worker is Claude Code headless (claude -p) writing stream-json to a log, and "reading the +# terminal" is reading that log. Lets a teammate without Superset coordinate, and lets CI +# or a server run a worker with no desktop app. Same interface as drivers/superset.sh. +# +# ws id = the worktree path. term id = the run log path. + +need git jq claude +WT_ROOT="${SKEIN_WORKTREES:-$HOME/.skein/worktrees}/$(basename "$ROOT")" +RUN_ROOT="${SKEIN_RUNS:-$HOME/.skein/runs}/$(basename "$ROOT")" +READY_RE="$(cfg .readyMarker "workspace '.*' ready")" +FATAL_RE="$(cfg .setupFatal "ERR_PNPM|ELIFECYCLE|npm ERR!|npm error|command not found|error TS[0-9]+")" +SETUP_SCRIPT="$(cfg .setupScript .superset/setup.sh)" + +driver_project_id() { printf 'local'; } + +driver_create() { + local name="$1" branch="$2" base="${3:-main}" tag="${4:-running}" path + mkdir -p "$WT_ROOT" "$RUN_ROOT" + path="$WT_ROOT/${branch//\//__}" + [ -e "$path" ] && die "workspace already exists at $path" + git -C "$ROOT" fetch -q origin 2>/dev/null + if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$ROOT" worktree add -q "$path" "$branch" || die "git worktree add failed" + else + git -C "$ROOT" worktree add -q -b "$branch" "$path" "origin/$base" || die "git worktree add failed (is origin/$base fetched?)" + fi + # Driver state lives OUTSIDE the worktree: anything inside shows as untracked, and a + # worker chasing a clean tree for the gate will delete it (seen on the first run). + local meta="$RUN_ROOT/$(basename "$path").meta.json" setuplog="$RUN_ROOT/$(basename "$path").setup.log" + jq -n --arg name "$name" --arg tag "$tag" --arg path "$path" --arg branch "$branch" '{name:$name, tag:$tag, path:$path, branch:$branch}' > "$meta" + # Setup runs synchronously here; wait_setup just checks its log. + ( cd "$path" && SUPERSET_ROOT_PATH="$ROOT" SUPERSET_WORKSPACE_NAME="$name" SUPERSET_WORKSPACE_PATH="$path" \ + bash "$SETUP_SCRIPT" ) > "$setuplog" 2>&1 + emit --arg ws "$path" --arg setup "$setuplog" --arg path "$path" '{ws:$ws, setup:$setup, path:$path}' +} + +driver_wait_setup() { + local log="$2" txt; txt="$(cat "$log" 2>/dev/null)" + grep -q -E "$READY_RE" <<<"$txt" && return 0 + grep -E "$FATAL_RE" <<<"$txt" | head -3 >&2 + echo "setup did not print the readiness marker (see $log)" >&2; return 1 +} + +driver_path() { printf '%s' "$1"; } + +driver_launch() { + local ws="$1" agent="$2" model="$3" effort="$4" prompt="$5" log + [ "$agent" = "claude" ] || die "the local driver runs claude only (got '$agent')" + log="$RUN_ROOT/$(basename "$ws").jsonl"; : > "$log" + local args=(-p "$prompt" --dangerously-skip-permissions --output-format stream-json --verbose) + [ -n "$model" ] && [ "$model" != "-" ] && args+=(--model "$model") + ( cd "$ws" && setsid claude "${args[@]}" >> "$log" 2>&1 & echo $! > "$log.pid" ) + printf '%s' "$log" +} + +# Render the stream-json log as the text a terminal would show: assistant text blocks, +# tool names, and the final result. +driver_read() { + local log="$2" lines="${3:-240}" + [ -f "$log" ] || return 0 + jq -r ' + if .type=="assistant" then (.message.content[]? | if .type=="text" then .text elif .type=="tool_use" then "● \(.name)(…)" else empty end) + elif .type=="result" then "--- result (\(.subtype // "done")) ---\n\(.result // "")" + else empty end' "$log" 2>/dev/null | tail -n "$lines" +} + +driver_send() { + local log="$2" text="$3" sid + sid="$(jq -r 'select(.type=="system" and .subtype=="init") | .session_id' "$log" 2>/dev/null | head -1)" + [ -n "$sid" ] || die "no session id in $log yet" + ( cd "$1" && setsid claude -p "$text" --resume "$sid" --dangerously-skip-permissions --output-format stream-json --verbose >> "$log" 2>&1 & echo $! > "$log.pid" ) +} + +driver_tag() { local m="$RUN_ROOT/$(basename "$1").meta.json"; [ -f "$m" ] && { jq --arg t "$2" '.tag=$t' "$m" > "$m.tmp" && mv "$m.tmp" "$m"; }; return 0; } + +driver_delete() { + local ws="$1" log pid + log="$RUN_ROOT/$(basename "$ws").jsonl" + pid="$(cat "$log.pid" 2>/dev/null)"; [ -n "$pid" ] && kill -- "-$pid" 2>/dev/null + [ -x "$ws/.superset/teardown.sh" ] && ( cd "$ws" && SUPERSET_WORKSPACE_PATH="$ws" bash .superset/teardown.sh ) >/dev/null 2>&1 + git -C "$ROOT" worktree remove --force "$ws" 2>/dev/null || rm -rf "$ws" + git -C "$ROOT" worktree prune + rm -f "$RUN_ROOT/$(basename "$ws").meta.json" "$RUN_ROOT/$(basename "$ws").setup.log" "$log.pid" +} + +driver_list() { + local m + for m in "$RUN_ROOT"/*.meta.json; do + [ -f "$m" ] || continue + jq -r '[.path, .name, .path, .tag] | @tsv' "$m" + done +} +driver_find() { driver_list | awk -F'\t' -v id="($1)" 'index($2, id) {print $1; exit}'; } +driver_running_count() { + local n=0 log pid + for log in "$RUN_ROOT"/*.jsonl; do + [ -f "$log.pid" ] || continue; pid="$(cat "$log.pid")" + kill -0 "$pid" 2>/dev/null && n=$((n+1)) + done + printf '%s' "$n" +} +driver_agent_terminal() { printf '%s' "$RUN_ROOT/$(basename "$1").jsonl"; } diff --git a/scripts/skein/lib/drivers/superset.sh b/scripts/skein/lib/drivers/superset.sh new file mode 100644 index 0000000..3c39114 --- /dev/null +++ b/scripts/skein/lib/drivers/superset.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Driver: Superset. Workspaces are Superset worktrees, agents run in Superset terminals, +# and the coordinator reads and drives them through the CLI. Needs the desktop app running +# on this machine (the CLI is a shim into it). +# +# Interface (every driver implements these): +# driver_project_id Superset project id for this repo, resolved +# driver_create <name> <branch> <base> <tag> -> json {ws, setup, path} +# driver_wait_setup <ws> <setup> [secs] wait for the readiness marker +# driver_path <ws> worktree path +# driver_launch <ws> <agent> <model> <effort> <prompt> -> terminal/session id +# driver_read <ws> <term> [lines] screen text +# driver_send <ws> <term> <text> +# driver_tag <ws> <tag> +# driver_delete <ws> +# driver_find <ID> ws id whose name carries "(ID)" +# driver_list tsv: ws name path tags +# driver_running_count workspaces tagged running for this repo here + +need superset jq +READY_RE="$(cfg .readyMarker "workspace '.*' ready")" +FATAL_RE="$(cfg .setupFatal "ERR_PNPM|ELIFECYCLE|npm ERR!|npm error|command not found|error TS[0-9]+")" + +driver_project_id() { + local id="${SKEIN_PROJECT_ID:-}" + [ -n "$id" ] && { printf '%s' "$id"; return 0; } + id="$(ucfg ".projects[\"$ROOT\"].supersetProjectId")" + [ -n "$id" ] && { printf '%s' "$id"; return 0; } + # Resolve by path: the desktop app knows which project this checkout is. + id="$(superset projects list --json 2>/dev/null | jq -r --arg p "$ROOT" '.[] | select(.path==$p) | .id' | head -1)" + [ -n "$id" ] || die "cannot resolve the Superset project for $ROOT: add this repo in Superset, or set SKEIN_PROJECT_ID" + printf '%s' "$id" +} + +driver_create() { + local name="$1" branch="$2" base="${3:-main}" tag="${4:-running}" P out ws setup + P="$(driver_project_id)" + out="$(superset ws create --local --project "$P" --name "$name" --branch "$branch" --base-branch "$base" --tag "$tag" --json 2>&1)" \ + || die "superset ws create: $out" + ws="$(jq -r '.workspace.id // empty' <<<"$out")"; [ -n "$ws" ] || die "no workspace id: $out" + [ "$(jq -r '.alreadyExists // false' <<<"$out")" = "true" ] && die "workspace '$name' already exists ($ws)" + setup="$(jq -r '.terminals[0].terminalId // empty' <<<"$out")" + emit --arg ws "$ws" --arg setup "$setup" --arg path "$(driver_path "$ws")" '{ws:$ws, setup:$setup, path:$path}' +} + +driver_wait_setup() { + local ws="$1" setup="$2" secs="${3:-300}" txt="" i + [ -n "$setup" ] || return 0 + for ((i=0; i<secs; i+=3)); do + txt="$(superset terminals read --workspace "$ws" --terminal "$setup" --max-lines 80 --json 2>/dev/null | jq -r '.text // ""')" + grep -q -E "$READY_RE" <<<"$txt" && return 0 + grep -q -E "$FATAL_RE" <<<"$txt" && { grep -E "$FATAL_RE" <<<"$txt" | head -3 >&2; return 1; } + sleep 3 + done + echo "setup did not print the readiness marker in ${secs}s" >&2; return 1 +} + +driver_path() { superset ws get "$1" --json 2>/dev/null | jq -r '.worktreePath // empty'; } + +driver_launch() { + local ws="$1" agent="$2" model="$3" effort="$4" prompt="$5" a term t started="" attempt cmd + local args=(--workspace "$ws" --agent "$agent" --json --prompt "$prompt") + [ -n "$model" ] && [ "$model" != "-" ] && args+=(--model "$model") + [ -n "$effort" ] && [ "$agent" = "claude" ] && args+=(--effort "$effort") + a="$(superset agents create "${args[@]}" 2>&1)" || die "superset agents create: $a" + term="$(jq -r '.sessionId // empty' <<<"$a")"; [ -n "$term" ] || die "no session id: $a" + + # Codex asks to trust a new directory once; answer it. + if [ "$agent" = "codex" ]; then + for _ in 1 2 3 4 5 6; do sleep 3 + t="$(driver_read "$ws" "$term" 40)" + grep -q "Do you trust" <<<"$t" && { superset terminals send --workspace "$ws" --terminal "$term" --text "" --json >/dev/null 2>&1; break; } + grep -q -E "Working|esc to interrupt|Starting MCP" <<<"$t" && break + done + fi + # Confirm the agent started. Superset has typed the launch before the shell put the + # agent binary on PATH; retype once, then fail loudly. + for attempt in 1 2; do + for _ in 1 2 3 4 5 6 7 8; do sleep 3 + t="$(driver_read "$ws" "$term" 40)" + grep -q -E "not found in PATH|command not found" <<<"$t" && break + grep -q -E "bypass permissions|esc to interrupt|… \(|Working|Starting MCP|gpt-|Opus|Sonnet|Fable" <<<"$t" && { started=1; break; } + done + [ -n "$started" ] && break + [ "$attempt" = 2 ] && break + cmd="$agent"; [ "$agent" = "claude" ] && cmd="claude --dangerously-skip-permissions" + [ -n "$model" ] && [ "$model" != "-" ] && cmd="$cmd --model $model" + superset terminals send --workspace "$ws" --terminal "$term" --text "clear; $cmd '${prompt//\'/\'\\\'\'}'" --json >/dev/null 2>&1 + done + [ -n "$started" ] || die "the agent did not start in terminal $term: read it with 'superset terminals read'" + printf '%s' "$term" +} + +driver_read() { superset terminals read --workspace "$1" --terminal "$2" --max-lines "${3:-240}" --json 2>/dev/null | jq -r '.text // ""'; } +driver_send() { superset terminals send --workspace "$1" --terminal "$2" --text "$3" --json >/dev/null; } +driver_tag() { superset ws update "$1" --local --tag "$2" --json >/dev/null 2>&1 || true; } +driver_delete() { superset ws delete "$1" --local --json 2>&1 | jq -r '.warnings[]? // empty' >&2; return 0; } + +driver_list() { + local P; P="$(driver_project_id)" + superset ws list --local --json 2>/dev/null \ + | jq -r --arg p "$P" '.[] | select(.projectId==$p and .type=="worktree") | [.id, .name, (.worktreePath // ""), (.tags // "")] | @tsv' +} +driver_find() { driver_list | awk -F'\t' -v id="($1)" 'index($2, id) {print $1; exit}'; } +driver_running_count() { driver_list | awk -F'\t' '$4 ~ /(^|,)running(,|$)/' | wc -l | tr -d ' '; } + +# The agent's terminal in a workspace: the newest live one that is not a plain shell. +driver_agent_terminal() { + superset terminals list --workspace "$1" --json 2>/dev/null \ + | jq -r '[.sessions[] | select((.exited|not) and ((.title // "")|test("^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:")|not))] | sort_by(.createdAt) | last | .terminalId // empty' +} diff --git a/scripts/skein/lib/gate.mjs b/scripts/skein/lib/gate.mjs new file mode 100644 index 0000000..46e5231 --- /dev/null +++ b/scripts/skein/lib/gate.mjs @@ -0,0 +1,286 @@ +#!/usr/bin/env node +// The merge gate for one task. One gate, three callers: the worker before it claims done, +// the coordinator before it merges, and CI. Vendored into the repo (scripts/skein/) and +// coordinator-owned there, so a worker can never weaken the gate it is judged by. +// Dependency-free on purpose: runs on whatever `node` is on PATH, before install. +// +// usage: skein gate <task-id> [--base <ref>] [--only <check>[,<check>]] [--timeout-min <n>] +// skein gate --from-branch +// skein gate --standard-only # no task: just the config's standard commands (CI on the base branch) +// +// Checks, in order: brief, clean-tree, boundary, secrets, test-integrity, invariants, +// standard, e2e, accept. The last three run the commands in .skein/config.json and the +// task's own `accept` list. Everything project-specific comes from that config: +// standard commands that must pass on every task (typecheck, lint, test) +// e2e optional heavier suite (production build + smoke) +// invariants [{name, pattern, except:[files], message}] added lines matching +// `pattern` outside `except` fail: the repo's own "never do this" rules +// monotonic [{file, pattern}] the count of `pattern` in `file` may never fall +// (shared regression suites only grow) +// secretPatterns extra [pattern, label] pairs; secretAllow: known dev keys to ignore + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const CONFIG_PATH = ".skein/config.json"; +const ALL_CHECKS = ["brief", "clean-tree", "boundary", "secrets", "test-integrity", "invariants", "standard", "e2e", "accept"]; +const VALUE_FLAGS = new Set(["--base", "--only", "--timeout-min"]); + +// ---------------------------------------------------------------- arguments +const argv = process.argv.slice(2); +let taskId = null, baseRef = null, only = null, timeoutMin = 30, fromBranch = false, standardOnly = false; +for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--from-branch") fromBranch = true; + else if (arg === "--standard-only") standardOnly = true; + else if (VALUE_FLAGS.has(arg)) { + const value = argv[++i]; + if (value === undefined) die(`${arg} needs a value`); + if (arg === "--base") baseRef = value; + if (arg === "--only") only = value.split(",").map((s) => s.trim()).filter(Boolean); + if (arg === "--timeout-min") timeoutMin = Number(value); + } else if (arg.startsWith("--")) die(`unknown flag: ${arg}`); + else if (taskId === null) taskId = arg; + else die(`unexpected argument: ${arg}`); +} +if (only) { + const unknown = only.filter((c) => !ALL_CHECKS.includes(c)); + if (unknown.length) die(`unknown check(s): ${unknown.join(", ")}. known: ${ALL_CHECKS.join(", ")}`); +} +if (!Number.isFinite(timeoutMin) || timeoutMin <= 0) die("--timeout-min must be a positive number"); + +// ---------------------------------------------------------------- helpers +function die(message) { process.stderr.write(`skein gate: ${message}\n`); process.exit(2); } +function sh(command, options = {}) { + return execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024, ...options }); +} +function shQuiet(command) { try { return sh(command); } catch { return null; } } +function globToRegExp(glob) { + let out = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { i++; if (glob[i + 1] === "/") { i++; out += "(?:.*/)?"; } else out += ".*"; } + else out += "[^/]*"; + } else if (c === "?") out += "[^/]"; + else if (".+^${}()|[]\\".includes(c)) out += "\\" + c; + else out += c; + } + return new RegExp("^" + out + "$"); +} +const results = []; +function record(name, status, notes = []) { + results.push({ name, status }); + process.stdout.write(`[${status}] ${name}\n`); + for (const note of notes.slice(0, 40)) process.stdout.write(` ${note}\n`); + if (notes.length > 40) process.stdout.write(` … and ${notes.length - 40} more\n`); +} +const enabled = (name) => (only ? only.includes(name) : true); + +// ---------------------------------------------------------------- config, plan, task +const repoRoot = (shQuiet("git rev-parse --show-toplevel") || "").trim(); +if (!repoRoot) die("not inside a git repository"); +process.chdir(repoRoot); +if (!existsSync(CONFIG_PATH)) die(`${CONFIG_PATH} not found: run 'skein init'`); +const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); +function runStandardOnly(reason) { + const cmds = config.standard || []; + if (!cmds.length) die("no `standard` commands in .skein/config.json"); + if (reason) process.stdout.write(`${reason}\n`); + let ok = true; + for (const command of cmds) { + process.stdout.write(`$ ${command}\n`); + try { sh(command, { stdio: ["ignore", "inherit", "inherit"], timeout: timeoutMin * 60_000 }); } catch { ok = false; break; } + } + process.stdout.write(`\nSKEIN_GATE_RESULT task=- status=${ok ? "PASS" : "FAIL"} checks=standard:${ok ? "PASS" : "FAIL"}\n`); + process.exit(ok ? 0 : 1); +} +if (standardOnly) runStandardOnly(); +const PLAN_PATH = config.plan || "docs/plan/wps.json"; +if (!existsSync(PLAN_PATH)) die(`${PLAN_PATH} not found`); +const plan = JSON.parse(readFileSync(PLAN_PATH, "utf8")); + +if (fromBranch) { + const branch = (shQuiet("git rev-parse --abbrev-ref HEAD") || "").trim(); + const candidate = (branch.split("/").pop() || "").toLowerCase(); + const match = plan.tasks + .filter((t) => { const id = t.id.toLowerCase(); return candidate === id || candidate.startsWith(id + "-") || candidate.endsWith("-" + id); }) + .sort((a, b) => b.id.length - a.id.length)[0]; + // A branch that names no task (a coordinator's docs or ADR PR) gets the standard checks. + if (!match) runStandardOnly(`branch "${branch}" names no task in ${PLAN_PATH}: running the standard commands only`); + taskId = match.id; + process.stdout.write(`task from branch "${branch}": ${taskId}\n`); +} +if (!taskId) die("no task id. usage: skein gate <task-id> | --from-branch"); +const task = plan.tasks.find((t) => t.id.toLowerCase() === taskId.toLowerCase()); +if (!task) die(`task "${taskId}" not found in ${PLAN_PATH}`); +taskId = task.id; +if (!baseRef) baseRef = shQuiet("git rev-parse --verify --quiet origin/main") ? "origin/main" : "main"; +process.stdout.write(`skein gate ${taskId} (base ${baseRef})\n`); + +// ---------------------------------------------------------------- diff +let changedCache = null, addedCache = null; +function changedFiles() { + if (changedCache) return changedCache; + const out = shQuiet(`git diff --name-only ${baseRef}...HEAD`); + if (out === null) die(`cannot diff against ${baseRef}: fetch it first (git fetch origin)`); + return (changedCache = out.split("\n").map((s) => s.trim()).filter(Boolean)); +} +function addedLines() { // added lines only: a rule about what you wrote + if (addedCache) return addedCache; + const out = shQuiet(`git diff -U0 ${baseRef}...HEAD`) || ""; + const lines = []; let file = null; + for (const raw of out.split("\n")) { + if (raw.startsWith("+++ b/")) file = raw.slice(6); + else if (raw.startsWith("+") && !raw.startsWith("+++")) lines.push({ file, text: raw.slice(1) }); + } + return (addedCache = lines); +} +const isComment = (text) => /^\s*(?:\/\/|\*|\/\*|#)/.test(text); + +// ---------------------------------------------------------------- checks +function checkBrief() { + const briefsDir = config.briefs || "docs/plan/briefs"; + if (!task.brief) return record("brief", "FAIL", [`${taskId} has "brief": null in ${PLAN_PATH}: it is not dispatchable.`, `Write ${briefsDir}/${taskId}.md, then set the path here.`]); + if (!existsSync(task.brief)) return record("brief", "FAIL", [`brief not found: ${task.brief}`]); + record("brief", "PASS", [task.brief]); +} +function checkCleanTree() { + const status = (shQuiet("git status --porcelain") || "").trim(); + if (status) return record("clean-tree", "FAIL", ["uncommitted changes:", ...status.split("\n").slice(0, 20)]); + record("clean-tree", "PASS"); +} +function checkBoundary() { + const owned = (task.owned || []).map(globToRegExp); + const allowed = (plan.alwaysAllowed || []).map(globToRegExp); + const coordinator = (plan.coordinatorOwned || []).map(globToRegExp); + const isCoordinatorTask = task.coordinator === true; + const bad = []; + for (const file of changedFiles()) { + if (!isCoordinatorTask && coordinator.some((re) => re.test(file))) { bad.push(`${file}: coordinator-owned`); continue; } + if (owned.some((re) => re.test(file)) || allowed.some((re) => re.test(file))) continue; + bad.push(`${file}: outside this task's owned globs`); + } + if (bad.length) return record("boundary", "FAIL", [...bad, "", `owned: ${JSON.stringify(task.owned || [])}`]); + record("boundary", "PASS", [`${changedFiles().length} changed file(s), all owned`]); +} + +const SECRET_PATTERNS = [ + [/\bsk_(?:live|test)_[A-Za-z0-9]{16,}/, "Stripe or Clerk secret key"], + [/\brk_(?:live|test)_[A-Za-z0-9]{16,}/, "Stripe restricted key"], + [/\bpk_live_[A-Za-z0-9]{16,}/, "Stripe live publishable key"], + [/\bwhsec_[A-Za-z0-9]{16,}/, "Stripe webhook signing secret"], + [/\bre_[A-Za-z0-9_-]{24,}/, "Resend API key"], + [/\bsk-ant-[A-Za-z0-9_-]{20,}/, "Anthropic API key"], + [/\bsk-proj-[A-Za-z0-9_-]{20,}/, "OpenAI API key"], + [/\bghp_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{30,}/, "GitHub token"], + [/\bAKIA[0-9A-Z]{16}\b/, "AWS access key id"], + [/\bxox[baprs]-[A-Za-z0-9-]{20,}/, "Slack token"], + [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "private key"], + ...((config.secretPatterns || []).map(([p, l]) => [new RegExp(p), l])), +]; +// Anvil / Hardhat published dev accounts are fine in scripts and tests. +const KEY_ALLOW = new Set([ + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + ...((config.secretAllow || []).map((s) => s.toLowerCase().replace(/^0x/, ""))), +]); +const PRIVATE_KEY_HEX = /\b(?:0x)?([0-9a-fA-F]{64})\b/; +const PRIVATE_KEY_NAME = /private[_-]?key|signer|secret|mnemonic/i; +const FORBIDDEN_FILES = [/(^|\/)\.env$/, /(^|\/)\.env\.local$/, /(^|\/)\.env\.production(\.local)?$/, /(^|\/)id_(?:rsa|ed25519)$/]; + +function checkSecrets() { + const bad = []; + for (const file of changedFiles()) if (FORBIDDEN_FILES.some((re) => re.test(file))) bad.push(`${file}: never commit this file`); + for (const { file, text } of addedLines()) { + let hit = null; + for (const [re, what] of SECRET_PATTERNS) if (re.test(text)) { hit = what; break; } + if (!hit && PRIVATE_KEY_NAME.test(text)) { + const m = PRIVATE_KEY_HEX.exec(text); + if (m && !KEY_ALLOW.has(m[1].toLowerCase())) hit = "32-byte private key"; + } + if (hit) bad.push(`${file}: looks like a committed ${hit}`); + } + if (bad.length) return record("secrets", "FAIL", [...new Set(bad), "", "Rotate anything real that reached a commit, then remove it from history."]); + record("secrets", "PASS"); +} + +const WEAKENERS = [ + [/(?:^|[^A-Za-z0-9_.])(?:it|test|describe)\s*\.\s*(?:only|skip|todo)\s*\(/, "focused or skipped test"], + [/(?:^|[^A-Za-z0-9_.])(?:xit|xdescribe|fit|fdescribe)\s*\(/, "focused or skipped test"], + [/@ts-nocheck/, "@ts-nocheck disables the typecheck for a whole file"], + [/@ts-ignore/, "@ts-ignore: use @ts-expect-error, which fails when the error goes away"], + [/eslint-disable(?!-next-line)/, "file-wide eslint-disable"], + [/#\[ignore\]|@pytest\.mark\.skip\b|\bunittest\.skip\b/, "skipped test"], +]; +function checkTestIntegrity() { + const bad = []; + for (const { file, text } of addedLines()) for (const [re, what] of WEAKENERS) if (re.test(text)) { bad.push(`${file}: ${what}: ${text.trim().slice(0, 100)}`); break; } + for (const { file, pattern } of config.monotonic || []) { + if (!changedFiles().includes(file)) continue; + const re = new RegExp(pattern, "g"); + const before = ((shQuiet(`git show ${baseRef}:${file}`) || "").match(re) || []).length; + const after = (existsSync(file) ? readFileSync(file, "utf8") : "").match(re)?.length ?? 0; + if (after < before) bad.push(`${file}: count of /${pattern}/ fell from ${before} to ${after}: shared suites only grow`); + } + if (bad.length) return record("test-integrity", "FAIL", [...bad, "", "AGENTS.md: never weaken a test to get green."]); + record("test-integrity", "PASS"); +} + +function checkInvariants() { + const rules = config.invariants || []; + if (!rules.length) return record("invariants", "PASS", ["none configured"]); + const bad = []; + for (const rule of rules) { + const re = new RegExp(rule.pattern); + const except = (rule.except || []).map(globToRegExp); + for (const { file, text } of addedLines()) { + if (except.some((x) => x.test(file)) || isComment(text)) continue; + if (re.test(text)) bad.push(`${rule.name}: ${file}: ${text.trim().slice(0, 100)}${rule.message ? `\n ${rule.message}` : ""}`); + } + } + if (bad.length) return record("invariants", "FAIL", bad); + record("invariants", "PASS", [`${rules.length} rule(s)`]); +} + +function run(name, commands) { + const notes = []; let failed = false; + for (const command of commands) { + process.stdout.write(` $ ${command}\n`); + try { sh(command, { stdio: ["ignore", "inherit", "inherit"], timeout: timeoutMin * 60_000 }); notes.push(`ok: ${command}`); } + catch (error) { failed = true; notes.push(`failed (${error.status ?? error.code ?? "error"}): ${command}`); break; } + } + record(name, failed ? "FAIL" : "PASS", notes); +} +function checkStandard() { + const cmds = config.standard || []; + if (!cmds.length) return record("standard", "FAIL", ["no `standard` commands in .skein/config.json: until the gate can run a real test suite, nothing is verifiable"]); + run("standard", cmds); +} +function checkE2E() { + const cmds = config.e2e || []; + if (!cmds.length) return record("e2e", "PASS", ["none configured"]); + run("e2e", cmds); +} +function checkAccept() { + const cmds = task.accept || []; + if (!cmds.length) return record("accept", "FAIL", [`${taskId} has no accept commands in ${PLAN_PATH}`]); + run("accept", cmds); +} + +// ---------------------------------------------------------------- main +if (enabled("brief")) checkBrief(); +if (enabled("clean-tree")) checkCleanTree(); +if (enabled("boundary")) checkBoundary(); +if (enabled("secrets")) checkSecrets(); +if (enabled("test-integrity")) checkTestIntegrity(); +if (enabled("invariants")) checkInvariants(); +const anyFailed = () => results.some((r) => r.status === "FAIL"); +if (enabled("standard")) { if (anyFailed() && !only) record("standard", "SKIP", ["static checks failed; fix those first"]); else checkStandard(); } +if (enabled("e2e")) { if (anyFailed() && !only) record("e2e", "SKIP", ["earlier checks failed"]); else checkE2E(); } +if (enabled("accept")) { if (anyFailed() && !only) record("accept", "SKIP", ["earlier checks failed"]); else checkAccept(); } +const failed = anyFailed(); +process.stdout.write(`\nSKEIN_GATE_RESULT task=${taskId} base=${baseRef} status=${failed ? "FAIL" : "PASS"} checks=${results.map((r) => `${r.name}:${r.status}`).join(",")}\n`); +process.exit(failed ? 1 : 0); diff --git a/scripts/skein/lib/plan.mjs b/scripts/skein/lib/plan.mjs new file mode 100644 index 0000000..96b8515 --- /dev/null +++ b/scripts/skein/lib/plan.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// The task DAG, read-only. Prints each task's readiness and the ownership overlaps that +// decide what can run together. Also validates a brief against its task. +// +// node plan.mjs the plan view +// node plan.mjs --json same, as JSON +// node plan.mjs --check <TASK-ID> validate the brief; exit 1 with reasons if not +// node plan.mjs --dispatchable ids that could be dispatched now, one per line +// +// "merged" means the plan says done: true (skein merge sets it) or origin/main carries the +// task's squash-merged PR, whose subject ends with "(<ID>)" per the naming convention. + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; + +const argv = process.argv.slice(2); +const json = argv.includes("--json"); +const checkId = argv.includes("--check") ? argv[argv.indexOf("--check") + 1] : null; +const listDispatchable = argv.includes("--dispatchable"); + +function sh(c) { try { return execSync(c, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); } catch { return ""; } } +const root = sh("git rev-parse --show-toplevel").trim(); if (root) process.chdir(root); +const config = existsSync(".skein/config.json") ? JSON.parse(readFileSync(".skein/config.json", "utf8")) : {}; +const PLAN_PATH = config.plan || "docs/plan/wps.json"; +const BRIEFS = config.briefs || "docs/plan/briefs"; +if (!existsSync(PLAN_PATH)) { console.error(`skein plan: ${PLAN_PATH} not found`); process.exit(2); } +const plan = JSON.parse(readFileSync(PLAN_PATH, "utf8")); + +function globToRegExp(glob) { + let out = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { if (glob[i + 1] === "*") { i++; if (glob[i + 1] === "/") { i++; out += "(?:.*/)?"; } else out += ".*"; } else out += "[^/]*"; } + else if (c === "?") out += "[^/]"; else if (".+^${}()|[]\\".includes(c)) out += "\\" + c; else out += c; + } + return new RegExp("^" + out + "$"); +} +// Two globs overlap when one's literal prefix contains the other's. Conservative on +// purpose: a false overlap costs one serialized merge round, a missed one costs a conflict. +function overlaps(a, b) { + const wild = (g) => /[*?]/.test(g); + if (!wild(a) && !wild(b)) return a === b; + const pa = a.split(/[*?]/)[0], pb = b.split(/[*?]/)[0]; + return pa.startsWith(pb) || pb.startsWith(pa); +} + +sh("git fetch -q origin"); +const mainSubjects = sh("git log --format=%s origin/main -n 1000").split("\n"); +// A task is merged when the plan says done: true (skein merge sets it), or when a commit +// subject on origin/main is its squash-merged PR: "<type>(<scope>): <summary> (<ID>) (#<n>)". +function merged(t) { + if (t.done === true) return true; + const re = new RegExp(`\\(${t.id}\\)(?: \\(#\\d+\\))?\\s*$`, "i"); + return mainSubjects.some((s) => re.test(s)); +} +const rows = plan.tasks.map((t) => { + const isMerged = merged(t); + const depsMet = (t.deps || []).every((d) => { const x = plan.tasks.find((y) => y.id === d); return x ? merged(x) : false; }); + const hasBrief = !!t.brief && existsSync(t.brief); + const status = isMerged ? "merged" : !hasBrief ? "no brief" : !depsMet ? "waiting on deps" : "ready"; + return { id: t.id, title: t.title, status, deps: t.deps || [], owned: t.owned || [], money: !!t.money, pii: !!t.pii, brief: t.brief || null }; +}); +const ready = rows.filter((r) => r.status === "ready"); +const conflicts = []; +for (let i = 0; i < ready.length; i++) for (let j = i + 1; j < ready.length; j++) { + const shared = ready[i].owned.filter((a) => ready[j].owned.some((b) => overlaps(a, b))); + if (shared.length) conflicts.push({ a: ready[i].id, b: ready[j].id, shared }); +} + +if (checkId) { + const t = plan.tasks.find((x) => x.id.toLowerCase() === checkId.toLowerCase()); + const problems = []; + if (!t) problems.push(`task ${checkId} not in ${PLAN_PATH}`); + else { + const briefPath = t.brief || `${BRIEFS}/${t.id}.md`; + if (!existsSync(briefPath)) problems.push(`brief missing: ${briefPath}`); + else { + const text = readFileSync(briefPath, "utf8"); + if (!t.brief) problems.push(`brief exists but ${PLAN_PATH} still has "brief": null`); + if (!/^#\s+.*\b/m.test(text)) problems.push("brief has no title"); + for (const h of ["Objective", "Read first", "You own", "Work", "Acceptance"]) if (!new RegExp(`^##\\s+${h}`, "im").test(text)) problems.push(`brief lacks a "## ${h}" section`); + const ownSection = (text.split(/^##\s+You own/im)[1] || "").split(/^##\s+/m)[0]; + for (const g of t.owned || []) if (!ownSection.includes(g)) problems.push(`owned glob not listed under "You own": ${g}`); + if (!(t.accept || []).length) problems.push("task has no accept commands"); + if (!(t.owned || []).length) problems.push("task has no owned globs"); + if (!text.includes(t.id)) problems.push("brief never mentions the task id"); + } + } + if (problems.length) { console.error(`skein brief --check ${checkId}: NOT READY\n ${problems.join("\n ")}`); process.exit(1); } + console.log(`skein brief --check ${checkId}: ok (${t.brief})`); process.exit(0); +} +if (listDispatchable) { for (const r of ready) console.log(r.id); process.exit(0); } +if (json) { console.log(JSON.stringify({ tasks: rows, conflicts }, null, 2)); process.exit(0); } + +const w = Math.max(...rows.map((r) => r.id.length), 4); +console.log(`plan: ${PLAN_PATH} (${rows.length} tasks, ${ready.length} ready, ${rows.filter((r) => r.status === "merged").length} merged)\n`); +for (const r of rows) { + const flags = [r.money && "money", r.pii && "pii"].filter(Boolean).join(","); + console.log(`${r.id.padEnd(w)} ${r.status.padEnd(15)} ${r.title}${flags ? ` [${flags}]` : ""}${r.deps.length ? ` deps=${r.deps.join(",")}` : ""}`); +} +if (conflicts.length) { + console.log("\nownership overlaps among ready tasks (do not run these together):"); + for (const c of conflicts) console.log(` ${c.a} × ${c.b}: ${c.shared.join(", ")}`); +} else if (ready.length > 1) console.log("\nno ownership overlaps among ready tasks: they can run together."); diff --git a/scripts/skein/lib/render.mjs b/scripts/skein/lib/render.mjs new file mode 100644 index 0000000..7eed399 --- /dev/null +++ b/scripts/skein/lib/render.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +// Render a template: node render.mjs <in> <out> KEY=value ... ({{KEY}} placeholders). +// Unknown placeholders are left as-is so a template can carry literal braces. +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +const [input, output, ...pairs] = process.argv.slice(2); +if (!input || !output) { console.error("usage: render.mjs <in> <out> KEY=value ..."); process.exit(2); } +const vars = Object.fromEntries(pairs.map((p) => { const i = p.indexOf("="); return [p.slice(0, i), p.slice(i + 1)]; })); +let text = readFileSync(input, "utf8"); +text = text.replace(/\{\{([A-Z0-9_]+)\}\}/g, (m, k) => (k in vars ? vars[k] : m)); +mkdirSync(path.dirname(output), { recursive: true }); +writeFileSync(output, text); diff --git a/scripts/skein/skein b/scripts/skein/skein new file mode 100755 index 0000000..c1bfe69 --- /dev/null +++ b/scripts/skein/skein @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# skein: coordinate a flock of coding agents on one repo, with no fixed leader. +# +# Runs from the kit (~/.claude/skills/skein/bin/skein) or from a repo's vendored copy +# (scripts/skein/skein). Commands live in ../commands, shared code in ../lib. +set -uo pipefail + +SELF="$(readlink -f "${BASH_SOURCE[0]}")" +SKEIN_HOME="$(cd "$(dirname "$SELF")/.." && pwd -P)" +# A vendored copy keeps bin/ flat: scripts/skein/skein next to lib/ and commands/. +[ -d "$SKEIN_HOME/commands" ] || SKEIN_HOME="$(dirname "$SELF")" +export SKEIN_HOME + +usage() { + cat <<USAGE +skein $(cat "$SKEIN_HOME/VERSION" 2>/dev/null || echo dev) — a flock of agents, rotating lead, one gate + +Usage: skein <command> [args] + +Setup + init [--name n --prefix P --board github|superset|none --driver superset|local] + scaffold a repo: .skein/config.json, vendored scripts, templates + upgrade resync the vendored scripts from the installed kit + doctor check this machine: driver, board, auth, hooks, caps + +Planning + plan the task DAG: status, dispatchable now, ownership overlaps + brief <TASK> [--check] validate a brief against its task (the skill writes it) + +Running + dispatch <TASK> [--tag t] [--model m] [--agent a] + claim on the board, create the workspace, launch the worker + watch <TASK> [ws] [term] [max-hours] [poll-s] + wait for the worker's completion envelope + status every running task, board and local, plus both caps + send <TASK> "message" talk to a running worker + +Finishing + gate <TASK> [--only a,b] [--base ref] + the merge gate (worker before DONE, coordinator before merge) + review <TASK> cross-model review of the PR in a read-only worktree + merge <TASK> gate, squash-merge, release the claim, delete the workspace + +Config: .skein/config.json (repo) and ~/.skein/config.json (this machine: maxAgents). +USAGE +} + +cmd="${1:-}"; shift || true +case "$cmd" in + ""|-h|--help|help) usage; exit 0 ;; + --version|version) cat "$SKEIN_HOME/VERSION"; exit 0 ;; +esac +script="$SKEIN_HOME/commands/$cmd.sh" +[ -f "$script" ] || { echo "skein: unknown command '$cmd'" >&2; usage >&2; exit 2; } +exec bash "$script" "$@" From ed9e6f85a5e7913e2ced77469b68d8d344031d13 Mon Sep 17 00:00:00 2001 From: Bayological <6872903+bayological@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:10:56 -0500 Subject: [PATCH 2/2] chore: skein 0.1.1, addressing the review Cursor Bugbot and CodeRabbit findings, fixed upstream in the kit and re-vendored: PR task resolution from GITHUB_HEAD_REF, CI permissions and no persisted token, accept commands in the PR gate, claims released on every dispatch failure, cap rechecked after claiming, ownership verified after the write, read-only reviewers with a machine-readable review marker that merge requires, --no-gate needs a green CI check, vendored templates for brief --new and review, macOS-safe entrypoint and local driver, workspaces get .env.example only by default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/skein-gate.yml | 8 ++++-- .superset/setup.sh | 16 ++++++------ scripts/skein/VERSION | 2 +- scripts/skein/commands/dispatch.sh | 19 +++++++++++--- scripts/skein/commands/doctor.sh | 2 +- scripts/skein/commands/merge.sh | 15 +++++++---- scripts/skein/commands/review.sh | 18 ++++++++++--- scripts/skein/lib/boards/github.sh | 24 ++++++++++++++++-- scripts/skein/lib/boards/superset.sh | 11 +++++--- scripts/skein/lib/drivers/local.sh | 12 ++++++--- scripts/skein/lib/gate.mjs | 4 ++- scripts/skein/skein | 8 +++++- scripts/skein/templates/brief.md.tmpl | 32 ++++++++++++++++++++++++ scripts/skein/templates/review-prompt.md | 10 ++++++++ 14 files changed, 145 insertions(+), 36 deletions(-) create mode 100644 scripts/skein/templates/brief.md.tmpl create mode 100644 scripts/skein/templates/review-prompt.md diff --git a/.github/workflows/skein-gate.yml b/.github/workflows/skein-gate.yml index 8933ac9..28389eb 100644 --- a/.github/workflows/skein-gate.yml +++ b/.github/workflows/skein-gate.yml @@ -11,6 +11,9 @@ concurrency: group: skein-gate-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: gate: runs-on: ubuntu-latest @@ -18,13 +21,14 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 - run: npm ci --prefer-offline --no-audit --no-fund - name: task gate (pull request) if: github.event_name == 'pull_request' - run: scripts/skein/skein gate --from-branch --only brief,boundary,secrets,test-integrity,invariants,standard + run: scripts/skein/skein gate --from-branch --only brief,boundary,secrets,test-integrity,invariants,standard,accept - name: standard (main) if: github.event_name == 'push' run: scripts/skein/skein gate --standard-only diff --git a/.superset/setup.sh b/.superset/setup.sh index bdf91f4..9ca94e6 100755 --- a/.superset/setup.sh +++ b/.superset/setup.sh @@ -8,19 +8,21 @@ ROOT="${SUPERSET_ROOT_PATH:-}" WS_NAME="${SUPERSET_WORKSPACE_NAME:-workspace}" [ -n "$ROOT" ] || { echo "SUPERSET_ROOT_PATH not set; run this via Superset or skein." >&2; exit 1; } -# 1. Env files are gitignored: copy each from the main checkout, never overwriting one the -# workspace already has. Lines matching ENV_WITHHOLD are blanked so a worker never holds -# a production credential it does not need (edit the pattern for this project). +# 1. Env files are gitignored. By default a workspace gets only the committed `.env.example` +# (names, no secrets): a worker runs with the credentials the repo chose to give it, not +# whatever the main checkout holds. A repo that needs real values sets ENV_COPY_REAL=1 +# and lists in ENV_WITHHOLD (an ERE of variable names) what must still be blanked. +ENV_COPY_REAL=0 ENV_WITHHOLD='' copy_env() { local rel="$1" if [ -f "$rel" ]; then echo "env: $rel already present" - elif [ -f "$ROOT/$rel" ]; then + elif [ "$ENV_COPY_REAL" = 1 ] && [ -f "$ROOT/$rel" ]; then mkdir -p "$(dirname "$rel")" if [ -n "$ENV_WITHHOLD" ]; then sed -E "s|^($ENV_WITHHOLD)=.*|\1= # skein workspace: withheld|" "$ROOT/$rel" > "$rel"; else cp "$ROOT/$rel" "$rel"; fi - chmod 600 "$rel"; echo "env: copied $rel from main checkout" - elif [ -f "$rel.example" ]; then cp "$rel.example" "$rel"; echo "env: $rel created from $rel.example (fill in values)" - else echo "env: no $rel in main checkout; skipping"; fi + chmod 600 "$rel"; echo "env: copied $rel from main checkout${ENV_WITHHOLD:+ (withheld: $ENV_WITHHOLD)}" + elif [ -f "$rel.example" ]; then cp "$rel.example" "$rel"; echo "env: $rel created from $rel.example" + else echo "env: no $rel.example; skipping"; fi } for f in .env; do copy_env "$f"; done diff --git a/scripts/skein/VERSION b/scripts/skein/VERSION index 6e8bf73..17e51c3 100644 --- a/scripts/skein/VERSION +++ b/scripts/skein/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/scripts/skein/commands/dispatch.sh b/scripts/skein/commands/dispatch.sh index b621d96..cb27746 100644 --- a/scripts/skein/commands/dispatch.sh +++ b/scripts/skein/commands/dispatch.sh @@ -20,7 +20,14 @@ BRANCH="$(task_branch "$t")"; WS_NAME="$(task_ws_name "$t")" GATE="$(cfg .gate 'scripts/skein/skein gate')" BASE="$(cfg .baseBranch main)" -fail() { emit --arg task "$ID" --arg error "$1" '{task:$task, ok:false, error:$error}'; exit 1; } +CLAIMED=""; WS="" +# One exit path for every failure: a claim is released (back to ready, assignee cleared) and +# a half-built workspace is deleted, so a bad dispatch never strands the board or the cap. +fail() { + [ -n "$WS" ] && driver_delete "$WS" 2>/dev/null + [ -n "$CLAIMED" ] && board_release "$ID" ready 2>/dev/null + emit --arg task "$ID" --arg error "$1" '{task:$task, ok:false, error:$error}'; exit 1 +} # 1. Dispatchable: brief valid, deps merged, and both on origin (workspaces fork from origin). node "$SKEIN_HOME/lib/plan.mjs" --check "$ID" >/dev/null 2>&1 || fail "$(node "$SKEIN_HOME/lib/plan.mjs" --check "$ID" 2>&1 | tr '\n' ' ')" @@ -33,11 +40,15 @@ git -C "$ROOT" diff --quiet "origin/$BASE" -- "$(task_field "$t" brief)" "$PLAN" capmsg="$(check_caps)" || fail "$capmsg" log "$capmsg" BOARD_URL="$(board_claim "$ID" "$TITLE" "$BRANCH")" || exit 1 +CLAIMED=1 +# Two coordinators can pass the cap check together and both claim. The claim is what counts, +# so recount now that ours is on the board and back out if the repo is over its cap. +[ "$(board_running_count)" -le "$(repo_cap)" ] || fail "repo cap exceeded after claiming ($(board_running_count) > $(repo_cap)); claim released, try again later" # 3. Workspace, setup, sanity. -created="$(driver_create "$WS_NAME" "$BRANCH" "$BASE" "$TAG")" || { board_release "$ID" ready; exit 1; } +created="$(driver_create "$WS_NAME" "$BRANCH" "$BASE" "$TAG")" || fail "workspace creation failed" WS="$(jq -r .ws <<<"$created")"; SETUP="$(jq -r .setup <<<"$created")" -driver_wait_setup "$WS" "$SETUP" "$(cfg .setupTimeout 300)" || { board_release "$ID" blocked; fail "setup failed in workspace $WS"; } +driver_wait_setup "$WS" "$SETUP" "$(cfg .setupTimeout 300)" || fail "setup failed in workspace $WS (see its setup output)" PATH_WS="$(driver_path "$WS")"; [ -d "$PATH_WS" ] || fail "cannot find the worktree for $WS_NAME" [ -f "$PATH_WS/$(task_field "$t" brief)" ] || fail "brief missing in the worktree (is it on origin/$BASE?)" git -C "$PATH_WS" fetch -q origin 2>/dev/null @@ -50,7 +61,7 @@ PROMPT="$(cfg .workerPrompt)" PROMPT="${PROMPT//\{ID\}/$ID}"; PROMPT="${PROMPT//\{NAME\}/$(cfg .name "$(basename "$ROOT")")}" PROMPT="${PROMPT//\{BRIEF\}/$(task_field "$t" brief)}"; PROMPT="${PROMPT//\{GATE\}/$GATE}" -TERM_ID="$(driver_launch "$WS" "$AGENT" "$MODEL" "$EFFORT" "$PROMPT")" || { board_release "$ID" blocked; exit 1; } +TERM_ID="$(driver_launch "$WS" "$AGENT" "$MODEL" "$EFFORT" "$PROMPT")" || fail "the agent did not start" board_comment "$ID" "Dispatched by $(me): workspace \`$WS_NAME\`, branch \`$BRANCH\`, agent $AGENT${MODEL:+ ($MODEL)}, head $HEAD." emit --arg task "$ID" --arg ws "$WS" --arg term "$TERM_ID" --arg branch "$BRANCH" --arg head "$HEAD" \ --arg model "$AGENT:$MODEL${EFFORT:+:$EFFORT}" --arg path "$PATH_WS" --arg board "$BOARD_URL" \ diff --git a/scripts/skein/commands/doctor.sh b/scripts/skein/commands/doctor.sh index d5d59a0..be430cb 100644 --- a/scripts/skein/commands/doctor.sh +++ b/scripts/skein/commands/doctor.sh @@ -23,7 +23,7 @@ case "$DRIVER_TYPE" in else bad "superset CLI not usable: is the desktop app running and logged in?"; fi ;; local) command -v claude >/dev/null && ok "claude on PATH (local driver)" || bad "claude CLI missing" ;; esac -command -v codex >/dev/null && ok "codex on PATH (cross-model review)" || printf ' \033[33m•\033[0m codex not installed: skein review falls back to claude %s\n' "$(cfg .review.fallbackModel)" +codex --version >/dev/null 2>&1 && ok "codex $(codex --version 2>/dev/null | head -1) (cross-model review)" || printf ' \033[33m•\033[0m codex not installed: skein review falls back to claude %s\n' "$(cfg .review.fallbackModel)" . "$SKEIN_HOME/lib/caps.sh"; ok "caps: machine $(machine_cap) ($USER_CONFIG), repo $(repo_cap) (.skein/config.json)" [ "$(jq '.standard|length' "$CONFIG")" -gt 0 ] && ok "standard: $(jq -r '.standard|join(" && ")' "$CONFIG")" || bad "no standard commands: the gate cannot judge work" [ "$FAILS" -eq 0 ] && echo "all good" || { echo "$FAILS problem(s)"; exit 1; } diff --git a/scripts/skein/commands/merge.sh b/scripts/skein/commands/merge.sh index 02c3774..5feffe6 100644 --- a/scripts/skein/commands/merge.sh +++ b/scripts/skein/commands/merge.sh @@ -2,8 +2,8 @@ # skein merge <TASK> [--no-gate] # The coordinator's merge: rerun the gate in the worker's worktree, squash-merge the PR, # close the claim, delete the workspace, mark the task done in the plan and log it. -# Refuses a task claimed by someone else, and never merges a money/pii task that has no -# review comment on its PR. +# Refuses a task claimed by someone else, never merges a money/pii task without the review +# marker on its PR, and honours --no-gate only when the PR's CI gate check succeeded. . "$SKEIN_HOME/lib/common.sh"; require_config; load_board; load_driver need gh jq git node TASK=""; NOGATE="" @@ -19,12 +19,17 @@ PR="$(gh pr list --head "$BRANCH" --state open --json number,url,reviews,comment [ -n "$PR" ] || die "no open PR for branch $BRANCH" PRNUM="$(jq -r .number <<<"$PR")"; PRURL="$(jq -r .url <<<"$PR")" if [ "$(task_field "$t" money)" = "true" ] || [ "$(task_field "$t" pii)" = "true" ]; then - n="$(gh pr view "$PRNUM" --json comments -q '[.comments[] | select(.body | test("review"; "i"))] | length' 2>/dev/null || echo 0)" - [ "$n" -gt 0 ] || die "$ID is money/pii and PR #$PRNUM has no review comment: run 'skein review $ID' first" + # Evidence is the marker `skein review` posts, from a collaborator, naming this task. + n="$(gh pr view "$PRNUM" --json comments -q "[.comments[] | select((.body | test(\"<!-- skein-review task=$ID \")) and (.authorAssociation | IN(\"OWNER\",\"MEMBER\",\"COLLABORATOR\")))] | length" 2>/dev/null || echo 0)" + [ "$n" -gt 0 ] || die "$ID is money/pii and PR #$PRNUM carries no skein review marker from a collaborator: run 'skein review $ID' first" fi WS="$(driver_find "$ID")"; WPATH=""; [ -n "$WS" ] && WPATH="$(driver_path "$WS")" -if [ -z "$NOGATE" ]; then +if [ -n "$NOGATE" ]; then + ok="$(gh pr checks "$PRNUM" --json name,state -q '[.[] | select((.name | test("gate"; "i")) and .state=="SUCCESS")] | length' 2>/dev/null || echo 0)" + [ "$ok" -gt 0 ] || die "--no-gate needs a successful CI gate check on PR #$PRNUM; none found" + log "--no-gate: CI gate check succeeded on PR #$PRNUM" +else [ -n "$WPATH" ] && [ -d "$WPATH" ] || die "no local worktree for $ID to run the gate in (use --no-gate only if CI ran it)" git -C "$WPATH" fetch -q origin [ -x "$WPATH/$(cfg .vendor scripts/skein)/skein" ] && GATE_ENTRY="$WPATH/$(cfg .vendor scripts/skein)/skein" diff --git a/scripts/skein/commands/review.sh b/scripts/skein/commands/review.sh index 69a127a..708b09d 100644 --- a/scripts/skein/commands/review.sh +++ b/scripts/skein/commands/review.sh @@ -12,7 +12,10 @@ while [ $# -gt 0 ]; do case "$1" in t="$(task_json "$TASK")"; ID="$(jq -r .id <<<"$t")"; BRANCH="$(task_branch "$t")"; SLUG="$(task_field "$t" slug)" [ -z "$AGENT" ] && AGENT="$(cfg .review.agent codex)" [ -z "$MODEL" ] && MODEL="$(cfg .review.model)" -command -v "$AGENT" >/dev/null 2>&1 || { [ "$AGENT" = codex ] && { warn "codex not installed; falling back to claude"; AGENT=claude; MODEL="$(cfg .review.fallbackModel claude-fable-5-1)"; }; } +# `codex` may be a shim with nothing behind it (Superset installs one); prove it runs. +if [ "$AGENT" = codex ] && ! codex --version >/dev/null 2>&1; then + warn "codex not usable; falling back to claude"; AGENT=claude; MODEL="$(cfg .review.fallbackModel claude-fable-5-1)" +fi PR="$(gh pr list --head "$BRANCH" --state open --json number,url -q '.[0]' 2>/dev/null)" [ -n "$PR" ] || die "no open PR for branch $BRANCH" @@ -25,19 +28,26 @@ driver_wait_setup "$WS" "$(jq -r .setup <<<"$created")" "$(cfg .setupTimeout 300 OUT="${TMPDIR:-/tmp}/skein-review-$ID-$(date +%s).md" PROMPT="$(node "$SKEIN_HOME/lib/render.mjs" "$SKEIN_HOME/templates/review-prompt.md" /dev/stdout ID="$ID" PR="$PRURL" BRIEF="$(task_field "$t" brief)" BASE="origin/$(cfg .baseBranch main)" MONEY="$(task_field "$t" money)")" log "reviewing $ID (PR #$PRNUM) with $AGENT${MODEL:+ $MODEL} in $RPATH" +# The reviewer reads; it never writes, runs the build, or reaches the network on its own. +# codex: its read-only sandbox. claude: only read tools and git diff/log/show are allowed; +# anything else is denied in headless mode. +READ_TOOLS="Read,Grep,Glob,LS,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*),Bash(cat:*),Bash(sed -n:*),Bash(head:*),Bash(tail:*),Bash(wc:*),Bash(ls:*)" case "$AGENT" in codex) # Headless on purpose: out of quota, Codex's interactive screen opens on an "Upgrade" # menu that a pasted keystroke can confirm. codex exec just exits with the reset time. - ( cd "$RPATH" && codex exec ${MODEL:+-m "$MODEL"} -c model_reasoning_effort=high --dangerously-bypass-approvals-and-sandbox -o "$OUT" "$PROMPT" < /dev/null ) >/dev/null 2>&1 \ + ( cd "$RPATH" && codex exec ${MODEL:+-m "$MODEL"} -c model_reasoning_effort=high --sandbox read-only -o "$OUT" "$PROMPT" < /dev/null ) >/dev/null 2>&1 \ || warn "codex exec exited non-zero; check $OUT" ;; claude) - ( cd "$RPATH" && claude -p "$PROMPT" ${MODEL:+--model "$MODEL"} --dangerously-skip-permissions > "$OUT" 2>/dev/null ) \ + ( cd "$RPATH" && claude -p "$PROMPT" ${MODEL:+--model "$MODEL"} --allowedTools "$READ_TOOLS" > "$OUT" 2>/dev/null ) \ || warn "claude exited non-zero; check $OUT" ;; *) die "unknown review agent $AGENT" ;; esac [ -s "$OUT" ] || die "review produced no output ($OUT)" -gh pr comment "$PRNUM" --body-file "$OUT" >/dev/null 2>&1 && log "posted review to $PRURL" || warn "could not post the PR comment; findings are in $OUT" +grep -q "REVIEW_DONE" "$OUT" || warn "review output has no REVIEW_DONE envelope; read $OUT before trusting it" +# The marker is what `skein merge` requires for money/pii tasks: a word in a comment is not evidence. +{ printf '<!-- skein-review task=%s reviewer=%s by=%s -->\n' "$ID" "$AGENT${MODEL:+:$MODEL}" "$(me)"; cat "$OUT"; } > "$OUT.comment" +gh pr comment "$PRNUM" --body-file "$OUT.comment" >/dev/null 2>&1 && log "posted review to $PRURL" || warn "could not post the PR comment; findings are in $OUT" board_release "$ID" gating driver_delete "$WS" emit --arg task "$ID" --arg pr "$PRURL" --arg file "$OUT" --arg agent "$AGENT:$MODEL" '{task:$task, ok:true, pr:$pr, file:$file, reviewer:$agent}' diff --git a/scripts/skein/lib/boards/github.sh b/scripts/skein/lib/boards/github.sh index bb3668b..12044ba 100644 --- a/scripts/skein/lib/boards/github.sh +++ b/scripts/skein/lib/boards/github.sh @@ -38,9 +38,21 @@ board_claim() { me="$(me)"; _ensure_labels issue="$(_issue_for "$id")" if [ -z "$issue" ]; then - gh issue create -R "$BOARD_REPO" --title "$id: $title" --label skein --label state:running --assignee "$me" \ - --body "Task \`$id\` in \`$(cfg .plan docs/plan/wps.json)\`. Branch \`$branch\`. Claimed by @$me via skein dispatch." 2>/dev/null \ + local url + url="$(gh issue create -R "$BOARD_REPO" --title "$id: $title" --label skein --label state:running --assignee "$me" \ + --body "Task \`$id\` in \`$(cfg .plan docs/plan/wps.json)\`. Branch \`$branch\`. Claimed by @$me via skein dispatch." 2>/dev/null)" \ || die "could not create the board issue for $id" + # Creation is not atomic across coordinators: if two issues now exist for this id, the + # lower number wins and the loser closes its own. + local mine all + mine="${url##*/}" + all="$(gh issue list -R "$BOARD_REPO" --state open --label skein --limit 200 --json number,title 2>/dev/null \ + | jq -r --arg id "$id" '[.[] | select(.title | ascii_downcase | startswith(($id|ascii_downcase) + ":"))] | map(.number) | sort | .[]')" + if [ "$(head -1 <<<"$all")" != "$mine" ]; then + gh issue close "$mine" -R "$BOARD_REPO" --comment "Duplicate claim; #$(head -1 <<<"$all") won." >/dev/null 2>&1 + die "$id was claimed by another coordinator at the same moment (issue #$(head -1 <<<"$all"))" + fi + printf '%s\n' "$url" return 0 fi num="$(jq -r .number <<<"$issue")" @@ -54,6 +66,14 @@ board_claim() { gh issue edit "$num" -R "$BOARD_REPO" --add-assignee "$me" --add-label state:running \ --remove-label state:ready --remove-label state:gating --remove-label state:blocked >/dev/null 2>&1 \ || die "could not claim issue #$num" + # Assignment is not atomic either: re-read and verify we are the only assignee. If someone + # else landed at the same time, the lower login wins deterministically and we back out. + local assignees + assignees="$(gh issue view "$num" -R "$BOARD_REPO" --json assignees -q '[.assignees[].login] | sort | .[]' 2>/dev/null)" + if [ "$(wc -l <<<"$assignees" | tr -d ' ')" -gt 1 ] && [ "$(head -1 <<<"$assignees")" != "$me" ]; then + gh issue edit "$num" -R "$BOARD_REPO" --remove-assignee "$me" >/dev/null 2>&1 + die "$id was claimed by @$(head -1 <<<"$assignees") at the same moment (issue #$num)" + fi jq -r .url <<<"$issue" } diff --git a/scripts/skein/lib/boards/superset.sh b/scripts/skein/lib/boards/superset.sh index df59001..9ac64f0 100644 --- a/scripts/skein/lib/boards/superset.sh +++ b/scripts/skein/lib/boards/superset.sh @@ -15,7 +15,7 @@ _ss_set_state() { # <task-json> <state> local id labels id="$(jq -r '.id' <<<"$1")" labels="$(_ss_labels "$1" | tr ',' '\n' | grep -v '^state:' | grep -v '^$' | paste -sd, -)" - superset tasks update "$id" --labels "${labels:+$labels,}skein,state:$2" --json >/dev/null 2>&1 || true + superset tasks update "$id" --labels "${labels:+$labels,}skein,state:$2" --json >/dev/null 2>&1 } board_claim() { @@ -32,12 +32,15 @@ board_claim() { if [ -n "$owner" ] && [ "$owner" != "$me" ]; then die "$id is claimed by another member on the Superset board"; fi if _ss_labels "$t" | grep -q 'state:running' && [ "$owner" = "$me" ]; then die "$id is already running under your claim"; fi superset tasks update "$(jq -r .id <<<"$t")" --assignee "$me" --json >/dev/null 2>&1 || die "could not claim $id" - _ss_set_state "$t" running + _ss_set_state "$t" running || die "claimed $id but could not mark it running; fix the task's labels on the board" + # Verify exclusive ownership after the write (assignment is not atomic across coordinators). + t="$(_ss_task_for "$id")"; owner="$(jq -r '.assigneeId // .assignee.id // .assignee // empty' <<<"$t")" + [ "$owner" = "$me" ] || die "$id was claimed by another member at the same moment" } -board_release() { local t; t="$(_ss_task_for "$1")"; [ -n "$t" ] && _ss_set_state "$t" "${2:-gating}"; return 0; } +board_release() { local t; t="$(_ss_task_for "$1")"; [ -n "$t" ] && { _ss_set_state "$t" "${2:-gating}" || true; }; return 0; } board_close() { local t id; t="$(_ss_task_for "$1")"; [ -n "$t" ] || return 0; id="$(jq -r .id <<<"$t")" - _ss_set_state "$t" done + _ss_set_state "$t" done || true [ -n "${2:-}" ] && superset tasks update "$id" --pr-url "$2" --json >/dev/null 2>&1 || true } board_owner() { _ss_task_for "$1" | jq -r '.assigneeId // .assignee.id // .assignee // empty'; } diff --git a/scripts/skein/lib/drivers/local.sh b/scripts/skein/lib/drivers/local.sh index aada04b..5a82199 100644 --- a/scripts/skein/lib/drivers/local.sh +++ b/scripts/skein/lib/drivers/local.sh @@ -51,16 +51,20 @@ driver_launch() { log="$RUN_ROOT/$(basename "$ws").jsonl"; : > "$log" local args=(-p "$prompt" --dangerously-skip-permissions --output-format stream-json --verbose) [ -n "$model" ] && [ "$model" != "-" ] && args+=(--model "$model") - ( cd "$ws" && setsid claude "${args[@]}" >> "$log" 2>&1 & echo $! > "$log.pid" ) + # stdout is the JSON stream; stderr goes to its own file so one warning line cannot break + # the parser. setsid (own process group) where it exists; plain nohup on macOS. + ( cd "$ws" && _bg claude "${args[@]}" >> "$log" 2>> "$log.err" & echo $! > "$log.pid" ) + sleep 2; kill -0 "$(cat "$log.pid")" 2>/dev/null || die "claude exited immediately; see $log.err" printf '%s' "$log" } +_bg() { if command -v setsid >/dev/null 2>&1; then setsid "$@"; else nohup "$@"; fi; } # Render the stream-json log as the text a terminal would show: assistant text blocks, # tool names, and the final result. driver_read() { local log="$2" lines="${3:-240}" [ -f "$log" ] || return 0 - jq -r ' + jq -R -r 'fromjson? | if .type=="assistant" then (.message.content[]? | if .type=="text" then .text elif .type=="tool_use" then "● \(.name)(…)" else empty end) elif .type=="result" then "--- result (\(.subtype // "done")) ---\n\(.result // "")" else empty end' "$log" 2>/dev/null | tail -n "$lines" @@ -70,7 +74,7 @@ driver_send() { local log="$2" text="$3" sid sid="$(jq -r 'select(.type=="system" and .subtype=="init") | .session_id' "$log" 2>/dev/null | head -1)" [ -n "$sid" ] || die "no session id in $log yet" - ( cd "$1" && setsid claude -p "$text" --resume "$sid" --dangerously-skip-permissions --output-format stream-json --verbose >> "$log" 2>&1 & echo $! > "$log.pid" ) + ( cd "$1" && _bg claude -p "$text" --resume "$sid" --dangerously-skip-permissions --output-format stream-json --verbose >> "$log" 2>> "$log.err" & echo $! > "$log.pid" ) } driver_tag() { local m="$RUN_ROOT/$(basename "$1").meta.json"; [ -f "$m" ] && { jq --arg t "$2" '.tag=$t' "$m" > "$m.tmp" && mv "$m.tmp" "$m"; }; return 0; } @@ -78,7 +82,7 @@ driver_tag() { local m="$RUN_ROOT/$(basename "$1").meta.json"; [ -f "$m" ] && { driver_delete() { local ws="$1" log pid log="$RUN_ROOT/$(basename "$ws").jsonl" - pid="$(cat "$log.pid" 2>/dev/null)"; [ -n "$pid" ] && kill -- "-$pid" 2>/dev/null + pid="$(cat "$log.pid" 2>/dev/null)"; [ -n "$pid" ] && { kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null; } [ -x "$ws/.superset/teardown.sh" ] && ( cd "$ws" && SUPERSET_WORKSPACE_PATH="$ws" bash .superset/teardown.sh ) >/dev/null 2>&1 git -C "$ROOT" worktree remove --force "$ws" 2>/dev/null || rm -rf "$ws" git -C "$ROOT" worktree prune diff --git a/scripts/skein/lib/gate.mjs b/scripts/skein/lib/gate.mjs index 46e5231..336e872 100644 --- a/scripts/skein/lib/gate.mjs +++ b/scripts/skein/lib/gate.mjs @@ -102,7 +102,9 @@ if (!existsSync(PLAN_PATH)) die(`${PLAN_PATH} not found`); const plan = JSON.parse(readFileSync(PLAN_PATH, "utf8")); if (fromBranch) { - const branch = (shQuiet("git rev-parse --abbrev-ref HEAD") || "").trim(); + // On a pull_request run actions/checkout leaves a detached HEAD, so the branch name is in + // GITHUB_HEAD_REF; locally it is the checked-out branch. + const branch = (process.env.GITHUB_HEAD_REF || process.env.SKEIN_BRANCH || shQuiet("git rev-parse --abbrev-ref HEAD") || "").trim(); const candidate = (branch.split("/").pop() || "").toLowerCase(); const match = plan.tasks .filter((t) => { const id = t.id.toLowerCase(); return candidate === id || candidate.startsWith(id + "-") || candidate.endsWith("-" + id); }) diff --git a/scripts/skein/skein b/scripts/skein/skein index c1bfe69..559551a 100755 --- a/scripts/skein/skein +++ b/scripts/skein/skein @@ -5,7 +5,13 @@ # (scripts/skein/skein). Commands live in ../commands, shared code in ../lib. set -uo pipefail -SELF="$(readlink -f "${BASH_SOURCE[0]}")" +# Resolve this script through symlinks without `readlink -f`, which BSD readlink (macOS) lacks. +SELF="${BASH_SOURCE[0]}" +while [ -L "$SELF" ]; do + target="$(readlink "$SELF")" + case "$target" in /*) SELF="$target" ;; *) SELF="$(dirname "$SELF")/$target" ;; esac +done +SELF="$(cd "$(dirname "$SELF")" && pwd -P)/$(basename "$SELF")" SKEIN_HOME="$(cd "$(dirname "$SELF")/.." && pwd -P)" # A vendored copy keeps bin/ flat: scripts/skein/skein next to lib/ and commands/. [ -d "$SKEIN_HOME/commands" ] || SKEIN_HOME="$(dirname "$SELF")" diff --git a/scripts/skein/templates/brief.md.tmpl b/scripts/skein/templates/brief.md.tmpl new file mode 100644 index 0000000..9a5cfe1 --- /dev/null +++ b/scripts/skein/templates/brief.md.tmpl @@ -0,0 +1,32 @@ +# {{ID}}: {{TITLE}} + +## Objective +<!-- What exists today, verified with file:line citations. What is wrong or missing. What + this task changes and, just as important, what it does not. Two or three paragraphs. --> + +## Read first +- `AGENTS.md`, all of it. +<!-- The files, in order, with line ranges, that the worker must read before writing. --> + +## You own +{{OWNED}} + +Nothing else. If a file outside this list seems to need a change, report `BLOCKED` with the +exact change rather than editing it. + +## Hard rules for this task +<!-- The invariants this task must not break, stated as rules, with the reason. --> + +## Work +1. <!-- Numbered, concrete steps. Each one names the file, the change, and the test. --> + +## What "done" looks like +`{{GATE}} {{ID}}` ends `status=PASS`. <!-- Plus the observable outcome: the command output, +the response shape, the count of tests, the thing the reviewer can check. --> + +## Acceptance +`{{GATE}} {{ID}}`, whose `accept` step runs: +{{ACCEPT}} + +Be direct and economical. Do not poll GitHub. Finish with the completion envelope from +`AGENTS.md`. diff --git a/scripts/skein/templates/review-prompt.md b/scripts/skein/templates/review-prompt.md new file mode 100644 index 0000000..2fac991 --- /dev/null +++ b/scripts/skein/templates/review-prompt.md @@ -0,0 +1,10 @@ +You are an adversarial reviewer for task {{ID}} ({{PR}}). You did not write this code and you are a different model from its author; your job is to find what the gate cannot see. Read `AGENTS.md`, then the brief at `{{BRIEF}}`, then the full diff: `git diff {{BASE}}...HEAD`. Money or personal data involved: {{MONEY}}. + +Look for, in this order: state that is read then written instead of claimed conditionally; money or quantities handled as floats or re-derived instead of captured; inputs trusted at a boundary; secrets or personal data reaching a log, an error, or a commit; a test that passes for the wrong reason or was weakened; behaviour the brief forbade; scope beyond the brief; a criterion the brief set that the diff satisfies vacuously. Verify each finding against the code before reporting it; do not report what you have not confirmed. + +Write a review in markdown with a one-line verdict first (MERGE / FIX FIRST / DO NOT MERGE), then findings ordered by severity, each with file:line, what happens, and the smallest fix. Then what is done well, in two lines at most. End with exactly: + +REVIEW_DONE +task: REVIEW-{{ID}} +verdict: <MERGE|FIX FIRST|DO NOT MERGE> +findings: <count>