feat: scaffold cluster worker orchestration

This commit is contained in:
Vincent Koc 2026-04-24 18:05:06 -07:00
commit 38c0d85f87
No known key found for this signature in database
27 changed files with 1072 additions and 0 deletions

View File

@ -0,0 +1,52 @@
---
name: projectclownfish-cluster-worker
description: Use when running or reviewing a projectclownfish cluster job that farms one GitHub issue/PR dedupe cluster to an isolated Codex worker for plan or execute mode.
---
# projectclownfish Cluster Worker
Use this skill for one cluster job at a time.
## Workflow
1. Read the job markdown frontmatter and body.
2. Confirm `repo`, `cluster_id`, `mode`, `allowed_actions`, and `candidates`.
3. Read the relevant policy files:
- `instructions/dedupe.md`
- `instructions/closure-policy.md`
- `instructions/merge-policy.md`
4. Fetch live state with `gh` before making any recommendation.
5. In `plan` mode, do not mutate GitHub.
6. In `execute` mode, mutate only when the job allows it and evidence is clear.
7. Emit final JSON matching `schemas/codex-result.schema.json`.
## Safety Rails
- One cluster only.
- Stop on unclear canonical selection.
- Stop on failing checks unless the job explicitly allows that risk.
- Stop on broad code deltas or generated-file churn.
- Preserve contributor credit in comments and summaries.
- Record an idempotency key for every planned or executed mutation.
## Commands
Read state:
```bash
gh issue view NUMBER --repo OWNER/REPO --comments --json number,title,state,author,labels,body,comments,url,updatedAt,closedAt
gh pr view NUMBER --repo OWNER/REPO --json number,title,state,author,labels,body,comments,url,updatedAt,closedAt,mergeStateStatus,isDraft,files,additions,deletions
gh pr checks NUMBER --repo OWNER/REPO
gh pr diff NUMBER --repo OWNER/REPO
```
Mutate only in execute mode:
```bash
gh issue comment NUMBER --repo OWNER/REPO --body-file comment.md
gh issue close NUMBER --repo OWNER/REPO --reason "not planned"
gh issue edit NUMBER --repo OWNER/REPO --add-label duplicate
gh pr comment NUMBER --repo OWNER/REPO --body-file comment.md
gh pr close NUMBER --repo OWNER/REPO --comment "Superseded by #CANONICAL."
gh pr merge NUMBER --repo OWNER/REPO --squash --delete-branch
```

View File

@ -0,0 +1,7 @@
interface:
display_name: "projectclownfish Cluster Worker"
short_description: "Run one GitHub issue/PR dedupe cluster as a guarded plan or execute job."
default_prompt: "Run this projectclownfish cluster job in plan mode and return structured JSON."
policy:
allow_implicit_invocation: true

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
GH_TOKEN=
OPENAI_API_KEY=
CLOWNFISH_ALLOWED_OWNER=openclaw
CLOWNFISH_ALLOW_EXECUTE=0
CLOWNFISH_CODEX_BYPASS=0

83
.github/workflows/cluster-worker.yml vendored Normal file
View File

