@miraland-labs/conduit-bridge 0.9.11 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
4
4
 
5
- **Package version:** `0.9.11` — heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. Cursor assignments with `external_network` allow-list `WebFetch(*)` and pass `--force` (required for headless fetch); research-only networked runs also deny `Shell(*)` / `Write(**)`. Attempt worktrees fetch/start from the initiative base when the source tip is behind or history was rewritten, without resetting the source checkout. Also includes **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, and optional `--ensure-checkout`. Protocol 1 heartbeats remain compatible for presence but cannot receive work.
5
+ **Package version:** `0.10.0` — **ExecutionClass** permission policy (`observe` | `observe_network` | `publish_artifact` | `verify` | `mutate_repo`) compiled once from the assignment contract and projected into every driver, so prompts match enforcement without a growing publish-command allow-list. Artifact evidence digests still normalize bare 64-hex to `sha256:<hex>`. New **Experimental Pi** lane (`pi` from `@earendil-works/pi-coding-agent`, local fuel); `observe_network` is refused on Pi (no fetch-without-bash). Heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. Attempt worktrees fetch/start from the initiative base when the source tip is behind or history was rewritten, without resetting the source checkout. Also includes **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, and optional `--ensure-checkout`. Protocol 1 heartbeats remain compatible for presence but cannot receive work.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -12,6 +12,7 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
12
12
  - **Codex** — `codex`
13
13
  - **OpenCode** — `opencode`
14
14
  - **Cursor Agent** — `agent` (local fuel)
15
+ - **Pi** — `pi` (local fuel, Experimental; pin `@earendil-works/pi-coding-agent`)
15
16
  - **Kiro CLI** — `kiro-cli` (local fuel)
16
17
  - **Antigravity** — `agy` (local fuel)
17
18
 
@@ -77,13 +78,16 @@ repository delivery with PR/evidence and a successful rework cycle. All other la
77
78
 
78
79
  ## Grant enforcement
79
80
 
81
+ Assignments compile to an **ExecutionClass**, then each driver projects that class:
82
+
80
83
  | Driver | Fuel | Mechanism |
81
84
  | --- | --- | --- |
82
- | `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per grant |
83
- | `codex` | pump or local | sandbox `read-only` / `workspace-write` |
84
- | `opencode` | pump or local | per-run `opencode.json` bash deny list |
85
- | `cursor` | local only | per-run `.cursor/cli.json` permissions |
85
+ | `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per class |
86
+ | `codex` | pump or local | sandbox `read-only` / `workspace-write` (+ network when class requires) |
87
+ | `opencode` | pump or local | plan/build agent + per-run bash deny list |
88
+ | `cursor` | local only | per-run `.cursor/cli.json` + `--force` when class needs open tools |
89
+ | `pi` | local only | `--tools` class map (`observe_network` refused) |
86
90
  | `kiro` | local only | `--trust-tools` allowlist |
87
91
  | `antigravity` | local only | `-p --mode plan` / `accept-edits` |
88
92
 
