feat: scaffold cluster worker orchestration
This commit is contained in:
commit
38c0d85f87
52
.agents/skills/projectclownfish-cluster-worker/SKILL.md
Normal file
52
.agents/skills/projectclownfish-cluster-worker/SKILL.md
Normal 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
|
||||
```
|
||||
@ -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
5
.env.example
Normal 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
83
.github/workflows/cluster-worker.yml
vendored
Normal 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
28
.github/workflows/validate.yml
vendored
Normal 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
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.projectclownfish/runs/*
|
||||
!.projectclownfish/runs/.gitkeep
|
||||
results/**/*.local.*
|
||||
*.log
|
||||
1
.projectclownfish/runs/.gitkeep
Normal file
1
.projectclownfish/runs/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
25
AGENTS.md
Normal file
25
AGENTS.md
Normal 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
75
README.md
Normal 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
55
docs/OPERATIONS.md
Normal 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`.
|
||||
26
instructions/closure-policy.md
Normal file
26
instructions/closure-policy.md
Normal 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
22
instructions/dedupe.md
Normal 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.
|
||||
25
instructions/merge-policy.md
Normal file
25
instructions/merge-policy.md
Normal 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.
|
||||
35
jobs/openclaw/cluster-example.md
Normal file
35
jobs/openclaw/cluster-example.md
Normal 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
16
package.json
Normal 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
24
prompts/execute.md
Normal 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
24
prompts/plan-only.md
Normal 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
13
prompts/review-result.md
Normal 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
29
prompts/worker-system.md
Normal 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`.
|
||||
55
schemas/codex-result.schema.json
Normal file
55
schemas/codex-result.schema.json
Normal 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
56
schemas/job.schema.json
Normal 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
49
scripts/dispatch-jobs.mjs
Executable 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
177
scripts/lib.mjs
Executable 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
20
scripts/render-prompt.mjs
Executable 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
107
scripts/run-worker.mjs
Executable 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
34
scripts/validate-all.mjs
Executable 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
21
scripts/validate-job.mjs
Executable 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}`);
|
||||
Loading…
Reference in New Issue
Block a user