Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
with:
node-version: 22
cache: pnpm
- run: sudo apt-get update && sudo apt-get install -y bubblewrap
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm build
Expand Down Expand Up @@ -68,6 +70,35 @@ jobs:
apps/web/test-results/
retention-days: 7

g1-local:
name: G1 real Git, runner and owner browser journey
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6.1.0
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- run: sudo apt-get update && sudo apt-get install -y bubblewrap
# Ubuntu's AppArmor default may deny unprivileged namespaces on ephemeral CI hosts.
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @commonforge-org/web exec playwright install --with-deps chromium
- run: pnpm test:g1:local
- if: always()
uses: actions/upload-artifact@v4
with:
name: g1-sanitized-results
path: |
.g1-artifacts/local-ci/report.json
.g1-artifacts/local-ci/recovery-report.json
apps/web/test-results/native/**/*.png
apps/web/test-results/native/**/source-acceptance.json
apps/web/test-results/native/**/recovery-acceptance.json
retention-days: 7

# Secrets are not readable in a job-level `if`, so detect them in a tiny job first.
# Fork PRs never receive secrets, so the live job is skipped there.
live-gate:
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ session.json

# run outputs of the live harness and demo scripts (commit a run deliberately with `git add -f`)
evidence/

# Task-local verification artifacts (may contain local coordinates)
.g1-artifacts/

