Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
d69bd0d
fix: the public plugin names no private repository, and CI stops one …
Lutherwaves Oct 21, 2026
a729785
fix: a broken or empty deny-list now fails the scrub instead of passi…
Lutherwaves Oct 21, 2026
12b3280
feat: builder remembers what it recommended and what you answered
Lutherwaves Oct 21, 2026
3062d1e
fix: a hung matchblox no longer hides the other signals, and a damage…
Lutherwaves Oct 21, 2026
7db6246
feat: every session starts with the builder soul, and a new builder i…
Lutherwaves Oct 21, 2026
689c4de
test: each skill shows, side by side, what an agent does without it a…
Lutherwaves Oct 21, 2026
bc1873b
fix: an eval prompt that starts with a dash runs, and agent errors st…
Lutherwaves Oct 21, 2026
e616389
feat: an agent builds the way a seasoned builder does, without being …
Lutherwaves Oct 21, 2026
6066bc2
fix: the builder report closes finished work, eval runs skip setup qu…
Lutherwaves Oct 21, 2026
09d401f
fix: every call in the builder report carries its reason
Lutherwaves Oct 21, 2026
099a996
feat: work starts from the person and the moment, and a repository's …
Lutherwaves Oct 21, 2026
6e22b11
fix: eval transcripts go to the standard state folder, not a private one
Lutherwaves Oct 21, 2026
75b937f
fix: a rule found with no incident keeps a home and asks for its scar…
Lutherwaves Oct 21, 2026
c411680
feat: a builder reaches teammates on the team's channel, and nothing …
Lutherwaves Oct 21, 2026
2149650
fix: a repeated message to a teammate is caught however it is spaced,…
Lutherwaves Oct 21, 2026
97f60f6
feat: many agents run side by side, each in its lane, and hand work o…
Lutherwaves Oct 21, 2026
4022bf8
fix: the person asks "where are we?" and gets every session in one li…
Lutherwaves Oct 21, 2026
d9f85cc
fix: an eval never steers a real session, runs in a real git repo, an…
Lutherwaves Oct 21, 2026
2e3d88e
fix: fleet answers "where are we?" when asked in those words
Lutherwaves Oct 21, 2026
f8c4896
fix: an eval cannot reach matchblox or tmux commands that steer a rea…
Lutherwaves Oct 21, 2026
33a93f8
refactor: the usage watcher and the code-graph tools move to their ne…
Lutherwaves Oct 21, 2026
f9f2d8a
feat: budget reads the real plan windows, and harness keeps one repos…
Lutherwaves Oct 21, 2026
3fd2843
fix: a blocked graph setup still hands over the verify question, and …
Lutherwaves Oct 21, 2026
5ffd4ab
ci: every commit and pull request title follows Conventional Commits,…
Lutherwaves Oct 21, 2026
da07286
ci: merge commits and empty titles fail the Conventional Commits chec…
Lutherwaves Oct 21, 2026
51b3856
feat: a builder can replace a hand-built setup with builder and keep …
Lutherwaves Oct 21, 2026
3ef5361
fix: builder sees a code graph added at local scope and reads plugins…
Lutherwaves Oct 21, 2026
f56de42
fix: the parity eval can never write to the real Claude Code config
Lutherwaves Oct 21, 2026
e19c7d6
feat!: builder 2 — the builder soul, agent-led setup and peer messaging
Lutherwaves Oct 21, 2026
0c014d8
fix: a new builder always meets builder in its first reply, even afte…
Lutherwaves Oct 21, 2026
2e50028
fix: a new builder hears each setup step from the ledger, never an in…
Lutherwaves Oct 21, 2026
57f3423
fix: a note to you never opens a matchblox console by mistake
Lutherwaves Oct 21, 2026
0ee9024
fix: setup describes the team channel as the place for teammate messages
Lutherwaves Oct 21, 2026
88c02b9
fix: builder stops asking for a canon once the canon skill writes RUL…
Lutherwaves Oct 21, 2026
ec63800
fix: the parity eval never deletes plugins or skills in a folder it d…
Lutherwaves Oct 21, 2026
d332b70
fix: harness setup keeps every other MCP server in .mcp.json, even wi…
Lutherwaves Oct 21, 2026
8297632
fix: a plain "commit this" request gets signed Conventional Commits i…
Lutherwaves Oct 21, 2026
64402f2
docs: the builder report says when a message went out by a send rule
Lutherwaves Oct 21, 2026
8f6149f
ci: a broken scrub, shape or subject guard now fails the pull request
Lutherwaves Oct 21, 2026
2b7ac9f
docs: a security reviewer sees every surface builder runs and what re…
Lutherwaves Oct 21, 2026
98a57dc
fix: a session starts on time even when a tool leaves a child holding…
Lutherwaves Oct 21, 2026
a78c510
fix: gstack installs where Claude Code reads it, also with CLAUDE_CON…
Lutherwaves Oct 21, 2026
ddc6d4b
fix: a note to you never runs a matchblox command that only starts wi…
Lutherwaves Oct 21, 2026
33fbde1
fix: the gstack step pastes as one clean command and a title edit ski…
Lutherwaves Oct 21, 2026
ad5802a
feat: builder knows which agent harness runs it and where that harnes…
Lutherwaves Oct 21, 2026
979e433
fix: builder knows where each harness keeps a project's MCP servers
Lutherwaves Oct 21, 2026
fdd010b
feat: builder keeps its own state in one place that every harness sha…
Lutherwaves Oct 21, 2026
bcb8688
feat: setup checks look in the right place on each harness and never …
Lutherwaves Oct 21, 2026
2a2fe24
fix: setup checks count agent sessions the same way on macOS and Linu…
Lutherwaves Oct 21, 2026
e26f294
fix: setup checks still work when a process on the machine has a name…
Lutherwaves Oct 21, 2026
572abf4
feat: every harness gets the builder soul at session start in the for…
Lutherwaves Oct 21, 2026
9a74d6d
feat: skills say what to do, and each harness gets a map from that ac…
Lutherwaves Oct 21, 2026
48c9530
fix: the repo's own tests never touch your git history, and the hook …
Lutherwaves Oct 21, 2026
4d025ce
feat: each setup step is offered only on a harness where it works, wi…
Lutherwaves Oct 21, 2026
77d4241
feat: Codex, Cursor and opencode install builder through their own pl…
Lutherwaves Oct 21, 2026
c329a5b
fix: builder's opencode plugin runs its setup check on every opencode…
Lutherwaves Oct 21, 2026
945d67c
feat: one eval suite grades builder on every harness, and the README …
Lutherwaves Oct 21, 2026
f6197be
fix: the support table calls a harness supported only when every eval…
Lutherwaves Oct 21, 2026
6e65e01
docs: anyone on Claude Code, Codex, Cursor or opencode can install bu…
Lutherwaves Oct 21, 2026
867c874
fix: every harness gets its own next setup step after the first one
Lutherwaves Oct 22, 2026
c1838a3
fix: a Claude Code session never prints Cursor's session-start shape
Lutherwaves Oct 22, 2026
f32fec8
fix: the code-graph step is offered only where builder can wire it
Lutherwaves Oct 22, 2026
b1b921f
fix: intake keeps the messages it already handled when its state moves
Lutherwaves Oct 22, 2026
c5795fe
fix: setup checks survive a config.toml that is not UTF-8
Lutherwaves Oct 22, 2026
6a5b590
docs: install notes say exactly what was proven on each harness
Lutherwaves Oct 22, 2026
ed0cb68
docs: skills point at the harness mapping instead of Claude-only names
Lutherwaves Oct 22, 2026
4f8bc80
docs: the README shows how builder did on Claude Code's evals, from t…
Lutherwaves Oct 22, 2026
6467821
fix: Cursor runs builder's own session-start hook, never a script fro…
Lutherwaves Oct 22, 2026
7627a93
fix: the validator test still catches a support table with no results…
Lutherwaves Oct 22, 2026
73ce978
ci: the validate jobs use actions/checkout v7.0.1, as main does
Lutherwaves Oct 22, 2026
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
12 changes: 12 additions & 0 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "builder",
"interface": { "displayName": "builder" },
"plugins": [
{
"name": "builder",
"source": { "source": "url", "url": "./" },
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
"category": "Developer Tools"
}
]
}
26 changes: 13 additions & 13 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "builder",
"description": "Operational tools for running AI coding agents at scale — recurring monitoring of Claude subscription usage limits (weekly/5h burn rate), session/context load with compaction picks, ctx-wire token savings, and per-repo codebase-memory graphs (kept fresh) so agents query structure instead of grep-reading",
"description": "Builder habits for coding agents: guided setup, decisions with reasons, proof before done, and nothing sent without your yes.",
"version": "0.1.4",
"author": {
"name": "Lutherwaves"
Expand All @@ -10,18 +10,18 @@
"license": "MIT",
"keywords": [
"claude-code",
"usage-limits",
"burn-rate",
"rate-limit",
"cctop",
"ctx-wire",
"context",
"compaction",
"monitoring",
"agent-ops",
"codebase-memory",
"soul",
"onboarding",
"shaping",
"rulings",
"peer-messaging",
"fleet",
"usage-budget",
"harness",
"code-graph",
"indexing",
"skills"
"codex",
"cursor",
"opencode",
"agent-skills"
]
}
18 changes: 18 additions & 0 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "builder",
"version": "0.1.4",
"description": "Builder habits for coding agents: guided setup, decisions with reasons, proof before done, and nothing sent without your yes.",
"author": { "name": "Lutherwaves" },
"homepage": "https://github.com/Lutherwaves/builder",
"repository": "https://github.com/Lutherwaves/builder",
"license": "MIT",
"keywords": ["claude-code","soul","onboarding","shaping","rulings","peer-messaging","fleet","usage-budget","harness","code-graph","codex","cursor","opencode","agent-skills"],
"skills": "./skills/",
"interface": {
"displayName": "builder",
"shortDescription": "Guided setup, decisions with reasons, proof before done",
"developerName": "Lutherwaves",
"category": "Developer Tools",
"capabilities": ["Interactive", "Read", "Write"]
}
}
13 changes: 13 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "builder",
"displayName": "builder",
"version": "0.1.4",
"description": "Builder habits for coding agents: guided setup, decisions with reasons, proof before done, and nothing sent without your yes.",
"author": { "name": "Lutherwaves" },
"homepage": "https://github.com/Lutherwaves/builder",
"repository": "https://github.com/Lutherwaves/builder",
"license": "MIT",
"keywords": ["claude-code","soul","onboarding","shaping","rulings","peer-messaging","fleet","usage-budget","harness","code-graph","codex","cursor","opencode","agent-skills"],
"skills": "./skills/",
"hooks": "./hooks/hooks-cursor.json"
}
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ labels: bug

