From 327bdc945bdac06fb39012bd8cc96de316ea4cad Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 24 Jul 2026 22:09:14 +0300 Subject: [PATCH 1/2] spike(the-framework): gates bind for topics Await-gates variant of #1121: a project-less topic run (#1120) ends a turn on an await-bind-project / await-create-project block; the framework resolves it by registering + binding the project and recording the bind. Reuses the same bind recording shapes as the MCP-tools spike (bind control entry, RunMeta.boundProjectId, bind event, terminal line) so only the trigger differs. The gate is taught via the runtime append layer (system-prompt.ts, topic-only), so no base-prompt edit and the prompt-drift check stays green. Spike for #1121, part of the #1129 decision. --- packages/the-framework/src/await-gate.ts | 90 ++++++++- packages/the-framework/src/cli.ts | 33 +++- packages/the-framework/src/control.ts | 8 + packages/the-framework/src/events.ts | 7 + .../the-framework/src/projects-gate.test.ts | 175 ++++++++++++++++++ packages/the-framework/src/prompt-run.ts | 12 +- packages/the-framework/src/run.ts | 14 +- packages/the-framework/src/store/run-store.ts | 9 + packages/the-framework/src/system-prompt.ts | 31 +++- packages/the-framework/src/terminal.ts | 2 + packages/the-framework/src/turn-gate.ts | 59 +++++- 11 files changed, 424 insertions(+), 16 deletions(-) create mode 100644 packages/the-framework/src/projects-gate.test.ts diff --git a/packages/the-framework/src/await-gate.ts b/packages/the-framework/src/await-gate.ts index 655b89274..52864087a 100644 --- a/packages/the-framework/src/await-gate.ts +++ b/packages/the-framework/src/await-gate.ts @@ -3,7 +3,10 @@ import { BROWSER_NOT_HANDLED, CONFIRM_APPROVED, CONFIRM_DECLINED, + CREATE_PROJECT_APPROVE, + CREATE_PROJECT_DECLINE, MAX_AWAIT_ROUNDS, + NO_PROJECTS_TO_BIND, PLAN_DECLINED_MESSAGE, continuationPrompt, isDeclinedConfirmation, @@ -20,6 +23,36 @@ import type { RunMessages } from './run-messages.js' // what removed the run <-> todo-loop cycle. run.ts composes these primitives into a lifecycle; // it does not own them. +/** One project the bind gate (#1121) can offer or register: the minimal fields the resolver needs. */ +export interface BindProjectChoice { + id: string + path: string +} + +/** + * The registry + recording seams a bind gate (#1121) resolves against, injected so the await + * machinery stays orchestrator-free: the CLI wires these to the real registry + control channel, + * a test to spies. Present only for a project-less topic run (#1120); absent for every other run. + */ +export interface BindProjectDeps { + /** The projects an `await-bind-project` gate offers, from the registry. */ + listProjects: () => Promise + /** Register (idempotent) a project by path, for an `await-create-project` gate. */ + addProject: (path: string) => Promise + /** Record the bind onto the run once a project is picked or created (#1121). */ + recordBind: (projectId: string) => void +} + +/** The gate-id stems by kind, so a POST-back is matched to the gate that asked (round 0 keeps it). */ +const GATE_BASE_ID: Record = { + choices: 'await-choices', + multi: 'await-multiselect', + confirm: 'await-confirmation', + browser: 'await-browser', + 'bind-project': 'await-bind-project', + 'create-project': 'await-create-project', +} + /** * Resolve one parsed await gate (#337/#339) to the user's answer text, ready to * seed the continuation prompt: emits the `choice`, parks for the pick (or the @@ -35,18 +68,13 @@ export async function resolveAwaitGate( requestChoice?: ((req: ChoiceRequest) => Promise) | undefined emit: (event: FrameworkEvent) => void signal?: AbortSignal | undefined + /** The bind seams for a topic run (#1121); absent for every other run. */ + bind?: BindProjectDeps | undefined }, ): Promise { const signalOpt = deps.signal ? { signal: deps.signal } : {} const choiceOpt = deps.requestChoice ? { requestChoice: deps.requestChoice } : {} - const baseId = - gate.kind === 'multi' - ? 'await-multiselect' - : gate.kind === 'confirm' - ? 'await-confirmation' - : gate.kind === 'browser' - ? 'await-browser' - : 'await-choices' + const baseId = GATE_BASE_ID[gate.kind] const id = round === 0 ? baseId : `${baseId}-${round}` if (gate.kind === 'browser') { // The agent is stuck on a page and needs a human to act on it (#796). Rides the same @@ -94,6 +122,47 @@ export async function resolveAwaitGate( const labels = gate.options.filter(o => picked.includes(o.id)).map(o => o.label) return labels.length ? labels.join(', ') : '(none)' } + if (gate.kind === 'bind-project') { + // A project-less topic run (#1120) picks from the registered projects (#1121). The framework + // fills the options from the registry, not the agent's block, so it can never offer one that + // is gone. With no registry wired or nothing registered, there is nothing to bind to. + const projects = deps.bind ? await deps.bind.listProjects() : [] + if (!deps.bind || projects.length === 0) return NO_PROJECTS_TO_BIND + const picked = await requestChoices({ + id, + title: gate.title, + options: projects.map(p => ({ id: p.id, label: p.path })), + emit: deps.emit, + ...choiceOpt, + ...signalOpt, + }) + const project = projects.find(p => p.id === picked) + if (!project) return NO_PROJECTS_TO_BIND + // #1122 re-homes the run into the bound project's worktree; here the bind is only recorded. + deps.bind.recordBind(project.id) + return `Bound this run to ${project.path}.` + } + if (gate.kind === 'create-project') { + // Registering a path grants the app filesystem access to it, so the confirmation IS the grant: + // recommended Approve, like the plan gate (#358). Declining just keeps the run project-less. + const picked = await requestChoices({ + id, + title: gate.path ? `${gate.title} (${gate.path})` : gate.title, + options: [ + { id: 'approve', label: CREATE_PROJECT_APPROVE }, + { id: 'decline', label: CREATE_PROJECT_DECLINE }, + ], + recommended: 'approve', + confirm: true, + emit: deps.emit, + ...choiceOpt, + ...signalOpt, + }) + if (picked === 'decline' || !gate.path || !deps.bind) return 'You chose not to register a project; continue without one.' + const project = await deps.bind.addProject(gate.path) + deps.bind.recordBind(project.id) + return `Registered and bound this run to ${project.path}.` + } const pickedId = await requestChoices({ id, title: gate.title, @@ -143,6 +212,8 @@ export interface AwaitRoundsOptions { * persisting must never stall or fail a run. Unset for a headless run, which has no chat. */ recordMessage?: RecordMessage | undefined + /** The bind seams for a project-less topic run (#1121); absent for every other run. */ + bind?: BindProjectDeps | undefined } /** @@ -160,6 +231,8 @@ export interface AwaitTurnDeps { emitTurnSignals: (text: string) => void signal?: AbortSignal | undefined recordMessage?: RecordMessage | undefined + /** The bind seams for a project-less topic run (#1121); absent for every other run. */ + bind?: BindProjectDeps | undefined } /** @@ -256,6 +329,7 @@ export async function runAwaitRounds(opts: AwaitRoundsOptions): Promise { // Assigned once the journal exists, which is after the control watcher is wired: a change that // arrives before then still lands on `armedHandoff`, it just has no event to announce it yet. let announceHandoff: ((push: boolean, pr: boolean) => void) | undefined + // Same deferral as announceHandoff: an await-bind-project / await-create-project gate binds this + // topic run to a project (#1121, gates spike), and only an event puts that on the meta a later- + // opened tab can read. Wired once the journal exists (below). + let recordBind: ((projectId: string) => void) | undefined let control: ControlWatcher | undefined if (isSteerable(opts, newDashboard, await daemonStatus() !== undefined)) { @@ -1452,6 +1456,11 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise { announceHandoff?.(armedHandoff.push, armedHandoff.pr) return } + if (entry.kind === 'bind') { + // #1122 re-homes the run into the bound project's worktree; here we only record it. + recordBind?.(entry.projectId) + return + } const resolve = pendingChoices.get(entry.id) if (resolve) { pendingChoices.delete(entry.id) @@ -1527,6 +1536,8 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise { // only thing a dashboard tab opened mid-run can read the boxes back from. announceHandoff = (push, pr) => onEvent({ kind: 'handoff-armed', push, pr }) announceHandoff(armedHandoff.push, armedHandoff.pr) + // Wired now the journal exists: a bind resolved from a topic run's gate folds to meta (#1121). + recordBind = projectId => onEvent({ kind: 'bind', projectId }) // Fire the #326 on-before-mergeable prompt once a --on-before-mergeable run has settled and the agent // signalled setReadyForMerge(). Skipped for a fake/offline run and when the run was stopped — @@ -1709,11 +1720,27 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise { // it reads, and who can answer it. They were written out twice, thirteen conditional spreads // each, so a new one had to be added to both by hand — and a run started as a build and a run // started as a prompt are the same run in every respect but the scaffolding around the prompt. + // A project-less topic run (#1120) can bind to a project mid-run via an await gate (#1121, gates + // spike): the resolver lists / registers projects against the real registry and signals the bind + // over the control channel, which the watcher above folds to the run's meta. Topic runs only. + const bindDeps: BindProjectDeps | undefined = opts.topic + ? { + listProjects: () => listProjects(), + addProject: async path => { + const record = await addProject(path, new Date().toISOString()) + return { id: record.id, path: record.path } + }, + recordBind: projectId => void appendControl(cwd, { kind: 'bind', projectId }), + } + : undefined + const sharedRunOptions = { driver, cwd, onEvent, signal: controller.signal, + ...(opts.topic ? { topic: true } : {}), + ...(bindDeps ? { bind: bindDeps } : {}), ...(requestChoice ? { requestChoice } : {}), ...chatQueue, ...(recordMessage ? { recordMessage } : {}), diff --git a/packages/the-framework/src/control.ts b/packages/the-framework/src/control.ts index 0e534aa59..a65df1e27 100644 --- a/packages/the-framework/src/control.ts +++ b/packages/the-framework/src/control.ts @@ -40,6 +40,12 @@ export type ControlEntry = * event, which is what puts it on the meta the checkboxes read. */ | { kind: 'handoff'; push: boolean; pr: boolean } + /** + * Bind a project-less topic run to a project (#1121): the await-gate resolver appends this once + * it registers + binds the picked project, and the run folds `projectId` onto its meta. Same + * shape as the tools-variant spike, so only the trigger differs. The worktree re-home is #1122. + */ + | { kind: 'bind'; projectId: string } /** The control log path for a workspace. */ export function controlPath(cwd: string): string { @@ -93,6 +99,8 @@ function isControlEntry(value: unknown): value is ControlEntry { // Both halves must be real booleans: a half-written entry would otherwise disarm by accident, // and this decides whether the session's work reaches the remote at all. if (v['kind'] === 'handoff') return typeof v['push'] === 'boolean' && typeof v['pr'] === 'boolean' + // A bind needs a non-empty projectId; it decides which project the run re-homes into (#1121). + if (v['kind'] === 'bind') return typeof v['projectId'] === 'string' && v['projectId'].length > 0 // `via` is optional (older entries have none), but a present one must be a safe transport name: // it is written into a line-parsed conversation heading, and a surface names itself (#917). if (v['kind'] === 'message') { diff --git a/packages/the-framework/src/events.ts b/packages/the-framework/src/events.ts index 6367950f3..15d29198f 100644 --- a/packages/the-framework/src/events.ts +++ b/packages/the-framework/src/events.ts @@ -209,6 +209,13 @@ export type FrameworkEvent = * a status that only changes when the run ends. */ | { kind: 'settled' } + /** + * A project-less topic run (#1120) bound itself to a project (#1121): the agent ended a turn on + * an `await-bind-project` / `await-create-project` gate, which the framework resolved by + * registering + binding the project. Recorded so the run's meta names the project it re-homes + * into (#1122). Spike: the await-gates bind variant (the trigger differs from the tools variant). + */ + | { kind: 'bind'; projectId: string } /** * Cumulative token + cost usage for the run so far (#322). Emitted after each * agent turn that reports usage; the dashboard renders a live spend readout and diff --git a/packages/the-framework/src/projects-gate.test.ts b/packages/the-framework/src/projects-gate.test.ts new file mode 100644 index 000000000..a7fbcefaf --- /dev/null +++ b/packages/the-framework/src/projects-gate.test.ts @@ -0,0 +1,175 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { resolveAwaitGate, type BindProjectDeps } from './await-gate.js' +import { parseAwaitGate, parseBindProjectGate, parseCreateProjectGate, NO_PROJECTS_TO_BIND } from './turn-gate.js' +import { addProject, listProjects, projectId, type RegistryFs } from './registry.js' +import { appendControl, watchControl, type ControlEntry } from './control.js' +import { metaFromEvents } from './store/run-store.js' +import { composeRunSystem, TOPIC_BIND_PROTOCOL } from './system-prompt.js' +import { SIGNAL_PROTOCOL } from './turn-gate.js' +import type { FrameworkEvent } from './events.js' + +// The gates spike for #1121: a project-less topic run (#1120) ends a turn on an `await-bind-project` +// / `await-create-project` block, and the framework resolves it by registering + binding the +// project. Mirrors projects-mcp.test.ts so the two spikes are comparable — only the trigger differs. + +const ISO = '2026-07-24T00:00:00.000Z' +const ENV = { XDG_CONFIG_HOME: '/cfg' } + +/** An in-memory {@link RegistryFs} so the registry round-trips without touching disk. */ +function memFs(): RegistryFs { + const files = new Map() + return { + async read(path) { + const v = files.get(path) + if (v === undefined) throw new Error(`ENOENT: ${path}`) + return v + }, + async write(path, contents) { + files.set(path, contents) + }, + async mkdir() {}, + async rename(from, to) { + files.set(to, files.get(from) ?? '') + files.delete(from) + }, + async chmod() {}, + } +} + +/** A {@link BindProjectDeps} bound to an in-memory registry, recording each bind into `recorded`. */ +function memBind(fs: RegistryFs, recorded: string[]): BindProjectDeps { + return { + listProjects: async () => (await listProjects(fs, ENV)).map(p => ({ id: p.id, path: p.path })), + addProject: async path => { + const record = await addProject(path, ISO, fs, ENV) + return { id: record.id, path: record.path } + }, + recordBind: id => recorded.push(id), + } +} + +const createBlock = (body: string) => `I need a repo to work in.\n\n\`\`\`await-create-project\n${body}\n\`\`\`` +const bindBlock = (body: string) => `Which project?\n\n\`\`\`await-bind-project\n${body}\n\`\`\`` + +test('parseAwaitGate recognises the bind + create-project gates alongside the other kinds (#1121)', () => { + assert.equal(parseAwaitGate(bindBlock('{ "title": "Pick one" }'))?.kind, 'bind-project') + const create = parseAwaitGate(createBlock('{ "title": "Register it", "path": "/repos/app" }')) + assert.equal(create?.kind, 'create-project') + assert.equal(create?.kind === 'create-project' ? create.path : '', '/repos/app') +}) + +test('parseCreateProjectGate reads the path, falls back on a blank title, and drops a non-string path', () => { + assert.deepEqual(parseCreateProjectGate(createBlock('{ "title": "New app", "path": "/repos/app" }')), { + title: 'New app', + path: '/repos/app', + }) + assert.deepEqual(parseCreateProjectGate(createBlock('{ "path": "/repos/app" }')), { + title: 'Register and bind this project?', + path: '/repos/app', + }) + assert.deepEqual(parseCreateProjectGate(createBlock('{ "title": "New app", "path": 42 }')), { title: 'New app' }) +}) + +test('parseBindProjectGate triggers even on an empty body, since the framework supplies the options', () => { + assert.deepEqual(parseBindProjectGate(bindBlock('{}')), { title: 'Bind this run to a project' }) + assert.deepEqual(parseBindProjectGate(bindBlock('not json')), { title: 'Bind this run to a project' }) + assert.equal(parseBindProjectGate('all done'), undefined) +}) + +test('resolving an await-create-project gate registers the project and records the bind (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const repo = resolve('/repos/my-app') + // No requestChoice: the headless path auto-accepts the recommended Approve, which IS the grant. + const answer = await resolveAwaitGate({ kind: 'create-project', title: 'Register it?', path: repo }, 0, { + emit: () => {}, + bind: memBind(fs, recorded), + }) + + assert.match(answer, /Registered and bound/) + assert.deepEqual(recorded, [projectId(repo)]) + const listed = await listProjects(fs, ENV) + assert.equal(listed.length, 1) + assert.equal(listed[0]!.path, repo) +}) + +test('a declined await-create-project gate registers nothing and binds nothing (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const answer = await resolveAwaitGate({ kind: 'create-project', title: 'Register it?', path: resolve('/repos/no') }, 0, { + emit: () => {}, + requestChoice: async () => ({ picked: 'decline' }), + bind: memBind(fs, recorded), + }) + + assert.match(answer, /not to register/) + assert.deepEqual(recorded, []) + assert.deepEqual(await listProjects(fs, ENV), []) +}) + +test('resolving an await-bind-project gate picks a registered project and records the bind (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const repo = resolve('/repos/existing') + await addProject(repo, ISO, fs, ENV) + + const answer = await resolveAwaitGate({ kind: 'bind-project', title: 'Which project?' }, 0, { + emit: () => {}, + requestChoice: async req => { + // The framework filled the options from the registry, not the agent's block. + assert.deepEqual(req.options.map(o => o.label), [repo]) + return { picked: projectId(repo) } + }, + bind: memBind(fs, recorded), + }) + + assert.match(answer, /Bound this run to/) + assert.deepEqual(recorded, [projectId(repo)]) +}) + +test('an await-bind-project gate with an empty registry has nothing to bind to (#1121)', async () => { + const recorded: string[] = [] + const answer = await resolveAwaitGate({ kind: 'bind-project', title: 'Which project?' }, 0, { + emit: () => {}, + bind: memBind(memFs(), recorded), + }) + assert.equal(answer, NO_PROJECTS_TO_BIND) + assert.deepEqual(recorded, []) +}) + +test('a topic run advertises the bind gate; a normal run does not, and the signal protocol stays last (#1121/#547)', () => { + const topic = composeRunSystem({ topic: true }) + assert.ok(topic.includes(TOPIC_BIND_PROTOCOL), 'a topic run is told it can bind to a project') + assert.ok(topic.includes('await-bind-project') && topic.includes('await-create-project')) + assert.ok(topic.endsWith(SIGNAL_PROTOCOL), 'the signal protocol is still the last thing in the channel') + assert.ok(!composeRunSystem().includes(TOPIC_BIND_PROTOCOL), 'a normal run says nothing about binding') +}) + +test('a bind event folds onto the run meta as boundProjectId (#1121)', () => { + const id = projectId(resolve('/repos/my-app')) + const events: FrameworkEvent[] = [{ kind: 'bind', projectId: id }] + assert.equal(metaFromEvents(events, ISO).boundProjectId, id) +}) + +test('recordBind signals the run over the control channel, and watchControl parses the bind (#1121)', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'framework-gate-bind-')) + const seen: ControlEntry[] = [] + const watcher = watchControl(cwd, e => seen.push(e), 20) + try { + const fs = memFs() + const repo = resolve('/repos/wired') + // The CLI wires recordBind to the control channel; here we exercise that real chain end to end. + const bind: BindProjectDeps = { ...memBind(fs, []), recordBind: id => void appendControl(cwd, { kind: 'bind', projectId: id }) } + await resolveAwaitGate({ kind: 'create-project', title: 'Register it?', path: repo }, 0, { emit: () => {}, bind }) + + for (let waited = 0; waited < 3000 && seen.length === 0; waited += 20) await new Promise(r => setTimeout(r, 20)) + assert.deepEqual(seen, [{ kind: 'bind', projectId: projectId(repo) }]) + } finally { + watcher.close() + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/the-framework/src/prompt-run.ts b/packages/the-framework/src/prompt-run.ts index 5ede138a5..7177157f7 100644 --- a/packages/the-framework/src/prompt-run.ts +++ b/packages/the-framework/src/prompt-run.ts @@ -1,6 +1,6 @@ import type { Driver, DriverSession } from './driver/index.js' import { type ChoicePick, type ChoiceRequest, type FrameworkEvent } from './events.js' -import { runAwaitRounds, type RecordMessage } from './await-gate.js' +import { runAwaitRounds, type BindProjectDeps, type RecordMessage } from './await-gate.js' import { composeRunSystem, renderSystemPrompt, type EcoOptions, type TfContext } from './system-prompt.js' import { createRunControls, emitSessionStart, endStopDetail } from './run-telemetry.js' import { createTurnSignalEmitter } from './turn-gate.js' @@ -42,6 +42,13 @@ export interface RunPromptOptions { antiLazyPill?: boolean /** This run has a real browser (#824), so the system channel says so. */ browser?: boolean + /** + * This is a project-less "topic" run (#1120): advertise the bind gate (#1121) in the system + * channel and wire {@link bind} so an `await-bind-project` / `await-create-project` gate resolves. + */ + topic?: boolean + /** The bind seams (#1121) a topic run's gate resolves against. Only meaningful with {@link topic}. */ + bind?: BindProjectDeps /** Transparent mode (#625): empty the system channel and pass the prompt verbatim (raw `claude -p`). */ transparent?: boolean /** Whether autopilot mode is on: steers the #326 prompt's maintenance stance (#325). Default false. */ @@ -116,7 +123,7 @@ export async function runPrompt(opts: RunPromptOptions): Promise declineController.abort(new Error('[framework] plan declined')), + // Topic runs only (#1121): resolves an await-bind-project / await-create-project gate. + ...(opts.bind ? { bind: opts.bind } : {}), } let preview: AppPreview | undefined try { @@ -538,6 +548,8 @@ function agentAwaitGate( signal?: AbortSignal /** Called when a confirmation gate is declined (#358): the run stops instead of building on. */ onDecline?: () => void + /** The bind seams for a topic run (#1121); absent for every other run. */ + bind?: BindProjectDeps }, ): (ctx: BuildContext) => Promise { return async ctx => { diff --git a/packages/the-framework/src/store/run-store.ts b/packages/the-framework/src/store/run-store.ts index 8c2681024..db56ac00e 100644 --- a/packages/the-framework/src/store/run-store.ts +++ b/packages/the-framework/src/store/run-store.ts @@ -153,6 +153,12 @@ export interface RunMeta { * restart loses), and so teardown retains vs removes its scratch dir by the same policy as a worktree. */ topic?: true + /** + * The project a topic run (#1120) bound itself to (#1121): set from a `bind` event when the + * agent resolves an `await-bind-project` / `await-create-project` gate. Present means the run is + * no longer project-less; the worktree re-home it implies is #1122. + */ + boundProjectId?: string } /** @@ -274,6 +280,9 @@ export function applyEventToMeta(meta: RunMeta, event: FrameworkEvent, at: strin case 'settled': next.settledAt = at break + case 'bind': + next.boundProjectId = event.projectId + break case 'driver': // Any new turn means the agent is working again, so the run is no longer parked (#785). if (event.event.type === 'start') delete next.settledAt diff --git a/packages/the-framework/src/system-prompt.ts b/packages/the-framework/src/system-prompt.ts index 575e848a5..eb1707dfa 100644 --- a/packages/the-framework/src/system-prompt.ts +++ b/packages/the-framework/src/system-prompt.ts @@ -2,6 +2,27 @@ import { renderTemplate } from './prompt-template.js' import { SYSTEM_PROMPT } from './prompts.generated.js' import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js' +/** + * Topic-run bind protocol (#1121, gates spike): told only to a project-less "topic" run (#1120) so + * the agent knows it can bind to a project mid-run, and how. It reuses the await-block emit format + * {@link AWAIT_PROTOCOL} already taught, so it only names the two topic-specific tags. It lives in + * this runtime append layer (a plain string) rather than the drift-guarded base prompt, so this + * spike needs no `.md` edit and a normal run's channel stays byte-identical (#547). Kept topic-only. + */ +export const TOPIC_BIND_PROTOCOL = [ + '## Binding this run to a project', + 'This run started with no project, in a scratch directory with no repo. When your work needs a real project to act on, end your turn with one fenced block, then stop.', + 'To bind to a project that is already registered, tag it `await-bind-project` (the framework shows you the list of projects to pick from):', + '```await-bind-project', + '{ "title": "" }', + '```', + 'To register a new project by its absolute path and bind this run to it, tag it `await-create-project`:', + '```await-create-project', + '{ "title": "", "path": "" }', + '```', + 'The framework registers and binds it, then re-prompts you with the result. Do not bind unless the work actually needs a repo.', +].join('\n') + // No Node imports here, deliberately. This module composes the prompt and the // dashboard renders it in the browser (#520), so reading the user's SYSTEM.md off // disk lives in `system-prompt-file.ts` instead. Keep it that way: one `node:fs` @@ -232,6 +253,11 @@ export interface SystemPromptOptions { * them — so it reaches for `WebFetch`, and the browser (and its preview) sits unused. */ browser?: boolean | undefined + /** + * This is a project-less "topic" run (#1120). Appends {@link TOPIC_BIND_PROTOCOL} so the agent + * knows it can bind to a project mid-run (#1121). Topic-only: a normal run's channel is unchanged. + */ + topic?: boolean | undefined } /** @@ -293,5 +319,8 @@ export function composeRunSystem(opts: RunSystemOptions = {}): string { // tools are there either way. // Ahead of the protocols, so the signal protocol stays the last thing in the channel (#547). const browser = opts.browser ? [BROWSER_PROTOCOL] : [] - return [...(promptBlock ? [promptBlock] : []), ...browser, AWAIT_PROTOCOL, SIGNAL_PROTOCOL].join('\n\n') + // Topic-run bind (#1121): rides with the await protocol (it is an await gate), so it sits right + // after it and keeps the signal protocol last (#547). Topic-only, so a normal channel is unchanged. + const topicBind = opts.topic ? [TOPIC_BIND_PROTOCOL] : [] + return [...(promptBlock ? [promptBlock] : []), ...browser, AWAIT_PROTOCOL, ...topicBind, SIGNAL_PROTOCOL].join('\n\n') } diff --git a/packages/the-framework/src/terminal.ts b/packages/the-framework/src/terminal.ts index 7f1293e49..cf05c9506 100644 --- a/packages/the-framework/src/terminal.ts +++ b/packages/the-framework/src/terminal.ts @@ -32,6 +32,8 @@ export function formatFrameworkEvent(event: FrameworkEvent): string { return `✓ ready for merge` case 'settled': return `◆ done for now — waiting for your next message` + case 'bind': + return `◆ bound to project ${event.projectId}` case 'on-before-mergeable': switch (event.outcome) { case 'queued': diff --git a/packages/the-framework/src/turn-gate.ts b/packages/the-framework/src/turn-gate.ts index 9b0ceb1de..b7799fd71 100644 --- a/packages/the-framework/src/turn-gate.ts +++ b/packages/the-framework/src/turn-gate.ts @@ -74,12 +74,38 @@ export interface ParsedBrowserGate { url?: string } +/** + * A project-less topic run (#1120) asking to bind to one of the projects already registered, + * parsed from an `await-bind-project` block (#1121). The options are not in the block: the + * framework fills them from the registry at resolution time, so the agent never has to know + * (or guess) which projects exist. + */ +export interface ParsedBindProjectGate { + /** The question shown above the project list. */ + title: string +} + +/** + * A project-less topic run (#1120) asking to register a new project by path and bind to it, + * parsed from an `await-create-project` block (#1121). The confirmation IS the permission grant + * (registering a path hands the app filesystem access to it), so resolution recommends Approve, + * like {@link ParsedConfirmationGate}. + */ +export interface ParsedCreateProjectGate { + /** The question shown above the Approve / Decline buttons. */ + title: string + /** The absolute repo path to register, when the agent named one. */ + path?: string +} + /** A parsed await gate: any kind, discriminated by `kind`. */ export type ParsedAwaitGate = | ({ kind: 'choices' } & ParsedChoicesGate) | ({ kind: 'multi' } & ParsedMultiSelectGate) | ({ kind: 'confirm' } & ParsedConfirmationGate) | ({ kind: 'browser' } & ParsedBrowserGate) + | ({ kind: 'bind-project' } & ParsedBindProjectGate) + | ({ kind: 'create-project' } & ParsedCreateProjectGate) /** The answer a resolved confirmation gate (#358) yields: the picked button's label. */ export const CONFIRM_APPROVED = 'Approve' @@ -89,6 +115,13 @@ export const CONFIRM_DECLINED = 'Decline' export const BROWSER_HANDLED = 'Handled it' export const BROWSER_NOT_HANDLED = 'Could not handle it' +/** The Approve / Decline buttons a create-project gate (#1121) shows. */ +export const CREATE_PROJECT_APPROVE = 'Register and bind' +export const CREATE_PROJECT_DECLINE = 'Not now' + +/** The answer a bind-project gate yields when the registry is empty — nothing to pick (#1121). */ +export const NO_PROJECTS_TO_BIND = 'No projects are registered yet, so there is nothing to bind to.' + /** * How many times the agent may stop to ask, and be resumed, before a run stops honoring * gates and just finishes. A property of the await protocol, so every path that runs gates @@ -333,6 +366,28 @@ export function parseBrowserGate(text: string): ParsedBrowserGate | undefined { return parseRecordGate(text, 'await-browser', 'url', 'Take over in the browser') } +/** + * Parse a trailing `await-bind-project` block (#1121): a project-less topic run asking to bind to + * a registered project. The block carries only a title — the framework fills the project list from + * the registry at resolution time — so an empty or malformed body still triggers the gate with the + * fallback title, rather than being dropped. + */ +export function parseBindProjectGate(text: string): ParsedBindProjectGate | undefined { + const block = lastBlock(text, 'await-bind-project') + if (!block) return undefined + const record = parseRecord(block.body) ?? {} + return { title: str(record.title) || 'Bind this run to a project' } +} + +/** + * Parse a trailing `await-create-project` block (#1121): a topic run asking to register a new + * project by `path` and bind to it. Same tolerance as {@link parseConfirmationGate} — blank-title + * fallback, malformed block ignored, a non-string path dropped. + */ +export function parseCreateProjectGate(text: string): ParsedCreateProjectGate | undefined { + return parseRecordGate(text, 'await-create-project', 'path', 'Register and bind this project?') +} + /** * Parse whichever await gate a build turn ended on (#337 / #339 / #358 / #796). When more * than one block kind is present (an agent shouldn't emit several), the one that @@ -349,12 +404,14 @@ export function parseAwaitGate(text: string): ParsedAwaitGate | undefined { return undefined } -/** The four gate kinds, each as its tag plus a parse that stamps the discriminant. */ +/** The gate kinds, each as its tag plus a parse that stamps the discriminant. */ const GATE_KINDS: readonly { tag: string; parse: (text: string) => ParsedAwaitGate | undefined }[] = [ { tag: 'await-choices', parse: text => tagged('choices', parseChoicesGate(text)) }, { tag: 'await-multiselect', parse: text => tagged('multi', parseMultiSelectGate(text)) }, { tag: 'await-confirmation', parse: text => tagged('confirm', parseConfirmationGate(text)) }, { tag: 'await-browser', parse: text => tagged('browser', parseBrowserGate(text)) }, + { tag: 'await-bind-project', parse: text => tagged('bind-project', parseBindProjectGate(text)) }, + { tag: 'await-create-project', parse: text => tagged('create-project', parseCreateProjectGate(text)) }, ] /** Stamp a parsed gate with its kind, passing an unparseable one through as `undefined`. */ From 7fea798dae715fdd1c2fa998b9b1abc7c60f116a Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 25 Jul 2026 00:18:43 +0300 Subject: [PATCH 2/2] feat(the-framework): bind a topic run to a project via a gate (#1121) Harden the topic-run bind gate to the real feature and add the list-as-context "read" half (#1129). - Inject the registered projects (name + path) into a topic run's system channel as context, not a tool: the node side reads listProjects through the same injected seam the gate resolves against, so no node:fs reaches the browser path. An empty registry steers the agent to await-create-project. - Validate an await-create-project path via resolveProjectPath (absolute, exists, is a directory); a bad path declines cleanly back to the agent instead of crashing. Registration stays idempotent by resolved path and surfaces the existing record, with a distinct "already registered" reply. - await-create-project recommends Approve, so autopilot and a headless run auto-accept the grant, exactly like the plan confirmation gate. - The bind still only registers + records boundProjectId; the worktree re-home it implies is left as a #1122 TODO. --- .changeset/topic-bind-project-gate.md | 5 + packages/the-framework/src/await-gate.ts | 30 +++- packages/the-framework/src/cli.ts | 21 ++- packages/the-framework/src/control.ts | 4 +- packages/the-framework/src/events.ts | 2 +- .../the-framework/src/projects-gate.test.ts | 150 ++++++++++++++++-- packages/the-framework/src/prompt-run.ts | 6 +- packages/the-framework/src/registry.ts | 22 +++ packages/the-framework/src/run.ts | 5 + packages/the-framework/src/system-prompt.ts | 43 ++++- 10 files changed, 254 insertions(+), 34 deletions(-) create mode 100644 .changeset/topic-bind-project-gate.md diff --git a/.changeset/topic-bind-project-gate.md b/.changeset/topic-bind-project-gate.md new file mode 100644 index 000000000..d2d5e0d79 --- /dev/null +++ b/.changeset/topic-bind-project-gate.md @@ -0,0 +1,5 @@ +--- +"@gemstack/the-framework": minor +--- + +feat(the-framework): let a project-less topic run bind to a project via an await gate, with the registered projects injected into its context as the list to pick from (#1121) diff --git a/packages/the-framework/src/await-gate.ts b/packages/the-framework/src/await-gate.ts index 52864087a..95d8a1aa9 100644 --- a/packages/the-framework/src/await-gate.ts +++ b/packages/the-framework/src/await-gate.ts @@ -29,6 +29,15 @@ export interface BindProjectChoice { path: string } +/** + * The outcome of an `await-create-project` registration (#1121). A result rather than a throw so an + * unusable path (relative, missing, not a directory) declines cleanly back to the agent instead of + * crashing the run; `created` is false when the path was already registered, so the gate can say so. + */ +export type AddProjectResult = + | { ok: true; project: BindProjectChoice; created: boolean } + | { ok: false; error: string } + /** * The registry + recording seams a bind gate (#1121) resolves against, injected so the await * machinery stays orchestrator-free: the CLI wires these to the real registry + control channel, @@ -37,8 +46,8 @@ export interface BindProjectChoice { export interface BindProjectDeps { /** The projects an `await-bind-project` gate offers, from the registry. */ listProjects: () => Promise - /** Register (idempotent) a project by path, for an `await-create-project` gate. */ - addProject: (path: string) => Promise + /** Validate + register (idempotent) a project by path, for an `await-create-project` gate. */ + addProject: (path: string) => Promise /** Record the bind onto the run once a project is picked or created (#1121). */ recordBind: (projectId: string) => void } @@ -136,6 +145,8 @@ export async function resolveAwaitGate( ...choiceOpt, ...signalOpt, }) + // Defensive: requestChoices coerces any invalid pick back to a valid registry id, so this miss + // is unreachable in practice. It stays as a safety net rather than an assumed-present lookup. const project = projects.find(p => p.id === picked) if (!project) return NO_PROJECTS_TO_BIND // #1122 re-homes the run into the bound project's worktree; here the bind is only recorded. @@ -158,10 +169,17 @@ export async function resolveAwaitGate( ...choiceOpt, ...signalOpt, }) - if (picked === 'decline' || !gate.path || !deps.bind) return 'You chose not to register a project; continue without one.' - const project = await deps.bind.addProject(gate.path) - deps.bind.recordBind(project.id) - return `Registered and bound this run to ${project.path}.` + if (picked === 'decline' || !deps.bind) return 'You chose not to register a project; continue without one.' + if (!gate.path) return 'No path was given, so nothing was registered; continue without a project, or bind to a registered one.' + // The seam validates the path (absolute, exists, is a directory) and never throws: an unusable + // path declines back to the agent, so a bad path can never crash the run. + const result = await deps.bind.addProject(gate.path) + if (!result.ok) return `Could not register that project: ${result.error}. Continue without a project, or give an existing absolute repo path.` + // #1122 re-homes the run into the bound project's worktree; here the bind is only recorded. + deps.bind.recordBind(result.project.id) + return result.created + ? `Registered and bound this run to ${result.project.path}.` + : `That project was already registered; bound this run to ${result.project.path}.` } const pickedId = await requestChoices({ id, diff --git a/packages/the-framework/src/cli.ts b/packages/the-framework/src/cli.ts index 284fa6044..bfbb6f9e7 100644 --- a/packages/the-framework/src/cli.ts +++ b/packages/the-framework/src/cli.ts @@ -59,7 +59,7 @@ import { daemonStatus, ensureDaemon, isLoopbackHost, registerHomeProject, runDae import { appendControl, resetControl, watchControl, type ControlWatcher } from './control.js' import { RunMessageQueue } from './run-messages.js' import { nodeGitRunner } from './project.js' -import { addProject, ensureDaemonToken, listProjects, readDaemonToken, readPreferences } from './registry.js' +import { addProject, ensureDaemonToken, listProjects, readDaemonToken, readPreferences, resolveProjectPath } from './registry.js' import { startConsumptionGuard } from './consumption-guard.js' import { planMaintenanceSweep, @@ -1432,8 +1432,8 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise { // arrives before then still lands on `armedHandoff`, it just has no event to announce it yet. let announceHandoff: ((push: boolean, pr: boolean) => void) | undefined // Same deferral as announceHandoff: an await-bind-project / await-create-project gate binds this - // topic run to a project (#1121, gates spike), and only an event puts that on the meta a later- - // opened tab can read. Wired once the journal exists (below). + // topic run to a project (#1121), and only an event puts that on the meta a later-opened tab can + // read. Wired once the journal exists (below). let recordBind: ((projectId: string) => void) | undefined let control: ControlWatcher | undefined @@ -1720,15 +1720,20 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise { // it reads, and who can answer it. They were written out twice, thirteen conditional spreads // each, so a new one had to be added to both by hand — and a run started as a build and a run // started as a prompt are the same run in every respect but the scaffolding around the prompt. - // A project-less topic run (#1120) can bind to a project mid-run via an await gate (#1121, gates - // spike): the resolver lists / registers projects against the real registry and signals the bind - // over the control channel, which the watcher above folds to the run's meta. Topic runs only. + // A project-less topic run (#1120) can bind to a project mid-run via an await gate (#1121): the + // resolver lists / registers projects against the real registry and signals the bind over the + // control channel, which the watcher above folds to the run's meta. Topic runs only. const bindDeps: BindProjectDeps | undefined = opts.topic ? { listProjects: () => listProjects(), addProject: async path => { - const record = await addProject(path, new Date().toISOString()) - return { id: record.id, path: record.path } + // Validate before touching the registry: a relative / missing / non-directory path + // declines cleanly back to the agent rather than landing junk in the home file (#1121). + const resolved = await resolveProjectPath(path) + if (!resolved.ok) return { ok: false, error: resolved.error } + const already = (await listProjects()).some(p => p.path === resolved.path) + const record = await addProject(resolved.path, new Date().toISOString()) + return { ok: true, project: { id: record.id, path: record.path }, created: !already } }, recordBind: projectId => void appendControl(cwd, { kind: 'bind', projectId }), } diff --git a/packages/the-framework/src/control.ts b/packages/the-framework/src/control.ts index a65df1e27..ba0a2f6e2 100644 --- a/packages/the-framework/src/control.ts +++ b/packages/the-framework/src/control.ts @@ -42,8 +42,8 @@ export type ControlEntry = | { kind: 'handoff'; push: boolean; pr: boolean } /** * Bind a project-less topic run to a project (#1121): the await-gate resolver appends this once - * it registers + binds the picked project, and the run folds `projectId` onto its meta. Same - * shape as the tools-variant spike, so only the trigger differs. The worktree re-home is #1122. + * it registers + binds the picked project, and the run folds `projectId` onto its meta. The + * worktree re-home this implies is #1122. */ | { kind: 'bind'; projectId: string } diff --git a/packages/the-framework/src/events.ts b/packages/the-framework/src/events.ts index 15d29198f..fcab06cdd 100644 --- a/packages/the-framework/src/events.ts +++ b/packages/the-framework/src/events.ts @@ -213,7 +213,7 @@ export type FrameworkEvent = * A project-less topic run (#1120) bound itself to a project (#1121): the agent ended a turn on * an `await-bind-project` / `await-create-project` gate, which the framework resolved by * registering + binding the project. Recorded so the run's meta names the project it re-homes - * into (#1122). Spike: the await-gates bind variant (the trigger differs from the tools variant). + * into (#1122). */ | { kind: 'bind'; projectId: string } /** diff --git a/packages/the-framework/src/projects-gate.test.ts b/packages/the-framework/src/projects-gate.test.ts index a7fbcefaf..06c4c97bf 100644 --- a/packages/the-framework/src/projects-gate.test.ts +++ b/packages/the-framework/src/projects-gate.test.ts @@ -5,16 +5,17 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { resolveAwaitGate, type BindProjectDeps } from './await-gate.js' import { parseAwaitGate, parseBindProjectGate, parseCreateProjectGate, NO_PROJECTS_TO_BIND } from './turn-gate.js' -import { addProject, listProjects, projectId, type RegistryFs } from './registry.js' +import { addProject, listProjects, projectId, resolveProjectPath, type RegistryFs } from './registry.js' import { appendControl, watchControl, type ControlEntry } from './control.js' import { metaFromEvents } from './store/run-store.js' -import { composeRunSystem, TOPIC_BIND_PROTOCOL } from './system-prompt.js' +import { composeRunSystem, topicBindBlock, TOPIC_BIND_PROTOCOL } from './system-prompt.js' import { SIGNAL_PROTOCOL } from './turn-gate.js' -import type { FrameworkEvent } from './events.js' +import type { FrameworkEvent, ChoiceRequest } from './events.js' -// The gates spike for #1121: a project-less topic run (#1120) ends a turn on an `await-bind-project` -// / `await-create-project` block, and the framework resolves it by registering + binding the -// project. Mirrors projects-mcp.test.ts so the two spikes are comparable — only the trigger differs. +// #1121: a project-less topic run (#1120) ends a turn on an `await-bind-project` / +// `await-create-project` block, and the framework resolves it by registering + binding the project. +// The registered projects are injected into the topic run's context as the "read" half (#1129). +// The actual worktree re-home the bind implies is a separate follow-up (#1122). const ISO = '2026-07-24T00:00:00.000Z' const ENV = { XDG_CONFIG_HOME: '/cfg' } @@ -40,13 +41,25 @@ function memFs(): RegistryFs { } } -/** A {@link BindProjectDeps} bound to an in-memory registry, recording each bind into `recorded`. */ -function memBind(fs: RegistryFs, recorded: string[]): BindProjectDeps { +/** + * A {@link BindProjectDeps} bound to an in-memory registry, recording each bind into `recorded`. + * `addProject` mirrors the real CLI wiring: it validates the path (via {@link resolveProjectPath}), + * reports whether it already existed, and declines a bad one. `isDirectory` defaults to "every path + * is a real directory" so the happy-path tests need not stage a fake fs. + */ +function memBind( + fs: RegistryFs, + recorded: string[], + isDirectory: (p: string) => Promise = async () => true, +): BindProjectDeps { return { listProjects: async () => (await listProjects(fs, ENV)).map(p => ({ id: p.id, path: p.path })), addProject: async path => { - const record = await addProject(path, ISO, fs, ENV) - return { id: record.id, path: record.path } + const resolved = await resolveProjectPath(path, isDirectory) + if (!resolved.ok) return { ok: false, error: resolved.error } + const already = (await listProjects(fs, ENV)).some(p => p.path === resolved.path) + const record = await addProject(resolved.path, ISO, fs, ENV) + return { ok: true, project: { id: record.id, path: record.path }, created: !already } }, recordBind: id => recorded.push(id), } @@ -149,6 +162,123 @@ test('a topic run advertises the bind gate; a normal run does not, and the signa assert.ok(!composeRunSystem().includes(TOPIC_BIND_PROTOCOL), 'a normal run says nothing about binding') }) +test('a topic run injects the registered projects as context; empty steers to create; a normal run gets none (#1121/#1129)', () => { + const populated = composeRunSystem({ topic: true, topicProjects: ['/repos/app', '/repos/api'] }) + assert.ok(populated.includes('/repos/app') && populated.includes('/repos/api'), 'both project paths are listed') + assert.ok(populated.includes('app:') && populated.includes('api:'), 'each path shows its basename as the name') + + const empty = composeRunSystem({ topic: true, topicProjects: [] }) + assert.ok(empty.includes('No projects are registered yet'), 'an empty registry steers the agent to create-project') + + // No topicProjects wired (a browser preview): the how-to shows, but no list and no "empty" note. + const unwired = composeRunSystem({ topic: true }) + assert.ok(unwired.includes(TOPIC_BIND_PROTOCOL) && !unwired.includes('No projects are registered yet')) + + // topicProjects is ignored off a topic run, so a normal channel stays byte-identical. + assert.equal(composeRunSystem({ topicProjects: ['/repos/app'] }), composeRunSystem()) +}) + +test('topicBindBlock: undefined = how-to only, [] = create hint, a list names each path (#1121)', () => { + assert.equal(topicBindBlock(undefined), TOPIC_BIND_PROTOCOL) + assert.ok(topicBindBlock([]).includes('No projects are registered yet')) + const listed = topicBindBlock(['/home/me/repos/my-app']) + assert.ok(listed.includes('my-app:') && listed.includes('/home/me/repos/my-app')) +}) + +test('resolveProjectPath rejects a relative, empty, or non-directory path and resolves a real one (#1121)', async () => { + assert.deepEqual(await resolveProjectPath(' '), { ok: false, error: 'no path was given' }) + const rel = await resolveProjectPath('repos/app', async () => true) + assert.equal(rel.ok, false) + const missing = await resolveProjectPath('/repos/gone', async () => false) + assert.equal(missing.ok, false) + const ok = await resolveProjectPath('/repos/app', async () => true) + assert.deepEqual(ok, { ok: true, path: resolve('/repos/app') }) +}) + +test('an await-create-project gate with a relative path declines cleanly and registers nothing (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const answer = await resolveAwaitGate({ kind: 'create-project', title: 'Register it?', path: 'repos/app' }, 0, { + emit: () => {}, + bind: memBind(fs, recorded), + }) + assert.match(answer, /Could not register that project/) + assert.deepEqual(recorded, []) + assert.deepEqual(await listProjects(fs, ENV), []) +}) + +test('an await-create-project gate with a non-directory path declines cleanly and registers nothing (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const answer = await resolveAwaitGate({ kind: 'create-project', title: 'Register it?', path: resolve('/repos/nope') }, 0, { + emit: () => {}, + bind: memBind(fs, recorded, async () => false), + }) + assert.match(answer, /Could not register that project/) + assert.deepEqual(recorded, []) + assert.deepEqual(await listProjects(fs, ENV), []) +}) + +test('an await-create-project gate with no path declines without registering (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const answer = await resolveAwaitGate({ kind: 'create-project', title: 'Register it?' }, 0, { + emit: () => {}, + bind: memBind(fs, recorded), + }) + assert.match(answer, /nothing was registered/) + assert.deepEqual(recorded, []) +}) + +test('re-registering an already-registered path is idempotent and still binds the run (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const repo = resolve('/repos/twice') + await addProject(repo, ISO, fs, ENV) + + const answer = await resolveAwaitGate({ kind: 'create-project', title: 'Register it?', path: repo }, 0, { + emit: () => {}, + bind: memBind(fs, recorded), + }) + assert.match(answer, /already registered/) + assert.deepEqual(recorded, [projectId(repo)], 'the existing record is surfaced and the bind still recorded') + assert.equal((await listProjects(fs, ENV)).length, 1, 'no duplicate row is added') +}) + +test('an await-bind-project pick is coerced to a registered id, so a stale pick still binds safely (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const repo = resolve('/repos/real') + await addProject(repo, ISO, fs, ENV) + // requestChoices guards invalid picks by falling back to the recommended (a real) id, so a + // stale/ghost pick can never bind to a project that is not in the list. + const answer = await resolveAwaitGate({ kind: 'bind-project', title: 'Which?' }, 0, { + emit: () => {}, + requestChoice: async () => ({ picked: 'ghost-id' }), + bind: memBind(fs, recorded), + }) + assert.match(answer, /Bound this run to/) + assert.deepEqual(recorded, [projectId(repo)]) +}) + +test('an await-create-project gate recommends Approve, so a headless run auto-accepts the grant (#1121)', async () => { + const fs = memFs() + const recorded: string[] = [] + const choices: ChoiceRequest[] = [] + // No requestChoice: the recommended option IS the pick, exactly like the plan confirmation gate. + const answer = await resolveAwaitGate({ kind: 'create-project', title: 'Register it?', path: resolve('/repos/auto') }, 0, { + emit: e => { + if (e.kind === 'choice') choices.push(e) + }, + bind: memBind(fs, recorded), + }) + assert.equal(choices.length, 1) + assert.equal(choices[0]!.recommended, 'approve', 'the grant is recommended so autopilot/headless auto-accept it') + assert.equal(choices[0]!.confirm, true, 'it renders as a confirmation, like the plan gate') + assert.match(answer, /Registered and bound/) + assert.deepEqual(recorded, [projectId(resolve('/repos/auto'))]) +}) + test('a bind event folds onto the run meta as boundProjectId (#1121)', () => { const id = projectId(resolve('/repos/my-app')) const events: FrameworkEvent[] = [{ kind: 'bind', projectId: id }] diff --git a/packages/the-framework/src/prompt-run.ts b/packages/the-framework/src/prompt-run.ts index 7177157f7..756685603 100644 --- a/packages/the-framework/src/prompt-run.ts +++ b/packages/the-framework/src/prompt-run.ts @@ -123,7 +123,11 @@ export async function runPrompt(opts: RunPromptOptions): Promise p.path) : undefined + const system = composeRunSystem({ antiLazyPill: opts.antiLazyPill, browser: opts.browser, topic: opts.topic, ...(topicProjects ? { topicProjects } : {}), transparent: opts.transparent, user: opts.systemPrompt, tf, context: opts.context }) // The template's `# User prompt` half carries the prompt (today it renders to // exactly `opts.prompt`; any framing Rom adds around the slot rides along). With // the built-in prompt off (or transparent, #625), the raw prompt is sent as-is. diff --git a/packages/the-framework/src/registry.ts b/packages/the-framework/src/registry.ts index 86d4772ac..3c243e59c 100644 --- a/packages/the-framework/src/registry.ts +++ b/packages/the-framework/src/registry.ts @@ -587,6 +587,28 @@ export async function listProjects( return (await readRegistry(fs, env)).projects } +/** A validated project path (#1121), or the reason it was rejected. */ +export type ProjectPathResult = { ok: true; path: string } | { ok: false; error: string } + +/** + * Validate a path a topic run wants to register + bind to (#1121): it must be a non-empty, + * absolute path to an existing directory. `resolve` collapses any `.`/`..` before it reaches the + * registry, so a relative-based traversal can never smuggle in a path the agent did not name; the + * absolute requirement is the guard, mirroring how {@link addProject} normalizes what it stores. + * `isDirectory` is injected so this is unit-testable without touching disk; it defaults to real fs. + */ +export async function resolveProjectPath( + path: string, + isDirectory: (p: string) => Promise = p => nodeFs().isDirectory(p), +): Promise { + const trimmed = typeof path === 'string' ? path.trim() : '' + if (!trimmed) return { ok: false, error: 'no path was given' } + if (!isAbsolute(trimmed)) return { ok: false, error: `the path must be absolute: ${trimmed}` } + const absolute = resolve(trimmed) + if (!(await isDirectory(absolute))) return { ok: false, error: `no such directory: ${absolute}` } + return { ok: true, path: absolute } +} + /** * Register a project. Idempotent by resolved path: when the path is already * registered, the existing record is returned untouched (addedAt survives); diff --git a/packages/the-framework/src/run.ts b/packages/the-framework/src/run.ts index 5ad6ae78e..96470bf47 100644 --- a/packages/the-framework/src/run.ts +++ b/packages/the-framework/src/run.ts @@ -301,12 +301,17 @@ export async function runFramework(opts: RunFrameworkOptions): Promise p.path) : undefined // One assembly path for the whole system channel (#501), shared with the // direct-prompt path so the two can never drift (the drift behind #500). const system = composeRunSystem({ antiLazyPill: opts.antiLazyPill, browser: opts.browser, topic: opts.topic, + ...(topicProjects ? { topicProjects } : {}), transparent: opts.transparent, user: opts.systemPrompt, tf, diff --git a/packages/the-framework/src/system-prompt.ts b/packages/the-framework/src/system-prompt.ts index eb1707dfa..eb1af00e2 100644 --- a/packages/the-framework/src/system-prompt.ts +++ b/packages/the-framework/src/system-prompt.ts @@ -3,11 +3,11 @@ import { SYSTEM_PROMPT } from './prompts.generated.js' import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js' /** - * Topic-run bind protocol (#1121, gates spike): told only to a project-less "topic" run (#1120) so - * the agent knows it can bind to a project mid-run, and how. It reuses the await-block emit format + * Topic-run bind protocol (#1121): told only to a project-less "topic" run (#1120) so the agent + * knows it can bind to a project mid-run, and how. It reuses the await-block emit format * {@link AWAIT_PROTOCOL} already taught, so it only names the two topic-specific tags. It lives in - * this runtime append layer (a plain string) rather than the drift-guarded base prompt, so this - * spike needs no `.md` edit and a normal run's channel stays byte-identical (#547). Kept topic-only. + * this runtime append layer (a plain string) rather than the drift-guarded base prompt, so it needs + * no `.md` edit and a normal run's channel stays byte-identical (#547). Kept topic-only. */ export const TOPIC_BIND_PROTOCOL = [ '## Binding this run to a project', @@ -23,6 +23,29 @@ export const TOPIC_BIND_PROTOCOL = [ 'The framework registers and binds it, then re-prompts you with the result. Do not bind unless the work actually needs a repo.', ].join('\n') +/** Node-free basename: the last non-empty segment of a posix or Windows path, for the project list. */ +function projectName(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean) + return segments[segments.length - 1] ?? path +} + +/** + * The registered-projects context injected for a topic run (#1121): the "read" half of the bind + * mechanism (#1129). Reading the list IS injecting it into the channel, not a tool the agent calls, + * so the agent can weigh `await-bind-project` (one of these) against `await-create-project` (a new + * path) up front. `undefined` means the caller did not wire the registry (a browser preview), so + * only the how-to shows; `[]` means nothing is registered, so the agent is steered to create one. + * Paths, not records, keep this module node-free; the display name is derived here. + */ +export function topicBindBlock(projects: readonly string[] | undefined): string { + if (projects === undefined) return TOPIC_BIND_PROTOCOL + if (projects.length === 0) { + return `${TOPIC_BIND_PROTOCOL}\n\nNo projects are registered yet, so you will most likely need \`await-create-project\` to register one by its absolute path.` + } + const list = projects.map(p => `- ${projectName(p)}: \`${p}\``).join('\n') + return `${TOPIC_BIND_PROTOCOL}\n\nProjects already registered that you can bind to with \`await-bind-project\`:\n${list}` +} + // No Node imports here, deliberately. This module composes the prompt and the // dashboard renders it in the browser (#520), so reading the user's SYSTEM.md off // disk lives in `system-prompt-file.ts` instead. Keep it that way: one `node:fs` @@ -258,6 +281,13 @@ export interface SystemPromptOptions { * knows it can bind to a project mid-run (#1121). Topic-only: a normal run's channel is unchanged. */ topic?: boolean | undefined + /** + * The absolute paths of the registered projects, injected into a topic run's channel as the + * "read" half of the bind mechanism (#1121/#1129): context, not a tool. `undefined` = the caller + * did not wire the registry (e.g. a browser preview); `[]` = none registered. Ignored unless + * {@link topic}. The node side reads {@link ./registry.listProjects} and passes the paths in. + */ + topicProjects?: readonly string[] | undefined } /** @@ -320,7 +350,8 @@ export function composeRunSystem(opts: RunSystemOptions = {}): string { // Ahead of the protocols, so the signal protocol stays the last thing in the channel (#547). const browser = opts.browser ? [BROWSER_PROTOCOL] : [] // Topic-run bind (#1121): rides with the await protocol (it is an await gate), so it sits right - // after it and keeps the signal protocol last (#547). Topic-only, so a normal channel is unchanged. - const topicBind = opts.topic ? [TOPIC_BIND_PROTOCOL] : [] + // after it and keeps the signal protocol last (#547). Carries the registered-project list as + // context (#1129). Topic-only, so a normal channel is unchanged. + const topicBind = opts.topic ? [topicBindBlock(opts.topicProjects)] : [] return [...(promptBlock ? [promptBlock] : []), ...browser, AWAIT_PROTOCOL, ...topicBind, SIGNAL_PROTOCOL].join('\n\n') }