# Reviewed, sanitized G1 evidence summaries are deliberately public; raw run evidence stays ignored.
!/docs/agent-native/evidence/
!/docs/agent-native/evidence/*.json
!/docs/agent-native/evidence/*.diff
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</picture>
</p>

<h3 align="center">Your clients build. Their agents help. You stay in control.</h3>
<h3 align="center">Your project’s understanding survives the agent.</h3>

<p align="center">
<a href="https://github.com/CommonForge-org/commonforge/actions/workflows/ci.yml"><img src="https://github.com/CommonForge-org/commonforge/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
Expand All @@ -14,10 +14,20 @@
<a href="https://developers.cloudflare.com/workers/"><img src="https://img.shields.io/badge/runs%20on-Cloudflare%20Workers%20%2B%20Artifacts-F38020?logo=cloudflare&logoColor=white" alt="Runs on Cloudflare Workers and Artifacts"></a>
</p>

CommonForge is a self-hosted workspace where an agency, its clients and their coding agents change
a client's website together. Clients ask for changes as tasks. Agents work on each task in an
isolated copy of the code. Every change is checked, previewed on a staging site and approved
before it reaches production, and every accepted version can be restored.
CommonForge is a public, self-hosted platform for people and coding agents to own software
and its project knowledge. The reference product now includes confirmed rules, behavioral scenarios,
pinned task context, independently verified changes, and durable correction history. The existing
agency workflow remains available while a separate private agency consumer is developed.

The **bounded G1 workflow is verified**, including real local execution, a fresh-agent handoff,
and an isolated Cloudflare release/expiry/recovery rehearsal. Run `pnpm dev:real-local` for the real Git/Worker/Node
stack, or `pnpm test:g1:local` for its bounded API and browser rehearsal. Knowledge, Outcomes and
Review are available to owners/developers. New web projects default to this workflow; existing
legacy projects retain explicit enablement.
See [execution status](docs/agent-native/execution-status.md) for measured gates and
[the local workflow](docs/agent-native/local-workflow.md) for setup and limits. Production native
release and complete Git bundle restoration have additional requirements; existing agency
production behavior is documented below.

For developers: it runs entirely on **Cloudflare Workers** and **Cloudflare Artifacts**. You
deploy it into your own Cloudflare account with one script, and agents work through a small CLI,
Expand Down
31 changes: 31 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,37 @@ The session is stored with mode 0600 in your config directory (`~/.config/common
Linux, `~/Library/Application Support/commonforge` on macOS, `%APPDATA%\commonforge` on
Windows; override with `CFORGE_CONFIG_DIR`).

## Project understanding handoff

For native projects, use the owner-issued task code instead of a broad user login:

```sh
cforge handoff --code '<one-use code>' --api-url https://forge.example.com
cforge checkout <taskId> ./work
cforge context <taskId>
# Edit and commit inside ./work, then:
cforge submit --workspace ./work
cforge propose <taskId>
cforge results <taskId>
```

Checkout carries protected `.commonforge/context.json` and `.md` with the exact rules, scenarios,
mandate and context digest. Submit refuses stale context. Propose may report pending integration;
repeat once the actual runner finishes. Results distinguish current applicable evidence from
superseded runs and failures; unavailable execution is not a successful behavioral check.

When an owner authorizes renewal, exchange the replacement into a new empty checkout:

```sh
cforge renew --code '<replacement code>' --out ./work-renewed
```

Renewal revokes prior task authority and pins new context. Preserve the old workspace as history;
it is not silently rewritten. See [renewal](../../docs/agent-native/renewal.md) and
[the reference workflow](../../docs/agent-native/local-workflow.md) for scope and limits.
These tools also have structured MCP equivalents. Task authority cannot confirm rules, approve,
accept, deploy, or access another task.

## Find a task

```sh
Expand Down
22 changes: 16 additions & 6 deletions apps/cli/src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* No token is ever written to disk, to git config, to workspace.json or to the output.
*/
import { mkdir, readdir, realpath, rm, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { MaterializationPlanSchema, type MaterializationPlan } from "@commonforge-org/core";
import { materialize, validatePlan, verifyMaterialization, type AssetObjectRequest } from "@commonforge-org/runner-protocol";
import { createNodeIo } from "@commonforge-org/runner-protocol/node";
Expand All @@ -21,6 +21,7 @@ import { int, parse, positional, type CommandContext } from "./command.js";
import { CliError, scrub } from "./context.js";
import type { CheckoutRecord } from "./config.js";
import { gitEnv, helperConfigEntries, helperConfigEnv, installHelperConfig, lfsConfigEntries } from "./git.js";
import { fetchNativeContext, writeNativeContext, type NativeContext } from "./native.js";
import { acquireLease, leaseTtl } from "./project.js";

const USAGE = "cforge checkout <taskId> <dir> [--lease-ttl <seconds>] [--wait <seconds>] [--json]";
Expand Down Expand Up @@ -80,9 +81,10 @@ export async function checkout(ctx: CommandContext, args: readonly string[]): Pr
let userApi: ApiClient | null = null;
let leaseInfo: Record<string, unknown>;
let leaseToken: string;
const envLease = await leaseBearer(ctx.io, ctx.store, taskId, apiUrl).then((b) => (b?.source === "env" ? b : null));
const nativeStored = await stat(join(ctx.store.dir, "native", `${taskId}.json`)).then(() => true, () => false);
const envLease = await leaseBearer(ctx.io, ctx.store, taskId, apiUrl).then(b => b?.source === "env" || nativeStored ? b : null);
if (envLease) {
leaseInfo = { source: "env" };
leaseInfo = { source: envLease.source };
leaseToken = envLease.token;
} else {
userApi = (await sessionClient(ctx.io, ctx.store, apiUrl)).api;
Expand Down Expand Up @@ -115,7 +117,10 @@ export async function checkout(ctx: CommandContext, args: readonly string[]): Pr
}
if (plan.workspaceRoot !== root) throw new CliError("invalid_plan", `the plan is for ${plan.workspaceRoot}, not ${root}`);
checkRemotes(plan);
await materializeAndVerify(ctx, plan, { apiUrl, taskId, root, leaseApi, leaseInfo, setRecord: (r) => void (recordPath = r) });
const task = await leaseApi.get<{ projectProtocolVersion?: number }>(`/api/tasks/${encodeURIComponent(taskId)}`);
if (nativeStored && task.projectProtocolVersion !== 1 || task.projectProtocolVersion !== undefined && task.projectProtocolVersion !== 1) throw new CliError("unsupported_version", "unsupported project protocol");
const nativeContext = task.projectProtocolVersion === 1 ? await fetchNativeContext(leaseApi, taskId) : null;
await materializeAndVerify(ctx, plan, { apiUrl, taskId, root, leaseApi, leaseInfo, setRecord: (r) => void (recordPath = r), ...(nativeContext ? { nativeContext } : {}) });
} catch (e) {
await cleanup();
throw e;
Expand All @@ -125,7 +130,7 @@ export async function checkout(ctx: CommandContext, args: readonly string[]): Pr
async function materializeAndVerify(
ctx: CommandContext,
plan: MaterializationPlan,
o: { apiUrl: string; taskId: string; root: string; leaseApi: ApiClient; leaseInfo: Record<string, unknown>; setRecord: (path: string) => void },
o: { apiUrl: string; taskId: string; root: string; leaseApi: ApiClient; leaseInfo: Record<string, unknown>; nativeContext?: NativeContext; setRecord: (path: string) => void },
): Promise<void> {
const { apiUrl, taskId, root } = o;
// The lease goes to an LFS URL only on the API's own origin (Tokens, folders phase 4).
Expand Down Expand Up @@ -213,7 +218,12 @@ async function materializeAndVerify(
...(m.taskWorkspaceId ? { taskWorkspaceId: m.taskWorkspaceId } : {}),
...(m.source.kind === "git" ? { kind: "git", sha: m.source.sha, remoteUrl: m.source.remoteUrl } : { kind: "assets", manifestId: m.source.manifest.manifestId }),
}));
ctx.out.result({ taskId, workspaceRoot: root, mounts, lease: o.leaseInfo, verified: true }, () =>
if(o.nativeContext){
const current=await fetchNativeContext(o.leaseApi,taskId);
if(current.attemptId!==o.nativeContext.attemptId||current.context.digest!==o.nativeContext.context.digest)throw new CliError('context_invalid','task authority changed during checkout');
await writeNativeContext(root,current);
}
ctx.out.result({ ...(o.nativeContext ? { projectProtocolVersion: 1, attemptId: o.nativeContext.attemptId, contextDigest: o.nativeContext.context.digest } : {}), taskId, workspaceRoot: root, mounts, lease: o.leaseInfo, verified: true }, () =>
[
`Checked out ${taskId} into ${root} (verified).`,
...mounts.map((m) => ` ${m.mountName.padEnd(20)} ${m.writable ? "read-write" : "read-only "} ${"sha" in m ? m.sha : m.manifestId}`),
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { createInterface as createLineReader } from "node:readline";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { handoff, contextCommand, nativeCommand, propose, nativeResults, renew } from "./native.js";
import { checkout } from "./checkout.js";
import { checkpoint } from "./checkpoint.js";
import type { CommandContext } from "./command.js";
Expand All @@ -26,6 +27,12 @@ Usage: cforge <command> [options]

login --email <addr> | --code <code> sign in (CLI code flow); the session is stored 0600 in the config dir
logout | whoami
renew --code <replacement> --out <new> renew task authority into a fresh verified checkout
handoff --code <code> exchange one-time task authority (no broad user session)
results <taskId> inspect this attempt’s proposal, evidence and decision history
propose <taskId> integrate submitted candidates, then freeze exact project review
context <taskId> [--out <workspace>] inspect and export pinned project understanding
native <taskId> --command <JSON> propose knowledge or an exact project change
project list | show <projectId> | tasks <projectId>
task create <projectId> --title <t> [--request <text>] [--write <mount>...] | lease <taskId> | release <taskId>
checkout <taskId> <dir> materialize a task workspace (verified); git uses the credential helper
Expand All @@ -51,6 +58,12 @@ const COMMANDS: Record<string, Handler> = {
project,
task,
checkout,
handoff,
renew,
context: contextCommand,
native: nativeCommand,
propose,
results: nativeResults,
status,
checkpoint,
submit,
Expand Down
47 changes: 45 additions & 2 deletions apps/cli/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export type InvokeCli = (argv: string[]) => Promise<{ code: number; stdout: stri
export const MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] as const;

const INSTRUCTIONS = `CommonForge tools for working on a website or app project an agency manages.
Start with sign_in if a tool answers login_required: the person gets a one-time code from the CommonForge app (Account page, or the task's Give to agent).
For agent-native projects start with exchange_task_handoff and inspect_project_context; preserve the confirmed correction and submit only within the mandate. Never use a broad user login to work around denied agent authority.
For legacy projects start with sign_in if a tool answers login_required: the person gets a one-time code from the CommonForge app (Account page, or the task's Give to agent).
To ask for a change: list_projects, then create_task with a title and the request in plain words. The project's owner decides which folders a task can change.
To do the work (needs local files and git): checkout_task into an empty folder, edit only inside it, then submit_task. save_progress keeps work safe without handing it in.`;

Expand Down Expand Up @@ -78,6 +79,48 @@ function absolute(path: string | undefined, what: string): string {
}

export const TOOLS: ToolDef[] = [
{
name: "renew_task_authority", title: "Renew into a fresh task checkout",
description: "Exchange the human-issued replacement renewal code and materialize a new empty workspace with fresh attempt/context. Prior mandate and token remain revoked. Reports success only after checkout and final authority validation; never extends an old pinned checkout.",
inputSchema: { type: "object", properties: { code: text("Human-issued one-use replacement task code."), folder: text("Absolute NEW empty workspace path."), api_url: text("CommonForge server origin.") }, required: ["code", "folder"], additionalProperties: false },
annotations: { idempotentHint: false, openWorldHint: true },
argv: a => ({ command: "renew", options: [opt("code", a.code ?? ""), opt("out", absolute(a.folder, "folder")), ...(a.api_url ? [opt("api-url", a.api_url)] : [])] }),
},
{
name: "inspect_attempt_results", title: "Inspect own attempt results",
description: "Read this task-scoped attempt's exact proposals, evidence, human decisions and accepted manifests. No access to other task attempts.",
inputSchema: { type: "object", properties: { task_id: text("Assigned task id.") }, required: ["task_id"], additionalProperties: false },
annotations: { readOnlyHint: true, openWorldHint: true },
argv: a => ({ command: "results", args: [a.task_id ?? ""] }),
},
{
name: "prepare_project_proposal", title: "Prepare exact project review",
description: "Integrate this task's submitted candidates. If queued, invoke again after completion to freeze the exact project envelope with current knowledge, source and runtime subjects. Does not approve or accept.",
inputSchema: { type: "object", properties: { task_id: text("Assigned task id."), inner_proposal_id: text("Optional exact integrated proposal id already returned by this task.") }, required: ["task_id"], additionalProperties: false },
annotations: { idempotentHint: true, openWorldHint: true },
argv: a => ({ command: "propose", options: a.inner_proposal_id ? [opt("inner-proposal", a.inner_proposal_id)] : [], args: [a.task_id ?? ""] }),
},
{
name: "exchange_task_handoff", title: "Use task-scoped authority",
description: "Exchange a one-time project handoff code for only this task. Does not create a broad user session.",
inputSchema: { type: "object", properties: { code: text("The org.project.cfh_ task handoff code."), api_url: text("The CommonForge server origin.") }, required: ["code"], additionalProperties: false },
annotations: { idempotentHint: false, openWorldHint: true },
argv: a => ({ command: "handoff", options: [opt("code", a.code ?? ""), ...(a.api_url ? [opt("api-url", a.api_url)] : [])] }),
},
{
name: "inspect_project_context", title: "Inspect pinned project understanding",
description: "Read exact confirmed rules, scenarios, assumptions and attempt authority; export protected JSON and Markdown context. Refuses stale knowledge.",
inputSchema: { type: "object", properties: { task_id: text("The assigned task id."), folder: text("Absolute workspace directory to receive .commonforge/context files.") }, required: ["task_id", "folder"], additionalProperties: false },
annotations: { idempotentHint: true, openWorldHint: true },
argv: a => ({ command: "context", options: [opt("out", absolute(a.folder, "folder"))], args: [a.task_id ?? ""] }),
},
{
name: "propose_project_record", title: "Propose knowledge or an exact change",
description: "Submit a structured draftKnowledge, draftScenario, or prepareProposal command with expectedGeneration and idempotencyKey. Agent scope cannot confirm, approve, accept or deploy.",
inputSchema: { type: "object", properties: { task_id: text("The assigned task id."), command: text("Strict JSON project command including record, expectedGeneration and idempotencyKey.") }, required: ["task_id", "command"], additionalProperties: false },
annotations: { idempotentHint: false, openWorldHint: true },
argv: a => ({ command: "native", options: [opt("command", a.command ?? "")], args: [a.task_id ?? ""] }),
},
{
name: "sign_in",
title: "Sign in to CommonForge",
Expand Down Expand Up @@ -264,7 +307,7 @@ export class McpServer {
} catch (e) {
return fail(e instanceof CliError ? e.code : "invalid_arguments", (e as Error).message);
}
if (tool.name === "sign_in") {
if (tool.name === "sign_in" || tool.name === "exchange_task_handoff" || tool.name === "renew_task_authority") {
const refused = await this.#refuseSwitch(cmd);
if (refused) return fail("signed_in_elsewhere", refused);
}
Expand Down
Loading
Loading