@ -0,0 +1,83 @@
name: cluster worker
on:
workflow_dispatch:
inputs:
job:
description: "Job markdown path, for example jobs/openclaw/cluster-001.md"
required: true
type: string
mode:
description: "Worker mode"
required: true
default: plan
type: choice
options:
- plan
- execute
runner:
description: "Runner label, e.g. ubuntu-latest or a Blacksmith label"
required: true
default: ubuntu-latest
type: string
model:
description: "Codex model"
required: true
default: gpt-5.4
type: string
codex_bypass:
description: "Use Codex bypass mode; only for externally sandboxed runners"
required: true
default: false
type: boolean
permissions:
contents: read
jobs:
cluster:
runs-on: ${{ inputs.runner }}
timeout-minutes: 90
env:
GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CLOWNFISH_ALLOWED_OWNER: ${{ vars.CLOWNFISH_ALLOWED_OWNER || 'openclaw' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ inputs.mode == 'execute' && vars.CLOWNFISH_ALLOW_EXECUTE || '0' }}
CLOWNFISH_CODEX_BYPASS: ${{ inputs.codex_bypass && '1' || '0' }}
CLOWNFISH_MODEL: ${{ inputs.model }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Verify GitHub token
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "CLOWNFISH_GH_TOKEN is required"
exit 1
fi
gh auth status
- name: Install Codex CLI
run: |
if ! command -v codex >/dev/null 2>&1; then
npm install -g @openai/codex@latest
fi
codex --version
- name: Validate job
run: npm run validate:job -- "${{ inputs.job }}"
- name: Run worker
run: npm run worker -- "${{ inputs.job }}" --mode "${{ inputs.mode }}"
- name: Upload worker artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: projectclownfish-${{ github.run_id }}-${{ github.run_attempt }}
path: |
.projectclownfish/runs/**
if-no-files-found: warn

28
.github/workflows/validate.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: validate
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Validate job specs
run: npm run validate
- name: Render example prompt
run: npm run render -- jobs/openclaw/cluster-example.md --mode plan > /tmp/projectclownfish-prompt.md
- name: Dry-run worker
run: npm run worker -- jobs/openclaw/cluster-example.md --mode plan --dry-run

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
.env
.env.*
!.env.example
.projectclownfish/runs/*
!.projectclownfish/runs/.gitkeep
results/**/*.local.*
*.log

View File

@ -0,0 +1 @@

25
AGENTS.md Normal file
View File

@ -0,0 +1,25 @@
# Projectclownfish Agent Notes
projectclownfish farms one GitHub issue/PR cluster to one isolated Codex worker.
## Hard Rules
- Default to `plan`; do not execute GitHub mutations unless the job says `mode: execute` and `CLOWNFISH_ALLOW_EXECUTE=1`.
- Re-fetch live GitHub state before any close, label, comment, merge, or fix action.
- If canonical choice is unclear, checks are failing, a PR has conflicts, or the cluster changed materially, stop with `needs_human`.
- Never print tokens, secrets, or full environment dumps.
- Every mutation needs an idempotency key and must be recorded in the result artifact.
- Preserve contributor credit. Comment before closing, and explain the canonical path.
- One worker owns one cluster job. Do not roam into adjacent clusters except to report likely follow-up jobs.
## Local Commands
```bash
npm run validate
npm run render -- jobs/openclaw/cluster-example.md --mode plan
npm run worker -- jobs/openclaw/cluster-example.md --mode plan --dry-run
```
## GitHub Actions
Use `cluster-worker.yml` for one cluster job. Use `scripts/dispatch-jobs.mjs` to fan out a selected list.

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# projectclownfish
Private cluster-ops control repo for farming GitHub issue/PR dedupe work to Codex workers.
The repo stays deliberately small:
- Markdown cluster job files are the control plane.
- GitHub Actions or Blacksmith runners are the compute plane.
- Codex workers use repo-local prompts and skills.
- Results land as artifacts and structured JSON.
## Quick Start
Create a job:
```bash
cp jobs/openclaw/cluster-example.md jobs/openclaw/cluster-001.md
$EDITOR jobs/openclaw/cluster-001.md
```
Validate and render locally:
```bash
npm run validate:job -- jobs/openclaw/cluster-001.md
npm run render -- jobs/openclaw/cluster-001.md --mode plan
```
Run locally without calling Codex:
```bash
npm run worker -- jobs/openclaw/cluster-001.md --mode plan --dry-run
```
Dispatch one worker:
```bash
gh workflow run cluster-worker.yml \
-f job=jobs/openclaw/cluster-001.md \
-f mode=plan \
-f runner=ubuntu-latest
```
Use a Blacksmith runner by passing its runner label:
```bash
gh workflow run cluster-worker.yml \
-f job=jobs/openclaw/cluster-001.md \
-f mode=plan \
-f runner=blacksmith-4vcpu-ubuntu-2404
```
## Secrets
Required for real worker runs:
- `CLOWNFISH_GH_TOKEN`: GitHub token with the narrowest possible repo scope.
- `OPENAI_API_KEY`: OpenAI API key for Codex CLI when the runner does not already have auth.
Optional:
- `CLOWNFISH_ALLOWED_OWNER`: defaults to `openclaw`.
- `CLOWNFISH_ALLOW_EXECUTE`: set to `1` only for execute jobs.
- `CLOWNFISH_CODEX_BYPASS`: set to `1` only for externally sandboxed runners.
## Modes
`plan` produces action recommendations only.
`execute` is gated by all of these:
- workflow input `mode=execute`
- job frontmatter `mode: execute`
- `CLOWNFISH_ALLOW_EXECUTE=1`
Start with `plan` over a batch of clusters. Promote only boring, obvious work to `execute`.

55
docs/OPERATIONS.md Normal file
View File

@ -0,0 +1,55 @@
# Operations
## Batch Flow
1. Create or export cluster job markdown files under `jobs/<repo>/`.
2. Run local validation:
```bash
npm run validate
```
3. Dispatch plan jobs:
```bash
npm run dispatch -- jobs/openclaw/cluster-001.md jobs/openclaw/cluster-002.md --mode plan
```
4. Review artifacts from GitHub Actions.
5. Change selected jobs to `mode: execute`.
6. Set repo variable `CLOWNFISH_ALLOW_EXECUTE=1` only for the execution window.
7. Dispatch execute jobs for reviewed clusters only.
8. Reset `CLOWNFISH_ALLOW_EXECUTE=0`.
## Runner Strategy
Use `ubuntu-latest` for correctness smoke tests.
Use Blacksmith labels for bulk planning/execution once the workflow is stable:
```bash
npm run dispatch -- jobs/openclaw/cluster-*.md --mode plan --runner blacksmith-4vcpu-ubuntu-2404
```
## Token Strategy
Prefer a fine-grained token or GitHub App token.
Minimum useful permissions depend on action tier:
- plan: metadata, issues read, pull requests read, contents read
- closure: issues write, pull requests write
- merge: contents write and pull requests write
- fix PRs: contents write
Do not put tokens in job files.
## Promotion Rules
Promote from `plan` to `execute` only when:
- the canonical item is clear;
- no unique reports are being closed;
- comments preserve contributor credit;
- idempotency keys are present;
- high-risk work is marked `needs_human`.

View File

@ -0,0 +1,26 @@
# Closure Policy
Only close when:
- the item is open;
- it is a true duplicate or superseded by a clear canonical item;
- a clear comment has been posted first;
- the comment preserves credit and gives a reopen path;
- the action is allowed by the job frontmatter.
Default close comment shape:
```md
Thanks for this. I am closing this as a duplicate of #CANONICAL because both reports track the same root cause: REASON.
I am keeping the canonical thread open there so fixes, validation, and follow-up stay in one place. If this has a different reproduction path or still reproduces after the canonical fix lands, please reply and we can reopen or split it back out.
```
Never close:
- unclear root cause;
- unique reproduction detail;
- unique affected platform/version;
- active maintainer discussion;
- assigned work in progress;
- contributor PR with useful code that should be merged or credited.

22
instructions/dedupe.md Normal file
View File

@ -0,0 +1,22 @@
# Dedupe Instructions
Classify every candidate against the canonical item or canonical family.
Prefer these outcomes:
- `canonical`: best surviving issue/PR for the root cause.
- `duplicate`: same root cause, same user-visible failure, no unique remaining work.
- `related`: same area or symptom family, but meaningfully different root cause or scope.
- `superseded`: PR or issue replaced by a better candidate.
- `independent`: should not be closed or merged as part of this cluster.
- `needs_human`: ambiguous, risky, changed live state, failing checks, unclear author credit, or broad code delta.
Evidence order:
1. Live GitHub state from `gh`.
2. Issue/PR body and maintainer comments.
3. Changed files and diff shape for PRs.
4. CI status and mergeability.
5. Cluster notes and ghcrawl summaries.
Do not close based on title similarity alone.

View File

@ -0,0 +1,25 @@
# Merge And Fix Policy
Merging is higher risk than closure. Prefer `needs_human` unless the merge path is obvious.
Safe-ish merge candidate:
- tests pass or maintainer explicitly accepts risk;
- no conflicts;
- small focused diff;
- no broad setup, generated, lockfile, or unrelated churn;
- author credit is preserved;
- superseded PRs are acknowledged before closing.
For multiple PRs:
- keep the clearest passing PR as canonical;
- mark overlapping PRs as superseded or related;
- if two PRs each contain useful parts, emit `needs_human` with a combine plan instead of trying to freestyle a merge.
For fix work:
- only create a fix PR when the job allows `fix` or `raise_pr`;
- keep the patch tiny;
- run the repo's narrow tests;
- include links to the cluster and canonical issue.

View File

@ -0,0 +1,35 @@
---
repo: openclaw/openclaw
cluster_id: example-cron-timeout
mode: plan
allowed_actions:
- comment
- label
- close
blocked_actions:
- force_push
- bypass_checks
require_human_for:
- failing_checks
- conflicting_prs
- unclear_canonical
- broad_code_delta
canonical:
- "#40868"
candidates:
- "#40868"
- "#41272"
notes: "Example only. Replace with a real cluster exported from ghcrawl or curated by hand."
---
# Cluster Task
Use the dedupe workflow to classify this cluster.
## Goal
Decide what should stay open, what should close as duplicate or superseded, and whether any PR should be merged, combined, or escalated.
## Context
Paste ghcrawl cluster summary, LLM key summaries, top touched files, and operator notes here.

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "projectclownfish",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"validate": "node scripts/validate-all.mjs",
"validate:job": "node scripts/validate-job.mjs",
"render": "node scripts/render-prompt.mjs",
"worker": "node scripts/run-worker.mjs",
"dispatch": "node scripts/dispatch-jobs.mjs"
},
"engines": {
"node": ">=20"
}
}

