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

The open lessons and operator rulings from the 2026-10-22 fleet run, each with a mechanical check where one fits.

#### Added
- Questions carry a default and a deadline: `session-questions.mjs ask` needs `--default` and `--deadline-minutes` and returns the `If no answer by <time>: <default>` line that ends the message; a flag (or feature switch), copy, destructive, credential or scope-narrowing question waits with none. The recovery pass resumes an idle Codex writer on its default once the deadline passes (`proceeded-on-default`). Writers' questions once sat unanswered for seven hours overnight.
- `watch.sh` exits with `HEARTBEAT` after `HEARTBEAT_SECONDS` (default 30 minutes) with no change, instead of `TIMEOUT` after about 6.7 hours, and `board.mjs status` fails with `WATCHER-MISSING` when a work dir that watches sessions or PRs has no running watcher.
- `fleet.sh` refuses a prompt that hands over `Done:` claims without a `Re-verify:` line, and the takeover prompt makes the incoming writer re-verify each claim with a test or a measurement.
- `migration-publish.mjs` refuses a push that adds a migration without a recorded `Migration test:` (an existing test that applies every migration counts), or one with a trigger, function or per-row work without a measured `Scale test:`; the environment worker runs it before every push, and handoff's diff rules run the same check. A pushed migration reaches shared preview schemas.
- Handoff's `spec-change` diff rule blocks a change to an existing `*-spec` file or PRD Markdown without `Spec change: <path>: <human>, <date>, <where>`, and `rulings-check.mjs --criteria-before` blocks a ticket version that rewords or drops an acceptance criterion no human ruling amends.
- `executed-check.mjs --kind ticket` refuses DONE while an acceptance criterion is unchecked without a follow-up ticket or a dated human exclusion; the ticket worker, cl-execute and cl-sweep run it before DONE, and `status-audit.mjs` needs each merged or DONE child's body and prints `UNREAD` and `UNCHECKED`.
- One review round per pull request (operator ruling): `shepherd.mjs --round-one-fixed-at` stops with `threads` (exit 13) on an unresolved round-one thread the writer has not answered last, so armed auto-merge no longer reads as done, and with `later-threads` (exit 14) on one raised after the round-one fixes, which is triaged and never blocks the merge. `review-followup.mjs` keeps exactly one follow-up ticket per pull request (later rounds append criteria), and `review-threads.mjs` resolves a later thread answered with that ticket's link or a reason. The board and the shepherd share one thread reader (`pr-threads.mjs`).
- A PreToolUse hook (`hooks/kill-guard.mjs`) refuses by-name and pattern kills, `xargs kill`, a command substitution, a pid of 1 or lower and a pid the session did not record (`kill-guard.mjs own <pid>`) in every Claude session with the plugin.
- The watcher marks `STALE-PR` when an open PR with unresolved threads, a conflict, a draft flag or a failed check has had no push for 45 minutes, and `QUESTION-UNANSWERED` when an idle writer's question waited 15 minutes; `board.mjs question` does the same for Claude writers. Writers push at least every 45 minutes (`push-cadence.mjs`), never held for optional evidence, a default's deadline is at most 30 minutes, and `session-questions.mjs` refuses a question that holds a PR.
- `rollout-gate.mjs` (handoff) needs each flag, rollback or rollout requirement in the ticket and its PRD backed by code at HEAD or a human ruling that names the ticket or PRD; `rulings-check.mjs --ruling --scope`, `executed-check.mjs --kind ticket --slug` and `status-audit.mjs` (`UNSCOPED`) refuse a ruling that names neither, and `qa-plan-check.mjs --criteria` makes each rollout requirement a checkpoint.
- `board.mjs writer --ready` records each writer's push-ready head and `board.mjs push-check` refuses any other head, so a head is pushed on a writer's behalf only when the writer called it ready.
- `green-watch.mjs` re-lists main's runs on every call and reports every lane of every run containing a program PR (`RED`, `PENDING`, `NO-RUN`), so a batch that starts later is not missed.
- `claude-usage.mjs` adds a `claude-usage` watcher line from `claude -p "/usage"` (session and weekly bands on the Codex thresholds); fleet-orchestrator moves Claude writers to Codex near the limit.
- `status-audit.mjs` is the program's status sweep: `PLAN-OPEN` for a DONE ticket whose plan is not EXECUTED and `ABSORBED-OPEN` for a child left open after its umbrella closed; `status-sweep` is a required board duty.
- The requirements worker observes a reference product live through the signed-in browser or returns `NEEDS_PERSON`, never public help material, matching the quality loop.
- fleet-orchestrator Program practices: green watch, load-aware placement (`fleet.sh slot heavy` refuses a machine over `MAX_LOAD_PER_CORE`), repo knowledge capture, close-out, deadline mode and runtime choice; `board.mjs status` prints `MISSING-DUTY` for a required duty (green-watch by default) with no owner.