- OS:
- Claude Code version:
- `cctop` / `ctx-wire` present? (`command -v cctop ctx-wire`)
- `matchblox` present? (`command -v matchblox`)
- `bash scripts/validate.sh` result:
42 changes: 41 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: validate

on:
pull_request:
types: [opened, synchronize, reopened, edited]
push:
branches: [main]

Expand All @@ -13,5 +14,44 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Check the pull request title and every commit subject
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
rc=0
printf '%s\n' "$PR_TITLE" | bash scripts/check-subjects.sh > "$RUNNER_TEMP/bad.txt" || rc=1
git log "$BASE_SHA..$HEAD_SHA" --format=%s | bash scripts/check-subjects.sh >> "$RUNNER_TEMP/bad.txt" || rc=1
if [ "$rc" -ne 0 ]; then
echo "Not Conventional Commits (type(scope)?: outcome):"
cat "$RUNNER_TEMP/bad.txt"
exit 1
fi
- name: Validate plugin manifests, skills, and scripts
run: bash scripts/validate.sh
env:
DENYLIST_SECRET: ${{ secrets.BUILDER_DENYLIST }}
run: |
if [ -n "$DENYLIST_SECRET" ]; then
printf '%s\n' "$DENYLIST_SECRET" > "$RUNNER_TEMP/deny.txt"
export BUILDER_DENYLIST="$RUNNER_TEMP/deny.txt"
fi
bash scripts/validate.sh