24
prompts/execute.md Normal file
View File

@ -0,0 +1,24 @@
# Execute Mode
Execute only the actions that are explicitly allowed by the job.
Before each mutation:
1. re-fetch live state;
2. check if the action already happened;
3. build an idempotency key;
4. perform the smallest safe mutation;
5. record the before/after state.
Allowed mutation commands may include:
- `gh issue comment`
- `gh issue close`
- `gh issue edit --add-label`
- `gh pr comment`
- `gh pr close`
- `gh pr merge`
Never force-push, rewrite contributor branches, or bypass failing checks unless the job explicitly says so and the policy allows it.
Return structured JSON only.

24
prompts/plan-only.md Normal file
View File

@ -0,0 +1,24 @@
# Plan Mode
Produce a plan only. Do not call mutating `gh` commands.
Allowed read commands include:
- `gh issue view`
- `gh pr view`
- `gh pr checks`
- `gh pr diff`
- `gh api` read endpoints
For each item, decide one action:
- keep canonical
- close duplicate
- close superseded
- keep related
- keep independent
- merge candidate
- fix needed
- needs human
Return structured JSON only.

13
prompts/review-result.md Normal file
View File

@ -0,0 +1,13 @@
# Result Review
Review worker output before trusting it.
Flag:
- action without evidence;
- closure without canonical link;
- merge recommendation with failing checks;
- broad PR diff hidden behind a simple title;
- missing idempotency key;
- final JSON that does not match the result schema;
- any mutation in plan mode.