### closedloop-core v1.5.2

#### Fixed
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.75",
"version": "1.0.76",
"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.75",
"version": "1.0.76",
"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
7 changes: 4 additions & 3 deletions plugins/vibe/agents/vibe-environment-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ worker an exclusive session-record and ticket-writing turn; never race another
writer. Product questions use the graph/live-decision research and necessity
gate, technical choices stay internal, and local plans are never deployed.
Before every push or deployment request run
`node <plugin-root>/skills/vibe/scripts/local-plans.mjs --worktree "<wt>"`.
If it fails, return `BLOCKED` and publish nothing; an unchanged committed plan
is unsafe too. Do not rewrite branch history to hide the leak.
`node <plugin-root>/skills/vibe/scripts/local-plans.mjs --worktree "<wt>"` and
`node <plugin-root>/skills/vibe/scripts/migration-publish.mjs --worktree "<wt>" --since <vercel.lastDeployedCommit, or baseCommit before the first push>`.
If either fails, return `BLOCKED` with its problems and publish nothing; an
unchanged committed plan is unsafe too. Do not rewrite branch history to hide the leak.
Use `environment.md`, "Main-sync before publication", for EVERY create,
redeploy, flags/Desktop request and handoff. Root supplies the exact granted
`operationContext`, purpose and current transaction in private stdin data;
Expand Down
3 changes: 2 additions & 1 deletion plugins/vibe/agents/vibe-guardrails-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ path merely because another rule describes how its content should look.
to the copy rule),
numeric limit, criterion worded more strongly than its source and removed
accessibility behavior; a shared-module edit lists its callers in the
change log.
change log. A spec or criterion edited to match the code is blocking
without a human source (`spec-change` diff rule).
16. Tech debt and flag keys (`guardrails.md`, "No new tech debt" and
"Feature flags"): each item of a kind that section lists with no
`Debt:` reason is blocking: a mechanism checks 1, 7, 8 or 12 find, a
Expand Down
11 changes: 7 additions & 4 deletions plugins/vibe/agents/vibe-requirements-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,12 @@ reference image paths of one (a screenshot, named or not), and the person's
words. Open every reference image path yourself as an image (`Read` in
Claude Code, `view_image` in Codex); never work from someone else's
description of it. When the product is named or identifiable from the
image, research how it does it from its public documentation, help center
and release notes, using the harness's web search and fetch tools.
Never sign in to it, create an account, or type credentials. Keep only the
image, its behaviors come from the live product, observed by whoever has the
operator's signed-in browser (`../skills/vibe/scripts/precedent-check.mjs`
brief), never from public documentation, a help center or search results.
When no actor here has that browser, return `NEEDS_PERSON` with the
interactions still to observe; the image alone still gives the Visual and UX
requirements. Never sign in to it, create an account, or type credentials. Keep only the
behaviors the request covers, mapped onto the existing screen and the
graph/live product decisions you would check for any request; a settled
decision outranks the reference.
Expand All @@ -125,7 +128,7 @@ writer's existing missing-primitive path.
Return `DONE` with a bulleted list of the behaviors to build, one plain line
each in the person's terms, each with its source link or reference image
path, then the Visual and UX requirements, each with its mapped component or
token, then one line naming anything the public material or the image did
token, then one line naming anything the live product or the image did
not establish. The orchestrator shows the
list to the person and keeps building without waiting, so the list is never a
question. It names behaviors, not wording: never propose the product's labels
Expand Down
8 changes: 6 additions & 2 deletions plugins/vibe/agents/vibe-ticket-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@ flag keys, each read now from the flag service), and rerun it. Only once it
passes, set `EXECUTED` through step F (with `expectedStatus` the status you
read) and record it. For a source PRD, move each claimed child the
handoff gate put in `IN_REVIEW` to `DONE` with the merge URL and commit as
evidence; change nothing else.
evidence, each only once
`node ../skills/handoff/scripts/executed-check.mjs --kind ticket --file <its latest text> --slug <its slug> --prd <the PRD slug>`
passes (return the ones it refuses). Change nothing else.

