Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to the claude-plugins project will be documented in this fil

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`.

### vibe v1.0.81

The 2026-10-23 and 2026-10-24 fleet lessons now have hooks, launcher behavior and evidence gates instead of prose alone.

#### Added
- The pull request evidence gate requires a passing run of every lane `pnpm test:lanes` names for the diff, by the command it prints. A hand-picked subset no longer passes (#9238). A mention-only lane needs a recorded reason.
- A diff that changes a check, CI script or workflow needs `ciCheckRepros`: a run in the CI job's own tier and checkout shape. The new `ci-job-shape.mjs` reports each job's tier, fetch depth, env names and `if` (#9230, fixed in #9237).
- The Bash safety hook refuses three more things: starting `watch.sh` other than as its own `run_in_background` call, `set -- $var` (zsh does not word-split it), and `e2e-slot.sh` without `--head <sha>`. The remote `e2e-slot.sh` accepts `--head` and refuses a HEAD that differs from it; a staged next version adds the image check.
- A SendMessage hook adds a TaskStop reminder when a message asks its recipient to pause or hold itself. It never refuses, and ordinary freeze instructions such as "push nothing more" or "stop the investigation and report" get nothing.
- `image-head.mjs` records, per compose project, the commit and worktree that built the e2e images. vibe's e2e lock refuses a `--no-build` run whose images came from another commit or worktree (ISS-13067, #9241), and a passing build records its images.
- A writer turn that hits its time cap reports `CAP-HIT` with its changed-file count, whether it was mid-merge, and a saved patch of its tracked changes.

#### Changed
- A review thread marked `source: bot` is fixed only when it reproduces and stays contained; otherwise it is answered with evidence and resolved. A human's reasoned answer still stays open.
- A new writer request supersedes a FAILED turn, and a turn whose launcher process is gone is closed by the next claim, so a stopped or rejected turn no longer wedges the queue. The launcher caps an over-long result instead of rejecting it.
- A session with no environment pushes through redeploy mode without requesting one. Human QA prep names `pnpm vibe up` and is ready only once a page loads past the org picker.
- The verify worker also runs the real-browser story test for every story file with a play on a component whose layout, position or scrolling changed, because the jsdom sweep passes those plays vacuously (#9238).
- The watcher's second-copy refusal says how to stop a detached copy. Fleet and worker templates run every named lane instead of `code_tests_for` alone, hold an agent with TaskStop, and batch asks to a writer.
- Instruction text is about 2 words shorter than in 1.0.80.

### vibe v1.0.80

Targeted validation of the final committed diff is now a required gate before a vibe pull request opens.
Expand Down
2 changes: 1 addition & 1 deletion plugins/vibe/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vibe",
"description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows",
"version": "1.0.80",
"version": "1.0.81",
"dependencies": ["closedloop-core"],
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}
}
2 changes: 1 addition & 1 deletion plugins/vibe/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vibe",
"version": "1.0.80",
"version": "1.0.81",
"description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows",
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"},
"homepage": "https://github.com/closedloop-ai/claude-plugins",
Expand Down
2 changes: 1 addition & 1 deletion plugins/vibe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ harness switch instead of another worker doing its work.

## Runtime Files

Version `1.0.80` contains 12 skills, 16 agents, and one hook manifest
Version `1.0.81` contains 12 skills, 16 agents, and one hook manifest
(`hooks/hooks.json`). It has no standalone commands, root-level shell scripts,
or production Python tools under
`tools/python/`; that directory contains the skill-contract test modules and
Expand Down
3 changes: 3 additions & 0 deletions plugins/vibe/agents/vibe-change-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ collapse a loop or `each` table left with one arm; and record each test still
named or placed after the old module for engineering (a rename on the missing
tests list, or done in a `pull-request` turn).

A new prop on a shared component goes into every capability ledger or
parity test listing a sibling prop, not only the one you edited.

## Checks, review and recording

Before publication/main-sync, read `environment.md`, "Main-sync before
Expand Down
8 changes: 6 additions & 2 deletions plugins/vibe/agents/vibe-environment-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,9 @@ every conflict/source problem. Do not run a raw merge/commit/push yourself.
if it failed. If the log shows the session's preview schema was dropped and
recreated (that script's recovery for a failed preview migration), the
session's data is gone: say so in your result.
7. Request the environment (below) again with the session's same mode (a
7. A session with no environment (no `lastRequestId`) stops after the push
with a Progress line "Pushed `<short sha>`". Otherwise request the
environment (below) again with the session's same mode (a
ready environment of the same mode is kept; only the deployments of the
new branch head are made ready), read its result (below), then record and
update the ticket (below), adding a Progress line "Redeployed `<short sha>`".
Expand Down Expand Up @@ -429,7 +431,9 @@ or `--body-file`).
`gh api graphql` (`pullRequest.reviewThreads`, with `isResolved`, the
thread `id`, and each comment's `databaseId`, author login and
`__typename`, `path`, `line` and body) and return them, marking
`awaitingReply` each thread a human other than the author spoke last on.
`awaitingReply` each thread a human other than the author spoke last on,
and `source: bot` each opened by a `Bot`, a self-described automated review or a
`git config --get-all vibe.reviewBots` login.
Change nothing.
- Update, with the pushed fixing commit and the writer's per-thread results:
1. Confirm the branch on `origin` is at that commit (step 1 above).
Expand Down
16 changes: 11 additions & 5 deletions plugins/vibe/agents/vibe-verify-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ repository's related-test command, never a wider suite.
Commands come from `.vibe/path-gates.json` or the `checks` of its
`.vibe/storybook-rules.json`; record flag pairs with `flag: "on"` or `flag: "off"`.

Never run `pnpm test`, `pnpm verify`, `pnpm test:affected`,
`pnpm test:lanes --exec`, a whole lane or package suite locally. Select over
Never run `pnpm test`, `pnpm verify`, `pnpm test:affected` or a blind
`pnpm test:lanes --exec`. Select over
the whole branch diff. Record import and E2E-reference selections in
`testSelection`, and commands, cases, flags, results and SHAs in `testRuns` of
`<gitdir>/vibe-pr-evidence.json`. `blocking.prEvidence` names gaps; skipped CI
Expand All @@ -80,7 +80,10 @@ In `pre-pr` mode, read the final committed diff, never the plan, and write
for the repository-owned lane names and commands. Inventory exact commands,
files and cases for every selected Vitest package, importing test, changed or
importing Storybook story, referenced web/Desktop E2E spec and matching
post-merge-only lane. Run that set once. After a writer fixes an isolated
post-merge-only lane. Run that set once, each named lane by the command
`pnpm test:lanes` prints (`testRuns` entry with `lane`). A changed check, CI
script or workflow reruns in its job's shape from
`../skills/handoff/scripts/ci-job-shape.mjs` (`ciCheckRepros`). After a writer fixes an isolated
failure, refresh the inventory and rerun only that failed case and any target
the fix newly reaches, never the whole set. Record every result and SHA. Carry
an earlier pass only when its covered-file hashes match final HEAD.
Expand All @@ -95,7 +98,8 @@ For targeted E2E, record the repository's capability probe and choose:
Do not run host-browser visual-regression specs against Linux baselines;
list them as `not run locally, covered by CI`.
3. Tier C: otherwise use the operator's configured remote runner and its
tested-head lock.
tested-head lock with `--head <the pushed sha>`, building fresh after a
worktree switch (`image-head.mjs` refuses stale images).

`git config --bool vibe.noNativeE2e` is the machine-local operator override;
when true, skip Tier B and use C or report no tier. If no tier is available,
Expand All @@ -111,7 +115,9 @@ Three more rules:
`.github/pr-test-event-tiers.md` and cite the tier of the job that runs
it. A `batch-only` job does not run on the pull request, and its `skipped`
conclusion there means admission, not validation.
- For every new or changed story with a `play` function, inspect every
- For every new or changed story with a `play` function, and every story
file with a `play` on a component whose layout, position or scrolling the
diff changes, inspect every
`storybook/test` interaction and report any call that is not awaited. Then
execute the touched stories' plays in a real browser with the repository's
Storybook test runner or Playwright against its containerized Storybook
Expand Down
9 changes: 9 additions & 0 deletions plugins/vibe/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-guard.mjs\""
}
]
},
{
"matcher": "SendMessage",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pause-guard.mjs\""
}
]
}
],
"UserPromptSubmit": [
Expand Down
43 changes: 43 additions & 0 deletions plugins/vibe/hooks/pause-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// PreToolUse reminder for SendMessage: a hold is TaskStop, not a message. A vibe
// session was told HOLD twice (2026-10-23) and kept its writer running: a
// message reaches a busy agent only at its next tool round, and never reaches
// the subagents it started. Repo memory already said so; the orchestrator
// skipped it. A message that asks the recipient to pause or hold itself gets a
// TaskStop reminder in context; the message still goes. Ordinary freeze
// instructions ("push nothing more", "stop the investigation and report") are
// not pauses and get nothing.
//
// Usage (hook): reads the PreToolUse JSON on stdin; prints additional context or nothing.

import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

/** Each pattern is a request that the recipient pause or hold itself. */
export const HOLD_PATTERNS = Object.freeze([
/^\s*(?:HOLD|PAUSE)\b/,
/\b(?:pause|hold|freeze)\s+(?:all\s+)?(?:of\s+)?(?:your\s+)?(?:work|writers?|sessions?|everything|agents?|subagents?|turns?)\b/i,
/\bstop\s+(?:all\s+)?(?:of\s+)?(?:your\s+)?(?:writers?|sessions?|everything|agents?|subagents?|all\s+work)\b/i,
/\bstand\s+down\b/i,
]);

export const TASKSTOP_REMINDER = "This message asks an agent to pause or hold. A busy agent reads it only at its next tool round, and its subagents never see it: stop it with TaskStop, then check that nothing it started still runs under its worktree.";

/** The TaskStop reminder for a message asking its recipient to pause or hold itself, or null. */
export function holdReminder(input) {
const message = input?.message;
if (typeof message !== "string" || message.includes("TaskStop")) return null;
return HOLD_PATTERNS.some((pattern) => pattern.test(message)) ? TASKSTOP_REMINDER : null;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const event = JSON.parse(readFileSync(0, "utf8") || "{}");
const reminder = event?.tool_name === "SendMessage" ? holdReminder(event.tool_input) : null;
if (reminder) {
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: reminder } })}\n`);
}
} catch (error) {
process.stderr.write(`pause-guard: ${error instanceof Error ? error.message : String(error)}\n`);
}
}
37 changes: 37 additions & 0 deletions plugins/vibe/hooks/pause-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { holdReminder, TASKSTOP_REMINDER } from "./pause-guard.mjs";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const HOOK = path.join(HERE, "pause-guard.mjs");