29
prompts/worker-system.md Normal file
View File

@ -0,0 +1,29 @@
# projectclownfish worker system prompt
You are a one-cluster GitHub maintenance worker.
You have one job file, one repository, and one cluster. Do not expand scope unless reporting follow-up clusters.
Priorities:
1. protect maintainer trust;
2. preserve contributor credit;
3. make only auditable, idempotent actions;
4. stop on ambiguity;
5. produce structured results.
Before action:
- read the job frontmatter and body;
- read `instructions/dedupe.md`;
- read `instructions/closure-policy.md`;
- read `instructions/merge-policy.md`;
- fetch live state with `gh issue view`, `gh pr view`, `gh pr checks`, and `gh pr diff` as needed.
Execution guard:
- In `plan` mode, do not mutate GitHub.
- In `execute` mode, mutate only if the job allows the action and the evidence is clear.
- If any safety condition is not met, return `needs_human`.
Final answer must match `schemas/codex-result.schema.json`.

View File

@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Projectclownfish worker result",
"type": "object",
"required": ["status", "repo", "cluster_id", "mode", "summary", "actions"],
"additionalProperties": true,
"properties": {
"status": {
"enum": ["planned", "executed", "needs_human", "blocked", "failed"]
},
"repo": {
"type": "string"
},
"cluster_id": {
"type": "string"
},
"mode": {
"enum": ["plan", "execute"]
},
"summary": {
"type": "string"
},
"actions": {
"type": "array",
"items": {
"type": "object",
"required": ["target", "action", "status", "idempotency_key"],
"additionalProperties": true,
"properties": {
"target": {
"type": "string"
},
"action": {
"type": "string"
},
"status": {
"enum": ["planned", "executed", "skipped", "blocked", "failed"]
},
"idempotency_key": {
"type": "string"
},
"reason": {
"type": "string"
}
}
}
},
"needs_human": {
"type": "array",
"items": {
"type": "string"
}
}
}
}

