Skip to content

Commit 14c2307

Browse files
authored
🔎 Let Plan ask for read-only XMD information (#762) (#792)
* 🔎 Let Plan ask for read-only XMD information (#762) An agent writing a Plan could not look at the project first. It can now answer any Plan-producing turn with a read-only XMD program instead of a draft; the workflow evaluates it under the host's own read authority, hands the rendered findings back as inert context, and asks again. Classification is lexical and happens before anything else touches the response. A draft is a response whose first nonempty body block, after optional lexically closed frontmatter, is a nonempty level-one heading; everything else is an information candidate. The frontmatter is never parsed, so closed-but-invalid YAML stays a draft and reaches the repair path that exists to explain it — parsing it there would classify that draft as a request and evaluate program text nobody approved. The rule lives in core rather than beside `Plan.md`, exported through `core/host` alone. It has to agree with core about where a Markdown body begins and what a heading is, and both the `---` delimiters and the parser are there; a classifier that drew the boundary differently from the structural check would send a draft to evaluation. It is pure, carries no authority, and reaches no document. Recovery is one narrow class. `generated-candidate.ts` marks a failure at the throw site that raises it and reads it back through a namespaced non-enumerable property that survives a separately loaded copy. Marking per site rather than per class is the point: `GeneratedXmdError` is raised for a refused construct and for a retained admission whose ceilings moved, and recovering by `instanceof` would recover stale history along with a typo. Anything unmarked is terminal, so a failure added later is not recoverable until someone decides it is. An unknown name given to `<Syntax names={…}>` is one of those recoverable mistakes, and it cannot be marked where it is raised: `<Syntax>` persists the lookup, and a failure crossing that durable boundary is rebuilt without its class or any non-enumerable property. Recognizing it by the declared name that does survive is not enough either — a symbols provider that throws can use that same name, and recovering one would hand a broken installation back to an agent as retry context. So core raises a private `SyntaxSelectionRefusal` around the selection it performs itself, after the provider has already returned, and the component recognizes that class *inside* the executor by `instanceof`, where the original is still in hand. Only the conclusion travels out, in a variable core's own closure owns. A provider that throws stays terminal whatever it names its error. `<PlanInformation>` is the only place that turns a failure back into a turn. It projects its child through `tryContent()`, which hands back the child's *original* failure rather than a boundary's account of it — `content()` would wrap it in a `ContentError` and collapse the distinction this depends on. It is paired and value-returning, so `as` is mandatory and its closed `{ status, text }` envelope is never rendered into a Plan. A refusal discards partial output; every unmarked failure is rethrown unchanged. Settled information text crosses a pre-disclosure secret check before that envelope is bound. The following Prompt is a disclosure destination like the progress stream and the journal, and unlike them it is not durable — it reaches the agent before any event of it is appended, so the serialized pre-append gate cannot be what protects it. Canonical execution owns the policy and the scanner; `<PlanInformation>` asks them and appends nothing. The policy is resolved live, so an absent or unauthentic one fails rather than reporting "off", and a host that explicitly disabled detection is followed exactly rather than given a second always-on policy. Both outcomes are scanned, a finding raises the existing `SecretDetectedError`, and because it throws, nothing binds: no findings progress, no following Prompt, no review, no artifact. `Plan.md` binds `requests = 8` beside its ten drafts and three repairs and applies one response-handling shape after each of the three Plan-producing Prompt sites, inserted at their three identical endings so they cannot drift. Successes and refusals each spend one request; drafts and repairs are untouched in both directions; the ninth candidate is not evaluated and starts no turn. The explanation turn stays outside the loop. The command host installs `ordinaryEvaluationProfile()`; `<Plan>` installs nothing. The fake agent now fails loudly on a turn nobody scripted, naming the turn and quoting the prompt, instead of synthesizing an empty reply. That fiction let a case whose script and expectations disagreed pass anyway — a document with two `<Plan>` sites silently gave its second site nothing — and removing it immediately found four more fixtures relying on untitled Plans. Ten fixtures across five suites replied with untitled programs, which are information requests by this contract. Each received the level-one title every Plan is required to have, so each row still tests what it tested. One row's premise genuinely changed: a fully fenced reply has no heading and is now a request, so "nothing is stripped" is re-expressed as a draft carrying a fence reaching review with that fence intact. The tenth was in a suite none of the focused commands run, and only `--changed=origin/main` found it. The disclosure-waits-for-cleanup evidence is a held barrier rather than a recorder: an admitted `component-answer` — the same profile arm canonical protected `<Syntax />` is admitted through — whose body registers cleanup that blocks. It is written last in the request on purpose. Cleanup inside a fragment is per element, so a barrier written first is released before the read even happens and proves nothing; the first version of that control was vacuous for exactly that reason, and the assertion that the read had already run is what catches it. The refusal path cannot hold a barrier at all — element cleanup cannot straddle the failure that produces the refusal, and a provider handler cannot register scope-bound cleanup because its claim window is deliberately synchronous. Its ordering is settled instead by `CE22`, which proves projection-owned work is still live where a failure is reported and already torn down by the time recovery runs, plus a Plan-level row for recovery reaching the following turn and `FE34` for a cleanup failure winning and staying terminal. The distribution evidence is an executable journey rather than asset hashes. `smoke-test/plan-information/` runs a mixed request-to-approved-Plan exchange — one request refused, one answered, then a Plan — through the source CLI, the emitted npm bin and the compiled binary. The tripwire is in the scenario rather than the assertions: its agent answers only prompts carrying the refusal and then the selected documentation, so a build that lost the packaged assets or the protected tier writes no Plan to assert about. A compiled control covers `plan-command.md`, which has no catalog entry to digest, by running the command until it fails on an unresolvable agent — reaching that point at all proves the document, the declaration and the protected tier are embedded. * 🔒 Retain a Syntax selection refusal as a value, not a marked error (#762) The classification was applied to whichever error left the durable operation, decided by a variable set inside it. That is wrong in two directions, and the second one only appears on a continuation. If the selection refused and *publication* of the result then failed, the error leaving the operation was the persistence, journal or secret failure — and it left wearing the classification. `<PlanInformation>` would have offered that back as retry context and started another Agent turn on a run whose journal had already stopped accepting entries. On replay the executor is skipped, so the variable was never set. The retained refusal was rebuilt without its class and without the non-enumerable mark, and came back unclassified. The same request was recoverable live and terminal after a partial continuation. Both follow from signalling a durable fact out of band. The refusal is now retained: core's own `SyntaxSelectionRefusal` becomes `{ refused }` in the record, beside the unchanged `{ symbols }`, and the closed value is interpreted after `createDurableOperation` returns — the same code on a live run and on a replay. A publication failure throws before that interpretation, so a result that was never recorded is never acted on. A provider that throws is not caught at all and fails the operation, which is what keeps infrastructure terminal whatever it names its error. Existing successful records are untouched, so a history written before this still reads as exactly what it meant. `markGeneratedCandidate` and `generatedCandidateReason` become `markGeneratedRequestRefusal` and `generatedRequestRefusal`, in `generated-request-refusal.ts`. The old names read as core granting a Plan permission to retry, which is not core's decision to make: core states the fact that it refused the generated request, and `<Plan>` alone decides that a refused request earns another turn. `<PlanInformation>` translates the fact into its existing refused result, unchanged. One existing case changed its premise rather than its expectation. `SYN31` held that a refusal retains no record. It now retains one, deliberately — that is what makes a replayed refusal mean what the live one meant — so the row asserts the record is exactly `{ refused }` and that a continuation reaches the same refusal. Its five malformed-list cases are unchanged and still retain nothing: those refuse before the occurrence is claimed. `PC27` gained the shapes the second alternative makes possible — both members present, a non-string refusal, and an empty reason. Both new controls were checked against the defect they exist for: with the closure restored, the publication row reports the classification it must not carry, and the continuation row calls the live provider it must not reach. * 📝 Say what core refused, not what a caller may do about it (#762) The classification was renamed but its prose was not. Core still described itself as granting permission — "a failure a trusted host may offer the candidate another chance at", "a mistake the candidate can correct", a private helper called `candidate()` — which is the framing the rename was meant to remove. Core knows one thing and should say only that: the generated request itself was refused. The `core/host` export documentation now states the fact and names its limits explicitly: core says nothing about whether a refusal is correctable, whether a host may ask again, or whether another turn should happen. `fragment- capabilities.ts` renames `candidate()` to `refusedRequest()`, because what it builds is a refusal of what the fragment asked for and not a verdict about who can fix it. The two `generated-xmd.ts` comments that read as retry policy now say which thing was refused instead. `syntax-refusal.ts` was also describing a mechanism that no longer exists. It still explained the closure variable the previous commit replaced, so it now describes what actually happens: the refusal is recognized inside the durable executor, retained as `{ refused }`, and re-raised when that value is read back — which is why a refusal that could not be published is never interpreted. Plan keeps its own voice, because the decision genuinely is Plan's. `<PlanInformation>` remains "the only place in the workflow that turns a failure back into another turn"; what changed is the one clause that had core answering that question. It now asks core the factual question and states, in the same breath, that turning a refused request into another turn is this workflow's policy and this is where it is made. Behavior, durable representation and record schema are unchanged; every edit here is a comment, a doc block, or the name of one private helper and its two local call sites. * 🏷️ Name the Plan writer, and state the whole syntax_symbols payload (#762) "Authorship profile" and `<PlanAuthorship>` were generic names for one specific thing: the constrained Agent and execution frame a Plan is written inside. The generic name made every reader work out which authorship was meant — the frame, or the drafting-and-review process that happens within it — and the two are not the same subject. The abstraction is now called the Plan writer throughout: the module, the private component, its origin, the profile, frame, placement, policy, observation, provider inputs, stack, roots, and the constants and helpers that name any of them. No compatibility alias is left behind for any of the former private names, so nothing can go on referring to the old abstraction by accident. Ordinary prose still says "authorship" where it means the process — a turn that fails "ends authorship", the command "reports authorship as it happens", the title requirement is "an authorship and human-review requirement". Those are about drafting, repair, review and approval, which is what the word is for. Two things were deliberately left alone and are worth naming. `AuthorshipFlags` is the base of `AgentFlags`, which `xmd run` uses, so it describes agent selection rather than the Plan writer; renaming it would leave `xmd run` extending a plan-writer type. And `git-host`'s own `authorship()` is a commit's author and committer, an unrelated word. `<PlanAuthorship>` becoming `<PlanWriter>` changes two lines of `Plan.md` and therefore its declared digest. That is accepted rather than worked around: this branch already changes those bytes, so a continuation from previously shipped source already crosses the stale-source boundary, and adding an alias, a dual registration or a replay exception would create a second way to be that Component. Every digest expectation recomputes the hash from the source file at test time, so what needed updating was the private-name inventories rather than any recorded constant. Public `<Plan>` keeps its name, origin and identity. The retained-Syntax documentation was also still describing the payload as it was before the durability correction. Four generic descriptions said an occurrence retains exactly `{ symbols }`; the value has carried a second alternative since a refusal became something the record distinguishes. They now state the closed protocol — `{ symbols: string } | { refused: non-empty string }`, exactly one member — along with what makes it closed: both alternatives are durable values, the component interprets the value only after publication succeeds, a retained refusal therefore means the same thing live and on replay, a publication failure prevents interpretation and stays terminal, a missing, additional, mistyped, empty or simultaneous member is stale input, and existing `{ symbols }` histories remain readable. Two statements that are specifically about `Plan.md`'s own bare `<Syntax />` still say `{ symbols }`, because that occurrence names no component and cannot produce a named-selection refusal. Both were reworded so they read as being about that occurrence rather than as the protocol. This extends one effect's closed payload description. It adds no durable record and does not touch the generic durable-event envelope. * 🏷️ Finish naming the Plan writer, and stop overstating the Syntax payload (#762) Five identifiers still called the Plan writer "authorship". The frame's field held the capability that installs a provider, and two commands held the settled stack in a local of the same name, so reading any of them meant deciding which authorship was meant — the writer, or the drafting the writer does. `PlanWriterFrame.authorship` is now `PlanWriterFrame.planWriter`, with the construction in `plan-component.ts` and the `frame.planWriter.installProvider` call that reads it, plus the two locals in `agent-stack.ts` and `cli.ts`. `planWriter` rather than `profile`, because the value is the capability that installs a provider; `PlanWriterProfile` is the constrained execution profile that results from it. The two legitimate uses stay: `AuthorshipFlags` describes Agent selection shared with `xmd run`, and `git-host`'s `authorship()` is a commit's author and committer. So does every sentence using the word for the drafting, repair, review and approval activity — including the `--agent-provider` help text, which says "agent provider for Plan authorship" and is describing exactly that. The Syntax suite's own documentation was also still promising more than the payload delivers. It introduced the behavior as recording "exactly `{ symbols }`", which stopped being true when a named selection refusal became something the record carries. It now states the closed payload — one member, either the rendered symbols or a refused selection — and says why the refusal is a value rather than a failure: a continuation has to reach the same refusal it reached live. `SYN19` exercises a bare occurrence that rendered, so its title now says so instead of claiming to close the protocol. No behavior, authority, classification or durable representation changes here, and no compatibility alias is added.
1 parent 03a2168 commit 14c2307

39 files changed

Lines changed: 3181 additions & 536 deletions

‎architecture.md‎

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

‎packages/acp/embedded-adapters.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* earn that, so it lives here and goes away with the thing it exists for.
1515
*
1616
* The CLI's three Agent profiles — the workflow attachment, `xmd run` and the
17-
* `xmd plan` authorship ceiling — are the callers.
17+
* `xmd plan` Plan writer ceiling — are the callers.
1818
*/
1919

2020
export {

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

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* whole of it and installs the registered provider into the Agent Api so a
77
* document may reach it. `xmd plan` writes a program and runs none, so it
88
* settles only who writes — the provider name, the default agent and the
9-
* adapters this build carries — and hands that to the authorship profile.
9+
* adapters this build carries — and hands that to the Plan writer profile.
1010
* Resolving it once, here, is what keeps `DEFAULT_AGENT_NAME` from being read
1111
* twice and answered differently.
1212
*
@@ -54,11 +54,11 @@ export const DEFAULT_ADAPTER_ROOT: string = join(homedir(), ".xmd", "adapters");
5454
* Who writes, and what this host launches them with.
5555
*
5656
* 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
57+
* because the Plan writer frame installs its own fixed one, and no command line
5858
* selects it: the flags that choose a permission mode configure a document
5959
* execution, and `xmd plan` starts none.
6060
*/
61-
export interface AuthorshipStack {
61+
export interface PlanWriterStack {
6262
/** The provider name the caller selected, already known to be registered. */
6363
provider: string;
6464
/** The agent every consumer defaults to, environment fallback applied. */
@@ -70,7 +70,7 @@ export interface AuthorshipStack {
7070
}
7171

7272
/** Everything one `xmd run` invocation settled about agents, resolved once. */
73-
export interface AgentStack extends AuthorshipStack {
73+
export interface AgentStack extends PlanWriterStack {
7474
permissionMode: PermissionMode;
7575
}
7676

@@ -82,10 +82,10 @@ export interface AgentStack extends AuthorshipStack {
8282
* so the same resolution serves a command that runs a document and one that
8383
* only writes one.
8484
*/
85-
export function* resolveAuthorshipStack(
85+
export function* resolvePlanWriterStack(
8686
flags: AuthorshipFlags,
8787
sessions: MachineSessionAssembly | undefined,
88-
): Operation<Result<AuthorshipStack>> {
88+
): Operation<Result<PlanWriterStack>> {
8989
if (flags.agentProvider !== "acpx") {
9090
return Err(new Error(`Unknown agent provider "${flags.agentProvider}"`));
9191
}
@@ -111,14 +111,14 @@ export function* resolveAgentStack(
111111
if ("error" in config) {
112112
return Err(new Error(config.error));
113113
}
114-
const authorship = yield* resolveAuthorshipStack(
114+
const planWriter = yield* resolvePlanWriterStack(
115115
{ agentProvider: flags.agentProvider, defaultAgent: config.defaultAgent },
116116
sessions,
117117
);
118-
if (!authorship.ok) {
119-
return authorship;
118+
if (!planWriter.ok) {
119+
return planWriter;
120120
}
121-
return Ok({ ...authorship.value, permissionMode: config.permissionMode });
121+
return Ok({ ...planWriter.value, permissionMode: config.permissionMode });
122122
}
123123

124124
/**
@@ -134,7 +134,7 @@ export function* resolveAgentStack(
134134
* ones a document could replace are not ones. The two advertised sets are stated
135135
* by the host, not inherited.
136136
*/
137-
export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDependencies {
137+
export function hostAcpDependencies(stack: PlanWriterStack): AcpxProviderDependencies {
138138
const { sessions } = stack;
139139
const adapters = embeddedAdapterDependencies(stack.adapters);
140140
if (sessions === undefined) {

‎packages/cli/src/cli.ts‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ import {
9595
import { installWebComponents, installWebElicitation } from "@executablemd/web";
9696
import { timebox } from "@effectionx/timebox";
9797
import { timeout as runTimeout } from "@executablemd/runtime";
98-
import { installRunAgentStack, resolveAgentStack, resolveAuthorshipStack } from "./agent-stack.ts";
98+
import { installRunAgentStack, resolveAgentStack, resolvePlanWriterStack } from "./agent-stack.ts";
9999
import { planComponentDeclaration } from "./plan-component.ts";
100-
import { planAgentContext } from "./authorship-profile.ts";
100+
import { planAgentContext } from "./plan-writer-profile.ts";
101101
import { useVerboseComponent } from "./verbose-component.ts";
102102
import type { AgentStack } from "./agent-stack.ts";
103103
import { reportFailure } from "./report.ts";
@@ -888,7 +888,7 @@ export interface DocumentMode {
888888
* a harness that owns a temporary tree names that tree, so a test never reads,
889889
* creates or removes anything under a real one.
890890
*/
891-
planAuthorshipRoot?: string;
891+
planWriterRoot?: string;
892892
/**
893893
* What a trusted host attaches to this one execution.
894894
*
@@ -1015,7 +1015,7 @@ function* runDocument(
10151015
// learns what Agent context it has only after its own configuration has
10161016
// been read — and a declaration built out here would have closed over the
10171017
// absence of one before that child existed. Each caller supplies the context
1018-
// it settled, the authorship root it owns and the scope its host acts run in;
1018+
// it settled, the Plan writer root it owns and the scope its host acts run in;
10191019
// everything else about the Component is this entrypoint's and identical for
10201020
// all of them.
10211021
const planDeclaration = (request: ChildPlanDeclaration): Operation<DeclaredMarkdownComponent> =>
@@ -1024,18 +1024,18 @@ function* runDocument(
10241024
includes: include,
10251025
context: request.context,
10261026
...(mode.machineSessions === undefined ? {} : { sessions: mode.machineSessions }),
1027-
...(request.authorshipRoot !== undefined
1028-
? { authorshipRoot: request.authorshipRoot }
1029-
: mode.planAuthorshipRoot === undefined
1027+
...(request.planWriterRoot !== undefined
1028+
? { planWriterRoot: request.planWriterRoot }
1029+
: mode.planWriterRoot === undefined
10301030
? {}
1031-
: { authorshipRoot: mode.planAuthorshipRoot }),
1031+
: { planWriterRoot: mode.planWriterRoot }),
10321032
// Captured before the document exists, so the two acts that are this
10331033
// host's — putting this build's adapter on disk, and opening the review
10341034
// form — run outside the frame the Component installs around itself.
10351035
host: request.host,
1036-
...(request.observeAuthorship === undefined
1036+
...(request.observePlanWriter === undefined
10371037
? {}
1038-
: { observeAuthorship: request.observeAuthorship }),
1038+
: { observePlanWriter: request.observePlanWriter }),
10391039
installElicitation: request.installElicitation,
10401040
});
10411041

@@ -2503,12 +2503,12 @@ function* dispatch(
25032503
// Who writes, and nothing else. There is no permission mode to settle:
25042504
// this command starts no program, and the ceiling authorship runs under
25052505
// is the host's rather than the command line's.
2506-
const authorship = yield* resolveAuthorshipStack(
2506+
const planWriter = yield* resolvePlanWriterStack(
25072507
{ agentProvider: config.agentProvider, defaultAgent: config.defaultAgent },
25082508
sessions,
25092509
);
2510-
if (!authorship.ok) {
2511-
console.error(authorship.error.message);
2510+
if (!planWriter.ok) {
2511+
console.error(planWriter.error.message);
25122512
yield* exit(1);
25132513
break;
25142514
}
@@ -2520,7 +2520,7 @@ function* dispatch(
25202520
...(config.session === undefined ? {} : { session: config.session }),
25212521
verbose: config.verbose,
25222522
...(config.journal === undefined ? {} : { journal: config.journal }),
2523-
stack: authorship.value,
2523+
stack: planWriter.value,
25242524
},
25252525
{
25262526
...(sessions === undefined ? {} : { sessions }),

0 commit comments

Comments
 (0)