## Handoff mode

Expand Down Expand Up @@ -402,7 +404,9 @@ historical inspections accurately. Preserve the existing grading block.
the PLN matches the current local plans: repair whatever is missing or
stale here; it is never a question for the person. A `NEEDS_CHANGE` from
step B goes back to the same writer like any other.
8. Write the body back with `create-document-version`, then save it to
8. `node ../skills/handoff/scripts/rulings-check.mjs --ticket <new body> --criteria-before <current latest text>`
must pass (a reworded or dropped criterion is restored unless a human
ruling amends it). Write the body back with `create-document-version`, then save it to
`$(git -C "<wt>" rev-parse --absolute-git-dir)/vibe-live-ticket.md` and run
`node ../skills/handoff/scripts/live-ticket-check.mjs --file "<that file>" --base-commit <inventory baseCommit> --worktree "<wt>"`.
Fix every problem you can from the session; a problem only the person can
Expand Down
12 changes: 12 additions & 0 deletions plugins/vibe/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kill-guard.mjs\"" }
]
}
]
}
}
157 changes: 157 additions & 0 deletions plugins/vibe/hooks/kill-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env node
// PreToolUse guard for Bash: a session signals only processes it recorded as
// its own. A writer once killed a shared laptop's test runs by pattern
// (`-f "turbo test --filter"`), which can end other sessions' runs, and a
// plugin test's kill(-1) once ended every app on the operator's machine; a
// written rule against both already existed. So the by-name killers (the p
// and all variants of kill), `xargs kill`, and `kill` with a pattern, a
// variable, a command substitution, or a pid of 1 or lower (zero and negative
// pids signal whole process groups) are refused. `kill <pid>`
// passes only for a pid recorded here as this session's own, still running the
// process that was recorded.
//
// Usage (hook): reads the PreToolUse JSON on stdin; prints a deny decision or nothing.
// record: kill-guard.mjs own <pid>... (run from the session's working directory, for example with $!)

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

const SEPARATOR = /\|\||&&|[;|&\n]|\$\(|`|\(|\)/;
const SIGNAL_OPTION = /^-(?:[A-Za-z][A-Za-z0-9+-]*|\d+)$/;
const SUBSTITUTED_KILL = /(?:^|[\s;&|(])kill\b[^;&|\n]*(?:\$\(|`)/;
const BY_NAME = /^p?kill(?:all)?$/;
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
const WRAPPERS = new Set(["sudo", "command", "exec", "nohup", "time", "builtin", "env"]);

/** The file recording the pids each session called its own. */
export function ledgerFile(home = os.homedir()) {
return path.join(home, ".claude", "vibe", "owned-pids.json");
}