56
schemas/job.schema.json Normal file
View File

@ -0,0 +1,56 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Projectclownfish cluster job",
"type": "object",
"required": ["repo", "cluster_id", "mode", "allowed_actions", "candidates"],
"additionalProperties": true,
"properties": {
"repo": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
},
"cluster_id": {
"type": "string",
"minLength": 1
},
"mode": {
"enum": ["plan", "execute"]
},
"allowed_actions": {
"type": "array",
"items": {
"enum": ["comment", "label", "close", "merge", "fix", "raise_pr"]
}
},
"blocked_actions": {
"type": "array",
"items": {
"type": "string"
}
},
"require_human_for": {
"type": "array",
"items": {
"type": "string"
}
},
"canonical": {
"type": "array",
"items": {
"type": "string",
"pattern": "^#?[0-9]+$"
}
},
"candidates": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^#?[0-9]+$"
}
},
"notes": {
"type": "string"
}
}
}

49
scripts/dispatch-jobs.mjs Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs";
const args = parseArgs(process.argv.slice(2));
const mode = args.mode ?? "plan";
const runner = args.runner ?? "ubuntu-latest";
const workflow = args.workflow ?? "cluster-worker.yml";
const files = args._;
if (files.length === 0) {
console.error("usage: node scripts/dispatch-jobs.mjs <job.md> [...] [--mode plan|execute] [--runner label]");
process.exit(2);
}
let failed = false;
for (const file of files) {
const job = parseJob(file);
const errors = validateJob(job);
if (errors.length > 0) {
failed = true;
console.error(`invalid job: ${file}`);
for (const error of errors) console.error(`- ${error}`);
continue;
}
const relative = path.relative(repoRoot(), path.resolve(file));
if (!fs.existsSync(path.join(repoRoot(), relative))) {
failed = true;
console.error(`job does not exist inside repo: ${file}`);
continue;
}
const result = spawnSync(
"gh",
["workflow", "run", workflow, "-f", `job=${relative}`, "-f", `mode=${mode}`, "-f", `runner=${runner}`],
{ cwd: repoRoot(), encoding: "utf8", stdio: "pipe" },
);
if (result.status !== 0) {
failed = true;
console.error(result.stderr || result.stdout);
} else {
console.log(`dispatched ${relative} (${mode}) on ${runner}`);
}
}
if (failed) process.exit(1);

177
scripts/lib.mjs Executable file
View File