validate-tests:
# The tests of the guards above: the scrub, the skill shape and the subject check.
# A guard whose test never runs is a wish (tenet 9). About 5 minutes; runs beside `validate`.
# An edit of the title or body changes no code, so it does not run these tests again.
if: github.event.action != 'edited'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Test the validator's guards
run: bash tests/test_validate.sh
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
__pycache__/
*.pyc
usage.json
ctxwire-last.json
224 changes: 224 additions & 0 deletions .opencode/plugins/builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* builder plugin for OpenCode.
*
* V1 (opencode 1.x): named export BuilderPlugin. The config hook registers the
* skills directory. experimental.chat.messages.transform injects the builder
* soul once into the first user message of a top-level session.
*
* V2 (opencode 2.x): default export { id, server, setup }. setup() registers
* every skill with ctx.skill.transform and injects the soul with
* ctx.session.hook("context").
*
* No dependencies. The only exports are BuilderPlugin and the default object: older 1.x hosts call each export.
*/

import path from "path";
import fs from "fs";
import { spawnSync } from "child_process";
import { fileURLToPath } from "url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "../..");
const skillsDir = path.join(root, "skills");
const MARKER = "builder root:";

// Test seam: tests set globalThis.__builderSpawn. It is not an export, because opencode 1.x calls every export as a plugin factory.
const spawn = (...args) =>
(typeof globalThis.__builderSpawn === "function" ? globalThis.__builderSpawn : spawnSync)(...args);