test("a message asking its recipient to pause or hold itself gets the TaskStop reminder (two ignored HOLDs, 2026-10-23)", () => {
for (const message of ["HOLD: main is being reverted", "PAUSE until the revert lands", "Please pause your writer now.",
"hold all work on #9230", "Stop your subagents and wait", "Stand down until I say go", "freeze everything"]) {
assert.equal(holdReminder({ to: "vibe-writer", message }), TASKSTOP_REMINDER, message);
}
});

test("ordinary freeze and stop instructions, and a hold already done with TaskStop, get nothing", () => {
for (const message of ["Push nothing more to the branch.", "Stop the investigation and report what you found.",
"Hold off on pushing until CI is green", "Holding steady on #9230, ready for review", "Stop at the first failure and report it",
"Fix the stop button label", "hold the PR open for review", "HOLD: stopped with TaskStop; wait for the revert"]) {
assert.equal(holdReminder({ to: "vibe-writer", message }), null, message);
}
assert.equal(holdReminder({ to: "lead", message: { type: "shutdown_response", request_id: "1", approve: true } }), null);
});

test("the SendMessage hook adds the reminder as context, never refuses, and is registered", () => {
const run = (message) => spawnSync(process.execPath, [HOOK], { input: JSON.stringify({ tool_name: "SendMessage", tool_input: { to: "w", message } }), encoding: "utf8" });
const output = JSON.parse(run("HOLD everything").stdout).hookSpecificOutput;
assert.equal(output.hookEventName, "PreToolUse");
assert.equal(output.additionalContext, TASKSTOP_REMINDER);
assert.equal(output.permissionDecision, undefined);
assert.equal(run("Push nothing more to the branch.").stdout, "");
const hooks = JSON.parse(readFileSync(path.join(HERE, "hooks.json"), "utf8")).hooks.PreToolUse;
assert.ok(hooks.some((entry) => entry.matcher === "SendMessage" && entry.hooks.some((hook) => hook.command.includes("hooks/pause-guard.mjs"))));
});
49 changes: 48 additions & 1 deletion plugins/vibe/hooks/shell-safety-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@
// PreToolUse guard for Bash: workers may not invent credential routes to fetch
// evidence or force-push any remote. Both mistakes can escape a written rule,
// especially through an ssh command aimed at another machine.
// Three more refusals, each from a written rule that was skipped (2026-10-23/24):
// the fleet watcher started with `&` or inside a compound command detached and
// held its lock while its events never arrived; `set -- $var` in a merge watcher
// never fired, because zsh (this tool's shell on macOS) does not word-split an
// unquoted parameter; and e2e-slot.sh reruns tested an old commit when they
// started before the push landed, because the slot captured whatever HEAD was.

