Skip to content

Commit d820e8f

Browse files
authored
✂️ Make xmd plan produce approved XMD source only (#724) (#749)
* ✂️ Make xmd plan produce approved XMD source only `xmd plan` mapped one request to a reviewed program and could then run it. Two responsibilities in one command meant a caller who wanted the program kept had to remember which flags configured which half, and a caller who only wanted source still paid for a grammar describing an execution. It now produces the artifact and stops. One invocation executes exactly one root — the packaged plan command document — awaits its complete teardown, structurally revalidates the returned bytes, and delivers them to stdout or to an exclusively created `--output` path. Whether that program runs is the caller's composition: `xmd plan … | xmd run -`, or a saved artifact a later `xmd run` names. The execution capability is gone structurally rather than left unselected: `PlanCommand.run`, `PlanExecution`, `PlanExecutionConfig`, the `PlanDependencies.execute` callback, `planExecutor`, the final `retainedSource("<plan>")` run, and the command-side property binding, signature freezing and reserved-token plumbing all had no consumer left. The final host gate stays, and is now structural — the same contract `<AdmitPlan>` applies — so a Plan declaring required root props is admitted rather than refused for arguments nobody has offered it yet. The grammar keeps one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, and ordinary help and version. Every option that configured the former execution is refused by name in fixed preflight, before the general parser can drop or coerce a token: `--run` reports the migration naming both compositions, and each of the rest reports that `xmd run` is where a program is configured. Authorship now settles a provider and a default agent and no permission mode at all. The same options are unchanged under `xmd run`. * ✂️ Correct the Plan gates, help preflight and property recognition Four blockers from the #724 feedback review. The command validated approved bytes twice, but nothing could tell the two apart: `<AdmitPlan>` and the host's gate reached the same conclusion from separately built inputs, so the C10 that removed a component during teardown was answered by the first of them and deleting the second left every suite green. The invocation now settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `<plan>` identity, the caller's includes and the run profile's declarations — and hands that one to `<CheckDraft>`, to `<AdmitPlan>` and to itself. What the three can disagree about is when it was asked, which is the whole point of keeping the last one: C10 and PS9 now let both Component gates really validate and succeed, move the tree immediately after that admission, and watch the host refuse. Deleting the host's gate fails both. `AuthorshipProfile.installElicitation` and `.assess` had no consumer and are gone; the dependencies `planComponentDeclaration` captures are unchanged. `--help` is lifted out of argv before any command's own grammar runs, so `xmd plan --help --run` printed a page describing a command that would have refused the caller. A shared pure `removedPlanOption()` — the same classification the scan uses — answers first, in either order. Help that names no removed option is still the exact approved help. Generated-property recognition was a prefix test, so `--propspective`, `--no-propspective` and a bare `--no-props` were told to configure their program with `xmd run`. It is now exactly `--props`, `--props-*` and `--no-props-*`; everything else keeps the generic unknown-option refusal. The three session-directory diagnostics are restored to their base wording — they were never part of the approved source-only copy. Stale prose that still had an approved Plan running or receiving the ordinary run stack is corrected in `agent-stack.ts`, the repository-provider comment in `cli.ts`, and `plan.test.ts`. * 🐛 Fix the Node typecheck and the site format check Two CI-only failures, both from checks that do not run in `deno task check`. `tsconfig.node.json` targets ES2022 and Deno's own lib is newer, so `Object.keys(scan).toSorted()` passed locally and failed all seven `test-node` shards with TS2550. The assertion is about which fields the scan established, not about their order, so it compares the two directions of set membership instead of sorting — `unicorn(no-array-sort)` would only have suggested `toSorted` back. `site/` runs its own `deno task check`, which begins with `deno fmt --check`; `oxfmt` formats `packages/` and never saw the homepage paragraph. Reflowed by `deno fmt`, leaving the sentence PS12 pins on one line.
1 parent 6fdad88 commit d820e8f

29 files changed

Lines changed: 1966 additions & 2562 deletions

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ with a run without a file in between:
7171
deno task xmd plan "prepare the release" | deno task xmd run -
7272
```
7373

74+
That composition is the whole relationship between the two commands:
75+
76+
```text
77+
Plan produces a program.
78+
Run executes a program from the host/CLI.
79+
Composition decides whether and when a planned program runs.
80+
```
81+
82+
`xmd plan` writes approved source and stops — to standard output, or to an
83+
`--output` file it creates exclusively. Running it is a command you write.
84+
7485
Preparation is common to all of them, so the block above is the document's own
7586
preamble: whichever target you select runs it first, and the checkout is
7687
prepared again before the work you asked for.

‎architecture.md‎

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

‎packages/cli/src/agent-config.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@
1010

1111
import type { PermissionMode } from "@executablemd/core";
1212

13-
export interface AgentFlags {
13+
/** What settling who writes needs, and the whole of it. */
14+
export interface AuthorshipFlags {
1415
agentProvider: string;
1516
defaultAgent: string | undefined;
17+
}
18+
19+
/** The same, plus the flags that select a document execution's permission mode. */
20+
export interface AgentFlags extends AuthorshipFlags {
1621
approveAll: boolean;
1722
approveReads: boolean;
1823
denyAll: boolean;

‎packages/cli/src/agent-stack.ts‎

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/**
2-
* The one Agent configuration an invocation resolves, and the two things a
3-
* host does with it (specs/acp-client-spec.md §Command-line configuration).
2+
* The Agent configuration an invocation resolves, and what a host does with it
3+
* (specs/acp-client-spec.md §Command-line configuration).
44
*
5-
* `xmd run` and `xmd plan` take the same Agent, permission and provider
6-
* options, so they resolve them once, here, rather than each reading the flags
7-
* again. What they do with the result differs, and deliberately: a run installs
8-
* the registered provider into the Agent Api so a document may reach it, while
9-
* `xmd plan` hands the same answer to two consumers — the authorship profile, which
10-
* takes the provider name and the default agent and nothing else, and the
11-
* approved document, which runs the ordinary run stack.
5+
* Two commands ask, and they ask for different amounts. `xmd run` settles the
6+
* whole of it and installs the registered provider into the Agent Api so a
7+
* document may reach it. `xmd plan` writes a program and runs none, so it
8+
* settles only who writes — the provider name, the default agent and the
9+
* adapters this build carries — and hands that to the authorship profile.
10+
* Resolving it once, here, is what keeps `DEFAULT_AGENT_NAME` from being read
11+
* twice and answered differently.
1212
*
1313
* `agent-config.ts` stays the pure flag-to-permission mapping. This module is
1414
* where the environment, the provider registry and the host's own machine
@@ -37,7 +37,7 @@ import { homedir } from "node:os";
3737
import { join } from "node:path";
3838

3939
import { resolveAgentConfig } from "./agent-config.ts";
40-
import type { AgentFlags } from "./agent-config.ts";
40+
import type { AgentFlags, AuthorshipFlags } from "./agent-config.ts";
4141
import type { MachineSessionAssembly } from "./session-coordinator.ts";
4242

4343
/**
@@ -50,55 +50,77 @@ import type { MachineSessionAssembly } from "./session-coordinator.ts";
5050
*/
5151
export const DEFAULT_ADAPTER_ROOT: string = join(homedir(), ".xmd", "adapters");
5252

53-
/** Everything one invocation settled about agents, resolved exactly once. */
54-
export interface AgentStack {
53+
/**
54+
* Who writes, and what this host launches them with.
55+
*
56+
* The whole of what Plan authorship settles. There is no permission mode here
57+
* because the authorship frame installs its own fixed one, and no command line
58+
* selects it: the flags that choose a permission mode configure a document
59+
* execution, and `xmd plan` starts none.
60+
*/
61+
export interface AuthorshipStack {
5562
/** The provider name the caller selected, already known to be registered. */
5663
provider: string;
5764
/** The agent every consumer defaults to, environment fallback applied. */
5865
defaultAgent: string;
59-
permissionMode: PermissionMode;
60-
/**
61-
* The ACP adapters this build carries, and where this host puts them.
62-
*
63-
* Part of the one settled answer because both consumers resolve agents
64-
* through it: the assistant that writes a Plan and the run of the approved
65-
* Plan are the same Codex or Claude, launched from the same snapshot.
66-
*/
66+
/** The ACP adapters this build carries, and where this host puts them. */
6767
adapters: EmbeddedAdapters;
6868
/** What this host states about machine-wide agent sessions, if anything. */
6969
sessions?: MachineSessionAssembly;
7070
}
7171

72+
/** Everything one `xmd run` invocation settled about agents, resolved once. */
73+
export interface AgentStack extends AuthorshipStack {
74+
permissionMode: PermissionMode;
75+
}
76+
7277
/**
73-
* Read the command line, the environment and the host's assembly into one
74-
* configuration.
78+
* Read the provider selection, the environment and the host's assembly into the
79+
* answer authorship needs.
7580
*
7681
* A failure comes back as a `Result` rather than as a printed line and an exit,
7782
* so the same resolution serves a command that runs a document and one that
78-
* generates one first.
83+
* only writes one.
7984
*/
80-
export function* resolveAgentStack(
81-
flags: AgentFlags,
85+
export function* resolveAuthorshipStack(
86+
flags: AuthorshipFlags,
8287
sessions: MachineSessionAssembly | undefined,
83-
): Operation<Result<AgentStack>> {
84-
const config = resolveAgentConfig(flags);
85-
if ("error" in config) {
86-
return Err(new Error(config.error));
87-
}
88+
): Operation<Result<AuthorshipStack>> {
8889
if (flags.agentProvider !== "acpx") {
8990
return Err(new Error(`Unknown agent provider "${flags.agentProvider}"`));
9091
}
9192
const defaultAgent =
92-
config.defaultAgent ?? (yield* readEnv("DEFAULT_AGENT_NAME")) ?? DEFAULT_AGENT_NAME;
93+
flags.defaultAgent ?? (yield* readEnv("DEFAULT_AGENT_NAME")) ?? DEFAULT_AGENT_NAME;
9394
return Ok({
9495
provider: flags.agentProvider,
9596
defaultAgent,
96-
permissionMode: config.permissionMode,
9797
adapters: createEmbeddedAdapters(DEFAULT_ADAPTER_ROOT),
9898
...(sessions === undefined ? {} : { sessions }),
9999
});
100100
}
101101

102+
/**
103+
* The same answer, plus the permission mode the document a run executes is
104+
* installed under.
105+
*/
106+
export function* resolveAgentStack(
107+
flags: AgentFlags,
108+
sessions: MachineSessionAssembly | undefined,
109+
): Operation<Result<AgentStack>> {
110+
const config = resolveAgentConfig(flags);
111+
if ("error" in config) {
112+
return Err(new Error(config.error));
113+
}
114+
const authorship = yield* resolveAuthorshipStack(
115+
{ agentProvider: flags.agentProvider, defaultAgent: config.defaultAgent },
116+
sessions,
117+
);
118+
if (!authorship.ok) {
119+
return authorship;
120+
}
121+
return Ok({ ...authorship.value, permissionMode: config.permissionMode });
122+
}
123+
102124
/**
103125
* What this host carries and what it built, stated to the provider.
104126
*
@@ -112,7 +134,7 @@ export function* resolveAgentStack(
112134
* ones a document could replace are not ones. The two advertised sets are stated
113135
* by the host, not inherited.
114136
*/
115-
export function hostAcpDependencies(stack: AgentStack): AcpxProviderDependencies {
137+
export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDependencies {
116138
const { sessions } = stack;
117139
const adapters = embeddedAdapterDependencies(stack.adapters);
118140
if (sessions === undefined) {

‎packages/cli/src/authorship-profile.ts‎

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
* runs under, and the only thing that ever runs under it
44
* (specs/plan-command-spec.md).
55
*
6-
* `xmd plan` executes this root on every invocation, and a second one — the
7-
* Plan it returns — only under `--run`, behind a complete scope boundary. This
8-
* module is the one that always happens. It supplies that document's inputs, a
6+
* `xmd plan` executes this root on every invocation, and no other: the Plan it
7+
* returns is source, and running it is a later command's business. This
8+
* module supplies that document's inputs, a
99
* constrained Agent provider, Elicitation, the fixed first-party components and
1010
* the host-declared draft checker, and it exposes no custom root and no
1111
* repository component search: the document it runs is the one the CLI ships.
@@ -52,7 +52,7 @@ import { API } from "@executablemd/runtime";
5252
import { FormOpener } from "@executablemd/web";
5353

5454
import { hostAcpDependencies } from "./agent-stack.ts";
55-
import type { AgentStack } from "./agent-stack.ts";
55+
import type { AuthorshipStack } from "./agent-stack.ts";
5656
import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts";
5757

5858
/**
@@ -108,8 +108,6 @@ export interface AuthorshipProfile {
108108
root: string;
109109
/** The Agent context this host can give a Plan, or why it can give none. */
110110
context: Result<PlanAuthorship>;
111-
/** Who answers the review question. */
112-
installElicitation(): Operation<void>;
113111
/**
114112
* The `<Plan>` declaration this command runs under.
115113
*
@@ -119,20 +117,11 @@ export interface AuthorshipProfile {
119117
* could supply or a document could reach.
120118
*/
121119
declaration: DeclaredMarkdownComponent;
122-
/**
123-
* The host's assessment of one candidate.
124-
*
125-
* A candidate-authored failure comes back as `valid: false` and is repairable.
126-
* A caller-source failure raises, which ends that execution: the document
127-
* has no way to catch it and no way to recategorize it as feedback
128-
* for an agent that could not have caused it.
129-
*/
130-
assess(source: string): Operation<CandidateAssessment>;
131120
}
132121

133122
/** What building the constrained provider needs, and nothing more. */
134123
export interface AuthorshipProviderInputs {
135-
readonly stack: AgentStack;
124+
readonly stack: AuthorshipStack;
136125
readonly acp?: AcpxProviderDependencies;
137126
}
138127

@@ -145,9 +134,9 @@ export interface AuthorshipProviderInputs {
145134
* middleware can reach or replace — which is what keeps "who may write a Plan"
146135
* a question about the host rather than about what a document arranged.
147136
*
148-
* Availability is all it decides. What the Plan then runs under — the permission
149-
* mode, the prompt-failure policy, the capability refusals and the session
150-
* directory — is {@link installAuthorshipFrame}'s fixed policy, identical for
137+
* Availability is all it decides. What writing a Plan then happens under — the
138+
* permission mode, the prompt-failure policy, the capability refusals and the
139+
* session directory — is {@link installAuthorshipFrame}'s fixed policy, identical for
151140
* every provider, so a second implementation cannot quietly bring a weaker one.
152141
*/
153142
export interface PlanAuthorship {
@@ -222,7 +211,7 @@ export function noAgentContextFrom(provider: string): string {
222211
* were when this was the only way to supply one.
223212
*/
224213
export function planAgentContext(
225-
stack: AgentStack | undefined,
214+
stack: AuthorshipStack | undefined,
226215
acp?: AcpxProviderDependencies,
227216
): Result<PlanAuthorship> {
228217
if (stack === undefined) {
@@ -359,7 +348,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation<R
359348
// supplies no Agent context refuses rather than writing a Plan under a weaker one.
360349
const context = profile.context;
361350
if (!context.ok) {
362-
return Err(new Error(`${context.error.message} Nothing was output or run.`));
351+
return Err(new Error(`${context.error.message} Nothing was output.`));
363352
}
364353

365354
return yield* scoped(function* (): Operation<Result<string>> {
@@ -644,7 +633,7 @@ function* establishDirectory(directory: string): Operation<Result<string>> {
644633
* nothing is deleted, because a leaf that changed underneath a conversation
645634
* nobody authorized to write there is interference, not a tidying job. The
646635
* failure raises out of the profile's scope, so no final admission follows it,
647-
* and the approved Plan reaches no stdout, no file and no run.
636+
* and the approved Plan reaches no stdout and no file.
648637
*
649638
* A directory the conversation never got — establishment refused it, or never
650639
* made it — is a different question, and one already answered: whatever

0 commit comments

Comments
 (0)