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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ 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.77

The last 2026-10-22 lessons and a trim that brings 1.0.76 and 1.0.77 together to fewer instruction words than 1.0.75.

#### Added
- Questions to the orchestrator carry an id: `board.mjs question`, `answered` and `ack` take `--id`, and `status` prints `UNACKED` for an answer not acknowledged in 15 minutes. Answers to writers in long turns had to be sent three times.
- `hooks/foreground-guard.mjs` refuses a foreground Bash wait over 5 minutes in a vibe session's worktree, so long runs go to the background and messages reach the writer between turns.
- `green-watch.mjs --state` prints `NEW-RED` the first time a lane fails, while the rest of its batch still runs, and `mock-gap.mjs` checks each `vi.mock` block for the exports the code reads, block by block.
- The watcher's PR set is `prs.txt` plus every open PR in `WATCH_NEW_PR_REPOS` that `PR_SEARCH` lists or `WATCH_PR_AUTHOR` opened, on any branch prefix; a merged or closed PR is reported once and dropped, and `STALE-PR` names the writer from an optional third `prs.txt` column. A writer sat silent for 1h40m on two PRs the hand-kept list did not hold.
- A UserPromptSubmit hook (`hooks/away-protocol.mjs`) puts the fleet away checklist in context when the operator says they are stepping away ("brb", "stepping away", "afk") and the return checklist when they say they are back. An orchestrator once answered "brb" with a summary and ran none of the written away protocol.
- `pre-pr-lanes.mjs` refuses to open a pull request until each test lane the repository's lane report names has a passing run recorded at the head (operator ruling); the environment worker, handoff and fleet QA prep run it.

#### Changed
- Instruction text is trimmed: prose a script now enforces keeps only the command and its reason, and repeated rules are stated once. Markdown under the vibe plugin is about 930 words shorter than in 1.0.75.

### vibe v1.0.76

The open lessons and operator rulings from the 2026-10-22 fleet run, each with a mechanical check where one fits.
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.76",
"version": "1.0.77",
"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.76",
"version": "1.0.77",
"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
4 changes: 3 additions & 1 deletion plugins/vibe/agents/vibe-environment-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,9 @@ changes and gates fields.
inline, briefly. `blocking.prBody` refuses a line naming one.
4. Title: `<live ticket slug>: <plain imperative summary>`, at most 72
characters including the ` (#NNNN)` GitHub appends, with no mention of AI
tools. Create it with
tools. First `node ../skills/vibe/scripts/pre-pr-lanes.mjs check --worktree "<wt>" --report <the repo's lane report output>`
must pass (each test lane the report names has a passing run recorded at
HEAD; `BLOCKED` with its `MISSING-LANE` lines otherwise). Create it with
`gh pr create --repo closedloop-ai/symphony-alpha --base main --head <branch> --title "<title>" --body-file <file>`,
the body file in the session's private git directory and named for the
live ticket (`pr-body-<live ticket slug>.md`), never a shared name such as
Expand Down
47 changes: 47 additions & 0 deletions plugins/vibe/hooks/away-protocol.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
// UserPromptSubmit hook: when the operator says they are stepping away or are
// back, put the fleet-orchestrator away or return checklist into context. An
// orchestrator once answered "stepping away. brb." with a friendly summary
// and ran none of the away protocol, though its skill required it.
//
// Usage (hook): reads the UserPromptSubmit JSON on stdin; prints the checklist as
// additional context, or nothing.

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