import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { commandSegments } from "./command-segments.mjs";

const TOKEN = /(?:\b(?:gh_token|github_token|[a-z][a-z0-9_]*token)\b|\$\{?[a-z][a-z0-9_]*token\}?|--password(?:-stdin)?\b|(?:^|\s)-p(?:\s|$)|["']?auth["']?\s*:)/i;
const CREDENTIAL_ROUTE = /(?:\bDOCKER_CONFIG\b|\bdocker\s+login\b|["']?auths["']?\s*:)/i;
const SHELLS = new Set(["bash", "sh", "zsh"]);
const BACKGROUND_AMPERSAND = /&/;
const REDIRECT_AMPERSAND = /\d*>&\d*|&>>?/g;
const ZSH_SPLIT = /(?:^|[\s;&|(])set\s+--\s+\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?=[\s;&|)]|$)/m;
const SHA = /^[0-9a-f]{7,40}$/i;
const FORCE_PUSH = /\bgit\b(?:(?![;&|\n]).)*?\bpush\b(?:(?![;&|\n]).)*(?:\s-f(?:\s|$)|--force(?:-with-lease)?\b|(?:^|\s)\+[^\s]+)/i;

/** The credential-safety refusal for a shell command, or null. */
Expand All @@ -22,12 +35,46 @@ export function forcePushProblem(command) {
return "force push refused: never rewrite any remote, including another machine or a scratch ref; create a fresh proof/<sha>-<n> ref and push normally";
}

/** The refusal for a fleet watcher launch that is not its own background tool call, or null. */
export function watcherLaunchProblem(input) {
const command = typeof input?.command === "string" ? input.command.replace(REDIRECT_AMPERSAND, " ") : "";
const segments = commandSegments(command).filter(({ words }) => words.length > 0);
if (!segments.some(({ words }) => invokes(words, "watch.sh"))) return null;
if (input.run_in_background === true && segments.length === 1 && !BACKGROUND_AMPERSAND.test(command)) return null;
return "start watch.sh only as its own Bash call with run_in_background, never with & or inside a compound command: a detached copy never reports to you and holds the lock (stop one with kill-guard.mjs own <pid>, then kill <pid>)";
}

/** The refusal for `set -- $var`, which splits nothing in zsh, or null. */
export function wordSplitProblem(command) {
if (!ZSH_SPLIT.test(command)) return null;
return "set -- $var does not word-split in zsh, so $1 holds the whole string: use read -r first second <<< \"$var\"";
}

/** The refusal for an e2e-slot.sh run that does not name the commit it must test, or null. */
export function e2eSlotProblem(command) {
for (const { words } of commandSegments(command)) {
const start = words.findIndex((word, index) => path.basename(word) === "e2e-slot.sh" && (index === 0 || SHELLS.has(path.basename(words[index - 1]))));
if (start < 0) continue;
const head = words.indexOf("--head", start);
if (head < 0 || !SHA.test(words[head + 1] ?? "")) {
return "e2e-slot.sh needs --head <sha> before the owner label, the pushed commit to test: without it a rerun started before the push tests the old HEAD";
}
}
return null;
}

function invokes(words, script) {
const name = path.basename(words[0]);
return name === script || (SHELLS.has(name) && path.basename(words[1] ?? "") === script);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const event = JSON.parse(readFileSync(0, "utf8") || "{}");
const command = event?.tool_input?.command;
if (event?.tool_name === "Bash" && typeof command === "string") {
const problem = credentialProblem(command) ?? forcePushProblem(command);
const problem = credentialProblem(command) ?? forcePushProblem(command) ?? watcherLaunchProblem(event.tool_input)
?? wordSplitProblem(command) ?? e2eSlotProblem(command);
if (problem) {
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: {
hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem,
Expand Down
Loading
Loading