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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 70 additions & 19 deletions architecture.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion packages/cli/src/deno-repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ export function denoReplEntry(helper: HelperAssembly, root: string): ReplEntryIn
// kept a reference and changed a field afterwards would be configuring every
// later entry from outside the selection the command already made.
const configured = snapshot(helper);
return (owner: string) => replRepositoryInstallation(owner, { helper: configured, root });
return (owner, placement) =>
replRepositoryInstallation(owner, {
helper: configured,
root,
...placement,
});
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/documents/sidekick/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ text for the person inside top-level `<Output>...</Output>`. Your response start
with no bindings from the REPL or earlier responses. Response expressions state
JSON data or local bindings; they do not execute JavaScript.

Sidekick responses and entries you submit start in this session’s workspace;
manual entries start in the invocation’s project directory. Read
`<Guide.Environment />` and `<Guide.Entries />` before creating session files or
components. Configured component directories are searched before this session’s
`components/`; inspect the selected definition before invoking it.

The attached index tells you which guides to read and their exact invocations.
For example, `<Guide.Entries />` teaches how to prepare literal entry source;
`<Guide.Effection />` teaches asynchronous entry code. You may request several
Expand Down
83 changes: 66 additions & 17 deletions packages/cli/src/documents/sidekick/entries.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,38 +58,87 @@ response. `select="code"` extracts the first matching fenced block without its
fence, and trims trailing whitespace — pass a direct string value when exact
string boundaries matter.

## Reading the repository in an entry
### Create and use a session component across responses

An entry can ask the checkout it is standing in four questions. Read their exact
forms with `<REPL.Syntax names={["Git.Files", "Git.Changes", "Git.Diff", "Git.File"]} />`
before writing one; each requires `as`, renders nothing, and binds JSON.
Read `<Guide.Environment />`, `<Guide.Entries />`, `<REPL.Environment />` and
`<REPL.Syntax names={["File"]} />` first. When asked to create this component,
submit its file definition in one response:

````xmd
<Let as="code" select="code">
```xmd
<Git.Files prefix="packages/core/" limit={5} as="files" />
<Json value={files.paths} />
<File path="components/Greeting.md">Hello from this session.</File>
```
</Let>
<REPL.EntryInput>{code}</REPL.EntryInput>
<REPL.Submit />
<REPL.Entry.Output />
````

A page is bounded — 100 records, or 4096 characters of text — and `next` holds
the complete arguments of the read that continues it, or `null` when there is no
more. Follow it in a later entry by passing the captured value back, which is an
ordinary binding the entry before it published:
Wait for the receipt and follow its read instruction to inspect the entry's
output before assuming the write succeeded. A completed entry can report a File
refusal in its output. A successful write creates exactly `Hello from this
session.` with no added newline and invokes no Greeting. Correct a reported
write failure before attempting invocation.

In a later response inspect the selected definition:

```xmd
<Git.Files prefix={files.next.prefix} limit={files.next.limit} cursor={files.next.cursor} as="more" />
<REPL.Syntax names={["Greeting"]} />
```

If the answer changed in between, that read refuses and says to restart without
the cursor — ask for a fresh read at the beginning rather than treating the
refusal as an error to work around. Nothing these reads return lets an entry
change the repository; a change still takes `<Git.Add>`, `<Git.Switch>` or
`<Git.Commit>`.
Read its selected origin. A configured component directory precedes the session
components directory, so inspect the definition that actually wins. Once the
selected definition is the intended one, submit its invocation in another
response:

````xmd
<Let as="code" select="code">
```xmd
<Greeting />
```
</Let>
<REPL.EntryInput>{code}</REPL.EntryInput>
<REPL.Submit />
````

Use the receipt's returned entry reference/read instruction to read that exact
entry with `<REPL.Entry.Output />`. For example, when the receipt names
`entry-2`, a later response writes:

