Merge v3-t45: core world extensions models, prompt, conversations, definitions (T45, P3-g, P3-e)

# Conflicts:
#	host/js/package.json
This commit is contained in:
2026-11-07 13:31:47 +00:00
13 changed files with 31985 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
# Conversation lifecycle
The `conversations` service exposes metadata subjects and the concluded set.
`concludeIn(tx, ids)` refuses unknown, owned, root, and standing conversations.
`setStanding`, `setAutomaticSummaries`, and `generatedTitle` update durable conversation state.
`generatedTitle` only marks a matching metadata title that no earlier call marked.
The `beforeInput` hook resumes the recipient and its owner/fork ancestors.
This change shares the transaction that places the input.
A write without an input does not resume a conversation.
Unrelated concluded conversations remain concluded.
Summaries do not run in this subset.
`summaryModel()` returns undefined and `requestSummary()` does nothing.
The durable automatic-summary preference remains available for the later summary port.
`host/tests/core_ext.rs` verifies the lifecycle through the real snapshot loader.
+5
View File
@@ -0,0 +1,5 @@
/** Durable conversation lifecycle state and the summary seam for later ports. */
import { defineDoc } from '@earendil-works/pi-durable'
export const ConversationsApi = {name:'conversations'}
export const State = defineDoc({kind:'conversations.state',version:1,scope:'conversation',history:'latest',fork:'initial',initial:()=>({}),checkpointWhen:()=>false})
export const Concluded = defineDoc({kind:'conversations.concluded',version:1,scope:'session',initial:()=>({ids:[]}),checkpointWhen:()=>false})
+43
View File
@@ -0,0 +1,43 @@
/** Conversation metadata and lifecycle. Summaries remain disabled until the summary port supplies them. */
import { defineExtension, GenerationTask, hook, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import { ConversationsApi, State, Concluded } from './api.ts'
export default function conversations(host: any) {
const concludedIn = async (tx: any) => new Set<number>((await tx.doc(Concluded)).ids)
host.implement(ConversationsApi, {
concludedIn,
concluded: async (context: any) => host.harness().commit(concludedIn,context),
concludeIn: async (tx: any, ids: number[]) => {
for (const id of ids) {
const record = await tx.conversation(id)
if (!record) throw new Error(`Unknown conversation ${id}`)
if (record.owner) throw new Error(`Conversation ${id} is owned`)
if (id === ROOT_CONVERSATION_ID || (await tx.doc(State,id)).standing) throw new Error(`Conversation ${id} is fixed`)
}
const state = await tx.doc(Concluded)
state.ids = [...new Set([...state.ids,...ids])]
},
setStanding: async (tx: any, id: number, standing: boolean) => { (await tx.doc(State,id)).standing = standing },
setAutomaticSummaries: async (tx: any, id: number, enabled: boolean) => { (await tx.doc(State,id)).automaticSummaries = enabled },
generatedTitle: async (tx: any, id: number, title: string) => {
const state = await tx.doc(State,id)
if (state.generatedTitle === undefined && (await tx.doc(host.metadata,id)).title === title) state.generatedTitle = title
},
subject: async (id: number, context: any) => {
const metadata = await host.harness().snapshot(host.metadata,id,context)
return metadata?.title ? {title:metadata.title} : {}
},
// Later summaries can supply these without changing lifecycle callers.
summaryModel: async () => undefined,
requestSummary: async () => {},
})
return defineExtension({name:'conversations',hooks:[hook(GenerationTask,{beforeInput:async (_input,{tx,conversationId})=>{
const ancestors = new Set<number>()
for (let id = conversationId; id !== undefined && !ancestors.has(id);) {
ancestors.add(id)
const record = await tx.conversation(id)
id = record?.owner?.conversationId ?? record?.parent?.conversationId
}
const state = await tx.doc(Concluded)
state.ids = state.ids.filter(id=>!ancestors.has(id))
}})]})
}
+22
View File
@@ -0,0 +1,22 @@
# Model selection
The `models` service starts a conversation with `start({agent, cwd, ownership?}, context)`.
The `agent` field names a built-in definition or a project overlay.
The service stores that name in `models.profile`.
It applies the definition during creation and before each new run.
Definitions without an explicit model follow their family, then the default family.
Each resolution reads `/src/sausage/agents/models.json` and the definition files through `host.fs`.
A file edit applies at the next run boundary without a restart.
The project definitions live under `<repo>/.sausage/agents/`.
The existing T37 parser controls validation, overlays, model suffixes, and tool denials.
The optional `models.pin` service supplies `pin(tx, conversationId)` for an external model override.
Without that provider, the definition or default family selects the model.
No login fallback runs.
Existing registered models retain their transport.
New logical model references reuse the host's OpenAI-compatible proxy provider and its auth resolver.
The extension never reads a credential or registers a direct provider SDK.
`host/tests/core_ext.rs` tests these services through the snapshot loader.
+7
View File
@@ -0,0 +1,7 @@
/** Model selection and definition-based conversation creation for coordination extensions. */
import { defineDoc } from '@earendil-works/pi-durable'
export const Profile = defineDoc({kind:'models.profile',version:1,scope:'conversation',history:'latest',fork:'current',initial:()=>({}),checkpointWhen:()=>false})
/** Optional external pin provider. The service returns a logical provider/modelId reference. */
export const Pin = {name:'models.pin'}
/** start({agent, cwd, ownership?}, context) creates a definition-based conversation. */
export const ModelsApi = {name:'models'}
+11
View File
@@ -0,0 +1,11 @@
/** World paths and optional files shared by the core extensions. */
export async function optional(fs: any, path: string): Promise<string | undefined> {
try { return await fs.read(path) } catch (error) { if (error.code === 'not_found') return undefined; throw error }
}
export const parent = (path: string) => path.replace(/\/+$/, '').replace(/\/[^/]+$/, '') || '/'
export async function repoRoot(fs: any, cwd: string): Promise<string> {
for (let at = cwd; ; at = parent(at)) {
try { await fs.stat(`${at}/.git`); return at } catch (error) { if (error.code !== 'not_found') throw error }
if (at === '/') return cwd
}
}
+73
View File
@@ -0,0 +1,73 @@
/** Resolve world families and definitions at each run boundary. All real requests retain the host proxy transport. */
import { AgentDoc, defineExtension, GenerationTask, hook } from '@earendil-works/pi-durable'
import { definitionRuns, discoverAgents, parseDefinition, parseOverlay, parseFamilies, parseFrontmatter, toolsFor } from '../core/agents.ts'
import { ModelsApi, Pin, Profile } from './api.ts'
import { repoRoot } from './files.ts'
export const order = -200
export default function models(host: any) {
const read = async (cwd: string) => {
const families = parseFamilies(await host.fs.read('/src/sausage/agents/models.json'))
const root = await repoRoot(host.fs, cwd)
const files = []
for (const folder of ['/src/sausage/agents', `${root}/.sausage/agents`]) {
let entries
try { entries = await host.fs.list(folder) } catch (error) { if (error.code !== 'not_found') throw error; entries = [] }
for (const entry of entries) if (entry.kind === 'file' && entry.name.endsWith('.md')) {
const path = `${folder}/${entry.name}`
files.push({path, text:await host.fs.read(path)})
}
}
const refs = Object.values(families).map(spec => spec.model.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ''))
// Explicit pins can name a proxy model outside the families.
for (const file of files) {
try {
const parsed = 'addTools' in parseFrontmatter(file.text).frontmatter ? parseOverlay(file.text,file.path).settings : parseDefinition(file.text,file.path)
if (parsed.model) refs.push(parsed.model.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ''))
} catch { /* discoverAgents records invalid files without replacing valid definitions. */ }
}
const found = discoverAgents(files, '/src/sausage', root, families, refs)
return {families, ...found}
}
const transport = host.models().getProvider('openai')
const template = host.models().getModels('openai')[0]
const register = (ref: any) => {
const store = host.models()
if (store.getModel(ref.provider, ref.modelId)) return
if (!transport || !template) throw new Error(`No proxy transport for ${ref.provider}/${ref.modelId}`)
const prior = store.getModels(ref.provider)
store.setProvider({...transport, id:ref.provider, getModels:()=>[...prior, {...template, provider:ref.provider, id:ref.modelId, name:ref.modelId}]})
}
const apply = async (tx: any, id: number) => {
const state = await tx.doc(AgentDoc, id)
const profile = await tx.doc(Profile, id)
const config = await read(state.cwd ?? '/')
const definition = profile.agent ? config.definitions.find(d => d.name === profile.agent) : undefined
if (profile.agent && !definition) throw new Error(`Unknown agent ${profile.agent}: ${JSON.stringify(config.errors)}`)
const selected = definition?.runs ?? definitionRuns({} as any,config.families,Object.values(config.families).map(spec=>spec.model.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, '')))
const pinned = await host.want(Pin)?.pin(tx, id)
const ref = pinned ?? selected.model
register(ref)
state.model = ref
if (selected.thinking) state.thinkingLevel = selected.thinking
else delete state.thinkingLevel
if (definition) {
state.instructions = definition.instructions
if (profile.inheritedTools === undefined) profile.inheritedTools = state.tools ?? null
const inherited = profile.inheritedTools
if (definition.tools || Array.isArray(inherited)) state.tools = toolsFor(definition,inherited ?? [])
else state.tools = {remove:[...new Set([...(inherited?.remove ?? []),...(definition.denyTools ?? [])])]}
}
return ref
}
host.implement(ModelsApi, {
resolve: apply,
start: async ({agent, cwd, ownership = {kind:'ownerless'}}: any, context: any) => {
const conversation = await host.harness().createConversation({ownership, agent:{cwd}, init:async (tx: any, id: number)=>{
(await tx.doc(Profile,id)).agent = agent
await apply(tx,id)
}}, context)
return conversation
},
})
return defineExtension({name:'models', hooks:[hook(GenerationTask,{beforeInput:async (_input, {tx,conversationId})=>{await apply(tx,conversationId)}})]})
}
+14
View File
@@ -0,0 +1,14 @@
# System context
The prompt extension supplies `project_context`, `skills`, `tools`, and `cwd` sections.
The effective environment cwd takes precedence over the configured agent cwd.
Project instructions include each AGENTS.md from the repository root to that cwd, in that order.
Outside a repository, the extension reads the cwd's AGENTS.md only.
Skills come from `/src/sausage/skills/*/SKILL.md` and `<repo>/.sausage/skills/*/SKILL.md`.
The prompt lists each skill's name, description, and path.
A project skill replaces a built-in skill with the same name.
The tools section lists the tools that the resolved agent can use.
No Ultimate build note enters the prompt.
`host/tests/core_ext.rs` tests these sections through the real snapshot loader.
+37
View File
@@ -0,0 +1,37 @@
/** System context from the effective cwd, project instructions, and discoverable world skills. */
import { defineExtension, section } from '@earendil-works/pi-durable'
import { parseFrontmatter } from '../core/agents.ts'
import { optional, parent, repoRoot } from '../models/files.ts'
export const order = -100
export default function prompt(host: any) {
const cwd = (input: any) => input.env?.cwd ?? input.agent.cwd ?? '/'
return defineExtension({name:'prompt',sections:[
section('cwd', input=>cwd(input)),
section('tools', input=>(input.agent.tools ?? []).map(tool=>`- ${tool.name}: ${tool.description}`).join('\n')),
section('project_context', async input=>{
const current = cwd(input), root = await repoRoot(host.fs,current), paths = []
for (let at = current; ; at = parent(at)) { paths.unshift(at); if(at === root || at === '/') break }
const parts = []
for (const path of paths) {
const file = `${path === '/' ? '' : path}/AGENTS.md`, text = await optional(host.fs,file)
if (text !== undefined) parts.push(`<project_instructions path="${file}">\n${text}\n</project_instructions>`)
}
return parts.join('\n\n')
}),
section('skills', async input=>{
const root = await repoRoot(host.fs,cwd(input)), skills = new Map()
for (const folder of ['/src/sausage/skills',`${root}/.sausage/skills`]) {
let entries
try { entries = await host.fs.list(folder) } catch(error) { if(error.code !== 'not_found') throw error; entries = [] }
for (const entry of entries.toSorted((a,b)=>a.name.localeCompare(b.name))) if(entry.kind === 'directory') {
const path = `${folder}/${entry.name}/SKILL.md`, text = await optional(host.fs,path)
if(text === undefined) continue
const {frontmatter} = parseFrontmatter(text)
if(typeof frontmatter.name !== 'string' || typeof frontmatter.description !== 'string') throw new Error(`Invalid skill ${path}`)
skills.set(frontmatter.name, `- ${frontmatter.name}: ${frontmatter.description}\n Path: ${path}`)
}
}
return [...skills.values()].join('\n')
}),
]})
}
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
/** Core extensions run through the real snapshot loader with a faux model. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { createModels } from '@earendil-works/pi-ai/models'
import { fauxProvider, fauxAssistantMessage } from '@earendil-works/pi-ai/providers/faux'
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable'
import { openStorage } from './sqlite'
import { loadExtensions, fs } from './extensions'
const assert = (ok:any,message:string)=>{if(!ok)throw new Error(message);Deno.core.ops.op_output(`ok - ${message}\n`)}
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
const proxyTemplate={...models.getModels('faux')[0],provider:'openai',baseUrl:'https://proxy.invalid/v1'}
models.setProvider({...faux.provider,id:'openai',getModels:()=>[proxyTemplate]})
const storage=await openStorage(globalThis.storagePath), registry=createRegistry()
const harness=await Harness.open(storage,{models,registry,settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context)
await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
const loader=await loadExtensions(harness,registry,storage,models,context)
try {
assert(!loader.state().error,`loader: ${loader.state().error ?? 'loaded'}`)
const {selection,conversations,State,metadata}=globalThis.core
const worker=await selection.start({agent:'worker',cwd:'/project/sub'},context)
let agent=await worker.agent(context)
assert(agent.model.modelId==='faux-1' && agent.thinkingLevel==='low','worker fast family and thinking')
assert(agent.tools.map(t=>t.name).join(',')==='read,write','worker tools and project overlay')
assert(agent.instructions.includes('Worker body') && agent.instructions.includes('Project body'),'definition body and overlay')
const sections:any={}
for(const section of agent.sections) sections[section.key]=await section.render({conversationId:worker.id,agent},context)
assert(sections.project_context.includes('Root instructions') && sections.project_context.includes('Nested instructions'),'cwd and parent AGENTS.md')
assert(!sections.project_context.includes('Outside instructions'),'instructions stop at repo root')
assert(sections.skills.includes('builtin-skill') && sections.skills.includes('project-skill'),'builtin and project skills')
assert(sections.cwd==='/project/sub' && sections.tools.includes('read'),'cwd and tools sections')
const seed=await harness.commit(tx=>tx.appendEntry(worker.id,{kind:'test.seed'}),context)
const child=await worker.fork(seed.id,{ownership:{kind:'ownerless'}},context)
const unrelated=await harness.createConversation({ownership:{kind:'ownerless'}},context)
await harness.commit(async tx=>{
await conversations.concludeIn(tx,[worker.id,child.id,unrelated.id])
await conversations.setAutomaticSummaries(tx,worker.id,false)
;(await tx.doc(metadata,worker.id)).title='Generated'
await conversations.generatedTitle(tx,worker.id,'Generated')
},context)
assert((await harness.snapshot(State,worker.id,context)).automaticSummaries===false,'automatic summary seam')
assert((await harness.snapshot(State,worker.id,context)).generatedTitle==='Generated','generated title')
await child.submit({type:'write',entry:{kind:'test.note'}},context)
assert((await conversations.concluded(context)).size===3,'write does not resume')
faux.setResponses([fauxAssistantMessage('done')])
const receipt=await child.submit({type:'input',content:'resume'},context)
assert(JSON.stringify([...await conversations.concluded(context)])===JSON.stringify([unrelated.id]),'input placement atomically resumes ancestors')
harness.resume();await receipt.wait(context)
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'faux/faux-1',thinking:'high'}}))
faux.setResponses([fauxAssistantMessage('next')])
await (await worker.submit({type:'input',content:'next'},context)).wait(context)
agent=await worker.agent(context)
assert(agent.thinkingLevel==='high','model config edit applies next turn')
// A second faux model proves selection changes, not only the thinking level.
const provider=models.getProvider('faux'), template=models.getModels('faux')[0]
models.setProvider({...provider,getModels:()=>[template,{...template,id:'second'}]})
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'faux/second'}}))
faux.setResponses([fauxAssistantMessage('second')])
await (await worker.submit({type:'input',content:'second'},context)).wait(context)
assert((await harness.snapshot(AgentDoc,worker.id,context)).model.modelId==='second','edited family changes next model without restart')
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'logical/new'}}))
faux.setResponses([fauxAssistantMessage('proxy')])
await (await worker.submit({type:'input',content:'proxy'},context)).wait(context)
assert(models.getModel('logical','new').baseUrl===proxyTemplate.baseUrl && models.getProvider('logical').auth===models.getProvider('openai').auth,'new model retains proxy transport and auth')
globalThis.modelPin={provider:'faux',modelId:'faux-1'}
faux.setResponses([fauxAssistantMessage('pin')])
await (await worker.submit({type:'input',content:'pin'},context)).wait(context)
assert((await worker.agent(context)).model.provider==='faux','models.pin overrides family selection')
await harness.commit(tx=>conversations.setStanding(tx,worker.id,true),context)
let refused=false;try{await harness.commit(tx=>conversations.concludeIn(tx,[worker.id]),context)}catch{refused=true}
assert(refused,'standing conversation cannot conclude')
} finally {await loader.close();await harness.close(context)}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js" },
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
+161
View File
@@ -0,0 +1,161 @@
//! Core world extensions execute through the snapshot loader and the actual VFS.
use sausage::{
exec::{Command, Exec, Options, Stream},
world::World,
};
use std::{path::Path, sync::Arc};
const BUNDLE: &str = include_str!("../js/core-ext-test-bundle.js");
struct Author(Arc<dyn Exec>);
#[async_trait::async_trait]
impl Exec for Author {
async fn exec(
&self,
command: Command,
mut options: Options,
output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send),
) -> anyhow::Result<i32> {
for (key, value) in [
("GIT_AUTHOR_NAME", "Test"),
("GIT_AUTHOR_EMAIL", "test@example.com"),
("GIT_COMMITTER_NAME", "Test"),
("GIT_COMMITTER_EMAIL", "test@example.com"),
] {
options.env.push((key.into(), value.into()));
}
self.0.exec(command, options, output).await
}
}
fn copy_tree(from: &Path, to: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let dest = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &dest)?;
} else {
std::fs::copy(entry.path(), dest)?;
}
}
Ok(())
}
fn write(root: &Path, path: &str, text: &str) -> anyhow::Result<()> {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, text)?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn core_extensions() -> anyhow::Result<()> {
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let userland = repo.join("userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
if !userland.join("cwd-init/init.o").exists() {
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
let disk = dir.path().join("world");
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
disk.join("bin/git.wasm"),
)?;
let source = disk.join("src/sausage");
for name in ["core", "models", "prompt", "conversations"] {
copy_tree(
&repo.join("extensions").join(name),
&source.join("extensions").join(name),
)?;
}
write(
&source,
"agents/worker.md",
"---\nname: worker\ndescription: Worker\nfamily: fast\ntools: [read]\n---\nWorker body",
)?;
write(
&source,
"agents/models.json",
r#"{"default":{"model":"faux/faux-1"},"fast":{"model":"faux/faux-1","thinking":"low"}}"#,
)?;
write(
&source,
"skills/builtin/SKILL.md",
"---\nname: builtin-skill\ndescription: Builtin skill\n---\nInstructions",
)?;
write(
&source,
"extensions/fixture/server.ts",
r#"import {defineExtension,defineTool} from '@earendil-works/pi-durable';
import {obj} from '@earendil-works/pi-schema';
import {State} from '../conversations/api.ts';
export const order=500;
export default host=>{host.implement({name:'models.pin'},{pin:async()=>globalThis.modelPin});host.onOpen(async()=>{globalThis.core={selection:host.need({name:'models'}),conversations:host.need({name:'conversations'}),State,metadata:host.metadata}});return defineExtension({name:'fixture',tools:['read','write'].map(name=>defineTool({name,description:name,parameters:obj({}),execute:async()=>({output:[]})}))})};"#,
)?;
write(&disk, "AGENTS.md", "Outside instructions")?;
write(&disk, "project/.git/HEAD", "ref: refs/heads/main")?;
write(&disk, "project/AGENTS.md", "Root instructions")?;
write(&disk, "project/sub/AGENTS.md", "Nested instructions")?;
write(
&disk,
"project/.sausage/agents/worker.md",
"---\nname: worker\naddTools: [write]\n---\nProject body",
)?;
write(
&disk,
"project/.sausage/skills/project/SKILL.md",
"---\nname: project-skill\ndescription: Project skill\n---\nInstructions",
)?;
assert!(
std::process::Command::new("git")
.args(["init", "-b", "main"])
.arg(&source)
.status()?
.success()
);
assert!(
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&source)
.status()?
.success()
);
assert!(
std::process::Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--no-gpg-sign",
"-m",
"core fixtures"
])
.current_dir(&source)
.status()?
.success()
);
let exec: Arc<dyn Exec> = Arc::new(Author(sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?));
sausage::harness::run_module(world, exec, BUNDLE).await
}