diff --git a/.claude/skills/neutral-mayor/SKILL.md b/.claude/skills/neutral-mayor/SKILL.md index b4c73c1..8b83b50 100644 --- a/.claude/skills/neutral-mayor/SKILL.md +++ b/.claude/skills/neutral-mayor/SKILL.md @@ -252,13 +252,11 @@ Then: delivered for the next human or fix attempt to read, no more. - **Explicit instruction to steer a loop** ("tell the hypaware loop to …") → inject the human's text verbatim into that loop's pane with the LLP 0034 - discipline: `tmux send-keys -t '=neutral-:' C-u` first (the trailing - colon matters — send-keys takes a pane target, and a bare `=name` fails on - tmux 3.3a; it also clears any harness - prefill — never press Enter on text you did not type), then the message, then - Enter; verify within ~30 s that it left the input box (one extra Enter if - swallowed). Confirm in the thread once submission is verified. You steer only - on a human request; healing on your own judgment is the watchdog's job + discipline: write the literal message to a file you own, then run + `neutral safety send --session neutral- --message-file `. + The controller clears prefill, pastes without interpreting shell syntax, + and verifies submission. Confirm in the thread only when `submitted` is + true. You steer only on a human request; healing on your own judgment is the watchdog's job (LLP 0041 §report-relay). - **An ask to raise a PR's review budget** ("give hypaware#12 two more review rounds") → author a comment on that PR — the rare authored case (LLP 0042): @@ -271,7 +269,8 @@ Then: unmarked relay above *is* the grant, since human comments grant directly.) - **A question** → answer from ground truth re-derived *now* — `neutral prs --json` / `neutral backlog --json` / `neutral issues --json` in the clones, - `gh`, transcripts, `tmux capture-pane` — never from memory of a past tick. + `gh`, transcripts, `neutral safety capture --session ` — never from + memory of a past tick. If the question asks **how or why Neutral works** — its pipeline, reconciler roles, artifacts, state transitions, scheduling, admission, review rungs, authority boundaries, or why an artifact is in a state — call the Skill tool diff --git a/.claude/skills/neutral-reconcile/SKILL.md b/.claude/skills/neutral-reconcile/SKILL.md index 7324805..342d830 100644 --- a/.claude/skills/neutral-reconcile/SKILL.md +++ b/.claude/skills/neutral-reconcile/SKILL.md @@ -119,6 +119,10 @@ work, the tick verifies it. least-recently-run one past its cooldown and not damped, LLP 0047) — run that member's initiative (code cleanup, below), then **return** and let the loop schedule the next tick. + For `"pr-backlog"`, read and follow + [PR backlog autophagy](references/pr-backlog-autophagy.md) (LLP 0079). + Its no-op hint is `--backlog-snapshot ` on subsequent idle + calls, separate from the target-HEAD-based `--damped` members. - `null` — **return**; the loop schedules the next tick (`ScheduleWakeup`). This is a legitimate, common outcome: every member may be off, cooling down since a recent PR disposition, or no-op damped. A deliberately idle tick is correct, not @@ -139,21 +143,26 @@ action. Include this completion contract in every dispatched worker prompt: -> Await background agents through the harness completion notification or -> `TaskOutput` with the task ID and `block: true, timeout: 600000`. Finish -> independent work first. For a CLI reviewer, run +> After starting a background agent or forked skill, finish independent work, +> then end your turn and await its completion notification. If `TaskOutput` +> is available, use the returned task ID with `block: true, timeout: 600000`. +> If tool discovery says it is unavailable, use the notification path. Keep +> the review incomplete until its actual result arrives; transcript silence +> and file timestamps are not completion. For a CLI reviewer, run > `neutral run-worker --timeout-ms 1800000 -- ` -> once using Bash background execution; await that task's completion. The -> runner buffers bounded output and returns the real exit status or timeout. -> Preserve full review artifacts on disk. A timeout or failed reviewer leaves -> the review incomplete; return the failure without a clean marker. For -> external CI, return and let the next reconcile tick observe it. - -The container installs a shared tool hook that forces blocking `TaskOutput` -waits and rejects standalone waiting echoes. It applies to review children as -well as the coordinator. The runner owns the process timeout; the model does -not poll it. A `TaskOutput` timeout is a bounded observation, not permission to -restart the still-running worker. +> once in the background and await that task's notification. Preserve full +> artifacts on disk. A failure or timeout leaves the review incomplete. +> For external CI, return and let the next reconcile tick observe it. +> Cancel only tasks you started, using `TaskStop` with their exact task IDs. +> If cancellation is unavailable, report the task ID and return. Cleanup is +> limited to your own worktree and files; process-name kills and synthetic +> background sleep tasks are forbidden in this shared fleet. + +The shared container hook applies to coordinators and review children. It +rejects process-name cleanup, background sleep commands, and standalone waiting +echoes. These checks guide behavior; the controller's private tmux socket and +separate server UID enforce the supervisor boundary (LLP 0077). The runner owns +CLI timeouts. A bounded wait timing out does not authorize restarting a worker. ### Admission — heal broadly, start narrowly (LLP 0060) @@ -212,10 +221,10 @@ tick's log lines (R2 — nothing may follow this destructive act): `tick: family=autophagy action=recycle detail=context= threshold=`, then **respawn the pane** — the tick's last act: ```sh - tmux respawn-pane -k "claude --model 'claude-opus-5[1m]' --dangerously-skip-permissions '/loop /neutral-reconcile'" + tmux respawn-pane -k "ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1 claude --model 'opus[1m]' --dangerously-skip-permissions '/loop /neutral-reconcile'" ``` - **Pin the model** to the 1M-context Opus 5 (the worker tier, matching `neutral - start` — LLP 0020): an unpinned respawn silently reverts the fresh orchestrator to + **Select `opus[1m]`** for the 1M-context worker tier (matching `neutral + start` — LLP 0020): a respawn without `--model` silently reverts the fresh orchestrator to the machine's session default, which may be a different tier or a 200K window too small for the autophagy threshold T (LLP 0013). Single-quote the `[1m]` token so `sh` doesn't glob the brackets. **Keep `--dangerously-skip-permissions`**: the loop is @@ -296,7 +305,13 @@ model's failure just re-opens the gap — so cheap models run wherever a verifie the result, and the strongest is reserved for judgement no machine re-checks. When you dispatch a worker below, pass the tier's model as the sub-agent's `model`: -- **Judgment tier — `claude-fable-5-1`, at `high` effort.** Output no verifier re-derives, where +The Agent tool accepts family aliases. Launch with +`ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1` so `model: "fable"` selects +Fable 5.1; `neutral start` and the Docker image set this binding. The Workflow +keeps its explicit model ID. After a CLI or model-binding update, recycle the +running loops and verify the response model in recorded usage. + +- **Judgment tier — `fable`, at `high` effort.** Output no verifier re-derives, where an error propagates: the **Designer**, the **Impl-designer**, and the **triage** rung. Run Fable 5.1 at **`high`**, not Claude Code's `xhigh` default — Fable 5.1 at `high` still exceeds prior models at their ceiling, so it's a low-risk cost lever on the priciest @@ -304,15 +319,19 @@ dispatch a worker below, pass the tier's model as the sub-agent's `model`: tool has no per-call `effort` override**, so the Designer/Impl-designer/triage inherit the **session** effort — run the orchestrator loop at `high` if you want them capped there too. -- **Worker tier — `opus` (Opus 5).** Bounded work behind a hard gate: **conflict +- **Worker tier — `opus` (Opus).** Bounded work behind a hard gate: **conflict resolution**, **issue-fix**, the Claude half of **review**, and the **orchestrator - itself** (pinned at launch — LLP 0020; the tick is mechanical, the CLI decides every + itself** (selected explicitly at launch — LLP 0020; the tick is mechanical, the CLI decides every rung). - **Mechanical tier — `sonnet`, or `haiku` for pure CLI relay.** Fully verifier-gated execution: **task implementation** and its **serial merger**, **fix-ci**, **review-fix** agents, and **derive-ready** (haiku). The implement Workflow already sets these per `agent()` call. +The `opus` and `sonnet` aliases follow the versions provided by the installed +Claude Code and provider. Keep Claude Code current; `ANTHROPIC_DEFAULT_OPUS_MODEL` +and `ANTHROPIC_DEFAULT_SONNET_MODEL` can override these aliases. + **Retry escalation (LLP 0021/0022).** A task's *first* attempt starts at the tier its planner-rated `complexity` seeds (1–3 mechanical, 4 worker, 5 judgment; absent ⇒ mechanical). It retries in place until it exhausts that tier's budget of **verified** @@ -322,7 +341,7 @@ escalation changes *which model retries*, never *what counts as done*. The imple Workflow's wave loop owns this ladder end-to-end; the other rungs below take a single tier per their heading. -## Fan-out worker: Designer (pipeline) — judgment tier (`claude-fable-5-1`) +## Fan-out worker: Designer (pipeline) — judgment tier (`fable`) Goal: every live request is `@ref`'d by a `design` LLP. Plan the **whole** backlog up front, then mint only the groups covered by this tick's reserved admission slots @@ -350,7 +369,7 @@ up front, then mint only the groups covered by this tick's reserved admission sl the remote branch); then `cd && git worktree remove --force "$WT"`. 4. **Verify:** `neutral backlog` is now **empty**. Never commit a design to the target branch. -## Fan-out worker: Impl-designer (pipeline) — judgment tier (`claude-fable-5-1`) +## Fan-out worker: Impl-designer (pipeline) — judgment tier (`fable`) Goal: every implementable `design` LLP has a `plan` LLP on its `integration/` branch. A design is implementable two ways: **neutral-minted** (already on @@ -382,7 +401,7 @@ content edit — immutability holds) so the merged change set reads as shipped ( Encode real code dependencies in `deps`. **Rate each task's `complexity` 1–5** (LLP 0022) — your judgement, made here with the whole design in view, seeds the first implementation attempt's model tier: **1–3** a mechanical task (Sonnet), - **4** needs the worker tier (Opus 5), **5** needs judgement (Fable 5.1). Rate for + **4** needs the worker tier (Opus), **5** needs judgement (Fable 5.1). Rate for the *hardest* part of the task; be honest, not generous — the rating only seeds the entry rung and a verified failure still escalates (LLP 0021), so under-rating costs one climbing attempt, over-rating overpays. Omit `complexity` only when you @@ -534,9 +553,14 @@ which replies are new. - **`review`** (rung 3, head not yet reviewed): dispatch the review in its **own worktree** (never the main checkout, LLP 0012) — `dual-review` does a `gh pr checkout --detach` *in place* and **refuses on a dirty tree**, so it must run in a - clean, isolated checkout. Run the review — `dual-review` when `command -v codex` - succeeds, else `code-review` — on the PR number; the review itself is **worker-tier** - work (LLP 0020 — Codex, when present, is the independent second family). **Capture + clean, isolated checkout. **First review:** run `dual-review` when `command -v codex` + succeeds, else `code-review`, on the full PR. **Re-review after changes:** when + `previousReviewSha` is present, read + [references/incremental-review.md](references/incremental-review.md) before + dispatch; pass its verified baseline, prior findings, and delta scope to every + reviewer, including nested helpers. Use the same reviewer families with that + explicit scope. The review itself is **worker-tier** work (LLP 0020 — Codex, + when present, is the independent second family). **Capture the head SHA you reviewed** (the `headSha` from `neutral prs`). For each finding, record a numbered disposition: `fix` for a current-PR defect, `defer` for safely out-of-scope work, `reject` with evidence, or `blocker` for an unresolved shipping risk. A pre-existing defect can still @@ -551,13 +575,16 @@ which replies are new. `clean` when the review found nothing actionable, `findings` when it found any (fixed or not; LLP 0029) — followed by the full review a human can act on: the verdict, each finding with severity and evidence (file:line), and what was fixed. + State the review scope, PR target branch, and comparison base SHA; incremental + rounds also include the previous review link and prior finding outcomes. **Post the record whatever the outcome** — a round that leaves no comment did not happen (`reviewRounds` counts these comments), and an unrecorded blocked round would re-review the same head forever. No separate `gh pr edit`: the comment is the single act. If you fixed findings the head has since moved, so the next tick - re-reviews the new head (round 2); if the review was `clean` the record covers + incrementally reviews the new head (round 2, LLP 0078); if the review was `clean` the record covers the current head. If findings remain at an unchanged head, the next tick - performs narrow triage instead of repeating the review. The CLI bounds full reviews to **N=2** rounds + performs narrow triage instead of repeating the review. The CLI bounds reviews + (full or incremental) to **N=2** rounds and also returns `triage` for unchanged findings — plus any budget a human granted in the thread with a `neutral: rounds +N` comment (LLP 0059; the CLI folds grants into the cap, so trust the `action` field as ever — no skill-side arithmetic). diff --git a/.claude/skills/neutral-reconcile/references/incremental-review.md b/.claude/skills/neutral-reconcile/references/incremental-review.md new file mode 100644 index 0000000..8070791 --- /dev/null +++ b/.claude/skills/neutral-reconcile/references/incremental-review.md @@ -0,0 +1,64 @@ +# Incremental re-review + +@ref LLP 0078 [implements] — changed heads reuse verified review evidence + +Use this procedure when `action: review` includes `previousReviewSha`. The +existing round cap, reviewer independence, finding dispositions, and final-head +ship-risk gate still apply. + +1. **Verify the baseline.** Read the latest marker-signed review comment in full, + including its numbered findings and linked evidence; follow earlier review + links when this baseline was itself incremental. Match its marker to + `previousReviewSha`; resolve that value and the observed current head to full + commit SHAs. Fetch missing objects once. Verify with + `git merge-base --is-ancestor `. + A legacy bare marker without the underlying review is insufficient evidence + for incremental coverage. Establish that the PR target is unchanged from the + previous report or GitHub's base-change history; an unknown or changed target + requires full review. If the head or latest record changed since the CLI + observation, return to observation before dispatch. +2. **Set the scope.** With a verified baseline, use + `git diff ` — the two committed + trees, not the whole PR against its target. Read enough surrounding code, + callers, tests, and applicable LLPs to assess the delta's effects. Account for + each prior finding: verify claimed fixes in the committed tree and with + targeted regression evidence; carry unresolved findings forward; retain + supported deferrals/rejections unless the delta invalidates their evidence. + Prior settled conclusions stand where their assumptions still hold. An empty + delta needs verification of that fact and outstanding findings, not another + broad scan. +3. **Widen from evidence.** Expand to affected interactions when the delta changes + an interface, invariant, or dependency used by otherwise unchanged code. + Use a full PR review when the previous commit/report is unavailable, history + was rewritten so ancestry cannot be verified, the PR was retargeted, or a + redesign or conflict resolution invalidates the earlier review broadly. + A merged target update is part of the delta; inspect its integration effects + and widen only as needed. A new head SHA alone is not a reason to start over. + Record the concrete reason and expanded files/invariants before doing the + additional work. A broad scan that happens to find something is not a scope + justification after the fact. +4. **Dispatch that scope explicitly.** Give every reviewer the exact previous + and current SHAs, previous review link and findings, delta command, and any + justified expansion. Require nested reviewers to inherit them. For an + incremental round, invoke `code-review` / `dual-review` only through a mode + that honors this comparison. If the available skill defaults to the whole PR + or silently starts broad nested reviews, use explicitly scoped reviewers + from the same required model families instead. Tool limitations alone do not + authorize a full re-review. Preserve independent verification and one + marker-signed round; nested helpers do not post additional round markers. +5. **Record and re-observe.** The normal `neutral-review` comment still names the + full head SHA actually reviewed and counts as one round, including an + incremental round. Its prose states `Scope: incremental ..` + (or `Scope: full — `), links the previous review, lists each prior + finding's verified outcome, new findings, tests, and any expanded scope. + `clean` requires both no new actionable findings and every prior finding + fixed with evidence or safely disposed of; unresolved findings cannot become + clean merely because the delta introduced none. If fixes are pushed, record + the reviewed pre-fix head as usual and return to observation. Recheck the + current PR head before publication; concurrent head movement returns to + observation and never receives a marker for unreviewed code. + +Completion means the delta and affected interactions have been reviewed, +every prior finding is accounted for, and the recorded scope is backed by the +actual git comparison. Incremental review is an instruction contract, not a +claim that the CLI can verify what a model read. diff --git a/.claude/skills/neutral-reconcile/references/pr-backlog-autophagy.md b/.claude/skills/neutral-reconcile/references/pr-backlog-autophagy.md new file mode 100644 index 0000000..5e372c1 --- /dev/null +++ b/.claude/skills/neutral-reconcile/references/pr-backlog-autophagy.md @@ -0,0 +1,57 @@ +# PR backlog relevance audit (LLP 0079) + +Run only when `neutral idle --json` selects `initiative: "pr-backlog"`. +Dispatch one judgment-tier worker in its own worktree off the configured target +branch, on `autophagy/pr-backlog-` (add a unique suffix on collision). +Give it the complete `prBacklog` observation and this brief. + +1. **Observe intent and remaining work.** For every listed PR, read the body, + linked issue/LLPs, reviews and discussion, exact head/base SHAs and current + diff. Inspect the current base tree and relevant merged and open replacement + PRs. Paginate searches; list anything unassessed if a work budget is reached. + Treat inaccessible or failed reads as uncertainty, never proof of absence. +2. **Assess relevance.** Assign each PR one of: + - `still-needed`: name the unmet requirement and unique remaining behavior. + - `already-implemented`: cite current-base code/commits and checks showing all + intended behavior already exists. Account for every meaningful diff part; + inspect squashed implementations instead of relying only on ancestry. + - `superseded`: map its intent to a cited replacement. If the replacement is + still open, recommend keeping the original until the replacement lands. + - `irrelevant`: cite a newer explicit decision or removed feature that makes + the original request inapplicable, and account for useful remaining changes. + - `uncertain`: name the missing evidence or concrete human choice. + Age, inactivity, conflicts, CI failures and similar titles are not closure + evidence. Partial overlap calls for salvaging the unique remainder. Inspect + stacked PRs, linked issues and LLP coverage/dependency edges before recommending + closure. Verify cross-repo replacements in their own repo, or mark uncertain. +3. **Prepare the proposal.** If there are actionable recommendations, write + `reports/pr-backlog/.md` (add a suffix if that file exists). + Use this tracked report directory because `.neutral/*` is normally ignored. + Include observation time, + inventory fingerprint, scope and unassessed PRs, then a row per assessed PR: + link, head/base SHAs, verdict, evidence links and reproducible checks, + remaining useful work, dependents, and recommended human action. Explain + proposed closures individually. The report is advisory; its merge changes no + PR state and never counts as completing a request. If all PRs remain needed + and no action is warranted, produce no PR. +4. **Verify freshness and publish.** Re-run `neutral idle --json` preserving + other members' current `--damped` hints, but omitting `--backlog-snapshot`. + Confirm the audit is still selected and the inventory fingerprint + matches the assessed one; re-read any external evidence cited by the report. + On changes, refresh the affected judgments before publishing, or defer. + Run the repo's checks and follow [PR publishing](pr-publishing.md). + Open one draft report PR. It stays held under the `autophagy/` policy. + +Keep the assessed PRs, their labels, comments, branches and accepted LLPs unchanged. +Closing a PR requires a separate human instruction; the report is the reviewable +proposal. If no report is warranted, return a complete no-op with the fingerprint. +An incomplete/failed scan returns that status and the unassessed list instead. + +On fan-in, verify any report PR via `gh`. Emit +`tick: family=autophagy action=pr-backlog detail=pr# assessed= unassessed=`. +For a complete no-op emit `action=pr-backlog-noop` with the fingerprint and count; +retain the fingerprint as a session scheduling hint and pass it on subsequent +`neutral idle` calls as `--backlog-snapshot `. The CLI automatically releases +the hint when PRs or bases change. For incomplete/failed scans log +`action=pr-backlog-incomplete` or `action=pr-backlog-failed`; retain no no-op hint. +Return and let the loop schedule its next tick. diff --git a/.claude/skills/neutral-watchdog/SKILL.md b/.claude/skills/neutral-watchdog/SKILL.md index f4908c8..d46e7cd 100644 --- a/.claude/skills/neutral-watchdog/SKILL.md +++ b/.claude/skills/neutral-watchdog/SKILL.md @@ -1,7 +1,7 @@ --- name: neutral-watchdog description: One watchdog tick for the neutral-loop container — health-check every reconcile-loop tmux session against transcript ground truth, and heal wedged loops (nudge the live session, or respawn it). Runs as the third loop, `/loop 55m /neutral-watchdog`, inside the container (LLP 0034). Use only there — it assumes the container's shared tmux server and /work layout. -allowed-tools: Bash, Read +allowed-tools: Bash, Read, Monitor --- # neutral-watchdog @@ -16,12 +16,11 @@ confirmation. Every decision below is yours to make from observed state. ## Targets -Sessions named `neutral-*` on the local tmux server (`tmux ls -F -'#{session_name}'`), **excluding your own session** (`tmux display-message -p -'#S'`) and `hyp-daemon`. Session `neutral-` ↔ working dir `/work/` ↔ -transcripts `~/.claude/projects/-work-/*.jsonl` — **except -`neutral-mayor`**, recognized by name (next bullet); the name→repo mapping -never applies to it. +Use `neutral safety sessions` to enumerate live `neutral-*` sessions, excluding +your own `$NEUTRAL_LOOP_SESSION`. The controller owns the private tmux +socket; use its `capture`, `send`, and `replace` operations instead of direct +tmux commands. Session `neutral-` maps to `/work/` and transcripts +`~/.claude/projects/-work-/*.jsonl`, except `neutral-mayor` below. - **`neutral-mayor` is a target, but not a reconcile loop** (LLP 0039). It runs in `/work` itself (like you), so its transcripts sit in @@ -57,7 +56,7 @@ never applies to it. Newer than **45 minutes** (loops promise ≤30-minute heartbeats, LLP 0013) → **healthy**, log and move on. -2. **Pane state** (`tmux capture-pane -p -t `), only for stale sessions: +2. **Pane state** (`neutral safety capture --session `), only for stale sessions: - **Working**: a live spinner / "esc to interrupt" footer — a long fan-out can be transcript-quiet while agents run. Treat as healthy-busy; do not nudge mid-work. If it is still stale *and* the pane is unchanged next tick, treat @@ -66,9 +65,8 @@ never applies to it. suggested next messages there (e.g. `stop the loop`, `keep going`) — it is NOT human input and NOT a wedge sign. Leave it alone; **never press Enter on text you did not type** — that submits the suggestion as a real - message. When you nudge (below), clear the input line first with - `tmux send-keys -t C-u` so your message does not concatenate - with the prefill. + message. The controller clears the input line before pasting your nudge, so it + cannot concatenate with the prefill. - **Interactive dialog/menu** (trust prompt, theme picker, permission ask, folder-sync menu): do not guess an answer — this is a respawn case. - **Idle prompt after an error** (e.g. `API Error` with an empty input box): @@ -76,19 +74,20 @@ never applies to it. ## Heal — gentlest act that works (LLP 0034 §recovery-ladder) -1. **Nudge** (wedged, session alive, no dialog): send one resume message stating - what you observed, then Enter: +1. **Nudge** (wedged, session alive, no dialog): write one resume message + stating the observed last-event time to a file you own, then submit it: ```bash - tmux send-keys -t "keep going - your last transcript event is and no heartbeat is armed. Re-derive ground truth, run a reconcile tick, and re-arm the heartbeat." Enter + neutral safety send --session --message-file ``` - **Verify submission**: within ~30 s the text must leave the input box and a - spinner appear; if it still sits in the prompt, send `Enter` once more (the - first can be swallowed as a paste). Then **verify recovery**: a new transcript - event within ~5 min (bounded `until` loop re-running the staleness check). The - nudge is preferred because it keeps the session's accumulated context and lets - it resume mid-flight work. + The controller checks the current generation, clears prefill, pastes the + literal message, and verifies submission with at most one extra Enter. + Check the returned `submitted` value. Then verify a new transcript event + within five minutes using a bounded Monitor condition when available. If + Monitor is unavailable, record recovery as unverified and recheck next tick. + A submitted message alone is not evidence of recovery. The nudge preserves + accumulated context. 2. **Respawn** (nudge failed its verification, interactive dialog, or missing session): read the pane tail and last transcript events, then ask the diff --git a/Dockerfile b/Dockerfile index 8e2d8a0..4ce44ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,9 @@ FROM node:22-bookworm-slim # git + gh are the loop's ground-truth controllers; tmux is required for context # autophagy (the pane is the respawn mutex — LLP 0013). +# Bookworm supplies Python 3.11 for worker scripts; support both command names. RUN apt-get update && apt-get install -y --no-install-recommends \ - git tmux curl ca-certificates jq procps util-linux tini \ + git tmux curl ca-certificates jq procps util-linux tini python3 python-is-python3 \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ @@ -50,10 +51,14 @@ RUN useradd -m -s /bin/bash neutral \ # neutral itself — the deterministic CLI has no runtime deps, so a symlink is # the whole install. COPY . /opt/neutral -RUN chown -R root:root /opt/neutral && chmod -R go-w /opt/neutral +# Build contexts can be extracted under a restrictive umask. Workers must be +# able to read/traverse the installed source while only root can modify it. +RUN chown -R root:root /opt/neutral && chmod -R a+rX,go-w /opt/neutral RUN ln -s /opt/neutral/bin/neutral.js /usr/local/bin/neutral USER neutral +RUN neutral --help > /dev/null +RUN sh /opt/neutral/docker/check-python.sh # Expose neutral's skills user-level so /neutral-reconcile resolves inside any # target repo (same shape as a ~/.claude/skills symlink on a dev machine). @@ -84,9 +89,19 @@ ARG NEUTRAL_REPOS="" ENV NEUTRAL_REPOS=$NEUTRAL_REPOS # Overridable knobs (defaults mirror src/commands/start.js). -ENV NEUTRAL_MODEL="claude-opus-5[1m]" +# Agent model overrides accept aliases; bind the judgment tier for every loop. +ENV ANTHROPIC_DEFAULT_FABLE_MODEL="claude-fable-5-1" +ENV NEUTRAL_MODEL="opus[1m]" ENV NEUTRAL_CLAUDE_ARGS="--dangerously-skip-permissions" +# Unattended loops do not need suggested user prompts or periodic progress-label +# model calls. In Claude Code 2.1.252, agent summaries are coupled to fork and +# coordinator modes; ordinary background/review subagents remain available. +# Keep these as image defaults so a rebuild does not re-enable auxiliary usage. +ENV CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION="false" +ENV CLAUDE_CODE_FORK_SUBAGENT="false" +ENV CLAUDE_CODE_COORDINATOR_MODE="false" + # Watchdog: a third LLM loop that hourly heals wedged reconcile loops # (LLP 0034). Set NEUTRAL_WATCHDOG=0 to disable the LLM watchdog. # The deterministic safety controller still bounds all registered loop recovery. NEUTRAL_WATCHDOG_MODEL overrides the @@ -125,7 +140,7 @@ ENV NEUTRAL_HYPAWARE="1" ENV HYP_REMOTE_NAME="prod" ENV HYP_REMOTE_URL="" -# @ref LLP 0072#controller [implements] — only the controller runs as root; all services drop to neutral +# @ref LLP 0077#supervisor [implements] — controller and private tmux server are root; panes drop to neutral USER root RUN mkdir -p /var/lib/neutral-safety && chmod 700 /var/lib/neutral-safety ENV NEUTRAL_SAFETY_SOCKET="/run/neutral-safety/client.sock" diff --git a/README.md b/README.md index 53fc7ee..1307bd7 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,35 @@ file, git state). No ledger to drift or fabricate. See ## Status +### Optional PR backlog audit + +Enable the advisory autophagy member in each repo's `.neutral/config.json`, +merging this field into its existing configuration: + +```json +{ "autophagy": { "prBacklog": true } } +``` + +On an eligible idle tick, the loop assesses all open PRs for remaining need, +already-landed work, supersession, or changed requirements. Evidence and proposed +human actions go into a held draft report PR; assessed PRs are left unchanged. +An unmerged replacement is a dependency, and uncertain cases remain open. +The option defaults off and shares autophagy's admission gate, cooldowns and +one-open-proposal limit, so busy repos may defer the audit. + +`neutral idle --json` exposes the selection, eligibility reasons and complete +PR inventory. Complete no-op scans use `--backlog-snapshot ` to +avoid repeats until the observed backlog changes. See +[LLP 0079](llp/0079-pr-backlog-autophagy.spec.md) for the evidence and delivery rules. + +Deploy the updated CLI and reconcile skill before enabling the option in the +production repos. Verify each repo's `neutral idle --json` includes a +`pr-backlog` member and a successful `prBacklog` observation; `initiative: null` +is expected when existing work, admission, cooldowns or an open autophagy PR +blocks selection. + +### Development + Milestone 0: deterministic core + `neutral status`. The reconcilers (Designer, Impl-designer, Implementer, Reviewer) land in M1–M4. diff --git a/bin/neutral.js b/bin/neutral.js index 4c7afbb..7182388 100755 --- a/bin/neutral.js +++ b/bin/neutral.js @@ -26,6 +26,9 @@ usage: neutral defer-findings record deferred findings; auto-admit evidenced defects only neutral safety status --json inspect the container's persistent restart breaker neutral safety replace --session request a budgeted repair + neutral safety sessions list live container sessions + neutral safety capture --session inspect a registered pane + neutral safety send --session --message-file submit literal input neutral safety recycle request replacement of this loop generation neutral safety init|rearm|stop operator commands; see docker/SAFETY.md neutral start launch the orchestrator loop in its tmux pane (LLP 0013) @@ -40,7 +43,7 @@ usage: neutral enqueue add an approved exact head to GitHub's merge queue neutral pr-stats [head] production-code diff lines, excluding tests + LLPs neutral issues [--json] open neutral:fix issues with their fix-attempt state - neutral idle [--json] is the tick idle, and should it recycle context (LLP 0013) + neutral idle [--json] [--damped ids] [--backlog-snapshot hash] select one idle initiative neutral llp [--json] inspect one LLP: metadata, role, coverage neutral help this message ` diff --git a/docker/SAFETY.md b/docker/SAFETY.md index 8bd3d3f..3202709 100644 --- a/docker/SAFETY.md +++ b/docker/SAFETY.md @@ -64,6 +64,24 @@ without capture; the guard itself is mandatory either way. ## Repairs, stops, and holds +### Auxiliary model requests + +The image defaults `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION`, +`CLAUDE_CODE_FORK_SUBAGENT`, and `CLAUDE_CODE_COORDINATOR_MODE` to `false`. +These disable suggested next user messages and automatic agent progress-label +requests for the unattended interactive loops. With Claude Code 2.1.252, +progress summaries are coupled to fork/coordinator mode: this also disables +the special built-in fork subagent that inherits the parent conversation. +Normal background and review subagents remain available. + +Keep the same values in deployment environment overrides. Recheck these +controls when upgrading Claude Code; their coupling is version-specific. +Environment changes take effect in newly launched processes, so recreate the +container through the planned deployment path below. Neither these settings +nor deployment clear an existing account quota limit or fleet safety hold. + +### Recovery operations + A watchdog or operator repairs a registered loop through the same gate: ```sh @@ -143,3 +161,31 @@ to a printed temporary directory. No production deployment is touched. The temporary external guard should remain until deployment passes these checks with its real restart policy and mounted safety volume. Implementing this code does not itself install the guard on the running fleet. + +## Worker process boundary (LLP 0077) + +The shared tmux server now runs as root, loading no user tmux configuration, +with its socket at `/run/neutral-safety/tmux/server.sock` in a root-only +directory. Every pane and capture helper drops to neutral. Workers cannot +signal the server or access its socket. They use these fixed operations: + +```sh +neutral safety sessions +neutral safety capture --session neutral-hypaware +neutral safety send --session neutral-hypaware --message-file /path/to/message.txt +``` + +Send reads stdin if no message file is supplied, bounds plain text to 16000 +characters, rejects control characters, and returns `submitted`. It checks the +observed generation and refuses held fleets and service-pane input. The bridge +and watchdog use the same submission path. Root operators can inspect directly: + +```sh +docker exec --user root neutral-loop tmux -S /run/neutral-safety/tmux/server.sock list-sessions +``` + +A code upgrade requires image rebuild and recreation, preserving every volume, +environment override, pinned dependency version, and the current hold. Validate +with the fake-service smoke first. Deployment and an explicit rearm are separate +operator actions. Workers still share one UID with each other; this change +protects the supervisor rather than providing per-worker isolation. diff --git a/docker/check-python.sh b/docker/check-python.sh new file mode 100644 index 0000000..7d9a9af --- /dev/null +++ b/docker/check-python.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Exercise the standard-library file edits used by workers under both names. +set -eu +for interpreter in python3 python; do + "$interpreter" - <<'PY' +import json +from pathlib import Path +import re +import sys +from tempfile import TemporaryDirectory + +assert sys.version_info[:2] == (3, 11), sys.version +with TemporaryDirectory() as directory: + path = Path(directory) / 'probe.json' + path.write_text(json.dumps({'message': 'before'}), encoding='utf-8') + path.write_text(re.sub('before', 'after', path.read_text(encoding='utf-8')), encoding='utf-8') + assert json.loads(path.read_text(encoding='utf-8')) == {'message': 'after'} +print(sys.version) +PY +done diff --git a/docker/install-worker-hooks.js b/docker/install-worker-hooks.js index f31d6b5..c8c955d 100644 --- a/docker/install-worker-hooks.js +++ b/docker/install-worker-hooks.js @@ -13,7 +13,7 @@ export function installWorkerHooks(directory) { const existing = settings.hooks?.PreToolUse || [] settings.hooks = { ...settings.hooks, PreToolUse: [ ...existing.filter((/** @type {any} */ entry) => !entry.hooks?.some((/** @type {any} */ hook) => hook.command === command)), - { matcher: 'Bash|TaskOutput', hooks: [{ type: 'command', command, timeout: 5 }] } + { matcher: 'Bash|TaskOutput|Monitor', hooks: [{ type: 'command', command, timeout: 5 }] } ] } writeFileSync(`${path}.neutral-tmp`, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }) renameSync(`${path}.neutral-tmp`, path) diff --git a/docker/outage-sentinel.js b/docker/outage-sentinel.js index 10895bb..d725ab3 100644 --- a/docker/outage-sentinel.js +++ b/docker/outage-sentinel.js @@ -183,7 +183,9 @@ async function runProbes(home) { const port = settings?._hypaware?.port if (port) gateway = touch(`http://127.0.0.1:${port}/`) } catch { /* no settings — gateway unknown */ } - const sessions = new Promise(resolve => { + const sessions = process.env.NEUTRAL_SAFETY_SOCKET + ? safetyRequest({ action: 'sessions' }).catch(() => null) + : new Promise(resolve => { execFile('tmux', ['list-sessions', '-F', '#S'], (err, stdout) => resolve(err ? null : stdout.split('\n').filter(Boolean))) }) diff --git a/docker/safety-controller.js b/docker/safety-controller.js index 14c58a7..d5f5a27 100644 --- a/docker/safety-controller.js +++ b/docker/safety-controller.js @@ -214,6 +214,17 @@ export class SafetyController { /** @param {SafetyRequest} request @param {boolean} operator */ async request(request, operator) { if (request.action === 'status') return this.status() + // @ref LLP 0077#supervisor [implements] — fixed pane operations, no caller-provided tmux arguments + if (request.action === 'sessions') return this.deps.platform.sessions() + if (request.action === 'capture' || request.action === 'send') { + const run = [...this.runs.values()].find(r => r.session === request.session) + if (!run || !(await this.deps.platform.observe(run)).alive) throw new Error('unknown or dead session') + if (request.action === 'capture') return this.deps.platform.capture(run) + if (this.closing || !this.state || this.state.hold) throw new Error('fleet held: pane input refused') + if (!this.deps.config.loops.some(l => l.session === run.session) || run.run !== request.run) throw new Error('unknown or stale loop generation') + if (typeof request.message !== 'string' || !request.message.trim() || request.message.length > 16000 || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(request.message)) throw new Error('plain message required (maximum 16000 characters)') + return { submitted: await this.deps.platform.send(run, request.message) } + } if (this.closing) throw new Error('controller stopping') if (['init', 'rearm', 'stop'].includes(request.action)) { if (!operator) throw new Error('operator socket required') @@ -266,7 +277,7 @@ export function serveSafety(controller, path, operator) { socket.on('data', data => { if (handled) return input += data.toString() - if (input.length > 4096) { + if (input.length > 100000) { socket.destroy() return } @@ -300,6 +311,7 @@ export function serveSafety(controller, path, operator) { export function prepareSafetyRuntime(dir = RUNTIME_DIR) { rmSync(dir, { recursive: true, force: true }) mkdirSync(`${dir}/permits`, { recursive: true, mode: 0o755 }) + mkdirSync(`${dir}/tmux`, { mode: 0o700 }) } async function main() { diff --git a/docker/safety-platform.js b/docker/safety-platform.js index d978dde..3da41bd 100644 --- a/docker/safety-platform.js +++ b/docker/safety-platform.js @@ -3,6 +3,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { readFileSync, readdirSync, readlinkSync, writeFileSync, existsSync, rmSync } from 'node:fs' import { createHash, randomUUID } from 'node:crypto' +import { injectIntoPane } from './slack-bridge.js' import { sessionName, ORCHESTRATOR_MODEL } from '../src/commands/start.js' const exec = promisify(execFile) @@ -92,9 +93,23 @@ export class SafetyPlatform { } /** @param {string} cmd @param {string[]} args @param {number} [timeout] */ async asNeutral(cmd, args, timeout = 5000) { - return (await exec('setpriv', ['--reuid=neutral', '--regid=neutral', '--init-groups', '--', cmd, ...args], + return (await exec('/usr/bin/setpriv', ['--reuid=neutral', '--regid=neutral', '--init-groups', '--', cmd, ...args], { env: this.env, timeout, maxBuffer: 1024 * 1024 })).stdout.trim() } + // @ref LLP 0077#supervisor [implements] — private root server; never load worker-owned tmux config + /** @param {string[]} args @param {string} [input] */ + async tmux(args, input) { + const child = exec('/usr/bin/tmux', ['-f', '/dev/null', '-S', `${RUNTIME_DIR}/tmux/server.sock`, ...args], + { env: { ...this.env, HOME: '/root', SHELL: '/bin/sh', TMUX: undefined, BASH_ENV: undefined, ENV: undefined }, + timeout: 5000, maxBuffer: 1024 * 1024 }) + if (input !== undefined) child.child.stdin?.end(input) + return (await child).stdout.trimEnd() + } + async sessions() { return (await this.tmux(['list-sessions', '-F', '#S'])).split('\n').filter(Boolean) } + /** @param {ProcessRun} run */ + async capture(run) { return this.tmux(['capture-pane', '-p', '-t', `=${run.session}:`]) } + /** @param {ProcessRun} run @param {string} message */ + async send(run, message) { return injectIntoPane(message, run.session, (args, input) => this.tmux(args, input)) } async prepare() { await this.asNeutral('/opt/neutral/docker/prepare.sh', [], 120_000) } async attach() { await this.asNeutral('hyp', ['attach', 'claude'], 3000).catch(() => {}) } /** @param {LoopDefinition} def @param {ProcessRun|undefined} old @returns {Promise} */ @@ -102,16 +117,19 @@ export class SafetyPlatform { const run = randomUUID() const permit = `${RUNTIME_DIR}/permits/${run}` const shell = `i=0; while [ ! -f ${quote(permit)} ]; do i=$((i+1)); [ "$i" -lt 200 ] || exit 75; sleep 0.05; done; export NEUTRAL_RUN_ID=${quote(run)}; export NEUTRAL_LOOP_SESSION=${quote(def.session)}; exec ${def.command}` + // Multiple argv entries make tmux exec setpriv directly, with no root shell. + const paneCommand = ['/usr/bin/setpriv', '--reuid=neutral', '--regid=neutral', '--init-groups', '--', + '/usr/bin/env', `HOME=${HOME}`, 'USER=neutral', 'LOGNAME=neutral', 'SHELL=/bin/bash', 'TMUX=', '/bin/sh', '-c', shell] if (old && (await this.observe(old)).exists) { - await this.asNeutral('tmux', ['respawn-pane', '-k', '-t', `=${def.session}:`, '-c', def.cwd, shell]) + await this.tmux(['respawn-pane', '-k', '-t', `=${def.session}:`, '-c', def.cwd, ...paneCommand]) } else { // No attach-or-create: an unknown live session is not permission to duplicate it. - await this.asNeutral('tmux', ['new-session', '-d', '-s', def.session, '-c', def.cwd, shell]) + await this.tmux(['new-session', '-d', '-s', def.session, '-c', def.cwd, ...paneCommand]) } - await this.asNeutral('tmux', ['set-option', '-w', '-t', `=${def.session}:`, 'remain-on-exit', 'on']) - await this.asNeutral('tmux', ['pipe-pane', '-t', `=${def.session}:`]) - await this.asNeutral('tmux', ['pipe-pane', '-t', `=${def.session}:`, `node /opt/neutral/docker/safety-capture.js ${quote(def.session)}`]) - const pid = Number(await this.asNeutral('tmux', ['display-message', '-p', '-t', `=${def.session}:`, '#{pane_pid}'])) + await this.tmux(['set-option', '-w', '-t', `=${def.session}:`, 'remain-on-exit', 'on']) + await this.tmux(['pipe-pane', '-t', `=${def.session}:`]) + await this.tmux(['pipe-pane', '-t', `=${def.session}:`, `/usr/bin/setpriv --reuid=neutral --regid=neutral --init-groups -- /usr/bin/env HOME=/home/neutral node /opt/neutral/docker/safety-capture.js ${quote(def.session)}`]) + const pid = Number(await this.tmux(['display-message', '-p', '-t', `=${def.session}:`, '#{pane_pid}'])) const identity = processIdentity(pid) if (!identity) throw new Error('new pane process missing') writeFileSync(permit, '', { mode: 0o644, flag: 'wx' }) @@ -121,7 +139,7 @@ export class SafetyPlatform { /** @param {ProcessRun} run @returns {Promise} */ async observe(run) { let text - try { text = await this.asNeutral('tmux', ['display-message', '-p', '-t', `=${run.session}:`, '#{pane_pid} #{pane_dead} #{pane_dead_status}']) } + try { text = await this.tmux(['display-message', '-p', '-t', `=${run.session}:`, '#{pane_pid} #{pane_dead} #{pane_dead_status}']) } catch { return { exists: false, alive: false, exit: null } } const [pid, dead, status] = text.split(' ') return { exists: true, alive: dead === '0' && Number(pid) === run.pid && processIdentity(run.pid) === run.identity, diff --git a/docker/safety-types.d.ts b/docker/safety-types.d.ts index 35eb1b7..969a017 100644 --- a/docker/safety-types.d.ts +++ b/docker/safety-types.d.ts @@ -1,4 +1,4 @@ export interface LoopDefinition { id: string; session: string; cwd: string; command: string } export interface ProcessRun extends LoopDefinition { run: string; pid: number; identity: string } export interface ProcessObservation { exists: boolean; alive: boolean; exit: number | null } -export interface SafetyRequest { action: string; session?: string; run?: string; incident?: string; reason?: string; deployment?: string } +export interface SafetyRequest { action: string; message?: string; session?: string; run?: string; incident?: string; reason?: string; deployment?: string } diff --git a/docker/slack-bridge.js b/docker/slack-bridge.js index 3730b7e..69713d2 100644 --- a/docker/slack-bridge.js +++ b/docker/slack-bridge.js @@ -12,6 +12,7 @@ // injectable Exec seam (LLP 0042 R4). // @ref LLP 0040#socket-mode [implements] — outbound WebSocket only; no inbound ports +import { safetyRequest } from '../src/safety-client.js' import { execFile } from 'node:child_process' import { pathToFileURL } from 'node:url' @@ -227,7 +228,13 @@ async function main() { // not interleave their C-u/paste/Enter sequences in the same pane. let queue = Promise.resolve() const inject = (/** @type {string} */ framed) => { - const turn = queue.then(() => injectIntoPane(framed, cfg.session, tmuxExec)) + const turn = queue.then(async () => { + if (!process.env.NEUTRAL_SAFETY_SOCKET) return injectIntoPane(framed, cfg.session, tmuxExec) + const status = await safetyRequest({ action: 'status' }) + const run = status.loops.find((/** @type {{session: string}} */ l) => l.session === cfg.session)?.run + const result = await safetyRequest({ action: 'send', session: cfg.session, run, message: framed }) + return result.submitted + }) .catch(err => { log(`WARNING: tmux injection failed: ${err}`); return false }) queue = turn.then(() => undefined) return turn diff --git a/docker/test-fixtures/safety/Dockerfile b/docker/test-fixtures/safety/Dockerfile index 70584d4..424b299 100644 --- a/docker/test-fixtures/safety/Dockerfile +++ b/docker/test-fixtures/safety/Dockerfile @@ -1,7 +1,7 @@ ARG BASE_IMAGE=node:22-bookworm-slim FROM ${BASE_IMAGE} USER root -RUN if ! command -v tmux >/dev/null || ! command -v setpriv >/dev/null || ! command -v tini >/dev/null; then apt-get update && apt-get install -y --no-install-recommends tmux util-linux tini && rm -rf /var/lib/apt/lists/*; fi +RUN if ! command -v tmux >/dev/null || ! command -v setpriv >/dev/null || ! command -v tini >/dev/null; then apt-get update && apt-get install -y --no-install-recommends tmux util-linux tini procps && rm -rf /var/lib/apt/lists/*; fi RUN (id neutral >/dev/null 2>&1 || useradd -m -s /bin/bash neutral) && mkdir -p /work/a /var/lib/neutral-safety && chown -R neutral:neutral /work && chmod 700 /var/lib/neutral-safety COPY . /opt/neutral RUN chown -R root:root /opt/neutral && chmod -R go-w /opt/neutral && \ diff --git a/docker/test-fixtures/safety/probe.js b/docker/test-fixtures/safety/probe.js index fd2e791..0ee635e 100644 --- a/docker/test-fixtures/safety/probe.js +++ b/docker/test-fixtures/safety/probe.js @@ -2,15 +2,17 @@ // Linux regression: same-uid /proc ownership checks work without CAP_SYS_PTRACE // and reject a reachable socket belonging to another daemon generation. import assert from 'node:assert/strict' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' import { setTimeout as delay } from 'node:timers/promises' import { SafetyPlatform, safetyClock, fleetConfig } from '../../safety-platform.js' import { SafetyController } from '../../safety-controller.js' import { SafetyStore } from '../../safety-store.js' mkdirSync('/run/neutral-safety/permits', { recursive: true }) +mkdirSync('/run/neutral-safety/tmux', { mode: 0o700 }) const platform = new SafetyPlatform(process.env) await platform.prepare() +writeFileSync('/home/neutral/.tmux.conf', 'run-shell "id -u > /work/tmux-config-loaded"\n') const first = await platform.launch({ id: 'hyp', session: 'hyp-daemon', cwd: '/work', command: 'hyp daemon run --foreground' }, undefined) await delay(200) const firstPort = Number(readFileSync('/work/fake-port', 'utf8')) @@ -28,3 +30,27 @@ const controller = new SafetyController({ platform, store: new SafetyStore('/var controller.runs.set('hyp', first) assert.equal(await controller.readyGateway(), true) console.log('same-user ownership, stale attachment rejection, and stable readiness verified') + +// @ref LLP 0077#supervisor [tests] — the real incident's broad match cannot signal the shared root server +const serverPid = Number(await platform.tmux(['display-message', '-p', '#{pid}'])) +assert.match(readFileSync(`/proc/${serverPid}/status`, 'utf8'), /Uid:\s+0\s+0/) +assert.match(readFileSync(`/proc/${first.pid}/status`, 'utf8'), /Uid:\s+(?!0\b)\d+/) +await assert.rejects(platform.asNeutral('kill', ['-TERM', String(serverPid)]), /permitted/) +await assert.rejects(platform.asNeutral('/usr/bin/tmux', ['-S', '/run/neutral-safety/tmux/server.sock', 'new-session', '-d', 'id']), /denied/) +// Replay the vulnerable launch alongside the fixed server. Both have sleep in +// the server argv; only the neutral-owned baseline should die from this signal. +await platform.asNeutral('/usr/bin/tmux', ['-f', '/dev/null', '-L', 'incident-baseline', 'new-session', '-d', '-s', 'baseline', + 'sleep 0.05; exec node -e "setInterval(() => {}, 1000)"']) +await delay(100) +const baselinePid = Number(await platform.asNeutral('/usr/bin/tmux', ['-L', 'incident-baseline', 'display-message', '-p', '#{pid}'])) +assert.match(readFileSync(`/proc/${baselinePid}/cmdline`, 'utf8'), /sleep/) +assert.match(readFileSync(`/proc/${serverPid}/cmdline`, 'utf8'), /sleep/) +// Invoke only inside this disposable fake-service container, never production. +await platform.asNeutral('pkill', ['-f', 'sleep']).catch(() => {}) +await delay(100) +await assert.rejects(platform.asNeutral('/usr/bin/tmux', ['-L', 'incident-baseline', 'list-sessions'])) +assert.equal(existsSync('/work/tmux-config-loaded'), false) +assert.equal((await platform.observe(first)).alive, true) +assert.equal((await platform.observe(second)).alive, true) +assert((await platform.capture(first)).includes('fake gateway ready')) +console.log('worker signals and direct socket access cannot reach the shared supervisor') diff --git a/llp/0009-maintenance-reconcilers.spec.md b/llp/0009-maintenance-reconcilers.spec.md index 84b6f2e..649686b 100644 --- a/llp/0009-maintenance-reconcilers.spec.md +++ b/llp/0009-maintenance-reconcilers.spec.md @@ -123,6 +123,9 @@ per-tick fan-out of LLP 0010.) review covered, so an unchanged head is not re-reviewed every tick; a new head SHA (from rungs 1–2, or a human's push) re-opens review. + > Extended-by: LLP 0078 — changed heads default to incremental re-review + > against the previous reviewed commit, with evidence-based scope expansion. + > **Extended-by [LLP 0017](0017-triage-at-review-cap.decision.md):** at the > `maxReviewRounds` cap neutral no longer goes straight to `neutral:stuck`. It first > **triages** the residual findings — if every one is a non-blocking preference they are diff --git a/llp/0011-autophagy.rfc.md b/llp/0011-autophagy.rfc.md index d3e6f1f..5265fcc 100644 --- a/llp/0011-autophagy.rfc.md +++ b/llp/0011-autophagy.rfc.md @@ -9,6 +9,9 @@ ## Context +> **Extended-by: LLP 0079.** Adds an optional PR backlog relevance audit whose +> output is an advisory held draft PR. + When a tick observes **no actionable gap** in either reconciler family (LLP 0008) — empty backlog, every PR action ∈ `{wait, held}`, no `needs-fix` issue — the repo has reached **neutral** and the orchestrator does nothing but burn the tick. Two diff --git a/llp/0028-review-record-comment.decision.md b/llp/0028-review-record-comment.decision.md index 5484dc1..cd9e755 100644 --- a/llp/0028-review-record-comment.decision.md +++ b/llp/0028-review-record-comment.decision.md @@ -52,6 +52,9 @@ round**: a worker cannot satisfy the rung without leaving the evidence, and because the comment carries a `` marker it can never read as a human reply (LLP 0027). +> Extended-by: LLP 0078 — the same comment records incremental scope, its +> verified baseline, prior finding outcomes, and any justified expansion. + **Legacy:** existing body markers are still read (as clean rounds, ordered before comment records), so heads already reviewed do not re-open. New reviews write comments only; the body is no longer edited for review state. The `neutral-triage` diff --git a/llp/0047-idle-initiative-throttling.decision.md b/llp/0047-idle-initiative-throttling.decision.md index 57abafb..926ddf3 100644 --- a/llp/0047-idle-initiative-throttling.decision.md +++ b/llp/0047-idle-initiative-throttling.decision.md @@ -66,6 +66,9 @@ on the next idle tick — the exact nag the reject cooldown exists to prevent. These durations are per-member policy shared across members for now; a per-member override is a later extension, not built until a member needs it. +> **Extended-by: LLP 0079.** PR backlog audits use the complete observed PR +> inventory fingerprint for no-op damping, since PR changes need not move HEAD. + **A no-op is damped by target HEAD, as a session-scoped scheduling hint.** A no-op leaves no git artifact, so it cannot be made a durable ground-truth fact — and must not be forged into one by churning diff --git a/llp/0072-restart-burn-safeguard.rfc.md b/llp/0072-restart-burn-safeguard.rfc.md index 4d61ff6..e7226f1 100644 --- a/llp/0072-restart-burn-safeguard.rfc.md +++ b/llp/0072-restart-burn-safeguard.rfc.md @@ -10,6 +10,8 @@ ## Recommendation +Extended-by: LLP 0077 (owned waiting and protected tmux supervision). + Put a deterministic circuit breaker ahead of every automatic loop start and replacement. Permit one recovery in a rolling hour; the second failure places the entire loop fleet on a persistent safety hold **before another diff --git a/llp/0075-bounded-review-work.spec.md b/llp/0075-bounded-review-work.spec.md index efb23d8..ee2cfaa 100644 --- a/llp/0075-bounded-review-work.spec.md +++ b/llp/0075-bounded-review-work.spec.md @@ -20,6 +20,8 @@ expensive review stages. Phil authorized implementing these three fixes. ## Waiting +Extended-by: LLP 0077 (owned waiting and protected tmux supervision). + CLI review workers run through a bounded process runner that buffers a tail of output and emits one result at completion. Timeout kills the owned process group and returns failure, never a clean review. Full reports stay on disk. @@ -36,6 +38,9 @@ claim that arbitrary agent tool use cannot waste tokens. ## Disposition before another review +Extended-by: LLP 0078 — changed heads reuse the previous review and inspect +the delta, prior fixes, and affected interactions by default. + After mergeability and CI, the latest `findings` review at an unchanged head routes to `triage` immediately, without marking it approved. New heads still follow the existing review cap. The CLI exposes `canFix` when another review diff --git a/llp/0077-owned-worker-waits-and-supervisor-isolation.spec.md b/llp/0077-owned-worker-waits-and-supervisor-isolation.spec.md new file mode 100644 index 0000000..4964115 --- /dev/null +++ b/llp/0077-owned-worker-waits-and-supervisor-isolation.spec.md @@ -0,0 +1,81 @@ +# LLP 0077: Owned worker waiting and protected tmux supervision + +**Type:** Spec +**Status:** Accepted +**Systems:** Engine, Engineer, Reviewer +**Author:** Codex +**Date:** 2026-09-14 +**Related:** 0072, 0075, 0076, 0034, 0042 + +@ref LLP 0075#waiting [extends] — completion guidance must match available tools +@ref LLP 0072#controller [extends] — protect the shared tmux server as well as admission state +@ref LLP 0076#init [constrained-by] — preserve tini reaping and held restart behavior +@ref LLP 0034#recovery-ladder [extends] — inspection and nudges go through the controller +@ref LLP 0042#inbound-framing [constrained-by] — retain literal input, prefill clearing, and submission verification + +## Incident + +On September 11 the PR #596 review worker searched for TaskOutput and received +“No matching deferred tools found.” It created background sleep tasks while +waiting for a forked reviewer, then issued a compound loop containing +`pkill -f "sleep"` at 23:53:13.051 UTC to clear them. The shared tmux server's +original command line contains `sleep 0.05` from the launch permit gate and +ran under the worker UID. Two missing loops tripped the fleet hold within +one second. The user authorized fixing this failure path on September 14. + + +## Owned completion and cancellation + +Every dispatched worker receives a completion contract: finish independent +work, then end its turn and await the existing task's completion notification. +Use blocking TaskOutput only when the tool is available, never assume its +presence. A missing tool is not a reason to create background sleep tasks or +infer reviewer completion from transcript timestamps. External conditions may +use Monitor; external CI remains a next-tick observation. + +Cancellation targets the task ID returned when the worker started that task, +using TaskStop. If unavailable, report the ID and return with work incomplete. +Cleanup covers the worker's own files and worktree, not process-name matches. +The shared hook rejects the observed compound pkill/killall commands and +background Bash sleep commands, including polling loops. It continues to +force blocking TaskOutput when present. Hook matching is behavioral guidance, +not a shell sandbox; alternate spellings and programs may evade it. + + +## Controller-owned tmux + +The tmux server runs as root with a private socket beneath a root-only runtime +directory. It loads no worker-owned tmux configuration. Pane launch execs +setpriv directly and drops to neutral before interpreting any shell command. +Capture helpers also run as neutral. Workers retain their home and role +variables but receive no direct tmux connection. The server's uid prevents +worker-originated signals, including the incident's full-command-line match. + +Expose only fixed controller operations: list actual sessions, capture a live +registered pane, and submit bounded plain text to a live registered loop at +its current generation. No caller-supplied tmux argv, format expansion, shell +command, or root process launch is accepted. Input is bracketed paste through +the existing submission verification; control characters are refused. Held +fleets refuse input. Inspection remains available for diagnostics. Watchdog, +mayor, and Slack bridge use these operations; root operators may attach to the +private socket directly for inspection. + +This isolates the supervisor from signals, not workers from one another. +Workers still share a UID and work volume. Strong per-worker isolation is a +separate change. The durable hold and explicit operator rearm remain intact. + + +## Verification + +Replay the incident command through the real hook. Exercise denied compound +cleanup and synthetic waits while retaining actual background work, Monitor +conditions, and task-specific cancellation. Test broker rejection of stale +or unknown targets, service-pane input, control characters, and input while +held, plus literal multiline message delivery. + +In a disposable no-model Docker test, prove the server UID is root and pane +UID is neutral, worker signals fail, the private socket refuses worker access, +and an actual worker-issued `pkill -f sleep` leaves both fake service panes +alive. Retain gateway ownership, process reaping, recovery counts, persistent +holds, controller-death teardown, and broker inspection/input checks. No +production restart or rearm follows from test success alone. diff --git a/llp/0078-incremental-re-review.spec.md b/llp/0078-incremental-re-review.spec.md new file mode 100644 index 0000000..477cf19 --- /dev/null +++ b/llp/0078-incremental-re-review.spec.md @@ -0,0 +1,67 @@ +# LLP 0078: Incremental re-review after changes + +**Type:** Spec +**Status:** Accepted +**Systems:** Engine, Reviewer +**Author:** Phil / Codex +**Date:** 2026-09-17 +**Related:** 0002, 0009, 0028, 0029, 0059, 0075 + +The September 17 token audit found that re-reviews inconsistently reuse prior +work: hypaware-server PR #774 reread the full PR, while #814 explicitly reviewed +only the repair delta. Phil requested incremental re-review as the default. + +@ref LLP 0009 [extends] — changed heads require review, with incremental scope +@ref LLP 0028 [extends] — the same review comment records baseline and scope +@ref LLP 0029 [constrained-by] — every round counts and retains its verdict +@ref LLP 0059 [constrained-by] — scope does not grant additional rounds +@ref LLP 0075#disposition [extends] — reuse prior findings after a changed head +@ref LLP 0002 [constrained-by] — compare observed committed trees + + +## Baseline and scope + +For own and fully adopted PRs, an eligible `review` action exposes +`previousReviewSha` from the latest observed review record, whether that record +was clean or had findings. First reviews have no baseline and cover the full PR. +The field is a pointer to evidence, not proof of ancestry or prior coverage. +Review-only foreign PRs retain their existing contributor-verdict flow. + +The worker verifies the previous report and commit, current head, and ancestry. +It compares the previous reviewed tree with the current tree, verifies previous +fixes, carries unresolved findings forward, and inspects affected interactions. +Every nested reviewer inherits the same scope. Unsupported incremental modes +in external review skills are replaced with explicitly scoped reviewers from +the same required model families, preserving reviewer independence. + +Missing prior evidence, rewritten history, retargeting, or broad invalidation +from a redesign or conflict resolution requires a full review. Local effects +require only local expansion. The worker records the reason before expanding. +A changed SHA by itself is never a full-review justification. + + +## Evidence and limits + +The existing head-keyed review comment records full or incremental scope, +the previous review link and SHAs, prior finding outcomes, new findings, tests, +and reasons for expansion. The marker format and round counting are unchanged: +an incremental round consumes one round. Outstanding findings cannot be erased +by a clean delta. A moved head returns to observation; approval, triage, +ship-risk, and merge authority retain their current gates. + +The CLI exposes the baseline deterministically; the skill controls review +behavior. It cannot mechanically prove which code a model examined. + +## Validation + +- First reviews omit the baseline; changed heads expose the latest clean or + findings record, including legacy markers, without claiming git verification. +- Unchanged findings, pending CI, exhausted rounds, and foreign review-only + actions retain their existing decisions and do not dispatch incremental work. +- A worker checks a simple repair delta and prior findings; a missing report or + non-ancestor baseline falls back to a full review with a recorded reason. +- Retargeting and invalidated assumptions widen scope; nested helpers inherit + the comparison and only one round marker is posted. + +Production token savings must be measured after deployment; unit checks do not +establish a savings percentage. diff --git a/llp/0079-pr-backlog-autophagy.spec.md b/llp/0079-pr-backlog-autophagy.spec.md new file mode 100644 index 0000000..1ee9218 --- /dev/null +++ b/llp/0079-pr-backlog-autophagy.spec.md @@ -0,0 +1,94 @@ +# LLP 0079: PR backlog relevance autophagy + +**Type:** Spec +**Status:** Active +**Systems:** Core, Engine +**Author:** Phil / Codex +**Date:** 2026-09-17 +**Related:** 0002, 0011, 0035, 0036, 0047, 0074 + +## Request + +Add an autophagy option for the three production-loop repositories that evaluates +whether open PRs are still needed, superseded, already implemented, or made +irrelevant by changes in the repository's requirements or architecture. + +This extends @ref LLP 0011 and @ref LLP 0047 with a built-in advisory member. +It does not require the Draft repo-declared registry proposed in LLP 0074. + +## Requirements + +### Configuration and scheduling + +`autophagy.prBacklog` is a boolean in `.neutral/config.json`, default `false`. +Enabling it adds `pr-backlog` to the existing least-recently-run rotation after +`cleanup` in the fixed tie order. Idle, context-recycle priority, admission, +one open autophagy PR, and disposition cooldowns remain unchanged. Thus a busy +or capacity-bound repo can defer this audit; it is not a new maintenance rung. +Enable the option explicitly in each production repo after deploying support. + +### Observe the whole PR backlog + +Read all open PRs using paginated GitHub observation, including drafts and PRs +outside Neutral's maintenance scope. A positive count is the mechanical signal +that there is something to inspect; it is not evidence that anything is obsolete. +An empty backlog is ineligible. Failed or malformed observation is unknown and +ineligible, never an empty successful scan. Surface the reason in `neutral idle`. + +The worker reads each PR's intent, current diff, linked requests, reviews and human +discussion, current base tree, and possible replacements. It records exact head +and base SHAs and links to supporting commits, PRs, code and decisions. Re-read +the inventory and cited facts before publishing; changed facts require a fresh +assessment. Bounded work must name unassessed PRs, never imply full coverage. + +### Relevance judgments + +- **Still needed:** identify the unmet requirement and unique remaining change. +- **Already implemented:** show the intended behavior in the current base and + account for every meaningful remaining part of the PR. Ancestry or patch + equivalence supports the claim; squashes require inspecting the resulting code. +- **Superseded:** cite the replacement and map the original intent to it. A + replacement still open is a dependency, not grounds for closure now. +- **Irrelevant:** cite an explicit newer requirement/decision or removal of the + affected feature, and account for remaining useful work and dependents. +- **Uncertain:** state missing evidence or the specific human decision needed. + +Age, conflict status, failing CI, similar titles, and inactivity alone never +justify closure. Partial overlap retains the useful remainder. Cross-repo +replacement claims require evidence from that repo; inaccessible evidence is +uncertain. Stacked PRs and LLP coverage dependencies must be checked before any +closure recommendation. + +### Recommendations, held for human disposition + +One worker produces a dated report on `autophagy/pr-backlog-` in its own +worktree, delivered as a draft PR. The report lists every assessed PR and its +verdict, evidence, unique remainder, dependents, and recommended human action. +It is a proposal, not completion/coverage state. The worker does not close, +merge, relabel, adopt, edit or comment on the assessed PRs, delete branches, or +rewrite accepted LLPs. Even merging the report authorizes no automatic action. +The report PR inherits the existing held-never-automerge boundary. + +If there are no actionable recommendations, open nothing and log a no-op. If +observation failed or the scan is incomplete, log that distinctly and keep the +member eligible for retry. A successful report passes the repository checks and +follows the shared publishing procedure. + +### Damp by the observed backlog + +Extend @ref LLP 0047#noop-dampening for this member: target HEAD alone is +insufficient because new PRs and replies need not advance it. `neutral idle` +returns `prBacklog.fingerprint`, hashing the sorted complete inventory's PR +numbers, URLs, titles, head/base refs and SHAs, and GitHub update timestamps. +After a complete no-op, the orchestrator retains that fingerprint in its session +and passes `--backlog-snapshot `. The CLI damps only while fresh observation +matches. New/closed PRs, updates or base movement invalidate it. A generic +`--damped pr-backlog` is ignored without the matching snapshot. A recycle resets +the hint; no durable agent-written receipt claims that a PR is done. + +## Verification + +Offline tests cover boolean opt-in, selection, empty/unknown observation, full +pagination, stable and changed fingerprints, cooldowns, global gating, idle and +admission gating, recycle priority, and snapshot-based no-op damping. Actual +semantic judgments remain reviewable human-facing proposals under @ref LLP 0002. diff --git a/scripts/safety-smoke.mjs b/scripts/safety-smoke.mjs index 5678385..0bce637 100644 --- a/scripts/safety-smoke.mjs +++ b/scripts/safety-smoke.mjs @@ -61,6 +61,14 @@ try { assert.equal(exec('id', '-u'), '0') assert.equal(exec('node', '-e', "console.log(require('fs').readFileSync('/proc/1/comm','utf8').trim())"), 'tini') mark('operator-rearm-launches-after-stable-gateway') + const paneRun = status().loops[0] + const client = (...args) => JSON.parse(docker('exec', '--user', 'neutral', run, 'neutral', 'safety', ...args)) + assert(client('sessions').includes(paneRun.session)) + assert.match(client('capture', '--session', paneRun.session), /fake model started/) + docker('exec', '--user', 'neutral', run, 'sh', '-c', "printf 'hello worker' > /work/message.txt") + assert.equal(client('send', '--session', paneRun.session, '--message-file', '/work/message.txt').submitted, true) + assert.throws(() => client('capture', '--session', 'unknown')) + mark('worker-pane-broker-inspection-and-input') // Exiting grandchildren are adopted by PID 1, outside Node's child handles. const orphanPids = JSON.parse(exec('node', '-e', ` const { spawnSync } = require('node:child_process') @@ -126,7 +134,7 @@ try { } finally { try { writeFileSync(join(output, 'container.log'), docker('logs', '--tail', '100', run)) } catch {} try { writeFileSync(join(output, 'last-status.json'), JSON.stringify(status(), null, 2)) } catch {} - try { writeFileSync(join(output, 'gateway-pane.txt'), docker('exec', '--user', 'neutral', run, 'tmux', 'capture-pane', '-p', '-t', '=hyp-daemon:')) } catch {} + try { writeFileSync(join(output, 'gateway-pane.txt'), exec('neutral', 'safety', 'capture', '--session', 'hyp-daemon')) } catch {} try { docker('rm', '-f', run) } catch {} // These two names were created above for this run only, never user data volumes. for (const volume of [safety, work]) { diff --git a/src/autophagy.js b/src/autophagy.js index eda460d..8156965 100644 --- a/src/autophagy.js +++ b/src/autophagy.js @@ -19,9 +19,11 @@ export const AUTOPHAGY_PREFIX = 'autophagy/' // branch namespace `autophagy/-*` and a config switch under `autophagy`. Adding a // member is one row here plus its worker; the selection machinery is member-agnostic. // @ref LLP 0047#rotation [implements] — the member registry the rotation ranks -/** @type {Array<{ id: string, configKey: 'codeCleanup' }>} */ +/** @type {Array<{ id: string, configKey: 'codeCleanup' | 'prBacklog' }>} */ export const AUTOPHAGY_MEMBERS = [ - { id: 'cleanup', configKey: 'codeCleanup' } // LLP 0036 — code cleanup + { id: 'cleanup', configKey: 'codeCleanup' }, // LLP 0036 — code cleanup + // @ref LLP 0079#configuration [implements] — same rotation and held-PR boundary + { id: 'pr-backlog', configKey: 'prBacklog' } ] const HOUR_MS = 3600_000 @@ -51,7 +53,7 @@ function lastDisposition(id, disposed) { * Classify one member's eligibility from ground truth + the clock (LLP 0047): off in * config, inside its disposition cooldown, or no-op damped ⇒ ineligible. A member that * has never run is eligible (subject to the global gate) — a fresh member gets its turn. - * @param {{ id: string, configKey: 'codeCleanup' }} member + * @param {{ id: string, configKey: 'codeCleanup' | 'prBacklog' }} member * @param {NeutralConfig} config * @param {Array<{ headRefName: string, mergedAt: string|null, closedAt: string|null }>} disposed * @param {number} now @@ -76,7 +78,8 @@ function memberState(member, config, disposed, now, damped) { } // @ref LLP 0047#noop-dampening [constrained-by] — the orchestrator's session hint if (damped.has(id)) { - return { id, eligible: false, cooldownRemaining: 0, lastDisposition: at, reason: 'no-op damped — target HEAD unchanged since last empty scan' } + const input = id === 'pr-backlog' ? 'PR backlog snapshot' : 'target HEAD' + return { id, eligible: false, cooldownRemaining: 0, lastDisposition: at, reason: `no-op damped — ${input} unchanged since last empty scan` } } return { id, eligible: true, cooldownRemaining: 0, lastDisposition: at, reason: last ? 'past cooldown' : 'never run' } } @@ -92,10 +95,11 @@ function memberState(member, config, disposed, now, damped) { * @param {Array<{ headRefName: string, mergedAt: string|null, closedAt: string|null }>} obs.disposed closed autophagy PRs * @param {NeutralConfig} obs.config * @param {number} obs.now epoch ms - * @param {Iterable} [obs.damped] member ids no-op damped at the current HEAD (orchestrator hint) + * @param {Iterable} [obs.damped] member ids no-op damped at the current observation (orchestrator hint) + * @param {number|null} [obs.backlogCount] null/absent means the full PR backlog is unobservable * @returns {InitiativeSelection} */ -export function selectInitiative({ openPRs, disposed, config, now, damped = [] }) { +export function selectInitiative({ openPRs, disposed, config, now, damped = [], backlogCount = null }) { // @ref LLP 0047#gate-global [implements] — one open autophagy PR, any member, blocks all const openAutophagy = openPRs.find(p => p.head.startsWith(AUTOPHAGY_PREFIX)) if (openAutophagy) { @@ -107,7 +111,14 @@ export function selectInitiative({ openPRs, disposed, config, now, damped = [] } return { initiative: null, members } } const dampedSet = new Set(damped) - const members = AUTOPHAGY_MEMBERS.map(m => memberState(m, config, disposed, now, dampedSet)) + const members = AUTOPHAGY_MEMBERS.map(m => { + const state = memberState(m, config, disposed, now, dampedSet) + // @ref LLP 0079#observation [implements] — empty and unknown are different gates + if (m.id === 'pr-backlog' && state.eligible && !(backlogCount !== null && backlogCount > 0)) { + return { ...state, eligible: false, reason: backlogCount === 0 ? 'PR backlog is empty' : 'PR backlog observation unavailable' } + } + return state + }) return { initiative: leastRecentlyRun(members), members } } diff --git a/src/commands/idle.js b/src/commands/idle.js index 666ee9f..13411fa 100644 --- a/src/commands/idle.js +++ b/src/commands/idle.js @@ -20,9 +20,11 @@ import { collectIssues } from './issues.js' import { idleState } from '../idle.js' import { admissionState } from '../admission.js' import { selectInitiative } from '../autophagy.js' -import { listDisposedAutophagyPRs } from '../github.js' +import { listDisposedAutophagyPRs, observePRBacklog } from '../github.js' import { readContextSize } from '../context.js' +/** @import { AdmissionState, IdleBlocker, MemberState, PRBacklogObservation } from '../types.d.ts' */ + /** * Evaluate the full idle-tick autophagy trigger from ground truth. `contextSize` is null * when the session's transcript can't be located (no `$CLAUDE_CODE_SESSION_ID` / not @@ -35,18 +37,19 @@ import { readContextSize } from '../context.js' * @param {string} repo * @param {typeof run} [exec] * @param {() => (number|null)} [readCtx] - * @param {{ now?: number, damped?: string[] }} [opts] - * @returns {Promise<{ idle: boolean, recycle: boolean, initiative: string|null, contextSize: number|null, threshold: number, admission: import('../types.d.ts').AdmissionState, blockers: import('../types.d.ts').IdleBlocker[], members: import('../types.d.ts').MemberState[] }>} + * @param {{ now?: number, damped?: string[], backlogSnapshot?: string }} [opts] + * @returns {Promise<{ idle: boolean, recycle: boolean, initiative: string|null, contextSize: number|null, threshold: number, admission: AdmissionState, blockers: IdleBlocker[], members: MemberState[], prBacklog: PRBacklogObservation|null }>} */ -export async function collectIdle(repo, exec = run, readCtx = readContextSize, { now = Date.now(), damped = [] } = {}) { +export async function collectIdle(repo, exec = run, readCtx = readContextSize, { now = Date.now(), damped = [], backlogSnapshot } = {}) { const config = loadConfig(repo) const { contextRecycleThreshold: threshold, maxActiveWork } = config - const [{ backlog }, implementable, prs, issues, disposed] = await Promise.all([ + const [{ backlog }, implementable, prs, issues, disposed, prBacklog] = await Promise.all([ collectBacklog(repo), collectImplementable(repo, exec), collectPRs(repo, exec), collectIssues(repo, exec), - listDisposedAutophagyPRs(repo, exec) + listDisposedAutophagyPRs(repo, exec), + config.autophagy.prBacklog ? observePRBacklog(repo, exec) : Promise.resolve(null) ]) // After the PR observation: `create-pr` vs "rollup in flight" needs the open heads. // @ref LLP 0052#idle-extension [implements] — the idle predicate sees change-set gaps @@ -55,14 +58,20 @@ export async function collectIdle(repo, exec = run, readCtx = readContextSize, { const admission = admissionState({ changesets, prs, issues }, maxActiveWork) const contextSize = readCtx() const recycle = idle && contextSize !== null && contextSize > threshold - const { initiative: member, members } = selectInitiative({ openPRs: prs, disposed, config, now, damped }) + // @ref LLP 0079#damping [implements] — new PRs/replies/base movement invalidate the hint + const currentDamped = damped.filter(id => id !== 'pr-backlog') + if (prBacklog?.fingerprint && prBacklog.fingerprint === backlogSnapshot) currentDamped.push('pr-backlog') + // Full enumeration also catches autophagy heads beyond the maintenance list cap. + const openPRs = prBacklog?.fingerprint ? prBacklog.prs : prs + const { initiative: member, members } = selectInitiative({ openPRs, disposed, config, now, + damped: currentDamped, backlogCount: prBacklog?.fingerprint ? prBacklog.prs.length : null }) // @ref LLP 0035#priority [implements] — recycle preempts repo hygiene // @ref LLP 0047#selection [implements] — else the LRR member, else null // Repo-hygiene creates a new work surface, so the same admission gate applies. // Context recycle creates no repo work and may still run while intake is paused. // @ref LLP 0060#admission-control [implements] const initiative = recycle ? 'recycle' : (idle && admission.open ? member : null) - return { idle, recycle, initiative, contextSize, threshold, admission, blockers, members } + return { idle, recycle, initiative, contextSize, threshold, admission, blockers, members, prBacklog } } /** @@ -77,7 +86,9 @@ export async function idleCommand(repo, args, exec = run, readCtx = readContextS // that scanned the current target HEAD and found nothing. A scheduling hint, not a fact. const dampedArg = args[args.indexOf('--damped') + 1] const damped = args.includes('--damped') && dampedArg ? dampedArg.split(',').map(s => s.trim()).filter(Boolean) : [] - const s = await collectIdle(repo, exec, readCtx, { damped }) + const snapshotIndex = args.indexOf('--backlog-snapshot') + const backlogSnapshot = snapshotIndex >= 0 ? args[snapshotIndex + 1] : undefined + const s = await collectIdle(repo, exec, readCtx, { damped, backlogSnapshot }) if (args.includes('--json')) { process.stdout.write(JSON.stringify(s, null, 2) + '\n') } else { diff --git a/src/commands/prs.js b/src/commands/prs.js index 4c30612..ce60ed6 100644 --- a/src/commands/prs.js +++ b/src/commands/prs.js @@ -34,7 +34,7 @@ const OWN_HEAD_RE = /^(integration\/|fix\/issue-|autophagy\/)/ * not completion, is what the label records. * @param {string} repo * @param {typeof run} [exec] - * @returns {Promise>} + * @returns {Promise>} */ export async function collectPRs(repo, exec = run) { const { maxReviewRounds, automerge, mergeQueue, shipRisk } = loadConfig(repo) @@ -42,7 +42,7 @@ export async function collectPRs(repo, exec = run) { // Own by head-branch ownership; delegated only when a maintainer explicitly labelled it — // `neutral:adopt` for full heal (LLP 0025) or `neutral:review` for review-only (LLP 0032). const inScope = open.filter(p => OWN_HEAD_RE.test(p.headRefName) || p.labels.includes(ADOPT_LABEL) || p.labels.includes(REVIEW_LABEL)) - /** @type {Array<{number: number, head: string, base: string, isDraft: boolean, headSha: string, foreign: boolean, reviewOnly: boolean, adopted: boolean, canPush: boolean, stuck: boolean, queued: boolean, guidance: number, markAdopted: boolean, rung: string, action: string, reason: string, approved?: boolean, canFix?: boolean, shipRisk?: string, shipRiskEvidence?: number, shipRiskEligible?: boolean, wouldAutomerge?: boolean}>} */ + /** @type {Array<{number: number, head: string, base: string, isDraft: boolean, headSha: string, foreign: boolean, reviewOnly: boolean, adopted: boolean, canPush: boolean, stuck: boolean, queued: boolean, guidance: number, markAdopted: boolean, rung: string, action: string, reason: string, approved?: boolean, canFix?: boolean, previousReviewSha?: string, shipRisk?: string, shipRiskEvidence?: number, shipRiskEligible?: boolean, wouldAutomerge?: boolean}>} */ const out = [] for (const p of inScope) { const obs = await viewPR(repo, p.number, exec) diff --git a/src/commands/start.js b/src/commands/start.js index 6968505..a437994 100644 --- a/src/commands/start.js +++ b/src/commands/start.js @@ -16,20 +16,21 @@ import { run } from '../git.js' export const ORCHESTRATOR_SESSION = 'neutral' // The orchestrator runs on the WORKER tier (LLP 0020): the tick is deliberately // mechanical — the CLI decides every rung, fan-in is git commands — so it does not -// need the judgment tier, and it is the single largest spend. Pinned explicitly so a +// need the judgment tier, and it is the single largest spend. Selected explicitly so a // respawn (LLP 0013) or a machine with a different session default can't silently -// revert it. The `[1m]` suffix is Claude Code's 1M-context variant of Opus 5 — -// REQUIRED here (not the plain 200K `opus`): the loop is long-lived, and the autophagy +// revert it. The `opus` alias follows Claude Code's current Opus version; `[1m]` +// explicitly requests its 1M context window. The loop is long-lived, and the autophagy // threshold T defaults to 500K tokens (LLP 0013), which only a 1M window can reach // before recycling. -// @ref LLP 0020#decision [implements] — orchestrator = worker tier, pinned at launch -export const ORCHESTRATOR_MODEL = 'claude-opus-5[1m]' +// @ref LLP 0020#decision [implements] — orchestrator = worker tier, explicit alias at launch +export const ORCHESTRATOR_MODEL = 'opus[1m]' // The loop, as one shell-command string tmux runs via `sh -c`. The model token is // single-quoted so `sh` doesn't glob the `[1m]` brackets. Permission bypass is part // of the loop's contract (the skill declares it autonomous — nobody is at the // terminal to answer): without it a fresh session wedges on the skill-consent // dialog before its first tick, observed in production after an autophagy respawn. -export const LOOP_SHELL_COMMAND = `claude --model '${ORCHESTRATOR_MODEL}' --dangerously-skip-permissions '/loop /neutral-reconcile'` +// @ref LLP 0020#decision [implements] — Agent accepts fable, so bind that alias in its inherited environment +export const LOOP_SHELL_COMMAND = `ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1 claude --model '${ORCHESTRATOR_MODEL}' --dangerously-skip-permissions '/loop /neutral-reconcile'` /** * The orchestrator's tmux session name for a repo: `neutral-` (e.g. @@ -86,7 +87,7 @@ export async function startCommand(repo, _args, deps = {}) { process.stderr.write( 'neutral start: tmux not found — context autophagy needs a tmux pane (LLP 0013).\n' + 'Install tmux, or run the loop without self-respawn (falls back to summarization):\n' + - ` claude --dangerously-skip-permissions "/loop /neutral-reconcile"\n` + ` ${LOOP_SHELL_COMMAND}\n` ) return 1 } diff --git a/src/config.js b/src/config.js index 5a8faab..7e4e469 100644 --- a/src/config.js +++ b/src/config.js @@ -128,6 +128,8 @@ export const DEFAULT_CONFIG = { // @ref LLP 0036#eligibility [implements] — the per-repo off-switch autophagy: { codeCleanup: true, + // @ref LLP 0079#configuration [implements] — opt-in PR relevance audit + prBacklog: false, // Idle-initiative cooldowns, wall-clock hours (LLP 0047). A member backs off this // long after its cleanup PR is disposed — longer after a rejection than a merge. // @ref LLP 0047#cooldown [implements] @@ -178,6 +180,9 @@ function merge(base, over) { codeCleanup: typeof (o.autophagy && o.autophagy.codeCleanup) === 'boolean' ? o.autophagy.codeCleanup : base.autophagy.codeCleanup, + prBacklog: typeof (o.autophagy && o.autophagy.prBacklog) === 'boolean' + ? o.autophagy.prBacklog + : base.autophagy.prBacklog, // 0 is a valid value (disables the arm), so the gate is `>= 0`, not `> 0`. cooldownAfterMergeHours: hoursOr(o.autophagy && o.autophagy.cooldownAfterMergeHours, base.autophagy.cooldownAfterMergeHours), cooldownAfterRejectHours: hoursOr(o.autophagy && o.autophagy.cooldownAfterRejectHours, base.autophagy.cooldownAfterRejectHours) diff --git a/src/github.js b/src/github.js index b8688f9..5df0337 100644 --- a/src/github.js +++ b/src/github.js @@ -9,8 +9,38 @@ import { run } from './git.js' import { ADOPT_LABEL } from './config.js' import { AUTOPHAGY_PREFIX } from './autophagy.js' +import { createHash } from 'node:crypto' -/** @import { PrObservation } from './types.d.ts' */ +/** @import { PrObservation, PRBacklogObservation, BacklogPR } from './types.d.ts' */ + +/** + * All open PRs, including foreign and draft heads, without a silent list cap. + * Unknown stays unknown: an API/parse failure cannot authorize a scan or no-op. + * @param {string} repo + * @param {typeof run} [exec] + * @returns {Promise} + * @ref LLP 0079#observation [implements] — paginated inventory and snapshot damping + */ +export async function observePRBacklog(repo, exec = run) { + try { + const pages = JSON.parse(await exec('gh', ['api', '--paginate', '--slurp', + 'repos/{owner}/{repo}/pulls?state=open&per_page=100'], repo)) + if (!Array.isArray(pages) || !pages.length || !pages.every(Array.isArray)) throw new Error('invalid pages') + /** @type {BacklogPR[]} */ + const prs = pages.flat().map(p => { + if (!p || !Number.isInteger(p.number) || p.number <= 0 || p.state !== 'open' || + ![p.html_url, p.title, p.head?.ref, p.head?.sha, p.base?.ref, p.base?.sha, p.updated_at] + .every(v => typeof v === 'string' && v.length > 0)) throw new Error('invalid PR') + return { number: p.number, url: p.html_url, title: p.title, head: p.head.ref, + headSha: p.head.sha, base: p.base.ref, baseSha: p.base.sha, updatedAt: p.updated_at } + }).sort((a, b) => a.number - b.number) + if (new Set(prs.map(p => p.number)).size !== prs.length) throw new Error('inventory changed during pagination') + const fingerprint = createHash('sha256').update(JSON.stringify(prs)).digest('hex') + return { prs, fingerprint, error: null } + } catch { + return { prs: [], fingerprint: null, error: 'PR backlog observation unavailable — retry on a later tick' } + } +} // The fields reconcilePR's rungs need: mergeability, the check rollup, the head SHA // (every downstream fact is keyed to it), the body (carries the triage/verdict markers diff --git a/src/prhealth.js b/src/prhealth.js index b21a6fe..3ac3a46 100644 --- a/src/prhealth.js +++ b/src/prhealth.js @@ -502,7 +502,11 @@ export function selectRung(pr, maxReviewRounds = DEFAULT_REVIEW_ROUNDS, automerg if (reviewRounds(pr.body, pr.comments) >= cap) { return { rung: 'reviewed', action: 'triage', canFix: false, reason: `${cap} review round(s) exhausted${granted ? ` (${maxReviewRounds} + ${granted} granted in-thread)` : ''} — triage residual findings (record safe deferrals, auto-admit evidenced defects only, else neutral:stuck)` } } - return { rung: 'reviewed', action: 'review', reason: 'head not yet reviewed — run the review, fix findings, post the marker-signed review record comment' } + // @ref LLP 0078#baseline [implements] — expose the prior record; the worker verifies its commit and scope + return { rung: 'reviewed', action: 'review', ...(latest ? { previousReviewSha: latest.sha } : {}), + reason: latest + ? 'head changed since review — verify the previous review baseline, review the delta and prior fixes, record any scope expansion' + : 'head not yet reviewed — run the review, fix findings, post the marker-signed review record comment' } } // Final-head ship-risk gate (LLP 0062/0069). `neutral:approved` means the diff --git a/src/safety-client.js b/src/safety-client.js index 7b522ec..5b5db1c 100644 --- a/src/safety-client.js +++ b/src/safety-client.js @@ -1,4 +1,5 @@ // @ts-check +import { readFileSync } from 'node:fs' import { createConnection } from 'node:net' /** @import { SafetyRequest } from '../docker/safety-types.d.ts' */ @@ -31,13 +32,14 @@ export async function safetyCommand(args) { const values = new Map() for (let i = 0; i < rest.length; i++) { if (rest[i] === '--json') continue - if (!['--session', '--incident', '--reason', '--deployment'].includes(rest[i]) || !rest[i + 1]) throw new Error('invalid safety arguments') + if (!['--session', '--incident', '--reason', '--deployment', '--message-file'].includes(rest[i]) || !rest[i + 1]) throw new Error('invalid safety arguments') values.set(rest[i], rest[++i]) } /** @type {SafetyRequest} */ const request = { action, session: values.get('--session'), incident: values.get('--incident'), reason: values.get('--reason'), deployment: values.get('--deployment') } - if (['replace', 'recycle'].includes(action)) { + if (action === 'send') request.message = readFileSync(values.get('--message-file') ?? 0, 'utf8') + if (['replace', 'recycle', 'send'].includes(action)) { request.session ??= process.env.NEUTRAL_LOOP_SESSION if (action === 'recycle') { if (request.session !== process.env.NEUTRAL_LOOP_SESSION || !process.env.NEUTRAL_RUN_ID) throw new Error('recycle must target this loop generation') diff --git a/src/types.d.ts b/src/types.d.ts index 27674fa..dcbfaf1 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -115,6 +115,8 @@ export interface NeutralConfig { autophagy: { /** The code-cleanup idle initiative (LLP 0036). Default on; the held-PR boundary is the safety. */ codeCleanup: boolean + /** PR relevance audit (LLP 0079). Opt-in, recommendations only. */ + prBacklog: boolean /** Hours a member backs off after an accepted (merged) cleanup PR (LLP 0047). 0 disables. */ cooldownAfterMergeHours: number /** Hours a member backs off after a rejected (closed-unmerged) cleanup PR; defaults longer (LLP 0047). 0 disables. */ @@ -122,6 +124,24 @@ export interface NeutralConfig { } } +/** Complete repository PR inventory for the advisory autophagy scan (LLP 0079). */ +export interface BacklogPR { + number: number + url: string + title: string + head: string + headSha: string + base: string + baseSha: string + updatedAt: string +} + +export interface PRBacklogObservation { + prs: BacklogPR[] + fingerprint: string | null + error: string | null +} + export type ShipRiskLevel = 'low' | 'medium' | 'high' | 'unknown' export type ShipRiskThreshold = 'none' | 'low' | 'medium' | 'high' @@ -245,6 +265,8 @@ export interface PrObservation { export interface RungDecision { /** Triage may attempt current-PR fixes only while a verification round remains (LLP 0075). */ canFix?: boolean + /** Prior review marker for an own/adopted PR; verify the commit and review evidence before using it as a delta baseline (LLP 0078). */ + previousReviewSha?: string /** mergeable | green | reviewed | ship-risk | terminal. */ rung: string /** diff --git a/src/worker-wait.js b/src/worker-wait.js index e090bac..1ff9c6e 100644 --- a/src/worker-wait.js +++ b/src/worker-wait.js @@ -10,11 +10,23 @@ export function waitHook(input) { updatedInput: { ...input.tool_input, block: true, timeout: 600000 } } } } const command = input.tool_input?.command - if (input.tool_name !== 'Bash' || typeof command !== 'string') return null - // Limit the guard to literal standalone output, preserving writes and real probes. + if (!['Bash', 'Monitor'].includes(input.tool_name ?? '') || typeof command !== 'string') return null + // @ref LLP 0077#waiting [implements] — reject the incident's compound cleanup and synthetic waits + // This is a behavioral guard, not a shell parser or the process isolation boundary. + if (/(?:^|[\s;|&()])(?:[\w./-]*\/)?(?:pkill|killall)(?=\s|$)/.test(command)) { + return deny('Cancel only a task you started, using TaskStop with its task ID. Process-name cleanup is unavailable in the shared fleet.') + } + if (/(?:^|[\s;|&()])(?:[\w./-]*\/)?sleep\s/.test(command) && input.tool_input?.run_in_background === true) { + return deny('Finish independent work, then end your turn and await the existing task completion notification. Do not create another background task just to wait. Use Monitor only for a real external condition.') + } + // Limit the echo guard to literal standalone output, preserving writes and real probes. if (/[\n\r;|&<>`$\\]/.test(command)) return null if (!/^\s*(?:echo|printf)\s+/.test(command)) return null if (!/\b(?:wait(?:ing)?|pending|idle|tick|poll(?:ing)?|keep[ -]?alive)\b/i.test(command)) return null - return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: 'Use TaskOutput with the running task ID; it blocks until completion or a bounded timeout. For a CLI worker use neutral run-worker. For external CI, return and let the next reconcile tick observe it.' } } + return deny('Await the existing task completion notification by ending your turn after independent work. Use TaskOutput only when available, with the running task ID. For external CI, return and let the next reconcile tick observe it.') +} + +/** @param {string} reason @returns {WaitHookResult} */ +function deny(reason) { + return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } } } diff --git a/test/maintenance-commands.test.js b/test/maintenance-commands.test.js index db0ab66..4cd3a96 100644 --- a/test/maintenance-commands.test.js +++ b/test/maintenance-commands.test.js @@ -70,6 +70,27 @@ test('collectPRs is empty when there are no open PRs (offline-safe)', async () = assert.deepEqual(await collectPRs('/r', fakeWorld({ prs: [] })), []) }) +// @ref LLP 0078#baseline [tests] — own and adopted PR observations deliver the baseline to the worker +test('collectPRs carries the previous review SHA through the public observation', async () => { + const exec = fakeWorld({ + prs: [ + { number: 1, headRefName: 'fix/issue-1' }, + { number: 2, headRefName: 'feature/adopted', labels: [{ name: 'neutral:adopt' }] } + ], + views: Object.fromEntries([1, 2].map(number => [number, { + number, headRefName: number === 1 ? 'fix/issue-1' : 'feature/adopted', + baseRefName: 'main', isDraft: true, mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', + statusCheckRollup: [], headRefOid: 'bbbbbbb', body: '', + labels: number === 1 ? [] : [{ name: 'neutral:adopt' }], + comments: [{ body: '', author: { login: 'phil' }, createdAt: '1' }] + }])) + }) + const got = await collectPRs('/r', exec) + assert.deepEqual(got.map(p => [p.number, p.action, p.previousReviewSha]), [ + [1, 'review', 'aaaaaaa'], [2, 'review', 'aaaaaaa'] + ]) +}) + test('collectPRs adopts a pushable neutral:adopt PR as its OWN — foreign: false (LLP 0025/0058)', async () => { const exec = fakeWorld({ prs: [ diff --git a/test/pr-backlog-autophagy.test.js b/test/pr-backlog-autophagy.test.js new file mode 100644 index 0000000..6314c33 --- /dev/null +++ b/test/pr-backlog-autophagy.test.js @@ -0,0 +1,139 @@ +// @ts-check +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DEFAULT_CONFIG, loadConfig } from '../src/config.js' +import { selectInitiative } from '../src/autophagy.js' +import { observePRBacklog } from '../src/github.js' +import { collectIdle } from '../src/commands/idle.js' + +/** @import { run } from '../src/git.js' */ + +const config = { ...DEFAULT_CONFIG, autophagy: { ...DEFAULT_CONFIG.autophagy, codeCleanup: false, prBacklog: true } } +const now = Date.parse('2026-09-17T12:00:00Z') +/** @param {number} number */ +const pull = number => ({ number, state: 'open', html_url: `https://github.com/example/repo/pull/${number}`, + title: `PR ${number}`, head: { ref: `feature/${number}`, sha: 'head' }, + base: { ref: 'main', sha: 'base' }, updated_at: '2026-09-17T00:00:00Z' }) +/** @param {Partial[0]>} [over] */ +const select = (over = {}) => selectInitiative({ config, now, openPRs: [], disposed: [], backlogCount: 1, ...over }) + +// @ref LLP 0079#configuration [tests] — optional, typed switch +test('PR backlog is opt-in; invalid config values do not enable it', () => { + const repo = mkdtempSync(join(tmpdir(), 'neutral-pr-backlog-')) + try { + mkdirSync(join(repo, '.neutral')) + assert.equal(loadConfig(repo).autophagy.prBacklog, false) + for (const value of [true, false, 'true', 1, null]) { + writeFileSync(join(repo, '.neutral/config.json'), JSON.stringify({ autophagy: { prBacklog: value } })) + assert.equal(loadConfig(repo).autophagy.prBacklog, value === true) + } + } finally { rmSync(repo, { recursive: true, force: true }) } +}) + +// @ref LLP 0079#observation [tests] — distinguish absent evidence from an empty inventory +test('selector requires a nonempty observed backlog and respects shared gates', () => { + assert.equal(select().initiative, 'pr-backlog') + for (const backlogCount of [0, null]) { + const s = select({ backlogCount }) + assert.equal(s.initiative, null) + assert.match(s.members[1].reason, backlogCount === 0 ? /empty/ : /unavailable/) + } + assert.equal(select({ config: DEFAULT_CONFIG, damped: ['cleanup'] }).initiative, null) + assert.equal(select({ openPRs: [{ number: 7, head: 'autophagy/cleanup-old' }] }).initiative, null) + assert.equal(select({ disposed: [{ headRefName: 'autophagy/pr-backlog-old', + mergedAt: null, closedAt: new Date(now - 25 * 3600_000).toISOString() }] }).initiative, null) + assert.equal(select({ disposed: [{ headRefName: 'autophagy/pr-backlog-old', + mergedAt: new Date(now - 25 * 3600_000).toISOString(), closedAt: new Date(now - 25 * 3600_000).toISOString() }] }).initiative, 'pr-backlog') + const both = { ...config, autophagy: { ...config.autophagy, codeCleanup: true } } + assert.equal(select({ config: both }).initiative, 'cleanup') + assert.equal(select({ config: both, disposed: [{ headRefName: 'autophagy/cleanup-old', + mergedAt: new Date(now - 48 * 3600_000).toISOString(), closedAt: new Date(now - 48 * 3600_000).toISOString() }] }).initiative, 'pr-backlog') +}) + +// @ref LLP 0079#observation [tests] — no cap and no fail-open interpretation +test('observer includes every page and rejects incomplete or malformed observations', async () => { + const pages = [Array.from({ length: 100 }, (_, i) => pull(i + 1)), [pull(101)]] + const s = await observePRBacklog('.', async (cmd, args) => { + assert.equal(cmd, 'gh') + assert.ok(args.includes('--paginate')) + assert.ok(args.includes('--slurp')) + return JSON.stringify(pages) + }) + assert.equal(s.prs.length, 101) + assert.equal(s.error, null) + assert.ok(s.fingerprint) + for (const raw of ['not json', '{}', '[]', '[{}]', JSON.stringify([[{}]]), JSON.stringify([[pull(1)], [pull(1)]])]) { + const failed = await observePRBacklog('.', async () => raw) + assert.equal(failed.fingerprint, null) + assert.match(failed.error ?? '', /unavailable/) + } + const failed = await observePRBacklog('.', async () => { throw new Error('offline') }) + assert.equal(failed.fingerprint, null) + const empty = await observePRBacklog('.', async () => '[[]]') + assert.deepEqual(empty.prs, []) + assert.ok(empty.fingerprint) +}) + +// @ref LLP 0079#damping [tests] — PR changes without a target HEAD change wake the member +test('fingerprints ignore enumeration order and change with PR/base activity', async () => { + /** @param {ReturnType[]} prs */ + const read = async prs => (await observePRBacklog('.', async () => JSON.stringify([prs]))).fingerprint + const original = await read([pull(1), pull(2)]) + assert.equal(original, await read([pull(2), pull(1)])) + for (const changed of [ + [pull(1)], [pull(1), pull(2), pull(3)], + [pull(1), { ...pull(2), head: { ref: 'feature/2', sha: 'new-head' } }], + [pull(1), { ...pull(2), base: { ref: 'main', sha: 'new-base' } }], + [pull(1), { ...pull(2), updated_at: '2026-09-17T01:00:00Z' }] + ]) assert.notEqual(original, await read(changed)) +}) + +// @ref LLP 0079#configuration [tests] — exercise the real CLI collector with offline boundaries +test('idle collector selects the audit, validates snapshot hints, and keeps existing gates', async () => { + const repo = mkdtempSync(join(tmpdir(), 'neutral-pr-backlog-')) + let pages = [[pull(1)]] + let offline = false + let busy = false + /** @type {run} */ + const exec = async (cmd, args) => { + if (cmd === 'git') return '' + if (cmd === 'gh' && args[0] === 'api') { + if (offline) throw new Error('offline') + return JSON.stringify(pages) + } + if (cmd === 'gh' && args[0] === 'issue' && busy) return JSON.stringify([{ number: 3, title: 'fix', labels: [{ name: 'neutral:fix' }] }]) + return '[]' + } + try { + mkdirSync(join(repo, '.neutral')) + const configure = (maxActiveWork = 6) => writeFileSync(join(repo, '.neutral/config.json'), JSON.stringify({ ...config, maxActiveWork })) + configure() + const first = await collectIdle(repo, exec, () => 100) + assert.equal(first.initiative, 'pr-backlog') + const backlogSnapshot = first.prBacklog?.fingerprint ?? undefined + const damped = await collectIdle(repo, exec, () => 100, { backlogSnapshot }) + assert.equal(damped.initiative, null) + assert.match(damped.members[1].reason, /snapshot unchanged/) + assert.equal((await collectIdle(repo, exec, () => 100, { damped: ['pr-backlog'] })).initiative, 'pr-backlog') + pages = [[pull(1), pull(2)]] + assert.equal((await collectIdle(repo, exec, () => 100, { backlogSnapshot })).initiative, 'pr-backlog') + assert.equal((await collectIdle(repo, exec, () => 900_000)).initiative, 'recycle') + busy = true + assert.equal((await collectIdle(repo, exec, () => 100)).initiative, null) + busy = false + configure(0) + assert.equal((await collectIdle(repo, exec, () => 100)).initiative, null) + configure() + pages = [[{ ...pull(1), head: { ref: 'autophagy/cleanup-existing', sha: 'head' } }]] + assert.equal((await collectIdle(repo, exec, () => 100)).initiative, null) + pages = [[]] + assert.equal((await collectIdle(repo, exec, () => 100)).initiative, null) + offline = true + const failed = await collectIdle(repo, exec, () => 100) + assert.equal(failed.initiative, null) + assert.match(failed.members[1].reason, /unavailable/) + } finally { rmSync(repo, { recursive: true, force: true }) } +}) diff --git a/test/review-economy.test.js b/test/review-economy.test.js index 9d61694..82fcece 100644 --- a/test/review-economy.test.js +++ b/test/review-economy.test.js @@ -30,6 +30,39 @@ test('the latest review controls early disposition; stale findings cannot replac assert.equal(selectRung({ ...pr, foreign: true }).action, 'review') }) +// @ref LLP 0078#baseline [tests] — changed heads carry the latest observed baseline without granting approval +test('re-review exposes the latest clean or findings record, while the first review has no baseline', () => { + const first = selectRung({ ...pr, comments: [] }) + assert.equal(first.action, 'review') + assert.equal(first.previousReviewSha, undefined) + for (const verdict of ['clean', 'findings']) { + const result = selectRung({ ...pr, headSha: 'ccccccc', comments: [ + ...pr.comments, + { ...pr.comments[0], body: `` } + ] }, 3) + assert.equal(result.action, 'review') + assert.equal(result.previousReviewSha, 'bbbbbbb') + assert.equal(result.approved, undefined) + } + const legacy = selectRung({ ...pr, body: '', comments: [] }) + assert.equal(legacy.previousReviewSha, 'aaaaaaa') +}) + +test('incremental scope does not bypass unchanged findings, CI, the round cap or foreign-review boundaries', () => { + const changed = { ...pr, headSha: 'bbbbbbb' } + for (const result of [ + selectRung(pr), + selectRung(changed, 1), + selectRung({ ...changed, rollup: [{ status: 'IN_PROGRESS' }] }), + selectRung({ ...changed, foreign: true }) + ]) assert.equal(result.previousReviewSha, undefined) + assert.equal(selectRung(pr).action, 'triage') + assert.equal(selectRung(changed, 1).action, 'triage') + assert.equal(selectRung({ ...changed, rollup: [{ status: 'IN_PROGRESS' }] }).action, 'wait') + const grant = { ...pr.comments[0], body: 'neutral: rounds +1' } + assert.equal(selectRung({ ...changed, comments: [...pr.comments, grant] }, 1).previousReviewSha, pr.headSha) +}) + const preference = { ordinal: 1, disposition: 'defer', kind: 'preference', severity: 'minor', title: 'Rename local helper', location: 'src/a.js:9', evidence: 'The current name is abbrevi­ated.', reason: 'No behavior changes.', acceptance: 'Use the longer descriptive name.' } @@ -73,3 +106,20 @@ test('numbered waiting echoes are denied while actual commands and redirected wr assert.equal(waitHook({ tool_name: 'Bash', tool_input: { command } }), null) } }) + +// @ref LLP 0077#waiting [tests] — replay the actual compound command and synthetic waits +test('worker hook blocks cross-task cleanup and background sleep accumulation', () => { + for (const command of [ + 'for t in bvy77nuht bodymqd46; do pkill -f "sleep" >/dev/null 2>&1; done', + '/usr/bin/pkill -f sleep', 'env pkill -u neutral', 'killall node', + 'sleep 580; echo tick', 'while true; do stat transcript.jsonl; sleep 30; done' + ]) { + assert.equal(waitHook({ tool_name: 'Bash', tool_input: { command, run_in_background: true } })?.hookSpecificOutput.permissionDecision, 'deny', command) + } + assert.equal(waitHook({ tool_name: 'Monitor', tool_input: { command: 'pkill -f sleep' } })?.hookSpecificOutput.permissionDecision, 'deny') + for (const input of [ + { tool_name: 'TaskStop', tool_input: { task_id: 'owned-task' } }, + { tool_name: 'Bash', tool_input: { command: 'npm test', run_in_background: true } }, + { tool_name: 'Monitor', tool_input: { command: 'until curl -fsS localhost:8080; do sleep 2; done' } } + ]) assert.equal(waitHook(input), null) +}) diff --git a/test/safety.test.js b/test/safety.test.js index 2add865..04bb2ff 100644 --- a/test/safety.test.js +++ b/test/safety.test.js @@ -127,6 +127,12 @@ class FakePlatform extends SafetyPlatform { modelAbsent = true failLaunch = false async prepare() {} + async sessions() { return this.launched.filter(r => this.live.get(r.run)).map(r => r.session) } + async capture() { return 'pane contents' } + /** @type {Array<{run: string, message: string}>} */ + sent = [] + /** @param {ProcessRun} run @param {string} message */ + async send(run, message) { this.sent.push({ run: run.run, message }); return true } async attach() {} hasHypConfig() { return true } async modelProcessesAbsent() { return this.modelAbsent } @@ -335,3 +341,28 @@ test('fleet configuration rejects naming collisions and preserves model override assert(cfg.loops[0].command.includes("'model'\\''with-quote'")) assert(cfg.loops[0].command.includes('/loop /neutral-reconcile')) }) + +// @ref LLP 0077#supervisor [tests] — broker cannot execute arbitrary tmux commands or address stale/non-model panes +test('pane broker scopes input to a live registered generation and preserves literal text', async t => { + const f = fixture(t, true) + await f.controller.boot() + const run = f.controller.runs.get('repo:owner/a') + assert(run) + const sessions = await f.controller.request({ action: 'sessions' }, false) + assert(Array.isArray(sessions) && sessions.includes(run.session)) + assert.equal(await f.controller.request({ action: 'capture', session: run.session }, false), 'pane contents') + const message = 'literal $(touch /tmp/should-not-exist) ; run-shell nope\nsecond line' + assert.deepEqual(await f.controller.request({ action: 'send', session: run.session, run: run.run, message }, false), { submitted: true }) + assert.deepEqual(f.platform.sent, [{ run: run.run, message }]) + for (const request of [ + { action: 'send', session: run.session, run: 'old', message: 'hello' }, + { action: 'send', session: 'hyp-daemon', run: f.controller.runs.get('hyp')?.run, message: 'hello' }, + { action: 'capture', session: 'a; run-shell bad' }, + { action: 'send', session: run.session, run: run.run, message: '\x1b[H' }, + { action: 'send', session: run.session, run: run.run, message: 'x'.repeat(16001) }, + { action: 'run-shell', message: 'anything' } + ]) await assert.rejects(f.controller.request(request, false)) + await f.controller.trip('test hold') + await assert.rejects(f.controller.request({ action: 'send', session: run.session, run: run.run, message }, false), /held/) + assert.equal(f.platform.sent.length, 1) +}) diff --git a/test/start.test.js b/test/start.test.js index e17cc23..78e5e28 100644 --- a/test/start.test.js +++ b/test/start.test.js @@ -1,14 +1,22 @@ // @ts-check import { test } from 'node:test' import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' import { tmuxStartArgv, startCommand, sessionName, LOOP_SHELL_COMMAND } from '../src/commands/start.js' +// @ref LLP 0020#decision [tests] — Agent accepts aliases; its Fable binding must reach child processes +test('launcher exports the judgment model binding to Claude and its workers', () => { + const script = `claude() { sh -c 'printf "%s" "$ANTHROPIC_DEFAULT_FABLE_MODEL"'; }; ${LOOP_SHELL_COMMAND}` + const output = execFileSync('sh', ['-c', script], { encoding: 'utf8', env: { PATH: process.env.PATH } }) + assert.equal(output, 'claude-fable-5-1') +}) + test('tmuxStartArgv: idempotent attach-or-create, detached when nested', () => { assert.deepEqual(tmuxStartArgv({ session: 'neutral-x' }), ['new-session', '-A', '-s', 'neutral-x', LOOP_SHELL_COMMAND]) assert.deepEqual(tmuxStartArgv({ session: 'neutral-x', nested: true }), ['new-session', '-d', '-A', '-s', 'neutral-x', LOOP_SHELL_COMMAND]) // the loop command runs via sh -c, with the orchestrator pinned to the 1M-context - // Opus 5 (LLP 0020); the model token is single-quoted so sh doesn't glob `[1m]` - assert.equal(LOOP_SHELL_COMMAND, "claude --model 'claude-opus-5[1m]' --dangerously-skip-permissions '/loop /neutral-reconcile'") + // Opus (LLP 0020); the model token is single-quoted so sh doesn't glob `[1m]` + assert.equal(LOOP_SHELL_COMMAND, "ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1 claude --model 'opus[1m]' --dangerously-skip-permissions '/loop /neutral-reconcile'") }) test('sessionName: per-repo `neutral-`, sanitized, with a bare fallback (LLP 0014)', () => { diff --git a/test/worker-runner.test.js b/test/worker-runner.test.js index b4885f4..1856667 100644 --- a/test/worker-runner.test.js +++ b/test/worker-runner.test.js @@ -67,3 +67,16 @@ test('hook installation is idempotent and preserves capture settings and unrelat assert.deepEqual(result.hooks.Stop, []) } finally { rmSync(dir, { recursive: true, force: true }) } }) + +// @ref LLP 0077#waiting [tests] — production hook protocol refuses the incident command before execution +test('hook executable denies the compound process cleanup command', async () => { + const hook = fileURLToPath(new URL('../docker/worker-wait-hook.js', import.meta.url)) + const child = exec(process.execPath, [hook]) + child.child.stdin?.end(JSON.stringify({ tool_name: 'Bash', tool_input: { + command: 'for t in first second; do pkill -f "sleep" >/dev/null 2>&1; done' + } })) + const result = JSON.parse((await child).stdout).hookSpecificOutput + assert.equal(result.permissionDecision, 'deny') + assert.match(result.permissionDecisionReason, /TaskStop/) + assert.equal(result.updatedInput, undefined) +})