From 1b6ab7cb64eea526f77a9751f6445a8b602a8acd Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:43:10 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Prepare=20the=20plan=20profi?= =?UTF-8?q?le's=20adapter=20through=20the=20host,=20not=20the=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materializing an embedded adapter runs `npm install`, and the authorship profile refuses a command to everything inside it — so the first fix turned #672's two backend failures into `xmd plan asked for a command, which the authorship profile grants to nothing`, before any turn. Preparation now runs in the scope `xmd plan` was called in, captured before the profile installs its refusals. The distinction is the point: the document decides what to write and may run nothing, while the host installs the adapter it was always going to launch. The wait is registered with a halt, so an ended command takes an unfinished install with it rather than leaving one running under a conversation that is over. AE6 drives the real command through adapters that install by running one, and reproduces that exact refusal without this change. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7 --- packages/cli/src/authorship-profile.ts | 42 ++++++++++- packages/cli/tests/agent-adapters.test.ts | 89 ++++++++++++++++++++++- specs/acp-client-spec.md | 6 ++ specs/plan-command-spec.md | 5 +- 4 files changed, 135 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index 1fe2266d..c4574c33 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -26,8 +26,8 @@ * network capability either. It decides what to write; it writes nothing. */ -import { ensure, Err, Ok, scoped, until } from "effection"; -import type { Operation, Result } from "effection"; +import { ensure, Err, Ok, scoped, until, useScope } from "effection"; +import type { Operation, Result, Scope } from "effection"; import { createHash } from "node:crypto"; import { mkdir, readdir, rmdir } from "node:fs/promises"; import { homedir } from "node:os"; @@ -143,6 +143,12 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation> { // First, and before anything is built: this session's directory is claimed, // established and proven empty, or the command stops here. Nothing has been @@ -158,7 +164,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation inScope(host, () => prepare(agentName)) }), ...profile.acp, // deno-lint-ignore require-yield *agentCwd() { @@ -283,6 +301,22 @@ export function authorshipCeiling( }; } +/** + * Run one operation in a scope this one is nested inside, and wait for it there. + * + * The wait is what makes it this operation's: a task created in an outer scope + * outlives the caller by construction, so the halt is registered before the wait + * and an ended command takes the work with it rather than leaving an install + * running under a conversation that is over. + */ +function* inScope(scope: Scope, operation: () => Operation): Operation { + return yield* scoped(function* () { + const task = scope.run(operation); + yield* ensure(() => task.halt()); + return yield* task; + }); +} + /** * What the assistant session is told once, before it is asked anything. * diff --git a/packages/cli/tests/agent-adapters.test.ts b/packages/cli/tests/agent-adapters.test.ts index 861d83a7..cc847f7d 100644 --- a/packages/cli/tests/agent-adapters.test.ts +++ b/packages/cli/tests/agent-adapters.test.ts @@ -20,9 +20,10 @@ import { exists } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { API } from "@executablemd/runtime"; +import { API, exec } from "@executablemd/runtime"; import { createEmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; +import { useScope } from "effection"; import type { Operation } from "effection"; import { @@ -33,6 +34,9 @@ import { import type { AgentStack } from "../src/agent-stack.ts"; import { authorshipCeiling } from "../src/authorship-profile.ts"; import type { AuthorshipProfile, CandidateAssessment } from "../src/authorship-profile.ts"; +import { runPlan } from "../src/plan.ts"; +import { scanPlanArgs } from "../src/plan-args.ts"; +import { AGENT, createPlanHarness, useWorkingDirectory } from "./support/plan-harness.ts"; /** The two agents this build carries a patched snapshot for. */ const EMBEDDED = ["codex", "claude"] as const; @@ -46,6 +50,32 @@ function stackWith(adapters: EmbeddedAdapters): AgentStack { return { provider: "acpx", defaultAgent: "codex", permissionMode: "deny-all", adapters }; } +/** A Plan the profile's validator accepts and the command writes out. */ +const PLAN = ['the draft ran', ""].join("\n"); + +const REQUEST = "write a greeting"; + +/** + * Adapters that carry the scripted agent and install it the way the real ones + * do: by running a command. + * + * The bytes are not the point — the capability is. A real snapshot install runs + * `npm install` in a private directory, and this stands in for it so a case can + * observe which scope that command was reached from. + */ +function installingAdapters(prepared: string[]): EmbeddedAdapters { + return { + providers: [AGENT], + identity: () => `test-embedded:${AGENT}`, + executablePath: () => join(tmpdir(), "never-written", "index.js"), + command: () => `${AGENT}-cmd`, + *materialize(provider: string): Operation { + prepared.push(provider); + yield* exec({ command: ["npm", "install"] }); + }, + }; +} + /** The profile `xmd plan` builds its ceiling from, with nothing else supplied. */ function profileWith(stack: AgentStack): AuthorshipProfile { return { @@ -105,7 +135,7 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { const root = adapterRoot(); const adapters = createEmbeddedAdapters(root); const stack = stackWith(adapters); - const ceiling = authorshipCeiling(profileWith(stack), join(root, "workdir")); + const ceiling = authorshipCeiling(profileWith(stack), join(root, "workdir"), yield* useScope()); const registry = ceiling.agentRegistry; if (registry === undefined) { throw new Error("the plan path handed its provider no agent registry"); @@ -136,6 +166,61 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { expect(yield* exists(root)).toBe(false); }); + it("AE6: the plan profile prepares its adapter through the host, not the document", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + // The command an install runs, answered here rather than spawned. What the + // case is about is which capability the preparation reaches, and a real + // `npm install` would answer that question with a subprocess. + // + // At `min`, so it is the weakest thing in the chain: the profile's own + // refusal is installed at the default strength and still wins wherever it + // applies. A recorder that outranked it would answer for the refused call + // too, and the case would pass against the defect. + const commands: string[][] = []; + yield* API.Process.around( + { + // deno-lint-ignore require-yield + *exec([options]) { + commands.push([...options.command]); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }, + { at: "min" }, + ); + + const prepared: string[] = []; + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: PLAN }); + harness.script({ decision: "Approve" }); + + const argv = ["plan", REQUEST]; + const code = yield* runPlan( + { + argv, + scan: scanPlanArgs(argv), + include: [dir], + output: join(dir, "plan.md"), + run: false, + stack: { ...stackWith(installingAdapters(prepared)), defaultAgent: AGENT }, + }, + harness.deps, + ); + + // The profile refuses a command to everything inside it, and putting this + // build's adapter on disk runs one. Preparation therefore happens in the + // scope the command was called in — the defect that made a real + // `xmd plan` end with "asked for a command, which the authorship profile + // grants to nothing" before any turn. + // Once per agent resolution — the document resolves one several times, and + // preparing an agent already prepared is defined to be harmless. + expect(prepared.length).toBeGreaterThan(0); + expect([...new Set(prepared)]).toEqual([AGENT]); + expect(commands).toEqual(prepared.map(() => ["npm", "install"])); + expect(code).toBe(0); + expect(yield* exists(join(dir, "plan.md"))).toBe(true); + }); + }); + it("AE5: a settled stack carries this host's own adapter root", function* () { yield* API.Env.around({ // deno-lint-ignore require-yield diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index ab9e1a47..2df50065 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -581,6 +581,12 @@ network capability, and the host decides for that whole execution that a failing `` ends it — so a turn that streamed text and then failed presents nothing. +That refusal covers the document, and installing this build's adapter is not the +document's act: it runs a command, and it runs it in the scope the invocation was +called in rather than inside the profile. Nothing the document, the assistant or +an authored element reaches can get there — the host prepares the adapter it was +always going to launch, and an ended command takes an unfinished install with it. + The profile's working directory is derived from the logical session name rather than shared or freshly made: `~/.xmd/plan/sessions/`, with the digest in the path and never the name. A session's key includes the directory it diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index e1e51617..846ce3ff 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -288,7 +288,10 @@ provider: document itself. The provider may use its own transport to perform the model turn; that does not -grant the Agent a native network tool. +grant the Agent a native network tool. Nor does putting this build's own ACP +adapter on disk, which runs a command in the scope that invoked `xmd plan`: the +document is refused a command, and the host still installs the adapter it is +about to launch ([`xmd run` and `xmd plan`](./acp-client-spec.md)). `--approve-all`, `--approve-reads` and `--deny-all` do not change this ceiling. They apply to the approved Plan later. A provider that cannot establish this From 0844eb3d3bec2b7b00258daef82cfbdde044dfd9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:49:38 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=96=20Bump=20workspace=20packages?= =?UTF-8?q?=20to=200.10.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A patch bump: v0.10.1 shipped the adapter overlay without the preparation scope, so `xmd plan` ends with `asked for a command, which the authorship profile grants to nothing` before any turn. This release carries that correction. Stamps every publishable manifest and restamps the matching workspace entries in bun.lock, which the bump task deliberately leaves alone. Only the ten @executablemd members move. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7 --- bun.lock | 20 ++++++++++---------- packages/acp/deno.json | 2 +- packages/acp/package.json | 2 +- packages/cli/deno.json | 2 +- packages/cli/package.json | 2 +- packages/code-review-agent/deno.json | 2 +- packages/code-review-agent/package.json | 2 +- packages/core/deno.json | 2 +- packages/core/package.json | 2 +- packages/durable-streams/deno.json | 2 +- packages/durable-streams/package.json | 2 +- packages/runtime/deno.json | 2 +- packages/runtime/package.json | 2 +- packages/test-agent/deno.json | 2 +- packages/test-agent/package.json | 2 +- packages/testing/deno.json | 2 +- packages/testing/package.json | 2 +- packages/web/deno.json | 2 +- packages/web/package.json | 2 +- packages/workflow/deno.json | 2 +- packages/workflow/package.json | 2 +- 21 files changed, 30 insertions(+), 30 deletions(-) diff --git a/bun.lock b/bun.lock index dbb61f55..19c2a3b9 100644 --- a/bun.lock +++ b/bun.lock @@ -51,7 +51,7 @@ }, "packages/acp": { "name": "@executablemd/acp", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@executablemd/core": "workspace:*", "@executablemd/runtime": "workspace:*", @@ -61,7 +61,7 @@ }, "packages/cli": { "name": "@executablemd/cli", - "version": "0.10.1", + "version": "0.10.2", "bin": { "xmd": "./src/node.ts", }, @@ -83,11 +83,11 @@ }, "packages/code-review-agent": { "name": "@executablemd/code-review-agent", - "version": "0.10.1", + "version": "0.10.2", }, "packages/core": { "name": "@executablemd/core", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/converge": "0.1.4", @@ -119,7 +119,7 @@ }, "packages/durable-streams": { "name": "@executablemd/durable-streams", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@durable-streams/client": "^0.2.2", "effection": "4.1.0", @@ -127,7 +127,7 @@ }, "packages/runtime": { "name": "@executablemd/runtime", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fetch": "0.2.1", @@ -139,7 +139,7 @@ }, "packages/test-agent": { "name": "@executablemd/test-agent", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@agentclientprotocol/sdk": "1.3.0", "@effectionx/node": "0.2.4", @@ -169,7 +169,7 @@ }, "packages/testing": { "name": "@executablemd/testing", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/scope-eval": "0.1.3", @@ -181,7 +181,7 @@ }, "packages/web": { "name": "@executablemd/web", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@effectionx/node": "0.2.4", "@executablemd/core": "workspace:*", @@ -208,7 +208,7 @@ }, "packages/workflow": { "name": "@executablemd/workflow", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fs": "0.3.0", diff --git a/packages/acp/deno.json b/packages/acp/deno.json index 1828f82e..869681b6 100644 --- a/packages/acp/deno.json +++ b/packages/acp/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/acp", - "version": "0.10.1", + "version": "0.10.2", "license": "MIT", "exports": { ".": "./mod.ts", diff --git a/packages/acp/package.json b/packages/acp/package.json index 8275e13f..0a72aadb 100644 --- a/packages/acp/package.json +++ b/packages/acp/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/acp", - "version": "0.10.1", + "version": "0.10.2", "description": "ACPX agent provider for executable.md documents: drives coding agents over the Agent Client Protocol.", "type": "module", "exports": { diff --git a/packages/cli/deno.json b/packages/cli/deno.json index 752e6411..f0d3e449 100644 --- a/packages/cli/deno.json +++ b/packages/cli/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/cli", - "version": "0.10.1", + "version": "0.10.2", "license": "MIT", "exports": "./src/deno.ts", "imports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 178ad072..07218353 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/cli", - "version": "0.10.1", + "version": "0.10.2", "description": "The xmd command-line interface for executable.md.", "bin": { "xmd": "./src/node.ts" diff --git a/packages/code-review-agent/deno.json b/packages/code-review-agent/deno.json index 44509dd7..beb0f261 100644 --- a/packages/code-review-agent/deno.json +++ b/packages/code-review-agent/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/code-review-agent", - "version": "0.10.1", + "version": "0.10.2", "exports": { ".": "./mod.ts" } diff --git a/packages/code-review-agent/package.json b/packages/code-review-agent/package.json index 002061f7..f1733eea 100644 --- a/packages/code-review-agent/package.json +++ b/packages/code-review-agent/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/code-review-agent", - "version": "0.10.1", + "version": "0.10.2", "description": "Parsers that turn git diff and Oxlint output into typed structures for executable.md reviews.", "type": "module", "exports": "./mod.ts" diff --git a/packages/core/deno.json b/packages/core/deno.json index 808eb8b3..6cf21db0 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/core", - "version": "0.10.1", + "version": "0.10.2", "exports": { ".": "./mod.ts", "./host": "./host.ts" diff --git a/packages/core/package.json b/packages/core/package.json index 6a5bf234..9f8eda6c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/core", - "version": "0.10.1", + "version": "0.10.2", "description": "Core engine that evaluates executable.md documents.", "type": "module", "exports": { diff --git a/packages/durable-streams/deno.json b/packages/durable-streams/deno.json index 8aeabd81..d3904886 100644 --- a/packages/durable-streams/deno.json +++ b/packages/durable-streams/deno.json @@ -1,5 +1,5 @@ { "name": "@executablemd/durable-streams", - "version": "0.10.1", + "version": "0.10.2", "exports": "./mod.ts" } diff --git a/packages/durable-streams/package.json b/packages/durable-streams/package.json index c29fdca5..c4a1eafe 100644 --- a/packages/durable-streams/package.json +++ b/packages/durable-streams/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/durable-streams", - "version": "0.10.1", + "version": "0.10.2", "description": "Durable, replayable event streams for executable.md.", "type": "module", "exports": "./mod.ts", diff --git a/packages/runtime/deno.json b/packages/runtime/deno.json index de5c654c..b1202283 100644 --- a/packages/runtime/deno.json +++ b/packages/runtime/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/runtime", - "version": "0.10.1", + "version": "0.10.2", "exports": { ".": "./mod.ts", "./files": "./files.ts", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 172f88d4..3a81be01 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/runtime", - "version": "0.10.1", + "version": "0.10.2", "description": "Runtime host APIs for executable.md documents.", "type": "module", "exports": { diff --git a/packages/test-agent/deno.json b/packages/test-agent/deno.json index 051aa7c1..27e22a1e 100644 --- a/packages/test-agent/deno.json +++ b/packages/test-agent/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/test-agent", - "version": "0.10.1", + "version": "0.10.2", "exports": { ".": "./mod.ts" }, diff --git a/packages/test-agent/package.json b/packages/test-agent/package.json index 37e1de79..a18a84ed 100644 --- a/packages/test-agent/package.json +++ b/packages/test-agent/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/test-agent", - "version": "0.10.1", + "version": "0.10.2", "description": "Build reliable ACP integration tests with deterministic, document-driven agent behavior.", "type": "module", "exports": { diff --git a/packages/testing/deno.json b/packages/testing/deno.json index c54e27bc..866db173 100644 --- a/packages/testing/deno.json +++ b/packages/testing/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/testing", - "version": "0.10.1", + "version": "0.10.2", "exports": { ".": "./mod.ts" } diff --git a/packages/testing/package.json b/packages/testing/package.json index 4f0d6e34..1c87b1d7 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/testing", - "version": "0.10.1", + "version": "0.10.2", "description": "Testing components for executable.md documents: , , and assertion components.", "type": "module", "exports": { diff --git a/packages/web/deno.json b/packages/web/deno.json index 4f0b0d22..971feef3 100644 --- a/packages/web/deno.json +++ b/packages/web/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/web", - "version": "0.10.1", + "version": "0.10.2", "exports": "./mod.ts", "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"] diff --git a/packages/web/package.json b/packages/web/package.json index 1e67cf62..1da3bc83 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/web", - "version": "0.10.1", + "version": "0.10.2", "description": "Schema-backed local web form input for executable.md documents.", "type": "module", "exports": { diff --git a/packages/workflow/deno.json b/packages/workflow/deno.json index a5ceb312..d6cbc6fd 100644 --- a/packages/workflow/deno.json +++ b/packages/workflow/deno.json @@ -1,6 +1,6 @@ { "name": "@executablemd/workflow", - "version": "0.10.1", + "version": "0.10.2", "license": "MIT", "exports": { ".": "./mod.ts", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 0120c841..99856234 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "@executablemd/workflow", - "version": "0.10.1", + "version": "0.10.2", "description": "Workflow runs for executable.md: one pinned definition per run, retained durably so another process can find it.", "type": "module", "exports": {