89
- Cursor/Kiro/Antigravity require local fuel (`ops install` sets this for you).
93
+ Cursor/Pi/Kiro/Antigravity require local fuel (`ops install` sets this for you).
package/dist/cli.js CHANGED
@@ -131,8 +131,8 @@ async function join() {
131
131
  console.log(`Proposed roles: ${capabilities.join(", ")}`);
132
132
  console.log(`Assignments at once: ${leaseCapacity}`);
133
133
  console.log(`Fuel source: ${plannedFuel === "local" ? "local subscription" : "Conduit pump"}`
134
- + (!fuelSource && plannedFuel === "local" ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
135
- console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/kiro/antigravity require local fuel).`);
134
+ + (!fuelSource && plannedFuel === "local" ? " (auto: only local-fuel agents detected)" : ""));
135
+ console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/pi/kiro/antigravity require local fuel).`);
136
136
  console.log("Use --machine <name> to override the computer label.\n");
137
137
  const response = await fetch(`${baseUrl}/runner/v1/connect/requests`, {
138
138
  method: "POST",
@@ -246,10 +246,10 @@ async function finishConnection(baseUrl, data, fuelSource) {
246
246
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
247
247
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
248
248
  console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
249
- + (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
249
+ + (fuelAutoLocal ? " (auto: only local-fuel agents detected)" : ""));
250
250
  const localOnly = localFuelOnlyClients(detected);
251
251
  if (config.fuelSource === "conduit" && localOnly.length) {
252
- console.log(`Note: ${localOnly.join(", ")} require local fuel per lane (set automatically for cursor/kiro/antigravity).`);
252
+ console.log(`Note: ${localOnly.join(", ")} require local fuel per lane (set automatically for cursor/pi/kiro/antigravity).`);
253
253
  }
254
254
  console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
255
255
  console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
package/dist/detect.js CHANGED
@@ -7,6 +7,7 @@ const CLIENTS = [
7
7
  { command: "opencode", label: "OpenCode" },
8
8
  { command: "agent", label: "Cursor Agent" },
9
9
  { command: "cursor", label: "Cursor" },
10
+ { command: "pi", label: "Pi" },
10
11
  { command: "kiro-cli", label: "Kiro CLI" },
11
12
  { command: "agy", label: "Antigravity" },
12
13
  { command: "code", label: "Visual Studio Code" },
@@ -14,13 +15,13 @@ const CLIENTS = [
14
15
  /** Agents Conduit /v1 pump fuel can drive today. */
15
16
  const PUMP_CAPABLE_LABELS = new Set(["Claude Code", "Codex CLI", "OpenCode"]);
16
17
  /** Agents that only accept vendor login — Bridge refuses Conduit pump for these drivers. */
17
- const LOCAL_FUEL_ONLY_LABELS = new Set(["Cursor Agent", "Cursor", "Kiro CLI", "Antigravity"]);
18
+ const LOCAL_FUEL_ONLY_LABELS = new Set(["Cursor Agent", "Cursor", "Pi", "Kiro CLI", "Antigravity"]);
18
19
  export function localFuelOnlyClients(detected) {
19
20
  return detected.filter((label) => LOCAL_FUEL_ONLY_LABELS.has(label));
20
21
  }
21
22
  /**
22
23
  * Default join fuel when `--fuel` is omitted.
23
- * Pump when any pump-capable client is present; local when only Cursor/Kiro/Antigravity are.
24
+ * Pump when any pump-capable client is present; local when only local-fuel agents are.
24
25
  */
25
26
  export function suggestFuelSource(detected) {
26
27
  const hasPumpCapable = detected.some((label) => PUMP_CAPABLE_LABELS.has(label));
package/dist/driver.js CHANGED
@@ -3,6 +3,8 @@ import { existsSync } from "node:fs";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
+ import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectKiroTools, projectPi, resolveExecutionClass, } from "./execution-class.js";
7
+ export { branchCreateCommands, deniedCommands, isBoundedVerificationCommand, prCreateCommands, resolveExecutionClass, } from "./execution-class.js";
6
8
  function deliveryLanguageRule(language) {
7
9
  if (language === "zh") {
8
10
  return "- Write every human-facing delivery packet string (outcome, changes, verification, assumptions, risks, limitations, criterion text) in Chinese (Simplified). Keep JSON keys and enum token values in English.";
@@ -45,39 +47,20 @@ export function tierForRisk(risk) {
45
47
  export function isSafeModelName(value) {
46
48
  return /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/.test(value);
47
49
  }
48
- /**
49
- * Shell commands each grant authorizes — one source of truth mapped per driver
50
- * (`Bash(cmd:*)` for Claude, `Shell(cmd)` for Cursor) so the two cannot drift.
51
- * rev-parse/log are read-only but required: the delivery contract reports the
52
- * committed head sha, so a committing agent must be able to read it.
53
- */
54
- export const branchCreateCommands = ["git status", "git diff", "git add", "git commit", "git branch", "git switch", "git rev-parse", "git log"];
55
- export const prCreateCommands = ["git push", "gh pr create"];
56
- /** Grants → Claude Code tool allow-list. Privileged grants are never mapped. */
50
+ /** Grants → Claude Code tool allow-list (mutate_repo mapping). Privileged grants are never mapped. */
57
51
  export function claudeToolsForGrants(grants, verificationCommands = []) {
58
- const tools = [];
59
- if (grants.includes("repo_read"))
60
- tools.push("Read", "Glob", "Grep");
61
- if (grants.includes("repo_write"))
62
- tools.push("Edit", "Write");
63
- if (grants.includes("test_run")) {
64
- tools.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`));
65
- }
66
- if (grants.includes("branch_create"))
67
- tools.push(...branchCreateCommands.map((command) => `Bash(${command}:*)`));
68
- if (grants.includes("pr_create"))
69
- tools.push(...prCreateCommands.map((command) => `Bash(${command}:*)`));
70
- return tools;
52
+ return projectClaude("mutate_repo", { grants, verificationCommands }).allowedTools;
71
53
  }
72
- /** Commands no driver may ever run, regardless of grants — one source for every driver's deny list. */
73
- export const deniedCommands = [
74
- "git merge", "git rebase", "git reset --hard", "git push --force",
75
- "npm run deploy", "pnpm deploy", "yarn deploy", "wrangler deploy",
76
- "kubectl", "terraform apply",
77
- ];
78
54
  export const claudeDeniedTools = deniedCommands.map((command) => `Bash(${command}:*)`);
79
- function isBoundedVerificationCommand(command) {
80
- return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check))$/.test(command);
55
+ function resolveRunClass(input) {
56
+ if (input.executionClass)
57
+ return input.executionClass;
58
+ return resolveExecutionClass({
59
+ grants: input.grants,
60
+ capabilities: input.capabilities,
61
+ deliverable: input.deliverable,
62
+ workRole: input.workRole,
63
+ });
81
64
  }
82
65
  export function buildAssignmentPrompt(context) {
83
66
  const packageContext = context.workPackage;
@@ -90,9 +73,6 @@ export function buildAssignmentPrompt(context) {
90
73
  const changeScope = packageContext?.change_scope?.length ? packageContext.change_scope : spec.change_scope;
91
74
  const evidence = packageContext?.required_evidence?.length ? packageContext.required_evidence : spec.required_evidence;
92
75
  const rework = packageContext?.rework_feedback ?? context.reworkFeedback;
93
- const labeledArtifact = (packageContext?.deliverable ?? spec.deliverable) === "artifact";
94
- // Research is discovery in the delivery report — never a published content-pack artifact.
95
- const artifact = labeledArtifact && role !== "research";
96
76
  const lines = [
97
77
  `You are completing one delegated Conduit assignment as the ${role} role. Work only inside the current workspace.`,
98
78
  "",
@@ -135,48 +115,22 @@ export function buildAssignmentPrompt(context) {
135
115
  lines.push("", `REWORK FEEDBACK — an independent review returned this delivery; address every point\n${rework}`);
136
116
  const mustCommit = context.grants.includes("repo_write") && context.grants.includes("branch_create");
137
117
  const mustOpenPr = mustCommit && context.grants.includes("pr_create") && Boolean(changeScope?.length);
138
- // Exactly what the driver's permission contract will allow, derived from the same source, so the
139
- // prompt cannot drift from the enforcement.
140
- const runnableCommands = [
141
- ...(context.grants.includes("test_run") ? (context.verificationCommands ?? []).filter(isBoundedVerificationCommand) : []),
142
- ...(context.grants.includes("branch_create") ? branchCreateCommands : []),
143
- ...(context.grants.includes("pr_create") ? prCreateCommands : []),
144
- ];
145
- const hasExternalNetwork = (spec.required_capabilities ?? []).includes("external_network");
118
+ const executionClass = context.executionClass ?? resolveExecutionClass({
119
+ grants: context.grants,
120
+ capabilities: spec.required_capabilities,
121
+ deliverable: packageContext?.deliverable ?? spec.deliverable,
122
+ workRole: role,
123
+ });
146
124
  const languageRule = deliveryLanguageRule(packageContext?.working_language ?? spec.working_language);
147
- lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(languageRule ? [languageRule] : []),
148
- // Name the executable commands. The shell allow-list is exact, so an agent that guesses at
149
- // `bash scripts/validate-pack.sh`, `python3 ...` or `echo` gets every call rejected and concludes
150
- // it has no shell at all — observed twice on live Daily Signal runs, both times while the
151
- // permitted commands sat unused.
152
- ...(runnableCommands.length
153
- ? [
154
- `- These are the ONLY shell commands you may run: ${runnableCommands.join("; ")}. Anything else is rejected — do not try variations, wrappers, or \`echo\`. Run the relevant ones and report their real output.`,
155
- ]
156
- : ["- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown."]),
157
- // Mirror .cursor/cli.json WebFetch(*) — without naming it, agents fall back to WebSearch (not
158
- // allow-listable) or invent status/title after "User Rejected" on unlisted fetches.
159
- ...(hasExternalNetwork
160
- ? ["- This assignment includes external_network: WebFetch is allow-listed. Use WebFetch for live HTTP status and page content; do not invent origin responses. Prefer WebFetch over WebSearch."]
161
- : []), ...(mustCommit
162
- ? [
163
- "- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) — do not create a new branch — and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected.",
164
- ...(mustOpenPr
165
- ? [
166
- "- This assignment includes pr_create: after committing, `git push -u origin HEAD` and `gh pr create` against the repository default branch, then set pull_request_url to that PR URL. Conduit merge-on-accept lands the PR when the owner Accepts — a head_commit without a PR leaves main unchanged and breaks later packages.",
167
- ]
168
- : ["- You may commit locally; opening a pull request is outside this assignment's grants."]),
169
- ]
170
- : artifact
171
- ? [
172
- // repo_write without branch_create: the agent may write, but the output is not repository
173
- // content. `.conduit/` is already in .git/info/exclude, so this path is ignored by git and
174
- // `git status` stays clean — which is the point for a content pack, and mandatory for
175
- // anything binary.
176
- "- This assignment delivers an artifact, not repository content. Write your output under `.conduit/artifacts/` — that path is excluded from git, so do not commit anything and do not report a head_commit.",
177
- "- Publish the artifact to its destination and report each output in your evidence with the published URL and a sha256 digest. An artifact nobody can fetch is not a delivery.",
178
- ]
179
- : ["- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead."]), "- Never merge, deploy, force-push, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
125
+ const classRules = executionClassPromptRules({
126
+ executionClass,
127
+ grants: context.grants,
128
+ verificationCommands: context.verificationCommands,
129
+ capabilities: spec.required_capabilities,
130
+ mustCommit,
131
+ mustOpenPr,
132
+ });
133
+ lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(languageRule ? [languageRule] : []), ...classRules, "- Never merge, deploy, force-push, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
180
134
  // A met claim with nothing backing it is rejected server-side ("Met acceptance criteria require
181
135
  // mapped evidence"). Observed live: six criteria marked met, evidence mapped to four, whole
182
136
  // delivery lost. Say it here rather than let the agent discover it by failing.
@@ -222,6 +176,19 @@ export function parseAgentReport(text, acceptance) {
222
176
  }
223
177
  return { ...parsed.data, acceptance_results: parsed.data.acceptance_results.map((item) => ({ ...item, evidence_artifact_ids: [] })) };
224
178
  }
179
+ /**
180
+ * Artifact publication receipts require `sha256:<64 hex>`. Agents often paste bare `shasum` output;
181
+ * accept that and canonicalize. Already-prefixed digests pass through (case-normalized hex).
182
+ */
183
+ export function normalizeEvidenceDigest(digest) {
184
+ const trimmed = digest.trim();
185
+ const prefixed = /^sha256:([0-9a-f]{64})$/i.exec(trimmed);
186
+ if (prefixed)
187
+ return `sha256:${prefixed[1].toLowerCase()}`;
188
+ if (/^[0-9a-f]{64}$/i.test(trimmed))
189
+ return `sha256:${trimmed.toLowerCase()}`;
190
+ return trimmed;
191
+ }
225
192
  /**
226
193
  * Tolerate benign agent-output noise before strict validation: drop empty/whitespace-only entries from
227
194
  * string arrays and remove empty optional scalars, so a single stray "" does not fail an otherwise-valid
@@ -251,6 +218,8 @@ function sanitizeReportShape(raw) {
251
218
  // Agents emit null/"" for optional fields they have no value for — treat as absent.
252
219
  if ("digest" in evidence && (typeof evidence.digest !== "string" || evidence.digest.trim() === ""))
253
220
  delete evidence.digest;
221
+ else if (typeof evidence.digest === "string")
222
+ evidence.digest = normalizeEvidenceDigest(evidence.digest);
254
223
  // Drop non-URL / empty uris so an optional bad uri doesn't fail the whole report.
255
224
  if ("uri" in evidence && (typeof evidence.uri !== "string" || !z.string().url().max(4_000).safeParse(evidence.uri).success))
256
225
  delete evidence.uri;
@@ -305,10 +274,14 @@ export const claudeCodeDriver = {
305
274
  error: "machine set to local fuel but claude-code has no login (ANTHROPIC_* or ~/.claude)",
306
275
  };
307
276
  }
308
- const tools = claudeToolsForGrants(input.grants, input.verificationCommands);
277
+ const executionClass = resolveRunClass(input);
278
+ const projected = projectClaude(executionClass, {
279
+ grants: input.grants,
280
+ verificationCommands: input.verificationCommands,
281
+ });
309
282
  // Fail closed: omitting --allowedTools would leave only the deny-list and
310
283
  // broaden the agent beyond the grant contract (privileged grants map to none).
311
- if (!tools.length) {
284
+ if (!projected.allowedTools.length) {
312
285
  return {
313
286
  status: "failed",
314
287
  resultText: null,
@@ -321,9 +294,9 @@ export const claudeCodeDriver = {
321
294
  args.push("--model", input.model);
322
295
  if (input.resumeSessionId)
323
296
  args.push("--resume", input.resumeSessionId);
324
- args.push("--allowedTools", tools.join(","));
325
- args.push("--disallowedTools", claudeDeniedTools.join(","));
326
- if (input.grants.includes("repo_write"))
297
+ args.push("--allowedTools", projected.allowedTools.join(","));
298
+ args.push("--disallowedTools", projected.disallowedTools.join(","));
299
+ if (projected.acceptEdits)
327
300
  args.push("--permission-mode", "acceptEdits");
328
301
  const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
329
302
  let message = null;
@@ -351,7 +324,7 @@ export function codexSandboxForGrants(grants) {
351
324
  }
352
325
  /**
353
326
  * Codex exec arguments. The sandbox is the enforcement boundary: network stays
354
- * off inside workspace-write unless the canonical contract requires external_network.
327
+ * off inside workspace-write unless the class/capabilities require it.
355
328
  */
356
329
  export function codexExecArgs(input, sandbox) {
357
330
  // exec is non-interactive by design (no approval flag), and `exec resume`
@@ -361,7 +334,9 @@ export function codexExecArgs(input, sandbox) {
361
334
  : ["exec", "--json", "--sandbox", sandbox];
362
335
  if (input.model)
363
336
  args.push("-m", input.model);
364
- if (sandbox === "workspace-write" && input.capabilities?.includes("external_network")) {
337
+ const networkAccess = input.networkAccess
338
+ ?? (sandbox === "workspace-write" && input.capabilities?.includes("external_network"));
339
+ if (sandbox === "workspace-write" && networkAccess) {
365
340
  args.push("-c", "sandbox_workspace_write.network_access=true");
366
341
  }
367
342
  if (input.resumeSessionId)
@@ -416,8 +391,7 @@ export const codexDriver = {
416
391
  error: "machine set to local fuel but codex has no login (OPENAI_API_KEY / CODEX_API_KEY / ~/.codex)",
417
392
  };
418
393
  }
419
- const sandbox = codexSandboxForGrants(input.grants);
420
- if (!sandbox) {
394
+ if (!hasMappedRepoAccess(input.grants)) {
421
395
  return {
422
396
  status: "failed",
423
397
  resultText: null,
@@ -425,8 +399,10 @@ export const codexDriver = {
425
399
  error: "No Bridge-mapped Codex sandbox for active grants; refusing to start agent",
426
400
  };
427
401
  }
402
+ const executionClass = resolveRunClass(input);
403
+ const projected = projectCodex(executionClass, { capabilities: input.capabilities });
428
404
  // Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
429
- const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs(input, sandbox), input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
405
+ const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
430
406
  const parsed = parseCodexJsonl(stdout);
431
407
  const resultText = parsed.resultText ?? (stdout || null);
432
408
  if (code !== 0) {
@@ -436,38 +412,19 @@ export const codexDriver = {
436
412
  },
437
413
  };
438
414
  /**
439
- * Grants + capabilities → Cursor CLI permissions (project .cursor/cli.json).
415
+ * Grants + capabilities → Cursor CLI permissions via ExecutionClass.
440
416
  * Current Cursor Agent schema accepts only `permissions.{allow,deny}` (no
441
417
  * `version` / `approvalMode`). Outside the allow list is denied without --force.
442
418
  *
443
- * `external_network` maps to WebFetch(*) plus `--force` on the run (see
419
+ * `observe_network` / networked mutate / publish_artifact use `--force` (see
444
420
  * cursorRunArgs). Field evidence: allow-listing WebFetch alone still yields
445
- * "User Rejected" under headless `-p`; `--force` is required for fetch to
446
- * succeed. Because `--force` auto-allows unlisted tools, research-only
447
- * networked assignments also deny Shell(*) and Write(**). Codex's parallel is
448
- * sandbox_workspace_write.network_access=true.
421
+ * "User Rejected" under headless `-p`; `--force` is required for fetch and for
422
+ * publish shell under open class policy. Research-only networked assignments
423
+ * deny Shell(*) and Write(**).
449
424
  */
450
- export function cursorPermissionsForGrants(grants, verificationCommands = [], capabilities = []) {
451
- const allow = [];
452
- if (grants.includes("test_run")) {
453
- allow.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
454
- }
455
- if (grants.includes("branch_create"))
456
- allow.push(...branchCreateCommands.map((command) => `Shell(${command})`));
457
- if (grants.includes("pr_create"))
458
- allow.push(...prCreateCommands.map((command) => `Shell(${command})`));
459
- // Cursor docs: WebFetch(domainOrPattern); WebFetch(*) auto-approves any domain.
460
- if (capabilities.includes("external_network"))
461
- allow.push("WebFetch(*)");
462
- const deny = deniedCommands.map((command) => `Shell(${command})`);
463
- // --force opens anything not denied; lock shell/write when the assignment has no mutate/shell grants.
464
- if (capabilities.includes("external_network")
465
- && !grants.includes("repo_write")
466
- && !grants.includes("test_run")
467
- && !grants.includes("branch_create")
468
- && !grants.includes("pr_create")) {
469
- deny.push("Shell(*)", "Write(**)");
470
- }
425
+ export function cursorPermissionsForGrants(grants, verificationCommands = [], capabilities = [], deliverable = "repository", workRole) {
426
+ const executionClass = resolveExecutionClass({ grants, capabilities, deliverable, workRole });
427
+ const { allow, deny } = projectCursor(executionClass, { grants, verificationCommands, capabilities });
471
428
  return { allow, deny };
472
429
  }
473
430
  /**
@@ -486,8 +443,13 @@ export function cursorRunArgs(input) {
486
443
  const args = ["-p", "--output-format", "json", "--workspace", input.workspace];
487
444
  if (input.trustWorkspace)
488
445
  args.push("--trust");
489
- // Headless WebFetch requires --force even when WebFetch(*) is allow-listed (Cursor CLI 2026.07+).
490
- if (input.capabilities?.includes("external_network"))
446
+ const executionClass = resolveRunClass(input);
447
+ const force = input.force ?? projectCursor(executionClass, {
448
+ grants: input.grants,
449
+ capabilities: input.capabilities,
450
+ }).force;
451
+ // Headless WebFetch / open publish shell require --force (Cursor CLI 2026.07+).
452
+ if (force)
491
453
  args.push("--force");
492
454
  if (input.model)
493
455
  args.push("--model", input.model);
@@ -566,7 +528,13 @@ export const cursorDriver = {
566
528
  // local contract instead of making either version fail every attempt worktree.
567
529
  const help = await execute(executable, ["--help"], input.workspace, 15_000, undefined, "local");
568
530
  const trustWorkspace = help.code === 0 && cursorSupportsWorkspaceTrust(`${help.stdout}\n${help.stderr}`);
569
- const configured = await withCursorPermissions(input.workspace, cursorPermissionsForGrants(input.grants, input.verificationCommands, input.capabilities ?? []), () => execute(executable, cursorRunArgs({ ...input, trustWorkspace }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
531
+ const executionClass = resolveRunClass(input);
532
+ const projected = projectCursor(executionClass, {
533
+ grants: input.grants,
534
+ verificationCommands: input.verificationCommands,
535
+ capabilities: input.capabilities ?? [],
536
+ });
537
+ const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
570
538
  const { code, stdout, stderr } = configured;
571
539
  const parsed = parseCursorOutput(stdout);
572
540
  if (code !== 0 || parsed.isError) {
@@ -602,10 +570,17 @@ async function withCursorPermissions(workspace, permissions, run) {
602
570
  }
603
571
  /**
604
572
  * OpenCode routes through any OpenAI/Anthropic-compatible endpoint, so it runs
605
- * on Conduit pump fuel or a local login. Read-only assignments use its `plan`
606
- * agent (no edit/bash tools); write assignments use `build`.
573
+ * on Conduit pump fuel or a local login. Observe classes use its `plan`
574
+ * agent (no edit/bash tools); write/publish/verify use `build`.
607
575
  */
608
- export function openCodeAgentForGrants(grants) {
576
+ function hasMappedRepoAccess(grants) {
577
+ return ["repo_read", "repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant));
578
+ }
579
+ export function openCodeAgentForGrants(grants, executionClass) {
580
+ if (!hasMappedRepoAccess(grants))
581
+ return null;
582
+ if (executionClass)
583
+ return projectCoarseMode(executionClass) === "plan" ? "plan" : "build";
609
584
  if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
610
585
  return "build";
611
586
  if (grants.includes("repo_read"))
@@ -714,7 +689,8 @@ export const openCodeDriver = {
714
689
  if (fuelSource === "local" && !hasOpenCodeLogin()) {
715
690
  return { status: "failed", resultText: null, sessionId: null, error: "machine set to local fuel but opencode has no login (auth.json / provider key in ~/.local/share/opencode or ~/.config/opencode)" };
716
691
  }
717
- const agent = openCodeAgentForGrants(input.grants);
692
+ const executionClass = resolveRunClass(input);
693
+ const agent = openCodeAgentForGrants(input.grants, executionClass);
718
694
  if (!agent) {
719
695
  return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped OpenCode agent for active grants; refusing to start agent" };
720
696
  }
@@ -738,7 +714,11 @@ export const openCodeDriver = {
738
714
  * (Antigravity-tier, not Claude-tier). Fine-grained command allowlisting would
739
715
  * need a kiro agent config file — deferred until it can be verified with a login.
740
716
  */
741
- export function kiroTrustedTools(grants) {
717
+ export function kiroTrustedTools(grants, executionClass) {
718
+ if (!hasMappedRepoAccess(grants))
719
+ return [];
720
+ if (executionClass)
721
+ return projectKiroTools(executionClass);
742
722
  const tools = [];
743
723
  if (grants.includes("repo_read"))
744
724
  tools.push("fs_read");
@@ -765,7 +745,8 @@ export const kiroDriver = {
765
745
  if (fuelSource === "conduit") {
766
746
  return { status: "failed", resultText: null, sessionId: null, error: "kiro driver requires local fuel (KIRO_API_KEY or `kiro-cli login`). Run: npx @miraland-labs/conduit-bridge fuel local" };
767
747
  }
768
- const trusted = kiroTrustedTools(input.grants);
748
+ const executionClass = resolveRunClass(input);
749
+ const trusted = kiroTrustedTools(input.grants, executionClass);
769
750
  if (!trusted.length) {
770
751
  return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Kiro tools for active grants; refusing to start agent" };
771
752
  }
@@ -794,7 +775,11 @@ export const kiroDriver = {
794
775
  * enforcement is mode-level, not per-tool — coarser than Claude, the Cursor tier.
795
776
  * Write mode adds `--sandbox` for terminal restrictions. Output is plain text.
796
777
  */
797
- export function antigravityModeForGrants(grants) {
778
+ export function antigravityModeForGrants(grants, executionClass) {
779
+ if (!hasMappedRepoAccess(grants))
780
+ return null;
781
+ if (executionClass)
782
+ return projectCoarseMode(executionClass) === "plan" ? "plan" : "accept-edits";
798
783
  if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
799
784
  return "accept-edits";
800
785
  if (grants.includes("repo_read"))
@@ -819,7 +804,8 @@ export const antigravityDriver = {
819
804
  if (!hasAntigravityLogin()) {
820
805
  return { status: "failed", resultText: null, sessionId: null, error: "antigravity has no login (set GEMINI_API_KEY or sign in with `agy`)" };
821
806
  }
822
- const mode = antigravityModeForGrants(input.grants);
807
+ const executionClass = resolveRunClass(input);
808
+ const mode = antigravityModeForGrants(input.grants, executionClass);
823
809
  if (!mode) {
824
810
  return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
825
811
  }
@@ -831,12 +817,67 @@ export const antigravityDriver = {
831
817
  return { status: "completed", resultText: stdout, sessionId: null };
832
818
  },
833
819
  };
820
+ /**
821
+ * Pi (`@earendil-works/pi-coding-agent`) — Experimental local-fuel lane.
822
+ * Class tools via `--tools`; observe_network fails closed (no fetch-without-bash).
823
+ */
824
+ export function piRunArgs(input) {
825
+ const args = ["--mode", "json", "-p", "--no-session", "--tools", input.tools];
826
+ if (input.model)
827
+ args.push("--model", input.model);
828
+ args.push(input.prompt);
829
+ return args;
830
+ }
831
+ export const piDriver = {
832
+ name: "pi",
833
+ async run(input) {
834
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
835
+ if (fuelSource === "conduit") {
836
+ return {
837
+ status: "failed",
838
+ resultText: null,
839
+ sessionId: null,
840
+ error: "pi driver requires local fuel (provider API key or Pi login). Run: npx @miraland-labs/conduit-bridge fuel local",
841
+ };
842
+ }
843
+ if (!hasMappedRepoAccess(input.grants)) {
844
+ return {
845
+ status: "failed",
846
+ resultText: null,
847
+ sessionId: null,
848
+ error: "No Bridge-mapped Pi access for active grants; refusing to start agent",
849
+ };
850
+ }
851
+ const executionClass = resolveRunClass(input);
852
+ const projected = projectPi(executionClass);
853
+ if ("error" in projected) {
854
+ return { status: "failed", resultText: null, sessionId: null, error: projected.error };
855
+ }
856
+ const executable = input.executable ?? "pi";
857
+ const version = await execute(executable, ["--version"], input.workspace, 15_000, undefined, "local");
858
+ if (version.code !== 0 || !(version.stdout || version.stderr).trim()) {
859
+ return { status: "failed", resultText: null, sessionId: null, error: "pi preflight could not verify the installed CLI version" };
860
+ }
861
+ const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
862
+ const parsed = parsePiJsonl(stdout);
863
+ if (code !== 0) {
864
+ return {
865
+ status: "failed",
866
+ resultText: parsed.resultText,
867
+ sessionId: parsed.sessionId,
868
+ error: (stderr || parsed.resultText || `pi exited with code ${code}`).slice(0, 20_000),
869
+ };
870
+ }
871
+ return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
872
+ },
873
+ };
834
874
  /** Stable driver ids shown in Connect / CLI. Order is product preference, not exclusivity. */
835
875
  export const SUPPORTED_AGENTS = [
836
876
  { id: "claude-code", label: "Claude Code", executableHint: "claude" },
837
877
  { id: "codex", label: "Codex (ChatGPT)", executableHint: "codex" },
838
878
  { id: "cursor", label: "Cursor Agent", executableHint: "agent" },
839
879
  { id: "opencode", label: "OpenCode", executableHint: "opencode" },
880
+ { id: "pi", label: "Pi", executableHint: "pi" },
840
881
  { id: "kiro", label: "Kiro CLI", executableHint: "kiro-cli" },
841
882
  { id: "antigravity", label: "Antigravity (agy)", executableHint: "agy" },
842
883
  ];
@@ -845,6 +886,7 @@ export const DRIVERS = {
845
886
  codex: codexDriver,
846
887
  cursor: cursorDriver,
847
888
  opencode: openCodeDriver,
889
+ pi: piDriver,
848
890
  kiro: kiroDriver,
849
891
  antigravity: antigravityDriver,
850
892
  };
package/dist/drivers.js CHANGED
@@ -11,10 +11,11 @@ const LABEL_TO_DRIVER = {
11
11
  OpenCode: "opencode",
12
12
  "Cursor Agent": "cursor",
13
13
  Cursor: "cursor",
14
+ Pi: "pi",
14
15
  "Kiro CLI": "kiro",
15
16
  Antigravity: "antigravity",
16
17
  };
17
- const LOCAL_FUEL_ONLY_DRIVERS = new Set(["cursor", "kiro", "antigravity"]);
18
+ const LOCAL_FUEL_ONLY_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity"]);
18
19
  export function isSupportedDriverId(id) {
19
20
  return Object.prototype.hasOwnProperty.call(DRIVERS, id);
20
21
  }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * ExecutionClass — one permission policy compiled from the assignment contract,
3
+ * then projected into each driver. Ends per-command allow-list republish churn.
4
+ */
5
+ /** Bounded verification commands a `test_run` grant may execute. */
6
+ export function isBoundedVerificationCommand(command) {
7
+ return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check))$/.test(command);
8
+ }
9
+ /** Shell commands each grant authorizes — mapped per driver so they cannot drift. */
10
+ export const branchCreateCommands = [
11
+ "git status", "git diff", "git add", "git commit", "git branch", "git switch", "git rev-parse", "git log",
12
+ ];
13
+ export const prCreateCommands = ["git push", "gh pr create"];
14
+ /** Commands no driver may ever run, regardless of grants. */
15
+ export const deniedCommands = [
16
+ "git merge", "git rebase", "git reset --hard", "git push --force",
17
+ "npm run deploy", "pnpm deploy", "yarn deploy", "wrangler deploy",
18
+ "kubectl", "terraform apply",
19
+ ];
20
+ /** Land commands denied for publish_artifact / observe* (delivery gate still enforces). */
21
+ export const landCommands = ["git commit", "git push", "gh pr create"];
22
+ export const EXECUTION_CLASSES = [
23
+ "observe",
24
+ "observe_network",
25
+ "publish_artifact",
26
+ "verify",
27
+ "mutate_repo",
28
+ ];
29
+ export function resolveExecutionClass(input) {
30
+ const grants = input.grants;
31
+ const capabilities = input.capabilities ?? [];
32
+ const deliverable = input.deliverable ?? "repository";
33
+ const workRole = input.workRole ?? null;
34
+ const hasWrite = grants.includes("repo_write");
35
+ const hasLand = grants.includes("branch_create") || grants.includes("pr_create");
36
+ const hasTest = grants.includes("test_run");
37
+ const hasRead = grants.includes("repo_read");
38
+ const external = capabilities.includes("external_network");
39
+ if (deliverable === "artifact" && workRole !== "research" && hasWrite)
40
+ return "publish_artifact";
41
+ if (external && !hasWrite && !hasLand && !hasTest)
42
+ return "observe_network";
43
+ // verify before observe: repo_read+test_run is the usual verify shape and must keep shell.
44
+ if (hasTest && !hasWrite && !hasLand)
45
+ return "verify";
46
+ if (!hasWrite && !hasLand && !hasTest && (hasRead || workRole === "research"))
47
+ return "observe";
48
+ return "mutate_repo";
49
+ }
50
+ /**
51
+ * Live HTTP is a property of the assignment's capabilities, not of its class.
52
+ *
53
+ * Every surface used to enumerate classes here, and the lists disagreed: the prompt promised WebFetch
54
+ * whenever the capability was present, Cursor granted it only for `observe_network`/`publish_artifact`/
55
+ * `mutate_repo`, Claude never granted it, and Codex granted it by capability. A `verify` assignment
56
+ * with `external_network` was therefore told to fetch by a prompt no driver could honour. One
57
+ * predicate, read by all of them, is what keeps that from recurring for the next class added.
58
+ *
59
+ * `observe_network` implies the capability by construction (`resolveExecutionClass` only returns it
60
+ * when `external_network` is present); it stays in the test so a spec that loses its capability list
61
+ * in transit still fails toward the class it was stamped with.
62
+ */
63
+ export function allowsExternalFetch(executionClass, capabilities = []) {
64
+ return executionClass === "observe_network" || capabilities.includes("external_network");
65
+ }
66
+ export function executionClassPromptRules(input) {
67
+ const { executionClass, grants, verificationCommands = [], capabilities = [], mustCommit, mustOpenPr, } = input;
68
+ // Derived, never passed in: a caller-supplied `artifact` flag let the prompt order a publish that
69
+ // the class had already denied. The class is the single source of truth for both halves.
70
+ const artifact = executionClass === "publish_artifact";
71
+ const rules = [];
72
+ const grantShell = [
73
+ ...(grants.includes("test_run") ? verificationCommands.filter(isBoundedVerificationCommand) : []),
74
+ ...(grants.includes("branch_create") ? branchCreateCommands : []),
75
+ ...(grants.includes("pr_create") ? prCreateCommands : []),
76
+ ];
77
+ if (executionClass === "publish_artifact") {
78
+ rules.push("- You have shell authority to write under `.conduit/artifacts/` and publish the artifact (HTTPS URL + sha256 digest). Do not invent command wrappers — publish with ordinary tools (for example gh gist, curl, shasum). Do not commit or open a pull request.");
79
+ }
80
+ else if (executionClass === "observe" || executionClass === "observe_network") {
81
+ rules.push("- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown.");
82
+ }
83
+ else if (grantShell.length) {
84
+ rules.push(`- These are the ONLY shell commands you may run: ${grantShell.join("; ")}. Anything else is rejected — do not try variations, wrappers, or \`echo\`. Run the relevant ones and report their real output.`);
85
+ }
86
+ else {
87
+ rules.push("- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown.");
88
+ }
89
+ if (allowsExternalFetch(executionClass, capabilities)) {
90
+ rules.push("- This assignment includes external_network: WebFetch is allow-listed. Use WebFetch (or the driver's live-fetch tool) for live HTTP status and page content; do not invent origin responses. Prefer WebFetch over WebSearch.");
91
+ }
92
+ if (mustCommit) {
93
+ rules.push("- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) — do not create a new branch — and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected.");
94
+ if (mustOpenPr) {
95
+ rules.push("- This assignment includes pr_create: after committing, `git push -u origin HEAD` and `gh pr create` against the repository default branch, then set pull_request_url to that PR URL. Conduit merge-on-accept lands the PR when the owner Accepts — a head_commit without a PR leaves main unchanged and breaks later packages.");
96
+ }
97
+ else {
98
+ rules.push("- You may commit locally; opening a pull request is outside this assignment's grants.");
99
+ }
100
+ }
101
+ else if (artifact) {
102
+ rules.push("- This assignment delivers an artifact, not repository content. Write your output under `.conduit/artifacts/` — that path is excluded from git, so do not commit anything and do not report a head_commit.", "- Publish the artifact to its destination and report each output in your evidence with the published HTTPS URL and digest exactly as `sha256:` plus the 64-character hex hash (bare hex from shasum is normalized, but prefer the prefixed form). An artifact nobody can fetch is not a delivery.");
103
+ }
104
+ else if (executionClass === "mutate_repo" && grants.includes("repo_write")) {
105
+ // Write without land grants: tools may edit files, but commit/PR stay outside the contract.
106
+ rules.push("- You may create and modify files in the workspace. Do not commit or open a pull request — this assignment has no branch_create/pr_create authority. A delivery reporting head_commit or pull_request_url will be rejected.");
107
+ }
108
+ else {
109
+ rules.push("- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead.");
110
+ }
111
+ return rules;
112
+ }
113
+ export function projectCursor(executionClass, input) {
114
+ const grants = input.grants;
115
+ const verificationCommands = input.verificationCommands ?? [];
116
+ const capabilities = input.capabilities ?? [];
117
+ const deny = [
118
+ ...deniedCommands.map((command) => `Shell(${command})`),
119
+ ...((executionClass === "publish_artifact" || executionClass === "observe" || executionClass === "observe_network")
120
+ ? landCommands.map((command) => `Shell(${command})`)
121
+ : []),
122
+ ];
123
+ if (executionClass === "observe" || executionClass === "observe_network") {
124
+ deny.push("Shell(*)", "Write(**)");
125
+ }
126
+ const allow = [];
127
+ if (allowsExternalFetch(executionClass, capabilities))
128
+ allow.push("WebFetch(*)");
129
+ if (executionClass === "mutate_repo" || executionClass === "verify") {
130
+ if (grants.includes("test_run")) {
131
+ allow.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
132
+ }
133
+ if (executionClass === "mutate_repo") {
134
+ if (grants.includes("branch_create"))
135
+ allow.push(...branchCreateCommands.map((command) => `Shell(${command})`));
136
+ if (grants.includes("pr_create"))
137
+ allow.push(...prCreateCommands.map((command) => `Shell(${command})`));
138
+ }
139
+ }
140
+ // Headless WebFetch and the open publish shell are the two things Cursor will not do unprompted.
141
+ const force = allowsExternalFetch(executionClass, capabilities) || executionClass === "publish_artifact";
142
+ return { allow, deny, force };
143
+ }
144
+ export function projectClaude(executionClass, input) {
145
+ const grants = input.grants;
146
+ const verificationCommands = input.verificationCommands ?? [];
147
+ // allowedTools is a whitelist, so an unnamed tool is a denied tool: Claude honoured no
148
+ // external_network assignment at all until this was added, whatever the prompt said.
149
+ const fetchTools = allowsExternalFetch(executionClass, input.capabilities) ? ["WebFetch"] : [];
150
+ const landDeny = landCommands.map((command) => `Bash(${command}:*)`);
151
+ const nucleusDeny = deniedCommands.map((command) => `Bash(${command}:*)`);
152
+ const disallowedTools = [
153
+ ...nucleusDeny,
154
+ ...((executionClass === "publish_artifact" || executionClass === "observe" || executionClass === "observe_network")
155
+ ? landDeny
156
+ : []),
157
+ ];
158
+ if (executionClass === "observe" || executionClass === "observe_network") {
159
+ return { allowedTools: ["Read", "Glob", "Grep", ...fetchTools], disallowedTools, acceptEdits: false };
160
+ }
161
+ if (executionClass === "publish_artifact") {
162
+ return {
163
+ allowedTools: ["Read", "Glob", "Grep", "Edit", "Write", "Bash", ...fetchTools],
164
+ disallowedTools,
165
+ acceptEdits: true,
166
+ };
167
+ }
168
+ if (executionClass === "verify") {
169
+ return {
170
+ allowedTools: [
171
+ "Read",
172
+ ...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`),
173
+ ...fetchTools,
174
+ ],
175
+ disallowedTools,
176
+ acceptEdits: false,
177
+ };
178
+ }
179
+ // mutate_repo — current grant mapping
180
+ const tools = [];
181
+ if (grants.includes("repo_read"))
182
+ tools.push("Read", "Glob", "Grep");
183
+ if (grants.includes("repo_write"))
184
+ tools.push("Edit", "Write");
185
+ if (grants.includes("test_run")) {
186
+ tools.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`));
187
+ }
188
+ if (grants.includes("branch_create"))
189
+ tools.push(...branchCreateCommands.map((command) => `Bash(${command}:*)`));
190
+ if (grants.includes("pr_create"))
191
+ tools.push(...prCreateCommands.map((command) => `Bash(${command}:*)`));
192
+ tools.push(...fetchTools);
193
+ return {
194
+ allowedTools: tools,
195
+ disallowedTools,
196
+ acceptEdits: grants.includes("repo_write"),
197
+ };
198
+ }
199
+ export function projectCodex(executionClass, input) {
200
+ const capabilities = input.capabilities ?? [];
201
+ const networkAccess = allowsExternalFetch(executionClass, capabilities);
202
+ // Read-only is the sandbox for the one class that may not write; everything else needs the
203
+ // workspace. Network is the shared predicate, so Codex cannot drift from Cursor and Claude again.
204
+ if (executionClass === "observe")
205
+ return { sandbox: "read-only", networkAccess };
206
+ return { sandbox: "workspace-write", networkAccess };
207
+ }
208
+ /** Coarse mode for OpenCode / Antigravity. */
209
+ export function projectCoarseMode(executionClass) {
210
+ if (executionClass === "observe" || executionClass === "observe_network")
211
+ return "plan";
212
+ return "build";
213
+ }
214
+ export function projectKiroTools(executionClass) {
215
+ if (executionClass === "observe" || executionClass === "observe_network")
216
+ return ["fs_read"];
217
+ if (executionClass === "verify")
218
+ return ["fs_read", "execute_bash"];
219
+ return ["fs_read", "fs_write", "execute_bash"];
220
+ }
221
+ export function projectPi(executionClass) {
222
+ switch (executionClass) {
223
+ case "observe":
224
+ return { tools: "read,grep,find,ls" };
225
+ case "observe_network":
226
+ return {
227
+ error: "Pi cannot enforce observe_network; use Cursor/Codex or add OS sandbox later",
228
+ };
229
+ case "publish_artifact":
230
+ case "mutate_repo":
231
+ return { tools: "read,write,edit,bash,grep,find,ls" };
232
+ case "verify":
233
+ return { tools: "read,bash,grep,find,ls" };
234
+ }
235
+ }
236
+ /** Extract final assistant text from `pi --mode json` JSONL. */
237
+ export function parsePiJsonl(stdout) {
238
+ let resultText = null;
239
+ let sessionId = null;
240
+ const texts = [];
241
+ for (const line of stdout.split("\n")) {
242
+ const trimmed = line.trim();
243
+ if (!trimmed.startsWith("{"))
244
+ continue;
245
+ let event;
246
+ try {
247
+ event = JSON.parse(trimmed);
248
+ }
249
+ catch {
250
+ continue;
251
+ }
252
+ const session = [event.sessionId, event.session_id].find((value) => typeof value === "string");
253
+ if (typeof session === "string")
254
+ sessionId = session;
255
+ if (event.type === "message_end" || event.type === "agent_end" || event.type === "assistant") {
256
+ const content = event.message?.content ?? event.message?.text ?? event.text ?? event.result;
257
+ if (typeof content === "string" && content.trim())
258
+ resultText = content;
259
+ else if (Array.isArray(content)) {
260
+ const joined = content
261
+ .map((part) => (typeof part === "string" ? part : (part && typeof part === "object" && "text" in part ? String(part.text) : "")))
262
+ .filter(Boolean)
263
+ .join("\n");
264
+ if (joined)
265
+ resultText = joined;
266
+ }
267
+ }
268
+ if (event.type === "text" && typeof event.text === "string")
269
+ texts.push(event.text);
270
+ }
271
+ if (!resultText && texts.length)
272
+ resultText = texts.join("\n");
273
+ if (!resultText && stdout.trim()) {
274
+ // Fall back to whole stdout so parseAgentReport can still find a fenced block.
275
+ resultText = stdout;
276
+ }
277
+ return { resultText, sessionId };
278
+ }
package/dist/execution.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { ConduitRequestError } from "./client.js";
4
4
  import { redactSecrets } from "./config.js";
5
- import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
5
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, pickModelCandidate, resolveExecutionClass, tierForRisk } from "./driver.js";
6
6
  import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
7
7
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
8
8
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
@@ -326,7 +326,32 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
326
326
  }
327
327
  // Same list the driver hands to the permission contract, so the prompt states exactly what is
328
328
  // executable rather than leaving the agent to guess and hit rejections.
329
- const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
329
+ const executionClass = resolveExecutionClass({
330
+ grants,
331
+ capabilities: spec.required_capabilities,
332
+ deliverable,
333
+ workRole: workPackage?.work_role ?? spec.work_role,
334
+ });
335
+ if (typeof spec.execution_class === "string" && spec.execution_class !== executionClass) {
336
+ console.error(`Assignment ${taskId}: stamped execution_class=${spec.execution_class} differs from Bridge-derived ${executionClass}; using Bridge-derived class for launch`);
337
+ }
338
+ // An artifact deliverable that does not classify as publish_artifact cannot write the file it is
339
+ // being asked to publish, so the run is doomed before it starts. Refuse here rather than spend an
340
+ // agent run and fail at the delivery gate with a missing artifact and no stated cause. Reachable
341
+ // when a control plane older than the artifact-grant normalization dispatches such a package —
342
+ // exactly the window between publishing Bridge and deploying the control plane.
343
+ if (artifactDelivery && executionClass !== "publish_artifact") {
344
+ const detail = `Artifact deliverable resolved to execution class ${executionClass}, which cannot write or publish it. The work package needs the repo_write grant.`;
345
+ console.error(`Assignment ${taskId}: ${detail}`);
346
+ // Not retryable: retrying cannot add a grant, so this must reach the owner as a plan correction
347
+ // rather than burn attempts. Same terminal path every other launch failure uses.
348
+ await queueTerminal(client, taskId, {
349
+ action: "fail",
350
+ body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:artifact-class` },
351
+ });
352
+ return;
353
+ }
354
+ const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [], executionClass });
330
355
  const resuming = Boolean(options.forceResumeSessionId);
331
356
  await client.attemptRequest(taskId, "progress", {
332
357
  phase: "changing",
@@ -383,6 +408,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
383
408
  grants,
384
409
  capabilities: spec.required_capabilities ?? [],
385
410
  verificationCommands: liveBrief?.verification ?? [],
411
+ deliverable,
412
+ workRole: workPackage?.work_role ?? spec.work_role,
413
+ executionClass,
386
414
  resumeSessionId,
387
415
  timeoutMs,
388
416
  model: selection.model,
@@ -629,9 +657,9 @@ async function prepareDelivery(client, attemptId, taskId, report) {
629
657
  // Publication receipts require a digest on every acceptance artifact. Textual agent
630
658
  // evidence has no file to hash, so default to a canonical content digest of the
631
659
  // evidence itself — deterministic and verifiable against the stored metadata.
632
- const digest = item.digest ?? `sha256:${createHash("sha256")
660
+ const digest = normalizeEvidenceDigest(item.digest ?? `sha256:${createHash("sha256")
633
661
  .update(JSON.stringify({ kind: item.kind, name: item.name, details: item.details, acceptance_criteria: item.acceptance_criteria }))
634
- .digest("hex")}`;
662
+ .digest("hex")}`);
635
663
  const response = await client.attemptRequest(taskId, "artifacts", {
636
664
  kind: item.kind, name: item.name, uri, digest,
637
665
  metadata: { details: item.details, acceptance_criteria: item.acceptance_criteria },
@@ -666,7 +694,10 @@ export function validateDeliveryReport(report, spec, grants = []) {
666
694
  const published = report.evidence.some((item) => {
667
695
  if (!["preview", "research", "documentation"].includes(item.kind))
668
696
  return false;
669
- if (!item.uri || !item.digest || !/^sha256:[0-9a-f]{64}$/i.test(item.digest))
697
+ if (!item.uri || !item.digest)
698
+ return false;
699
+ const digest = normalizeEvidenceDigest(item.digest);
700
+ if (!/^sha256:[0-9a-f]{64}$/i.test(digest))
670
701
  return false;
671
702
  try {
672
703
  const protocol = new URL(item.uri).protocol;
package/dist/ops.js CHANGED
@@ -11,7 +11,7 @@ import { describePreflightIssue, runBridgePreflight } from "./preflight.js";
11
11
  export const OPS_VERBS = [
12
12
  "connect", "install", "online", "offline", "status", "doctor", "disconnect", "uninstall",
13
13
  ];
14
- const LOCAL_FUEL_DRIVERS = new Set(["cursor", "kiro", "antigravity"]);
14
+ const LOCAL_FUEL_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity"]);
15
15
  export function defaultOpsEnvPath(home = homedir()) {
16
16
  return join(home, ".config", "conduit", "ops.env");
17
17
  }
package/dist/preflight.js CHANGED
@@ -21,6 +21,7 @@ const DRIVER_COMMAND = {
21
21
  codex: "codex",
22
22
  cursor: "agent",
23
23
  opencode: "opencode",
24
+ pi: "pi",
24
25
  kiro: "kiro-cli",
25
26
  antigravity: "agy",
26
27
  };
@@ -48,6 +49,9 @@ async function localAuthenticationReady(driver, workspace, run) {
48
49
  const status = await run(executableFor(driver), ["whoami"], workspace);
49
50
  return status.code === 0 && !/not logged in|not authenticated|login required/i.test(`${status.stdout}\n${status.stderr}`);
50
51
  }
52
+ // Pi stores provider auth in its own config; CLI has no stable whoami — version probe is enough.
53
+ if (driver === "pi")
54
+ return true;
51
55
  return false;
52
56
  }
53
57
  export async function runBridgePreflight(input, deps = {}) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.9.11",
4
- "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
3
+ "version": "0.10.0",
4
+ "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conduit": "dist/cli.js"