```xmd
<REPL.Entry.Output entry={2} />
```

The session definition prints `Hello from this session.`. Response captures
remain local to their response; the later response does not inherit `code`.

### Read the repository in an entry

An entry can ask a selected checkout four questions. Read their exact forms
with `<REPL.Syntax names={["Git.Files", "Git.Changes", "Git.Diff", "Git.File"]} />`
first. Enter the explicitly known checkout path before asking Git; the workspace
is not a hidden route back to the project. Replace `/known/project` below with
the actual known checkout path:

````xmd
<Let as="code" select="code">
```xmd
<Dir path="/known/project">
<Git.Files prefix="packages/core/" limit={5} as="files" />
<Json value={files.paths} />
</Dir>
```
</Let>
<REPL.EntryInput>{code}</REPL.EntryInput>
<REPL.Submit />
<REPL.Entry.Output />
````

Each read requires `as`, renders nothing and binds JSON. A page is bounded —
100 records, or 4096 characters of text — and `next` holds the complete
arguments for continuation, or `null` when there is no more. If a later read's
cursor refuses because the answer changed, restart the read without the cursor.
Nothing these reads return authorizes a repository change.

## Common mistake

Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/documents/sidekick/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ answer.

## Instructions

- Responses and entries submitted through `<REPL.Submit />` start in this
session's workspace, beside its journals. A manually submitted entry starts
in the invocation's project directory. Read `<REPL.Environment />` for your
current cwd; do not infer a project path from the workspace path.
- Write session components under `components/`, copied inputs under `inputs/`,
and generated files at an appropriate workspace-relative path. These files
survive reopening; another session has its own directory.
- Component lookup searches the invocation's configured directories first,
then this session's `components/`, then ordinary registered defaults. Explicit
includes replace the project defaults. Read the selected origin through
`<REPL.Syntax names={["Greeting"]} />` before invoking a new component: a
configured definition can win. A selected malformed definition fails; fix it
rather than assuming another candidate runs.
- A nested `<Dir>` selects another known, authorized directory for its content;
later siblings return to the workspace. To copy project input, enter its
explicitly known project path, capture the read there, then write the copy
after leaving Dir. Ask for the project path if you do not know it.
- Reopening restores acknowledged results, not file snapshots. A retained read
answers with its old text; a retained write does not replace an external edit.
Work performed before its acknowledgement may repeat after interruption.
- This chapter teaches you how to *interpret* host facts. It states none of
them, because a fact written into teaching text is a fact that goes stale.
Ask for the current ones with `<REPL.Environment />`.
Expand All @@ -32,12 +52,25 @@ answer.
## Example

```xmd
<Guide.Entries />
<REPL.Environment />
<REPL.Syntax names={["File"]} />
```

The host returns the facts it knows — and its explicit unknowns — as feedback,
then asks for your next response.

For an explicitly known project at `/known/project`, this **entry** copies one
input into the session workspace. Replace that example path with the actual
known project path before submission:

```xmd
<Dir path="/known/project">
<File path="request.md" as="request" />
</Dir>
<File path="inputs/request.md">{request}</File>
```

## Common mistake

Writing an entry against a path, platform or capability you assumed rather than
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/repl-entry-installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ import type { ExecutionInstallation } from "@executablemd/core/host";
* no initialization of its own happens here, and the registration belongs to
* the entry task rather than to a scope that closes when this returns.
*/
export type ReplEntryInstallation = (owner: string) => Operation<ExecutionInstallation>;
export type ReplEntryInstallation = (
owner: string,
placement?: { readonly projectCwd?: string; readonly workspace?: string },
) => Operation<ExecutionInstallation>;

