feat(agent-workflows): Cursor Cloud Agents parallel workflow (#1480) (#1759)

This commit is contained in:
CodeGhost21
2026-05-14 21:19:15 -07:00
committed by GitHub
parent 72a365c9ce
commit b2d45a9e85
11 changed files with 1190 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
# Cursor Cloud Agents — parallel workflow
Operator playbook for running 1520 [Cursor Cloud Agents](https://docs.cursor.com/agents/cloud) in parallel against OpenHuman. Companion to [`codex-pr-checklist.md`](codex-pr-checklist.md); the same merge gates apply.
This doc closes [`tinyhumansai/openhuman#1480`](https://github.com/tinyhumansai/openhuman/issues/1480).
## TL;DR
1. Write a **batch spec** — one JSON file naming N agents, their issues, branches, and owned paths.
2. **Validate** it (`pnpm agent-batch validate <spec>`) and **prove ownership disjointness** (`pnpm agent-batch overlap <spec>`).
3. Post **one launch comment** per agent (generated from the spec) into Cursor; each agent opens a branch and PR matching the spec.
4. Track progress with `pnpm agent-batch status <spec>` — markdown table of PR + CI per agent.
5. Pilot at N=3 before scaling to 1520.
Concretely, none of this is "Cursor magic" — it is a JSON contract + three small scripts that fail loudly if humans break it.
## Why a contract
Running N agents in parallel breaks in three ways:
- **Branch / PR collisions** — two agents picking the same branch name, or opening duplicate PRs against the same issue.
- **File collisions** — two agents editing the same module, producing conflicting merges.
- **Quality drift** — agents skipping format / typecheck / coverage and pushing red PRs.
The batch spec is the single source of truth that prevents the first two. The third is enforced by upstream CI ([`.github/workflows/coverage.yml`](../../.github/workflows/coverage.yml), [`.github/workflows/pr-quality.yml`](../../.github/workflows/pr-quality.yml), [`.github/workflows/test.yml`](../../.github/workflows/test.yml)) — agents do not get to opt out.
## Batch spec
A batch is a JSON file living under `docs/agent-workflows/batches/` (gitignored — see [Privacy](#secrets-posture)) or generated ad hoc. Shape:
```json
{
"batch_id": "pilot-2026-05-15",
"base_repo": "tinyhumansai/openhuman",
"base_branch": "main",
"tracking_issue": 1480,
"agents": [
{
"id": "a01",
"issue": 1234,
"title": "short slug for the branch name",
"branch": "cursor/a01-1234-short-slug",
"owned_paths": ["app/src/features/foo/", "src/openhuman/foo/"],
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
"labels": ["cursor-agent", "pilot"]
}
]
}
```
Field rules:
| Field | Required | Notes |
| ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `batch_id` | yes | Stable identifier — appears in PR bodies and the tracking comment. |
| `base_repo` | yes | Always `tinyhumansai/openhuman` unless explicitly delegated. |
| `base_branch` | yes | `main`. |
| `tracking_issue` | yes | One upstream issue per batch; that issue's comment thread is the dashboard (AC #6). |
| `agents[].id` | yes | Two-char + digits, e.g. `a01``a20`. Unique within batch. |
| `agents[].issue` | yes | Upstream issue number. **One issue per agent**, **one agent per issue.** |
| `agents[].branch` | yes | Must start with `cursor/` and contain the agent id and issue number. |
| `agents[].owned_paths` | yes | **Path prefixes** (directory ending in `/`) or exact files. **No globs.** Disjoint across agents — `overlap` enforces this. |
| `agents[].allowed_shared_paths` | no | Files the agent may touch even if another agent's prefix contains them (e.g. `docs/TEST-COVERAGE-MATRIX.md`, capability catalog). Best-effort only — overlap on these is **warned**, not blocked. |
| `agents[].labels` | no | PR labels. Always include `cursor-agent`. Add `docs` or `chore` to opt out of the soft `pr-quality` checks per [`pr-quality.yml`](../../.github/workflows/pr-quality.yml). |
### Why prefixes, not globs
A glob ownership model (`app/src/components/**`) is tempting but makes overlap detection ambiguous: does `app/src/**/*.test.ts` collide with `app/src/components/Foo/`? With prefixes the answer is mechanical: prefix containment in either direction = collision. CI files like `.github/workflows/*.yml`, the capability catalog at `src/openhuman/about_app/`, and similar shared surfaces should be assigned to **one** agent for the batch (or to no agent — the batch should be designed not to need them).
## Branch & PR conventions
- Branch off **`origin/main` (upstream)** at the moment the spec is written. Each agent fetches `origin/main` itself.
- Branch name format: `cursor/<id>-<issue>-<short-slug>` (e.g. `cursor/a04-1456-memory-namespace`). Enforced by `validate.mjs`.
- Push to the **forking remote the Cursor workspace is configured with**, not directly to `tinyhumansai/openhuman`. PRs are opened with `--head <fork-owner>:<branch>` against `tinyhumansai/openhuman:main`.
- **One PR per issue**, **one PR per branch**. If a retry is needed, update the existing PR; do not open a duplicate. Use the duplicate cleanup recipe in [`codex-pr-checklist.md`](codex-pr-checklist.md#duplicate-pr-cleanup).
- PR title: `<area>: <short imperative> (#<issue>)`. PR body **must** follow [`.github/PULL_REQUEST_TEMPLATE.md`](../../.github/PULL_REQUEST_TEMPLATE.md) verbatim, including the `## AI Authored PR Metadata` section.
- PR labels include at minimum `cursor-agent` and the batch id label `batch:<batch_id>` so the tracking comment can find them.
## Ownership boundaries
Disjointness rule: for any two agents A and B in the batch, no path prefix in `A.owned_paths` may be a prefix of any path in `B.owned_paths`, in either direction. `scripts/agent-batch/overlap.mjs` checks this and exits non-zero on a collision.
The rule applies to **prefixes**, not file existence. Two agents may own paths that don't exist yet (new modules), as long as no prefix contains another.
If two issues genuinely need the same module, **do not split them across agents**. Combine them into a single agent's scope or sequence the work.
## Quality gates
Agents run the same gates as any other PR. The launch comment instructs them explicitly — they do not get to drop any of these:
- **Format**: `pnpm --filter openhuman-app format:check`, `cargo fmt --manifest-path Cargo.toml --all --check`, and the Tauri shell equivalent if shell files changed.
- **Lint / typecheck**: `pnpm lint`, `pnpm typecheck`.
- **Tests (focused)**: targeted Vitest for changed files, focused Rust tests via `pnpm debug rust <filter>` for changed Rust.
- **Coverage**: agents must run `pnpm test:coverage` and `pnpm test:rust` locally and add tests for changed lines. The merge gate is `≥ 80% diff coverage`, enforced server-side by [`coverage.yml`](../../.github/workflows/coverage.yml). PRs below the threshold do not merge — agents that cannot reach the threshold must say so in the PR body, not paper over it.
- **PR checklist + coverage matrix**: [`pr-quality.yml`](../../.github/workflows/pr-quality.yml) checks the PR body and `docs/TEST-COVERAGE-MATRIX.md`. The `docs` and `chore` labels exempt a PR from these soft gates — use them only for PRs that genuinely change no behavior.
If the agent's environment cannot run a gate, the PR body must report the **exact command and error** under `### Validation Blocked`, not claim it passed. This is the same rule as the codex checklist.
## Secrets posture
Cursor Cloud Agents inherit env from the workspace. For OpenHuman, the cloud workspace MUST be configured with:
- **No** `STAGING_*` / `PRODUCTION_*` secrets.
- **No** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or any other LLM provider key used by the production agent runtime — agents do code work, not LLM calls into production providers.
- A scoped `GITHUB_TOKEN` with `contents:write` and `pull_requests:write` on the **fork** the workspace pushes to, plus `pull_requests:write` on `tinyhumansai/openhuman` for PR creation. **No `admin:*`, no `actions:write`, no `secrets:*`.**
- `OPENHUMAN_APP_ENV` MUST be unset or set to `dev`. Never `staging` or `production` — staging writes `~/.openhuman-staging/core.token` referenced by [`AGENTS.md`](../../AGENTS.md) "Cursor Cloud specific instructions" and that token is **per-developer**, not for shared cloud workspaces.
- `.env.local`, `app/.env.local`, and `core.token` files are gitignored and must not be committed.
The agent's own environment is the smallest blast-radius surface. Production credentials are out of scope for code-writing agents.
## Progress visibility
One tracking issue per batch (`tracking_issue` in the spec). The launch script posts a **single comment** on that issue containing a markdown table generated by `scripts/agent-batch/status.mjs`:
| Agent | Issue | Branch | PR | CI | Coverage | Status |
| ----- | ----- | ------------------- | ------- | --------- | -------- | ------------ |
| a01 | #1234 | `cursor/a01-1234-…` | `#1234` | ✓ green | 87% | merged |
| a02 | #1235 | `cursor/a02-1235-…` | `#1235` | × failing | — | needs review |
Re-running `status` rewrites the same comment (looked up by a `<!-- batch:<id> -->` marker) so the issue thread doesn't fill with stale tables. The script reads:
- `gh pr list --repo tinyhumansai/openhuman --label batch:<id> --json …` for PR + state.
- `gh pr checks <pr>` for CI rollup.
- The `diff-coverage.md` artifact from `coverage.yml`, if downloaded — otherwise coverage shows `—`.
No external dashboard — GitHub issues + labels + the table are the single pane of glass.
## Pilot-then-scale
Do not launch 1520 agents on day one.
1. **N=3 pilot.** Pick three issues that are small (~200 LOC each), in three distinct domains, no overlap. Run the full flow: spec → validate → overlap → launch → status → merge.
2. **N=5 expansion.** Once the N=3 pilot has 3 green PRs (merged or at-review), expand to 5 in one batch. Watch CI queue times and rate limits.
3. **N=1520 production.** Only after two clean expansion batches. At this scale, watch `gh api rate_limit`, GitHub Actions concurrency, and Cursor's per-workspace agent limit.
If a batch surfaces a class of failure (e.g. agents inventing API names, agents skipping `cargo fmt`), fix the **launch comment template** that all agents inherit — don't fix it case-by-case.
## Operator quickstart
```bash
# 1. Draft the spec
cp docs/agent-workflows/pilot-batch-example.json /tmp/my-batch.json
$EDITOR /tmp/my-batch.json
# 2. Validate shape + naming
pnpm agent-batch validate /tmp/my-batch.json
# 3. Prove ownership disjointness
pnpm agent-batch overlap /tmp/my-batch.json
# 4. Generate one launch comment per agent (paste into Cursor)
pnpm agent-batch launch /tmp/my-batch.json --print-only
# 5. After agents have pushed, refresh the tracking comment
pnpm agent-batch status /tmp/my-batch.json --post
```
All scripts are in [`scripts/agent-batch/`](../../scripts/agent-batch/). They are zero-dep Node, executable from the repo root, and exit non-zero on policy violations so they are CI-friendly.
## Reference
- [`pilot-batch-example.json`](pilot-batch-example.json) — canonical example with 3 disjoint agents.
- [`scripts/agent-batch/`](../../scripts/agent-batch/) — validate / overlap / launch / status implementations + `node:test` suites.
- [`codex-pr-checklist.md`](codex-pr-checklist.md) — parent checklist; per-agent rules inherit from it.
- [`AGENTS.md`](../../AGENTS.md) and [`CLAUDE.md`](../../CLAUDE.md) — repo-wide rules every agent must follow.
@@ -0,0 +1,35 @@
{
"batch_id": "example-pilot-2026-05-15",
"base_repo": "tinyhumansai/openhuman",
"base_branch": "main",
"tracking_issue": 1480,
"agents": [
{
"id": "a01",
"issue": 9001,
"title": "tighten memory namespace migration logging",
"branch": "cursor/a01-9001-memory-namespace-logging",
"owned_paths": ["src/openhuman/memory/"],
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
"labels": ["cursor-agent", "pilot", "batch:example-pilot-2026-05-15"]
},
{
"id": "a02",
"issue": 9002,
"title": "deduplicate cron RPC validation helpers",
"branch": "cursor/a02-9002-cron-rpc-dedupe",
"owned_paths": ["src/openhuman/cron/"],
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
"labels": ["cursor-agent", "pilot", "batch:example-pilot-2026-05-15"]
},
{
"id": "a03",
"issue": 9003,
"title": "settings panel a11y labels",
"branch": "cursor/a03-9003-settings-a11y-labels",
"owned_paths": ["app/src/components/settings/"],
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
"labels": ["cursor-agent", "pilot", "batch:example-pilot-2026-05-15"]
}
]
}
+2
View File
@@ -32,6 +32,8 @@
"reset": "bash scripts/shortcuts/ws-reset.sh",
"review": "bash scripts/shortcuts/review/cli.sh",
"work": "bash scripts/shortcuts/work/cli.sh",
"agent-batch": "node scripts/agent-batch/cli.mjs",
"agent-batch:test": "node --test scripts/agent-batch/__tests__/lib.test.mjs scripts/agent-batch/__tests__/cli.test.mjs",
"debug": "bash scripts/debug/cli.sh",
"test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1",
"rust:check": "pnpm --filter openhuman-app rust:check",
+126
View File
@@ -0,0 +1,126 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, ".."); // scripts/agent-batch
const VALIDATE = join(ROOT, "validate.mjs");
const OVERLAP = join(ROOT, "overlap.mjs");
const STATUS = join(ROOT, "status.mjs");
const EXAMPLE = resolve(
ROOT,
"..",
"..",
"docs",
"agent-workflows",
"pilot-batch-example.json",
);
function writeTempSpec(spec) {
const dir = mkdtempSync(join(tmpdir(), "agent-batch-test-"));
const path = join(dir, "spec.json");
writeFileSync(path, JSON.stringify(spec, null, 2));
return path;
}
function run(script, args, opts = {}) {
return spawnSync(process.execPath, [script, ...args], {
encoding: "utf8",
...opts,
});
}
test("validate.mjs exits 0 on the canonical example spec", () => {
const r = run(VALIDATE, [EXAMPLE]);
assert.strictEqual(r.status, 0, r.stderr);
assert.match(r.stdout, /ok: batch example-pilot-2026-05-15/);
});
test("validate.mjs exits 1 on a spec with malformed branch", () => {
const path = writeTempSpec({
batch_id: "pilot-test",
base_repo: "tinyhumansai/openhuman",
base_branch: "main",
tracking_issue: 1480,
agents: [
{
id: "a01",
issue: 100,
title: "t",
branch: "feature/foo",
owned_paths: ["src/openhuman/foo/"],
},
],
});
const r = run(VALIDATE, [path]);
assert.strictEqual(r.status, 1, r.stdout);
assert.match(r.stderr, /branch must match cursor/);
});
test("overlap.mjs exits 0 on disjoint example", () => {
const r = run(OVERLAP, [EXAMPLE]);
assert.strictEqual(r.status, 0, r.stderr);
assert.match(r.stdout, /disjoint paths/);
});
test("overlap.mjs exits 1 when two agents own the same prefix", () => {
const path = writeTempSpec({
batch_id: "pilot-test",
base_repo: "tinyhumansai/openhuman",
base_branch: "main",
tracking_issue: 1480,
agents: [
{
id: "a01",
issue: 100,
title: "t",
branch: "cursor/a01-100-x",
owned_paths: ["src/openhuman/foo/"],
},
{
id: "a02",
issue: 101,
title: "t",
branch: "cursor/a02-101-y",
owned_paths: ["src/openhuman/foo/inner/"],
},
],
});
const r = run(OVERLAP, [path]);
assert.strictEqual(r.status, 1, r.stdout);
assert.match(r.stderr, /ownership collision/);
assert.match(r.stderr, /a01 ↔ a02/);
});
test("status.mjs renders a markdown table from a fixture (no gh needed)", () => {
const fixture = [
{
headRefName: "cursor/a01-9001-memory-namespace-logging",
number: 5001,
url: "https://github.com/tinyhumansai/openhuman/pull/5001",
state: "OPEN",
statusCheckRollup: "SUCCESS",
},
{
headRefName: "cursor/a02-9002-cron-rpc-dedupe",
number: 5002,
url: "https://github.com/tinyhumansai/openhuman/pull/5002",
state: "OPEN",
statusCheckRollup: "FAILURE",
},
];
const dir = mkdtempSync(join(tmpdir(), "agent-batch-test-"));
const fixturePath = join(dir, "fixture.json");
writeFileSync(fixturePath, JSON.stringify(fixture));
const r = run(STATUS, [EXAMPLE, "--fixture", fixturePath]);
assert.strictEqual(r.status, 0, r.stderr);
assert.match(r.stdout, /<!-- batch:example-pilot-2026-05-15 -->/);
assert.match(r.stdout, /\| a01 \|.*\[#5001\]/);
assert.match(r.stdout, /\| a02 \|.*failing/);
// a03 has no PR in the fixture — must still appear with placeholders.
assert.match(r.stdout, /\| a03 \|.*\| — \| — \| no pr \|/i);
});
+175
View File
@@ -0,0 +1,175 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
validateSpec,
findOverlaps,
parseArgs,
SpecError,
BRANCH_RE,
} from "../lib.mjs";
function baseSpec(overrides = {}) {
return {
batch_id: "pilot-test",
base_repo: "tinyhumansai/openhuman",
base_branch: "main",
tracking_issue: 1480,
agents: [
{
id: "a01",
issue: 100,
title: "fix foo",
branch: "cursor/a01-100-fix-foo",
owned_paths: ["src/openhuman/foo/"],
},
{
id: "a02",
issue: 101,
title: "fix bar",
branch: "cursor/a02-101-fix-bar",
owned_paths: ["app/src/components/bar/"],
},
],
...overrides,
};
}
test("validateSpec accepts a well-formed spec", () => {
const spec = baseSpec();
assert.strictEqual(validateSpec(spec), spec);
});
test("validateSpec rejects wrong base_repo", () => {
const spec = baseSpec({ base_repo: "somefork/openhuman" });
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects wrong base_branch", () => {
const spec = baseSpec({ base_branch: "develop" });
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects non-positive tracking_issue", () => {
const spec = baseSpec({ tracking_issue: 0 });
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects empty agents array", () => {
const spec = baseSpec({ agents: [] });
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects batch larger than the hard cap", () => {
const agents = Array.from({ length: 26 }, (_, i) => {
const id = `a${String(i + 1).padStart(2, "0")}`;
return {
id,
issue: 1000 + i,
title: "t",
branch: `cursor/${id}-${1000 + i}-x`,
owned_paths: [`src/openhuman/dom${i}/`],
};
});
assert.throws(() => validateSpec(baseSpec({ agents })), SpecError);
});
test("validateSpec rejects malformed agent id", () => {
const spec = baseSpec();
spec.agents[0].id = "agent-one";
spec.agents[0].branch = "cursor/agent-one-100-fix-foo";
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects branch whose id segment does not match agent id", () => {
const spec = baseSpec();
spec.agents[0].branch = "cursor/a99-100-fix-foo";
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects branch whose issue segment does not match agent issue", () => {
const spec = baseSpec();
spec.agents[0].branch = "cursor/a01-999-fix-foo";
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects duplicate agent ids", () => {
const spec = baseSpec();
spec.agents[1].id = "a01";
spec.agents[1].branch = "cursor/a01-101-fix-bar";
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects duplicate issues", () => {
const spec = baseSpec();
spec.agents[1].issue = 100;
spec.agents[1].branch = "cursor/a02-100-fix-bar";
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects glob characters in owned_paths", () => {
const spec = baseSpec();
spec.agents[0].owned_paths = ["src/openhuman/**"];
assert.throws(() => validateSpec(spec), SpecError);
});
test("validateSpec rejects absolute paths", () => {
const spec = baseSpec();
spec.agents[0].owned_paths = ["/etc/passwd"];
assert.throws(() => validateSpec(spec), SpecError);
});
test("findOverlaps returns empty for disjoint prefixes", () => {
assert.deepStrictEqual(findOverlaps(baseSpec()), []);
});
test("findOverlaps detects identical paths", () => {
const spec = baseSpec();
spec.agents[1].owned_paths = ["src/openhuman/foo/"];
const collisions = findOverlaps(spec);
assert.strictEqual(collisions.length, 1);
assert.strictEqual(collisions[0].reason, "exact");
});
test("findOverlaps detects prefix containment in either direction", () => {
const spec = baseSpec();
spec.agents[1].owned_paths = ["src/openhuman/foo/sub/"];
const collisions = findOverlaps(spec);
assert.strictEqual(collisions.length, 1);
assert.strictEqual(collisions[0].reason, "prefix");
});
test("findOverlaps ignores paths listed in the other agent's allowed_shared_paths", () => {
const spec = baseSpec();
spec.agents[0].owned_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
spec.agents[1].owned_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
spec.agents[0].allowed_shared_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
spec.agents[1].allowed_shared_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
assert.deepStrictEqual(findOverlaps(spec), []);
});
test("BRANCH_RE accepts well-formed branches", () => {
assert.ok(BRANCH_RE.test("cursor/a01-1234-short-title"));
assert.ok(BRANCH_RE.test("cursor/a100-99-x"));
});
test("BRANCH_RE rejects malformed branches", () => {
assert.ok(!BRANCH_RE.test("feature/foo"));
assert.ok(!BRANCH_RE.test("cursor/a01-1234"));
assert.ok(!BRANCH_RE.test("cursor/a01-abc-foo"));
assert.ok(!BRANCH_RE.test("cursor/A01-1234-foo"));
});
test("parseArgs splits positional, --flag, --flag=v, --flag v", () => {
const r = parseArgs([
"spec.json",
"--post",
"--fixture=foo.json",
"--agent",
"a02",
]);
assert.deepStrictEqual(r.positional, ["spec.json"]);
assert.strictEqual(r.flags.post, true);
assert.strictEqual(r.flags.fixture, "foo.json");
assert.strictEqual(r.flags.agent, "a02");
});
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Thin dispatcher so `pnpm agent-batch <verb> <spec> [...]` works.
// Verbs map to sibling .mjs scripts.
import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const VERBS = new Set(["validate", "overlap", "launch", "status"]);
function usage(code = 2) {
process.stderr.write(
"usage: pnpm agent-batch <validate|overlap|launch|status> <spec.json> [...]\n",
);
process.exit(code);
}
const [verb, ...rest] = process.argv.slice(2);
if (!verb || verb === "--help" || verb === "-h") usage(verb ? 0 : 2);
if (!VERBS.has(verb)) {
process.stderr.write(`[agent-batch] unknown verb "${verb}"\n`);
usage();
}
const here = dirname(fileURLToPath(import.meta.url));
const child = spawn(process.execPath, [join(here, `${verb}.mjs`), ...rest], {
stdio: "inherit",
});
child.on("exit", (code) => process.exit(code ?? 1));
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
// Print one launch comment per agent in the batch.
// The operator pastes each into Cursor as the agent prompt.
// Usage:
// node scripts/agent-batch/launch.mjs <spec.json> [--agent <id>] [--print-only]
//
// `--print-only` is the default and the only mode currently supported; the
// flag exists so callers can be explicit and so a future `--post` mode (e.g.
// posting directly to a Cursor API) is unambiguous.
import {
loadSpec,
validateSpec,
findOverlaps,
SpecError,
parseArgs,
} from "./lib.mjs";
function renderComment(spec, agent) {
const owned = agent.owned_paths.map((p) => ` - \`${p}\``).join("\n");
const shared = (agent.allowed_shared_paths ?? [])
.map((p) => ` - \`${p}\``)
.join("\n");
return `@Cursor work issue #${agent.issue} for batch \`${spec.batch_id}\`.
Repo: ${spec.base_repo}
Base: latest \`${spec.base_branch}\` from upstream
Branch: \`${agent.branch}\`
PR target: ${spec.base_repo}:${spec.base_branch} (from your fork)
Owned paths (you MAY edit these and nothing else outside the shared-paths list):
${owned}
${shared ? `Allowed shared paths (touch only if strictly necessary):\n${shared}\n` : ""}
Required reading before editing:
- docs/agent-workflows/cursor-cloud-agents.md
- docs/agent-workflows/codex-pr-checklist.md
- .github/PULL_REQUEST_TEMPLATE.md
- AGENTS.md and CLAUDE.md
Pre-PR validation (run all, report any blocker exactly in the PR body):
- pnpm --filter openhuman-app format:check
- pnpm typecheck
- pnpm lint
- focused vitest for changed TS/React files
- cargo fmt --manifest-path Cargo.toml --all --check (if Rust changed)
- focused \`pnpm debug rust <filter>\` for changed Rust
- pnpm test:coverage and pnpm test:rust — coverage on changed lines must be ≥ 80%
PR rules:
- Title: \`<area>: ${agent.title} (#${agent.issue})\`
- Body MUST follow .github/PULL_REQUEST_TEMPLATE.md verbatim, including the AI Authored PR Metadata section.
- Add labels: ${(agent.labels ?? ["cursor-agent"]).map((l) => `\`${l}\``).join(", ")}
- One PR per issue. Do not open duplicates. If retrying, update the existing PR.
- Push to your fork; open the PR with \`--head <fork-owner>:${agent.branch}\` against ${spec.base_repo}:${spec.base_branch}.
- Close the issue with \`Closes #${agent.issue}\` in the Related section.
Tracking: progress for this batch is reported on issue #${spec.tracking_issue}.
`;
}
function main() {
const { positional, flags } = parseArgs(process.argv.slice(2));
const specPath = positional[0];
if (!specPath) {
process.stderr.write(
"usage: launch.mjs <spec.json> [--agent <id>] [--print-only]\n",
);
process.exit(2);
}
let spec;
try {
spec = validateSpec(loadSpec(specPath));
} catch (e) {
if (e instanceof SpecError) {
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
process.exit(1);
}
throw e;
}
const collisions = findOverlaps(spec);
if (collisions.length > 0) {
process.stderr.write(
`[agent-batch] refusing to launch: ${collisions.length} ownership collision(s) — run overlap.mjs\n`,
);
process.exit(1);
}
const onlyId = typeof flags.agent === "string" ? flags.agent : null;
const agents = onlyId
? spec.agents.filter((a) => a.id === onlyId)
: spec.agents;
if (onlyId && agents.length === 0) {
process.stderr.write(
`[agent-batch] no agent with id "${onlyId}" in spec\n`,
);
process.exit(1);
}
for (const agent of agents) {
process.stdout.write(`\n===== agent ${agent.id} (#${agent.issue}) =====\n`);
process.stdout.write(renderComment(spec, agent));
}
process.stdout.write("\n");
}
main();
+247
View File
@@ -0,0 +1,247 @@
// Shared helpers for the agent-batch tooling.
// Zero dependencies — Node 20+ stdlib only.
import { readFileSync } from "node:fs";
export const BRANCH_RE = /^cursor\/(a\d{2,3})-(\d+)-[a-z0-9][a-z0-9-]*$/;
const REQUIRED_TOP = [
"batch_id",
"base_repo",
"base_branch",
"tracking_issue",
"agents",
];
const REQUIRED_AGENT = ["id", "issue", "title", "branch", "owned_paths"];
export class SpecError extends Error {
constructor(message, path) {
super(path ? `${path}: ${message}` : message);
this.path = path;
}
}
export function loadSpec(filePath) {
let text;
try {
text = readFileSync(filePath, "utf8");
} catch (e) {
throw new SpecError(`cannot read ${filePath}: ${e.message}`);
}
let json;
try {
json = JSON.parse(text);
} catch (e) {
throw new SpecError(`invalid JSON in ${filePath}: ${e.message}`);
}
return json;
}
// Validate a parsed spec. Returns the spec on success, throws SpecError on
// any policy violation. The caller is responsible for printing.
export function validateSpec(spec) {
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
throw new SpecError("spec must be a JSON object");
}
for (const key of REQUIRED_TOP) {
if (!(key in spec))
throw new SpecError(`missing required top-level field "${key}"`);
}
if (
typeof spec.batch_id !== "string" ||
!/^[a-z0-9][a-z0-9-]*$/.test(spec.batch_id)
) {
throw new SpecError("batch_id must be a kebab-case slug", "batch_id");
}
if (spec.base_repo !== "tinyhumansai/openhuman") {
throw new SpecError(
`base_repo must be "tinyhumansai/openhuman" (got "${spec.base_repo}")`,
"base_repo",
);
}
if (spec.base_branch !== "main") {
throw new SpecError(
`base_branch must be "main" (got "${spec.base_branch}")`,
"base_branch",
);
}
if (!Number.isInteger(spec.tracking_issue) || spec.tracking_issue <= 0) {
throw new SpecError(
"tracking_issue must be a positive integer",
"tracking_issue",
);
}
if (!Array.isArray(spec.agents) || spec.agents.length === 0) {
throw new SpecError("agents must be a non-empty array", "agents");
}
if (spec.agents.length > 25) {
throw new SpecError(
`batch size ${spec.agents.length} exceeds hard cap of 25`,
"agents",
);
}
const seenId = new Set();
const seenIssue = new Set();
const seenBranch = new Set();
for (let i = 0; i < spec.agents.length; i++) {
const agent = spec.agents[i];
const at = `agents[${i}]`;
if (!agent || typeof agent !== "object" || Array.isArray(agent)) {
throw new SpecError("must be an object", at);
}
for (const key of REQUIRED_AGENT) {
if (!(key in agent))
throw new SpecError(`missing required field "${key}"`, at);
}
if (typeof agent.id !== "string" || !/^a\d{2,3}$/.test(agent.id)) {
throw new SpecError(
`id must match /^a\\d{2,3}$/ (got "${agent.id}")`,
`${at}.id`,
);
}
if (seenId.has(agent.id))
throw new SpecError(`duplicate id "${agent.id}"`, `${at}.id`);
seenId.add(agent.id);
if (!Number.isInteger(agent.issue) || agent.issue <= 0) {
throw new SpecError("issue must be a positive integer", `${at}.issue`);
}
if (seenIssue.has(agent.issue)) {
throw new SpecError(`duplicate issue #${agent.issue}`, `${at}.issue`);
}
seenIssue.add(agent.issue);
if (typeof agent.title !== "string" || agent.title.trim().length === 0) {
throw new SpecError("title must be a non-empty string", `${at}.title`);
}
const m = BRANCH_RE.exec(agent.branch);
if (!m) {
throw new SpecError(
`branch must match cursor/<id>-<issue>-<slug> (got "${agent.branch}")`,
`${at}.branch`,
);
}
if (m[1] !== agent.id) {
throw new SpecError(
`branch id segment "${m[1]}" does not match agent id "${agent.id}"`,
`${at}.branch`,
);
}
if (Number(m[2]) !== agent.issue) {
throw new SpecError(
`branch issue segment "${m[2]}" does not match agent issue ${agent.issue}`,
`${at}.branch`,
);
}
if (seenBranch.has(agent.branch)) {
throw new SpecError(`duplicate branch "${agent.branch}"`, `${at}.branch`);
}
seenBranch.add(agent.branch);
if (!Array.isArray(agent.owned_paths) || agent.owned_paths.length === 0) {
throw new SpecError(
"owned_paths must be a non-empty array",
`${at}.owned_paths`,
);
}
for (let j = 0; j < agent.owned_paths.length; j++) {
const p = agent.owned_paths[j];
if (typeof p !== "string" || p.length === 0) {
throw new SpecError(
"must be a non-empty string",
`${at}.owned_paths[${j}]`,
);
}
if (p.includes("*") || p.includes("?")) {
throw new SpecError(
`globs not allowed — use directory prefixes (got "${p}")`,
`${at}.owned_paths[${j}]`,
);
}
if (p.startsWith("/")) {
throw new SpecError(
`paths must be repo-relative, not absolute (got "${p}")`,
`${at}.owned_paths[${j}]`,
);
}
}
if ("allowed_shared_paths" in agent) {
if (!Array.isArray(agent.allowed_shared_paths)) {
throw new SpecError("must be an array", `${at}.allowed_shared_paths`);
}
}
if ("labels" in agent && !Array.isArray(agent.labels)) {
throw new SpecError("must be an array", `${at}.labels`);
}
}
return spec;
}
// Pure overlap detection: returns an array of collisions. Each entry is
// { a, b, pathA, pathB, reason } where reason is "prefix" (one contains the
// other) or "exact" (identical). Empty array = disjoint.
//
// Shared paths in allowed_shared_paths are NOT considered collisions — they
// are escape hatches for unavoidably-shared files like the coverage matrix.
export function findOverlaps(spec) {
const collisions = [];
for (let i = 0; i < spec.agents.length; i++) {
for (let j = i + 1; j < spec.agents.length; j++) {
const a = spec.agents[i];
const b = spec.agents[j];
const sharedA = new Set(a.allowed_shared_paths ?? []);
const sharedB = new Set(b.allowed_shared_paths ?? []);
for (const pa of a.owned_paths) {
if (sharedB.has(pa)) continue;
for (const pb of b.owned_paths) {
if (sharedA.has(pb)) continue;
const c = comparePaths(pa, pb);
if (c) {
collisions.push({
a: a.id,
b: b.id,
pathA: pa,
pathB: pb,
reason: c,
});
}
}
}
}
}
return collisions;
}
function comparePaths(p1, p2) {
if (p1 === p2) return "exact";
const n1 = p1.endsWith("/") ? p1 : p1 + "/";
const n2 = p2.endsWith("/") ? p2 : p2 + "/";
if (n1.startsWith(n2) || n2.startsWith(n1)) return "prefix";
return null;
}
// CLI helper: parse argv after the script name and return { positional, flags }.
// Recognizes `--flag` and `--flag=value` and `--flag value`.
export function parseArgs(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const eq = a.indexOf("=");
if (eq !== -1) {
flags[a.slice(2, eq)] = a.slice(eq + 1);
} else {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
flags[key] = true;
} else {
flags[key] = next;
i++;
}
}
} else {
positional.push(a);
}
}
return { positional, flags };
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Prove ownership disjointness in a batch spec.
// Usage: node scripts/agent-batch/overlap.mjs <spec.json>
// Exits 0 if all agents own disjoint path prefixes, 1 on any collision.
import {
loadSpec,
validateSpec,
findOverlaps,
SpecError,
parseArgs,
} from "./lib.mjs";
function main() {
const { positional } = parseArgs(process.argv.slice(2));
const specPath = positional[0];
if (!specPath) {
process.stderr.write("usage: overlap.mjs <spec.json>\n");
process.exit(2);
}
let spec;
try {
spec = validateSpec(loadSpec(specPath));
} catch (e) {
if (e instanceof SpecError) {
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
process.exit(1);
}
throw e;
}
const collisions = findOverlaps(spec);
if (collisions.length === 0) {
process.stdout.write(
`[agent-batch] ok: ${spec.agents.length} agent(s) own disjoint paths\n`,
);
process.exit(0);
}
process.stderr.write(
`[agent-batch] ${collisions.length} ownership collision(s):\n`,
);
for (const c of collisions) {
process.stderr.write(
` ${c.a}${c.b}: "${c.pathA}" vs "${c.pathB}" (${c.reason})\n`,
);
}
process.exit(1);
}
main();
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
// Render a markdown progress table for an agent batch.
// Usage:
// node scripts/agent-batch/status.mjs <spec.json> # query gh, print to stdout
// node scripts/agent-batch/status.mjs <spec.json> --post # also rewrite the tracking comment
// node scripts/agent-batch/status.mjs <spec.json> --fixture <file.json>
// (for tests — read PR data from a JSON fixture instead of shelling out to gh)
//
// Fixture shape (array of records keyed by branch):
// [{ "headRefName": "cursor/a01-…", "number": 1234, "url": "…",
// "state": "OPEN" | "MERGED" | "CLOSED",
// "statusCheckRollup": "SUCCESS" | "FAILURE" | "PENDING" | null }]
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { loadSpec, validateSpec, SpecError, parseArgs } from "./lib.mjs";
const COMMENT_MARKER = (id) => `<!-- batch:${id} -->`;
function ciCell(rollup) {
if (rollup === "SUCCESS") return "green";
if (rollup === "FAILURE") return "failing";
if (rollup === "PENDING") return "pending";
return "—";
}
function stateCell(state) {
if (!state) return "no PR";
return state.toLowerCase();
}
function renderTable(spec, prsByBranch) {
const lines = [];
lines.push(COMMENT_MARKER(spec.batch_id));
lines.push(`## Batch \`${spec.batch_id}\` — progress`);
lines.push("");
lines.push("| Agent | Issue | Branch | PR | CI | Status |");
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const agent of spec.agents) {
const pr = prsByBranch.get(agent.branch) ?? null;
const prCell = pr ? `[#${pr.number}](${pr.url})` : "—";
const ci = pr ? ciCell(pr.statusCheckRollup) : "—";
const state = stateCell(pr?.state);
lines.push(
`| ${agent.id} | [#${agent.issue}](https://github.com/${spec.base_repo}/issues/${agent.issue}) | \`${agent.branch}\` | ${prCell} | ${ci} | ${state} |`,
);
}
return lines.join("\n") + "\n";
}
function fetchPrsFromGh(spec) {
// One `gh pr list` call per batch — cheap and avoids N+1.
const args = [
"pr",
"list",
"--repo",
spec.base_repo,
"--state",
"all",
"--search",
`label:batch:${spec.batch_id}`,
"--json",
"headRefName,number,url,state,statusCheckRollup",
"--limit",
"100",
];
const r = spawnSync("gh", args, { encoding: "utf8" });
if (r.status !== 0) {
throw new Error(
`gh failed (${r.status}): ${r.stderr?.trim() || "unknown error"}`,
);
}
return JSON.parse(r.stdout || "[]");
}
function indexByBranch(prs) {
const m = new Map();
for (const pr of prs) {
// `statusCheckRollup` from gh is an array of contexts when populated; we
// only care about the worst-status rollup for the cell.
let rollup = null;
if (
Array.isArray(pr.statusCheckRollup) &&
pr.statusCheckRollup.length > 0
) {
const states = pr.statusCheckRollup
.map((c) => c.conclusion || c.state)
.filter(Boolean);
if (
states.some(
(s) => s === "FAILURE" || s === "CANCELLED" || s === "TIMED_OUT",
)
) {
rollup = "FAILURE";
} else if (states.every((s) => s === "SUCCESS")) {
rollup = "SUCCESS";
} else {
rollup = "PENDING";
}
} else if (typeof pr.statusCheckRollup === "string") {
// Fixture-friendly shape.
rollup = pr.statusCheckRollup;
}
m.set(pr.headRefName, {
number: pr.number,
url: pr.url,
state: pr.state,
statusCheckRollup: rollup,
});
}
return m;
}
function postOrUpdateTrackingComment(spec, body) {
const issue = spec.tracking_issue;
const list = spawnSync(
"gh",
[
"api",
`repos/${spec.base_repo}/issues/${issue}/comments`,
"--paginate",
"--jq",
// Emit one object per line. `gh api --paginate` runs the jq filter
// per-page; wrapping in `[...]` would produce concatenated array
// fragments that aren't valid JSON. NDJSON sidesteps that.
`.[] | select(.body | contains("${COMMENT_MARKER(spec.batch_id)}")) | {id, html_url}`,
],
{ encoding: "utf8" },
);
if (list.status !== 0) {
throw new Error(
`gh api failed (${list.status}): ${list.stderr?.trim() || "unknown error"}`,
);
}
const existing = (list.stdout || "")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line));
if (existing.length === 0) {
const r = spawnSync(
"gh",
[
"issue",
"comment",
String(issue),
"--repo",
spec.base_repo,
"--body-file",
"-",
],
{ encoding: "utf8", input: body },
);
if (r.status !== 0) {
throw new Error(`gh issue comment failed: ${r.stderr?.trim() || ""}`);
}
process.stdout.write(
`[agent-batch] posted new tracking comment on #${issue}\n`,
);
} else {
const id = existing[0].id;
// Pass the comment body via stdin (-F body=@-) rather than a command-line
// arg. Long markdown tables can grow large and -f body=${body} risks
// hitting OS argv length limits (ARG_MAX).
const r = spawnSync(
"gh",
[
"api",
"--method",
"PATCH",
`repos/${spec.base_repo}/issues/comments/${id}`,
"-F",
"body=@-",
],
{ encoding: "utf8", input: body },
);
if (r.status !== 0) {
throw new Error(`gh api PATCH failed: ${r.stderr?.trim() || ""}`);
}
process.stdout.write(
`[agent-batch] updated tracking comment ${existing[0].html_url}\n`,
);
}
}
function main() {
const { positional, flags } = parseArgs(process.argv.slice(2));
const specPath = positional[0];
if (!specPath) {
process.stderr.write(
"usage: status.mjs <spec.json> [--post] [--fixture <file>]\n",
);
process.exit(2);
}
let spec;
try {
spec = validateSpec(loadSpec(specPath));
} catch (e) {
if (e instanceof SpecError) {
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
process.exit(1);
}
throw e;
}
let prs;
if (typeof flags.fixture === "string") {
prs = JSON.parse(readFileSync(flags.fixture, "utf8"));
} else {
prs = fetchPrsFromGh(spec);
}
const body = renderTable(spec, indexByBranch(prs));
process.stdout.write(body);
if (flags.post) {
postOrUpdateTrackingComment(spec, body);
}
}
main();
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
// Validate a Cursor Cloud Agents batch spec.
// Usage: node scripts/agent-batch/validate.mjs <spec.json>
// Exits 0 on success, 1 on any policy violation. Prints a one-line summary
// on success and a structured error otherwise.
import { loadSpec, validateSpec, SpecError, parseArgs } from "./lib.mjs";
function main() {
const { positional } = parseArgs(process.argv.slice(2));
const specPath = positional[0];
if (!specPath) {
process.stderr.write("usage: validate.mjs <spec.json>\n");
process.exit(2);
}
try {
const spec = validateSpec(loadSpec(specPath));
process.stdout.write(
`[agent-batch] ok: batch ${spec.batch_id} with ${spec.agents.length} agent(s)\n`,
);
process.exit(0);
} catch (e) {
if (e instanceof SpecError) {
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
process.exit(1);
}
process.stderr.write(
`[agent-batch] unexpected error: ${e.stack || e.message}\n`,
);
process.exit(2);
}
}
main();