const readSoul = () => {
try {
return fs.readFileSync(path.join(skillsDir, "builder", "soul-compact.md"), "utf8");
} catch {
return null;
}
};

const runHook = () => {
try {
const result = spawn(
"python3",
[path.join(root, "hooks", "session_start.py"), "--harness", "opencode", "--format", "text"],
{ timeout: 1000, encoding: "utf8" },
);
if (result && !result.error && result.status === 0 && typeof result.stdout === "string" && result.stdout.trim()) {
return result.stdout;
}
} catch {
// fall through to the soul
}
return readSoul();
};

// sessionID -> bootstrap text. The hook fires on every agent step, so run python once per session.
const CACHE_MAX = 512;
const _bootstrapCache = new Map();

const bound = (cache) => {
if (cache.size < CACHE_MAX) return;
let toDrop = Math.ceil(CACHE_MAX / 4);
for (const key of cache.keys()) {
if (toDrop-- <= 0) break;
cache.delete(key);
}
};

const getBootstrap = (sessionID) => {
if (sessionID && _bootstrapCache.has(sessionID)) return _bootstrapCache.get(sessionID);
const text = runHook();
if (sessionID) {
bound(_bootstrapCache);
_bootstrapCache.set(sessionID, text);
}
return text;
};

// A session with a parentID is a subagent session: it does not get the bootstrap.
const _childSessionCache = new Map();

const isChildSession = async (fetchSession, sessionID) => {
if (!sessionID) return false;
if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID);

let isChild = false;
try {
const result = await fetchSession(sessionID);
// V1 returns an SDK envelope, V2 a direct session record.
if (!result || typeof result !== "object" || Array.isArray(result)) {
throw new Error("Session lookup returned no usable record");
}
if (result.error != null || result.response?.ok === false) {
throw new Error("Session lookup was unsuccessful");
}
const session = "data" in result ? result.data : result;
if (!session || typeof session !== "object" || Array.isArray(session) || session.id !== sessionID) {
throw new Error("Session lookup returned an invalid session identity");
}
if (session.parentID !== undefined && (typeof session.parentID !== "string" || session.parentID.length === 0)) {
throw new Error("Session lookup returned an invalid parent identity");
}
isChild = session.parentID !== undefined;
} catch (err) {
// Fail open and do not cache, so a transient failure can recover on the next step.
console.error("[builder] session lookup failed, treating session as top-level:", err);
return false;
}
bound(_childSessionCache);
_childSessionCache.set(sessionID, isChild);
return isChild;
};