/** The factory this host runs REPL entries with, or absence. */
export const ReplEntryHost: Context<ReplEntryInstallation | undefined> = createContext<
Expand Down
155 changes: 91 additions & 64 deletions packages/cli/src/repl-profile.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
/**
* The one immutable profile a REPL execution runs under.
* Capture command selections once, then finalize one execution's component paths.
*
* Assembled once, in the command's own scope, before a terminal is opened or a
* history file exists — and then read and never changed. What a REPL execution
* may resolve, what ceiling a generated fragment runs under and how permission
* requests are answered are facts about the command a person invoked, not about
* the entry they later type: a profile assembled per entry could differ between
* two entries of one session, and there is only ever one.
*
* It carries data and installations, never authority. No Agent stack, provider,
* Plan writer, live request or scope reaches the application model, the route or
* the Journal through it.
* The command owns Agent and Plugin installation. Storage supplies the execution
* id before the includes-dependent Plan declaration and entry vocabulary exist.
*/

import type { AcpxProviderDependencies } from "@executablemd/acp";
Expand All @@ -19,6 +11,8 @@ import type { PermissionMode } from "@executablemd/core";
import type { GeneratedSelection } from "@executablemd/core/api";
import type { ExecutionInstallation } from "@executablemd/core/host";
import { useScope } from "effection";
import { cwd } from "@executablemd/runtime";
import { resolve, join } from "node:path";
import type { Operation } from "effection";

import { contextualAgent, hostAcpDependencies, installAgentProviderStack } from "./agent-stack.ts";
Expand Down Expand Up @@ -91,8 +85,15 @@ export interface EntryVocabulary {
documentation(names: readonly string[]): Operation<string>;
}

/** What one REPL execution runs under, decided once and read from then on. */
/** The captured command basis, before its execution directory is known. */
export interface ReplProfileAssembly {
readonly projectCwd: string;
finalize(workspace?: string): Operation<ReplExecutionProfile>;
}