const AWAY = /\b(?:brb|be right back|afk|stepping away|step(?:ping)? out|going to bed|off to bed|heading (?:out|off|to bed|home)|signing off|back in (?:\d+|a (?:few|bit|while)|an hour|a minute|ten|twenty|thirty)\b)/i;
const BACK = /(?:^|[.!?]\s*)(?:i'?m back|i am back|back now|back again|ok(?:ay)?,? back)\b|^\s*back[.!]*\s*$/i;

export const AWAY_CHECKLIST = [
"The operator is stepping away. If you orchestrate a fleet (fleet-orchestrator), run the away protocol now, before anything else:",
"1. Write <orchDir>/attention.md: each decision needed with your recommendation, and what is blocked.",
"2. Keep the watcher armed (watcher.mjs prints `watcher ok`) and start a recurring schedule every 20 to 30 minutes (loop or cron) that reads every teammate message, answers or applies defaults, and runs `board.mjs <orchDir> status`.",
"3. Keep reading idle writers' last messages and keep attention.md current until they return.",
].join("\n");

export const BACK_CHECKLIST = [
"The operator is back. If you orchestrate a fleet (fleet-orchestrator):",
"1. Lead your reply with <orchDir>/attention.md: decisions needed, recommendations, what is blocked.",
"2. Delete the away schedule you started (loop or cron), keeping the watcher armed.",
].join("\n");

/** The checklist an operator prompt calls for, or null. */
export function checklistFor(prompt) {
const text = String(prompt ?? "");
if (BACK.test(text)) return BACK_CHECKLIST;
if (AWAY.test(text)) return AWAY_CHECKLIST;
return null;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const event = JSON.parse(readFileSync(0, "utf8") || "{}");
const checklist = checklistFor(event?.prompt);
if (checklist) {
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: checklist } })}\n`);
}
} catch (error) {
process.stderr.write(`away-protocol: ${error instanceof Error ? error.message : String(error)}\n`);
}
}
30 changes: 30 additions & 0 deletions plugins/vibe/hooks/away-protocol.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { AWAY_CHECKLIST, BACK_CHECKLIST, checklistFor } from "./away-protocol.mjs";

const HOOK = path.join(path.dirname(fileURLToPath(import.meta.url)), "away-protocol.mjs");

test("away and back phrases bring the checklist; ordinary prompts do not (brb with no away protocol)", () => {
for (const prompt of ["stepping away. brb.", "brb", "afk for 20", "going to bed, see you tomorrow", "heading out for lunch", "back in 30", "back in a few", "ok I need to step out"]) {
assert.equal(checklistFor(prompt), AWAY_CHECKLIST, prompt);
}
for (const prompt of ["I'm back", "im back, what happened?", "back now", "back.", "Okay, back. Status?"]) {
assert.equal(checklistFor(prompt), BACK_CHECKLIST, prompt);
}
for (const prompt of ["go back to the PR and fix the thread", "roll back the migration", "the back button is broken", "write it back to the ticket", "feedback on the board", "bring back the legacy list"]) {
assert.equal(checklistFor(prompt), null, prompt);
}
});

test("the hook adds the checklist as context and stays silent otherwise", () => {
const run = (prompt) => spawnSync(process.execPath, [HOOK], { input: JSON.stringify({ hook_event_name: "UserPromptSubmit", prompt }), encoding: "utf8" }).stdout;
const away = JSON.parse(run("stepping away. brb.")).hookSpecificOutput;
assert.equal(away.hookEventName, "UserPromptSubmit");
assert.match(away.additionalContext, /attention\.md/);
assert.match(away.additionalContext, /every 20 to 30 minutes/);
assert.match(JSON.parse(run("I'm back")).hookSpecificOutput.additionalContext, /Delete the away schedule/);
assert.equal(run("go back to the PR"), "");
});
46 changes: 46 additions & 0 deletions plugins/vibe/hooks/foreground-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
// PreToolUse guard for Bash in a vibe session's worktree: no foreground command
// may wait longer than 5 minutes. A Claude pane teammate reads messages only
// between turns, so writers blocked in long foreground runs kept re-asking
// questions already answered, and answers had to be sent three times. A long
// command runs in the background (`run_in_background`) and the turn ends while
// it waits.
//
// Usage (hook): reads the PreToolUse JSON on stdin; prints a deny decision or nothing.

import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

export const MAX_FOREGROUND_MS = 5 * 60 * 1000;

/** Whether `cwd` is inside a vibe session's worktree (its git dir holds the session record). */
export function inVibeSession(cwd) {
try {
const gitDir = execFileSync("git", ["-C", cwd, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
return existsSync(path.join(gitDir, "vibe-session.json"));
} catch {
return false;
}
}

/** The deny reason for a Bash call that would wait in the foreground longer than allowed, or null. */
export function foregroundProblem(input) {
if (input?.run_in_background === true) return null;
const timeout = Number(input?.timeout);
if (!Number.isFinite(timeout) || timeout <= MAX_FOREGROUND_MS) return null;
return `a foreground command may wait at most 5 minutes in a vibe session (timeout ${timeout} ms): run it with run_in_background and end the turn while it runs, so messages reach you between turns`;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const event = JSON.parse(readFileSync(0, "utf8") || "{}");
const problem = event?.tool_name === "Bash" ? foregroundProblem(event.tool_input) : null;
if (problem && inVibeSession(typeof event.cwd === "string" ? event.cwd : process.cwd())) {
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem } })}\n`);
}
} catch (error) {
process.stderr.write(`foreground-guard: ${error instanceof Error ? error.message : String(error)}\n`);
}
}
29 changes: 29 additions & 0 deletions plugins/vibe/hooks/foreground-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { foregroundProblem } from "./foreground-guard.mjs";

const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "foreground-guard.mjs");

test("a foreground wait over 5 minutes is refused; background runs and short waits pass (answers sent three times)", () => {
assert.match(foregroundProblem({ command: "pnpm test", timeout: 600000 }), /at most 5 minutes/);
assert.equal(foregroundProblem({ command: "pnpm test", timeout: 600000, run_in_background: true }), null);
assert.equal(foregroundProblem({ command: "git status", timeout: 300000 }), null);
assert.equal(foregroundProblem({ command: "git status" }), null);
});

test("the hook applies only inside a vibe session's worktree", (t) => {
const dir = mkdtempSync(path.join(tmpdir(), "foreground-guard-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
execFileSync("git", ["init", "--quiet", dir]);
const long = { tool_name: "Bash", cwd: dir, tool_input: { command: "pnpm test", timeout: 900000 } };
const run = (event) => spawnSync(process.execPath, [GUARD], { input: JSON.stringify(event), encoding: "utf8" }).stdout;
assert.equal(run(long), "", "outside a vibe session the guard stays silent");
writeFileSync(path.join(dir, ".git", "vibe-session.json"), "{}\n");
assert.equal(JSON.parse(run(long)).hookSpecificOutput.permissionDecision, "deny");
assert.equal(run({ ...long, tool_input: { ...long.tool_input, run_in_background: true } }), "");
});
19 changes: 18 additions & 1 deletion plugins/vibe/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,24 @@
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kill-guard.mjs\"" }
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kill-guard.mjs\""
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/foreground-guard.mjs\""
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/away-protocol.mjs\""
}
]
}
]
Expand Down
Loading
Loading