diff --git a/docs/agent-workflows/cursor-cloud-agents.md b/docs/agent-workflows/cursor-cloud-agents.md new file mode 100644 index 000000000..b81e3219f --- /dev/null +++ b/docs/agent-workflows/cursor-cloud-agents.md @@ -0,0 +1,165 @@ +# Cursor Cloud Agents — parallel workflow + +Operator playbook for running 15–20 [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 `) and **prove ownership disjointness** (`pnpm agent-batch overlap `). +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 ` — markdown table of PR + CI per agent. +5. Pilot at N=3 before scaling to 15–20. + +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/--` (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 :` 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: `: (#)`. 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:` 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 ` 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 `` marker) so the issue thread doesn't fill with stale tables. The script reads: + +- `gh pr list --repo tinyhumansai/openhuman --label batch: --json …` for PR + state. +- `gh pr checks ` 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 15–20 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=15–20 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. diff --git a/docs/agent-workflows/pilot-batch-example.json b/docs/agent-workflows/pilot-batch-example.json new file mode 100644 index 000000000..e22ad5206 --- /dev/null +++ b/docs/agent-workflows/pilot-batch-example.json @@ -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"] + } + ] +} diff --git a/package.json b/package.json index 7e4fa0967..526fe60de 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/agent-batch/__tests__/cli.test.mjs b/scripts/agent-batch/__tests__/cli.test.mjs new file mode 100644 index 000000000..e8368d014 --- /dev/null +++ b/scripts/agent-batch/__tests__/cli.test.mjs @@ -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, //); + 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); +}); diff --git a/scripts/agent-batch/__tests__/lib.test.mjs b/scripts/agent-batch/__tests__/lib.test.mjs new file mode 100644 index 000000000..749e77c84 --- /dev/null +++ b/scripts/agent-batch/__tests__/lib.test.mjs @@ -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"); +}); diff --git a/scripts/agent-batch/cli.mjs b/scripts/agent-batch/cli.mjs new file mode 100755 index 000000000..c52f614c3 --- /dev/null +++ b/scripts/agent-batch/cli.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Thin dispatcher so `pnpm agent-batch [...]` 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 [...]\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)); diff --git a/scripts/agent-batch/launch.mjs b/scripts/agent-batch/launch.mjs new file mode 100755 index 000000000..2f9976c66 --- /dev/null +++ b/scripts/agent-batch/launch.mjs @@ -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 [--agent ] [--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 \` for changed Rust + - pnpm test:coverage and pnpm test:rust — coverage on changed lines must be ≥ 80% + +PR rules: + - Title: \`: ${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 :${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 [--agent ] [--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(); diff --git a/scripts/agent-batch/lib.mjs b/scripts/agent-batch/lib.mjs new file mode 100644 index 000000000..a6cf482ff --- /dev/null +++ b/scripts/agent-batch/lib.mjs @@ -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/-- (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 }; +} diff --git a/scripts/agent-batch/overlap.mjs b/scripts/agent-batch/overlap.mjs new file mode 100755 index 000000000..53490f476 --- /dev/null +++ b/scripts/agent-batch/overlap.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Prove ownership disjointness in a batch spec. +// Usage: node scripts/agent-batch/overlap.mjs +// 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 \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(); diff --git a/scripts/agent-batch/status.mjs b/scripts/agent-batch/status.mjs new file mode 100755 index 000000000..a69239361 --- /dev/null +++ b/scripts/agent-batch/status.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// Render a markdown progress table for an agent batch. +// Usage: +// node scripts/agent-batch/status.mjs # query gh, print to stdout +// node scripts/agent-batch/status.mjs --post # also rewrite the tracking comment +// node scripts/agent-batch/status.mjs --fixture +// (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) => ``; + +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 [--post] [--fixture ]\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(); diff --git a/scripts/agent-batch/validate.mjs b/scripts/agent-batch/validate.mjs new file mode 100755 index 000000000..7f7fe9bcf --- /dev/null +++ b/scripts/agent-batch/validate.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Validate a Cursor Cloud Agents batch spec. +// Usage: node scripts/agent-batch/validate.mjs +// 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 \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();