@ -0,0 +1,177 @@
import fs from "node:fs";
import path from "node:path";
export function repoRoot() {
return path.resolve(import.meta.dirname, "..");
}
export function readText(relativePath) {
return fs.readFileSync(path.join(repoRoot(), relativePath), "utf8");
}
export function parseJob(filePath) {
const absolute = path.resolve(filePath);
const raw = fs.readFileSync(absolute, "utf8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) {
throw new Error(`missing YAML frontmatter: ${filePath}`);
}
return {
path: absolute,
relativePath: path.relative(repoRoot(), absolute),
frontmatter: parseSimpleYaml(match[1]),
body: match[2].trim(),
raw,
};
}
export function parseSimpleYaml(text) {
const out = {};
let currentKey = null;
for (const line of text.split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith("#")) continue;
const listMatch = line.match(/^\s+-\s+(.*)$/);
if (listMatch && currentKey) {
if (!Array.isArray(out[currentKey])) out[currentKey] = [];
out[currentKey].push(parseScalar(listMatch[1]));
continue;
}
const kv = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
if (!kv) {
throw new Error(`unsupported YAML line: ${line}`);
}
currentKey = kv[1];
const value = kv[2] ?? "";
out[currentKey] = value === "" ? [] : parseScalar(value);
}
return out;
}
function parseScalar(value) {
const trimmed = value.trim();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "null") return null;
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed
.slice(1, -1)
.split(",")
.map((part) => parseScalar(part))
.filter((part) => part !== "");
}
return trimmed;
}
export function validateJob(job) {
const errors = [];
const fm = job.frontmatter;
requireString(errors, fm, "repo");
requireString(errors, fm, "cluster_id");
requireString(errors, fm, "mode");
requireArray(errors, fm, "allowed_actions");
requireArray(errors, fm, "candidates");
if (typeof fm.repo === "string" && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(fm.repo)) {
errors.push("repo must be owner/repo");
}
if (fm.mode && !["plan", "execute"].includes(fm.mode)) {
errors.push("mode must be plan or execute");
}
for (const key of ["allowed_actions", "blocked_actions", "require_human_for", "canonical", "candidates"]) {
if (fm[key] !== undefined && !Array.isArray(fm[key])) {
errors.push(`${key} must be a list`);
}
}
for (const action of fm.allowed_actions ?? []) {
if (!["comment", "label", "close", "merge", "fix", "raise_pr"].includes(action)) {
errors.push(`unsupported allowed action: ${action}`);
}
}
for (const ref of [...(fm.canonical ?? []), ...(fm.candidates ?? [])]) {
if (!/^#?[0-9]+$/.test(String(ref))) {
errors.push(`candidate refs must look like #123: ${ref}`);
}
}
return errors;
}
function requireString(errors, object, key) {
if (typeof object[key] !== "string" || object[key].trim() === "") {
errors.push(`${key} is required`);
}
}
function requireArray(errors, object, key) {
if (!Array.isArray(object[key]) || object[key].length === 0) {
errors.push(`${key} must be a non-empty list`);
}
}
export function renderPrompt(job, requestedMode) {
const mode = requestedMode ?? job.frontmatter.mode;
const modePrompt = mode === "execute" ? "prompts/execute.md" : "prompts/plan-only.md";
return [
readText("prompts/worker-system.md"),
readText(modePrompt),
"## Dedupe policy",
readText("instructions/dedupe.md"),
"## Closure policy",
readText("instructions/closure-policy.md"),
"## Merge policy",
readText("instructions/merge-policy.md"),
"## Job file",
"```md",
job.raw.trim(),
"```",
"## Required final output",
"Return JSON matching `schemas/codex-result.schema.json` and nothing else.",
].join("\n\n");
}
export function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) {
args._.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
}
return args;
}
export function assertAllowedOwner(repo, allowedOwner) {
if (!allowedOwner) return;
const owner = repo.split("/")[0];
if (owner !== allowedOwner) {
throw new Error(`repo owner ${owner} does not match CLOWNFISH_ALLOWED_OWNER=${allowedOwner}`);
}
}
export function makeRunDir(job, mode) {
const slug = `${path.basename(job.path, ".md")}-${mode}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = path.join(repoRoot(), ".projectclownfish", "runs", slug);
fs.mkdirSync(dir, { recursive: true });
return dir;
}

20
scripts/render-prompt.mjs Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
import { parseArgs, parseJob, renderPrompt, validateJob } from "./lib.mjs";
const args = parseArgs(process.argv.slice(2));
const jobPath = args._[0];
const mode = args.mode;
if (!jobPath) {
console.error("usage: node scripts/render-prompt.mjs <job.md> [--mode plan|execute]");
process.exit(2);
}
const job = parseJob(jobPath);
const errors = validateJob(job);
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
process.stdout.write(renderPrompt(job, mode));

107
scripts/run-worker.mjs Executable file
View File

@ -0,0 +1,107 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import {
assertAllowedOwner,
makeRunDir,
parseArgs,
parseJob,
renderPrompt,
repoRoot,
validateJob,
} from "./lib.mjs";
const args = parseArgs(process.argv.slice(2));
const jobPath = args._[0];
const mode = args.mode ?? "plan";
const dryRun = Boolean(args["dry-run"] || process.env.CLOWNFISH_DRY_RUN === "1");
const model = args.model ?? process.env.CLOWNFISH_MODEL ?? "gpt-5.4";
if (!jobPath) {
console.error("usage: node scripts/run-worker.mjs <job.md> --mode plan|execute [--dry-run]");
process.exit(2);
}
if (!["plan", "execute"].includes(mode)) {
console.error("mode must be plan or execute");
process.exit(2);
}
const job = parseJob(jobPath);
const errors = validateJob(job);
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
assertAllowedOwner(job.frontmatter.repo, process.env.CLOWNFISH_ALLOWED_OWNER);
if (mode === "execute") {
if (job.frontmatter.mode !== "execute") {
throw new Error("refusing execute: job frontmatter mode is not execute");
}
if (process.env.CLOWNFISH_ALLOW_EXECUTE !== "1") {
throw new Error("refusing execute: CLOWNFISH_ALLOW_EXECUTE must be 1");
}
}
const runDir = makeRunDir(job, mode);
const promptPath = path.join(runDir, "prompt.md");
const resultPath = path.join(runDir, "result.json");
const transcriptPath = path.join(runDir, "codex.jsonl");
const prompt = renderPrompt(job, mode);
fs.writeFileSync(promptPath, prompt);
if (dryRun) {
const dryResult = {
status: "planned",
repo: job.frontmatter.repo,
cluster_id: job.frontmatter.cluster_id,
mode,
summary: "dry run only; prompt rendered but Codex was not invoked",
actions: [],
prompt_path: path.relative(repoRoot(), promptPath),
};
fs.writeFileSync(resultPath, `${JSON.stringify(dryResult, null, 2)}\n`);
console.log(JSON.stringify(dryResult, null, 2));
process.exit(0);
}
const codexArgs = [
"exec",
"--cd",
repoRoot(),
"--model",
model,
"--output-schema",
path.join(repoRoot(), "schemas", "codex-result.schema.json"),
"--output-last-message",
resultPath,
"--json",
];
if (process.env.CLOWNFISH_CODEX_BYPASS === "1") {
codexArgs.push("--dangerously-bypass-approvals-and-sandbox");
} else {
codexArgs.push("--full-auto");
}
codexArgs.push("-");
const child = spawnSync("codex", codexArgs, {
cwd: repoRoot(),
input: prompt,
encoding: "utf8",
env: process.env,
});
fs.writeFileSync(transcriptPath, child.stdout ?? "");
if (child.stderr) fs.writeFileSync(path.join(runDir, "codex.stderr.log"), child.stderr);
if (child.status !== 0) {
console.error(child.stderr || child.stdout);
process.exit(child.status ?? 1);
}
console.log(`result: ${path.relative(repoRoot(), resultPath)}`);

34
scripts/validate-all.mjs Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { parseJob, repoRoot, validateJob } from "./lib.mjs";
const root = repoRoot();
const jobsDir = path.join(root, "jobs");
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
if (entry.isFile() && entry.name.endsWith(".md")) files.push(full);
}
}
walk(jobsDir);
let failed = false;
for (const file of files) {
const job = parseJob(file);
const errors = validateJob(job);
if (errors.length > 0) {
failed = true;
console.error(`invalid job: ${job.relativePath}`);
for (const error of errors) console.error(`- ${error}`);
} else {
console.log(`valid job: ${job.relativePath}`);
}
}
if (failed) process.exit(1);
console.log(`validated ${files.length} job(s)`);

21
scripts/validate-job.mjs Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env node
import { parseArgs, parseJob, validateJob } from "./lib.mjs";
const args = parseArgs(process.argv.slice(2));
const jobPath = args._[0];
if (!jobPath) {
console.error("usage: node scripts/validate-job.mjs <job.md>");
process.exit(2);
}
const job = parseJob(jobPath);
const errors = validateJob(job);
if (errors.length > 0) {
console.error(`invalid job: ${job.relativePath}`);
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}
console.log(`valid job: ${job.relativePath}`);