/** Problems with the signal-sending commands in `command`; `isOwn(pid)` says whether a pid is recorded as this session's. */
export function killProblems(command, isOwn) {
const problems = SUBSTITUTED_KILL.test(command)
? ["kill with a command substitution signals whatever a search matched; kill only a pid you recorded as your own"]
: [];
for (const segment of command.split(SEPARATOR)) {
const words = segment.trim().split(/\s+/).filter((word) => word && !/[<>]/.test(word)).map((word) => word.replace(/^["']|["']$/g, ""));
while (words.length > 0 && (ENV_ASSIGN.test(words[0]) || WRAPPERS.has(path.basename(words[0])))) words.shift();
const name = path.basename(words[0] ?? "");
if (name !== "kill" && BY_NAME.test(name)) {
problems.push(`${name} signals by pattern, which can end other sessions' processes; record your own pid when you start a process and run kill <pid>`);
} else if (name === "xargs" && words.slice(1).some((word) => path.basename(word) === "kill")) {
problems.push("xargs kill signals whatever a search matched; kill only a pid you recorded as your own");
} else if (name === "kill") {
problems.push(...killTargets(words.slice(1), isOwn));
}
}
return problems;
}

function killTargets(args, isOwn) {
if (args.some((arg) => arg === "-l" || arg === "-L")) return [];
const targets = [];
let signalSeen = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--") {
targets.push(...args.slice(index + 1));
break;
}
if (!signalSeen && (arg === "-s" || arg === "-n")) {
signalSeen = true;
index += 1;
} else if (!signalSeen && SIGNAL_OPTION.test(arg)) {
signalSeen = true;
} else {
targets.push(arg);
}
}
const problems = [];
for (const target of targets) {
if (/^%\d+$/.test(target)) continue;
if (!/^-?\d+$/.test(target)) {
problems.push(`kill ${target}: name the pid as a number this session recorded as its own, never a pattern, variable or command substitution`);
} else if (Number(target) <= 1) {
problems.push(`kill ${target}: a pid of 1 or lower signals every process or a whole process group`);
} else if (!isOwn(Number(target))) {
problems.push(`kill ${target}: pid ${target} is not recorded as this session's own; record a process you start with \`node <vibe plugin>/hooks/kill-guard.mjs own <pid>\``);
}
}
return problems;
}

/** A process's start time as ps reports it, or null when it is not running. */
export function startTimeOf(pid) {
try {
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
} catch {
return null;
}
}

/** Whether `pid` is recorded for a directory containing `cwd` and still runs the recorded process. */
export function recordedOwn(ledger, pid, cwd, startOf = startTimeOf) {
const entry = ledger[String(pid)];
if (!entry || !(cwd === entry.cwd || cwd.startsWith(`${entry.cwd}${path.sep}`))) return false;
const started = startOf(pid);
return started !== null && started === entry.started;
}

function readLedger(file) {
try {
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
} catch {
return {};
}
}

function recordOwn(pids, cwd, file = ledgerFile()) {
const ledger = readLedger(file);
for (const text of pids) {
const pid = Number(text);
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || pid === process.ppid) {
throw new Error(`refusing to record ${text}: only a pid above 1 that this session started`);
}
const started = startTimeOf(pid);
if (started === null) throw new Error(`pid ${pid} is not running`);
ledger[String(pid)] = { cwd, started };
}
for (const key of Object.keys(ledger)) if (startTimeOf(Number(key)) !== ledger[key].started) delete ledger[key];
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
}

function hook(input) {
const event = JSON.parse(input || "{}");
const command = event?.tool_input?.command;
if (event?.tool_name !== "Bash" || typeof command !== "string") return null;
const ledger = readLedger(ledgerFile());
const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd();
const problems = killProblems(command, (pid) => recordedOwn(ledger, pid, cwd));
if (problems.length === 0) return null;
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `Signal only processes this session started and recorded: ${problems.join("; ")}`,
},
};
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
if (process.argv[2] === "own") {
recordOwn(process.argv.slice(3), process.cwd());
} else {
const decision = hook(readFileSync(0, "utf8"));
if (decision) process.stdout.write(`${JSON.stringify(decision)}\n`);
}
} catch (error) {
process.stderr.write(`kill-guard: ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(process.argv[2] === "own" ? 1 : 0);
}
}
Loading
Loading