const extractFrontmatter = (content) => {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter = {};
let lastKey = null;
for (const rawLine of match[1].split("\n")) {
const line = rawLine.replace(/\r$/, "");
const colonIdx = line.indexOf(":");
if (colonIdx > 0 && !/^\s/.test(line)) {
lastKey = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
frontmatter[lastKey] = /^(>[+-]?|\|[+-]?)$/.test(value) ? "" : value;
} else if (lastKey !== null && line.trim() !== "") {
frontmatter[lastKey] = `${frontmatter[lastKey]} ${line.trim()}`.trim();
}
}
for (const key of Object.keys(frontmatter)) {
frontmatter[key] = frontmatter[key].replace(/^(["'])([\s\S]*)\1$/, "$2");
}
return { frontmatter, body: match[2] };
};

export const BuilderPlugin = async ({ client }) => ({
config: async (config) => {
// V2: skills is a flat array and setup() registers them.
if (Array.isArray(config.skills)) return;
config.skills = config.skills || {};
config.skills.paths = config.skills.paths || [];
if (!config.skills.paths.includes(skillsDir)) config.skills.paths.push(skillsDir);
},

"experimental.chat.messages.transform": async (_input, output) => {
if (!output.messages.length) return;
const firstUser = output.messages.find((m) => m.info.role === "user");
if (!firstUser || !firstUser.parts.length) return;
if (firstUser.parts.some((p) => p.type === "text" && p.text.includes(MARKER))) return;
// V1 passes no input to this hook, so take the sessionID from the message.
const sessionID = firstUser.info.sessionID;
if (client && (await isChildSession((id) => client.session.get({ path: { id } }), sessionID))) return;
const bootstrap = getBootstrap(sessionID);
if (!bootstrap) return;
firstUser.parts.unshift({ ...firstUser.parts[0], type: "text", text: bootstrap });
},
});

async function setup(ctx) {
// V1 also calls default.setup, with a ctx that lacks the skill and session domains.
if (!ctx || !ctx.skill || typeof ctx.skill.transform !== "function" || !ctx.session || typeof ctx.session.hook !== "function") {
return;
}

try {
const skills = [];
if (fs.existsSync(skillsDir)) {
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
const skillPath = path.join(skillsDir, entry.name, "SKILL.md");
if (!fs.existsSync(skillPath)) continue;
const { frontmatter, body } = extractFrontmatter(fs.readFileSync(skillPath, "utf8"));
skills.push({
id: entry.name,
name: frontmatter.name || entry.name,
...(frontmatter.description ? { description: frontmatter.description } : {}),
path: skillPath,
content: body,
});
}
}
await ctx.skill.transform((draft) => {
// A throw escaping this callback disables the whole plugin, so contain each skill.
for (const skill of skills) {
try {
draft.add(skill);
} catch (err) {
console.error(`[builder] skill "${skill.id}" rejected by host, skipping:`, err);
}
}
});
} catch (err) {
console.error("[builder] skill registration failed:", err);
}

try {
await ctx.session.hook("context", async (event) => {
try {
if (!event.messages || !event.messages.length) return;
const firstUser = event.messages.find((m) => m.role === "user");
if (firstUser && (!firstUser.content || !firstUser.content.length)) return;
if (firstUser?.content.some((p) => p.type === "text" && p.text && p.text.includes(MARKER))) return;
if (
typeof ctx.session.get === "function" &&
(await isChildSession((id) => ctx.session.get({ sessionID: id }), event.sessionID))
) return;
const bootstrap = getBootstrap(event.sessionID);
if (!bootstrap) return;
if (firstUser) {
firstUser.content.unshift({ type: "text", text: bootstrap });
} else {
event.messages.push({ role: "user", content: [{ type: "text", text: bootstrap }] });
}
} catch (err) {
console.error("[builder] context hook failed:", err);
}
});
} catch (err) {
console.error("[builder] session hook registration failed:", err);
}
}

export default {
id: "builder",
server: BuilderPlugin,
setup,
};
Loading
Loading