/** One finalized execution profile, shared by validation, descriptions and entries. */
export interface ReplExecutionProfile {
readonly projectCwd?: string;
/** Where a declared name is looked for, outermost first. */
readonly includes: readonly string[];
/**
Expand Down Expand Up @@ -194,8 +195,11 @@ export function* assembleReplProfile(
stack: AgentStack,
plugins: CommandPlugins,
facts: ReplProfileFacts = {},
): Operation<ReplExecutionProfile> {
const includes = Object.freeze([...(facts.includes ?? REPL_INCLUDES)]);
): Operation<ReplProfileAssembly> {
const projectCwd = yield* cwd();
const configuredIncludes = Object.freeze(
(facts.includes ?? REPL_INCLUDES).map((path) => resolve(projectCwd, path)),
);
yield* installAgentProviderStack(stack, facts.acp);
// Settled here, with the stack and the Plugins, because it is a fact about
// the command a person invoked rather than about any conversation they later
Expand All @@ -204,58 +208,81 @@ export function* assembleReplProfile(
// and the one place that applies it is the one place it is right.
const agent = facts.sidekickAgent ?? (yield* contextualAgent());

const plan = yield* planComponentDeclaration({
surface: "component",
...(facts.planWriterRoot === undefined ? {} : { planWriterRoot: facts.planWriterRoot }),
includes,
// The command's own Plugin assembly, not a second installation of it: two
// would be two catalogs claiming the same names.
plugins,
// The same seam `planAgentContext` already takes: production states none and
// gets the real ACPX runtime, and a proof states a scriptable one so a
// journey can drive this exact assembler rather than a copy of it.
context: planAgentContext(stack, facts.acp),
...(stack.sessions === undefined ? {} : { sessions: stack.sessions }),
host: yield* useScope(),
// Plan review is answered by the REPL's own Elicit provider, which the
// session installs around the execution. A Component that installed one of
// its own here would answer the question in front of the person with
// something else.
installElicitation: noElicitationOfItsOwn,
});

// The host's selection, read once in the command scope. A value read per
// entry would be a value a later installation could have changed.
const entryInstallation = yield* ReplEntryHost.get();

const host = yield* useScope();
const dependencies = facts.acp ?? hostAcpDependencies(stack);
const sidekick = sidekickAssembly(agent, {
...dependencies,
agentCwd:
dependencies.agentCwd ??
function* () {
return projectCwd;
},
});
const context = planAgentContext(stack, facts.acp);
const planWriterRoot = facts.planWriterRoot;
const sessions = stack.sessions;
const permissionMode = stack.permissionMode;
const names = Object.freeze(plugins.plugins.map((plugin) => plugin.name));
const installations = Object.freeze([...plugins.installations]);
return Object.freeze({
includes,
vocabulary: entryVocabulary(includes, plugins),
// Read off the admitted assembly here, once, while it is in hand. Copied
// rather than referenced so nothing downstream can reach a Plugin value
// through the profile, and frozen because a profile is read and never
// changed.
plugins: Object.freeze(plugins.plugins.map((plugin) => plugin.name)),
installations: Object.freeze([
...plugins.installations,
Object.freeze({
components: agentIdentityComponents(),
declarations: Object.freeze([plan]),
}),
]),
generated: ordinaryGeneratedSelection(),
permissionMode: stack.permissionMode,
...(entryInstallation === undefined ? {} : { entryInstallation }),
// Settled here, with the stack and the Plugins, because it is a fact about
// the command a person invoked rather than about any conversation they
// later have. Resolved rather than retained as "unstated": a profile that
// carried the absence would leave every reader of it to apply the fallback
// again, and the one place that applies it is the one place it is right.
sidekickAgent: agent,
// Built from the same resolved dependencies the provider stack above was
// installed with, so a journey that scripts the runtime drives this exact
// assembly rather than a second one standing in for it.
sidekickAssembly: sidekickAssembly(agent, facts.acp ?? hostAcpDependencies(stack)),
projectCwd,
*finalize(workspace?: string): Operation<ReplExecutionProfile> {
const includes = Object.freeze([
...configuredIncludes,
...(workspace === undefined ? [] : [join(workspace, "components")]),
]);
const plan = yield* planComponentDeclaration({
surface: "component",
...(planWriterRoot === undefined ? {} : { planWriterRoot }),
includes,
// The command's own Plugin assembly, not a second installation of it: two
// would be two catalogs claiming the same names.
plugins,
// The same seam `planAgentContext` already takes: production states none and
// gets the real ACPX runtime, and a proof states a scriptable one so a
// journey can drive this exact assembler rather than a copy of it.
context,
...(sessions === undefined ? {} : { sessions }),
host,
// Plan review is answered by the REPL's own Elicit provider, which the
// session installs around the execution. A Component that installed one of
// its own here would answer the question in front of the person with
// something else.
installElicitation: noElicitationOfItsOwn,
});

return Object.freeze({
includes,
projectCwd,
vocabulary: entryVocabulary(includes, plugins),
// Read off the admitted assembly here, once, while it is in hand. Copied
// rather than referenced so nothing downstream can reach a Plugin value
// through the profile, and frozen because a profile is read and never
// changed.
plugins: names,
installations: Object.freeze([
...installations,
Object.freeze({
components: agentIdentityComponents(),
declarations: Object.freeze([plan]),
}),
]),
generated: ordinaryGeneratedSelection(),
permissionMode,
...(entryInstallation === undefined ? {} : { entryInstallation }),
// Settled here, with the stack and the Plugins, because it is a fact about
// the command a person invoked rather than about any conversation they
// later have. Resolved rather than retained as "unstated": a profile that
// carried the absence would leave every reader of it to apply the fallback
// again, and the one place that applies it is the one place it is right.
sidekickAgent: agent,
// Built from the same resolved dependencies the provider stack above was
// installed with, so a journey that scripts the runtime drives this exact
// assembly rather than a second one standing in for it.
sidekickAssembly: sidekick,
});
},
});
}

Expand Down
Loading
Loading