diff --git a/.changeset/cloud-prompt-lands-work.md b/.changeset/cloud-prompt-lands-work.md new file mode 100644 index 00000000..f020b949 --- /dev/null +++ b/.changeset/cloud-prompt-lands-work.md @@ -0,0 +1,5 @@ +--- +'@gemstack/the-framework': patch +--- + +A `--run-on web` hand-off now reads like a message to a person and tells the cloud session to land its work (#1496, #1497). The prompt a claude.ai session receives is the one string a human actually reads when they open the session link, but it led with the framework's injected system framing and buried the task at the bottom; now the task comes first and each injected block follows behind a labeled `===` rule. And nothing ever told the session to publish: the local run ends at the hand-off, no local machinery can reach the cloud VM's workspace, so a session that left its results in the conversation — or in a gitignored `ANALYSIS_RESULT.md` — ended with "the session committed nothing" and no PR. The hands-off protocol now carries the missing half: commit on the session branch and open a pull request, write analysis/plan deliverables into committed files, and end without a PR only when the task genuinely required no repository change. diff --git a/packages/the-framework/prompts/protocols/hands_off.md b/packages/the-framework/prompts/protocols/hands_off.md index 02a532ec..cfff32ad 100644 --- a/packages/the-framework/prompts/protocols/hands_off.md +++ b/packages/the-framework/prompts/protocols/hands_off.md @@ -8,3 +8,13 @@ AWAIT, that capability is not available here. Do not emit an await block and do - state in one line which assumption you made - carry the work through to the end The non-blocking blocks (show-markdown, set-session-name, ready-for-merge) are unaffected. + +## Land your work before the session ends +The machine that started this run cannot publish for you either: nothing local sees this +session's workspace, so work you do not land yourself does not exist to anyone. Before ending: +- commit your work on your session branch and open a pull request for it +- if the deliverable is analysis, a plan, or a decision, write it into committed files — a + result that lives only in this conversation, or in a gitignored file, + reaches nobody +- end without a pull request only when the task genuinely required no repository change, and + say so explicitly in your final message diff --git a/packages/the-framework/src/driver/cloud.test.ts b/packages/the-framework/src/driver/cloud.test.ts index 53c0fcdf..335164db 100644 --- a/packages/the-framework/src/driver/cloud.test.ts +++ b/packages/the-framework/src/driver/cloud.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { CLOUD_COMMAND, CloudDriver, trustRootOf, type RunPtyOptions } from './cloud.js' +import { CLOUD_COMMAND, CLOUD_PROMPT_SEPARATOR, CloudDriver, cloudHandOffPrompt, trustRootOf, type RunPtyOptions } from './cloud.js' import type { DriverEvent } from './types.js' /** @@ -56,14 +56,28 @@ test('the session link rides an `action` event, the way the Actions run link doe assert.ok(events.some(e => e.type === 'result' && e.sessionLink === URL)) }) -test('the prompt carries the session framing and the per-call system prompt', async () => { +test('the task leads the prompt; framing and per-call system follow behind labeled rules (#1497)', async () => { const calls: RunPtyOptions[] = [] const session = await driverWith(CREATED, calls).start({ cwd: '/repo', system: 'FRAMING' }) await session.prompt('do the thing', { system: 'EXTRA' }) - assert.equal(calls[0]?.prompt, 'FRAMING\n\nEXTRA\n\ndo the thing') + assert.equal( + calls[0]?.prompt, + [ + 'do the thing', + CLOUD_PROMPT_SEPARATOR, + 'Instructions from The Framework, the tool that started this session:\n\nFRAMING', + CLOUD_PROMPT_SEPARATOR, + 'EXTRA', + ].join('\n\n\n'), + ) assert.equal(calls[0]?.cwd, '/repo') }) +test('cloudHandOffPrompt with nothing injected is the bare task — no rule, no label', () => { + assert.equal(cloudHandOffPrompt('do the thing'), 'do the thing') + assert.equal(cloudHandOffPrompt('do the thing', undefined, undefined), 'do the thing') +}) + test('the invocation is stopped as soon as the session link lands', async () => { const calls: RunPtyOptions[] = [] const session = await driverWith(CREATED, calls).start({ cwd: '/repo' }) diff --git a/packages/the-framework/src/driver/cloud.ts b/packages/the-framework/src/driver/cloud.ts index 426abd9a..29fe088a 100644 --- a/packages/the-framework/src/driver/cloud.ts +++ b/packages/the-framework/src/driver/cloud.ts @@ -4,7 +4,7 @@ import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { killTree, registerChild, unregisterChild } from './child-registry.js' -import { combineFraming, makeEmit } from './session-support.js' +import { makeEmit } from './session-support.js' import type { Driver, DriverEvent, DriverPromptOptions, DriverSession, DriverStartOptions, DriverTurn } from './types.js' /** @@ -132,6 +132,28 @@ function trustAdvice(cwd: string): string { /** Model ids we will pass through, kept to characters that cannot act as shell syntax. */ const SAFE_MODEL = /^[A-Za-z0-9._:-]+$/ +/** + * The rule between the task and the injected instructions in a hand-off prompt (#1497). + * Exported so a test can pin the exact seam the claude.ai reader sees. + */ +export const CLOUD_PROMPT_SEPARATOR = '===============================' + +/** + * Assemble the one prompt a cloud session receives (#1497). Unlike every streamed driver — + * where the system channel is invisible plumbing — this whole string is what a *human* reads + * when they open the claude.ai session. So the task comes first (it is what the user is + * looking for), and each injected block follows behind a hard `===` rule with a one-line + * label, because the blocks' own markdown headers run into each other and read as one + * confusing document without it. + */ +export function cloudHandOffPrompt(task: string, ...injected: (string | undefined)[]): string { + const blocks = injected.filter((part): part is string => Boolean(part)) + if (blocks.length === 0) return task + const rule = `\n\n\n${CLOUD_PROMPT_SEPARATOR}\n\n\n` + const header = 'Instructions from The Framework, the tool that started this session:' + return `${task}${rule}${header}\n\n${blocks.join(rule)}` +} + /** * One hand-off to Claude Code on the web — **exactly one, for the life of the session.** * @@ -168,7 +190,9 @@ export class CloudSession implements DriverSession { async prompt(text: string, opts: DriverPromptOptions = {}): Promise { if (this.disposed) throw new Error('[framework] claude-web session disposed') - const full = combineFraming(this.framing, opts.system, text) + // Task first, injected framing behind labeled rules (#1497): on claude.ai this string is + // read by a human, and the task is what they open the session to find. + const full = cloudHandOffPrompt(text, this.framing, opts.system) this.emit({ type: 'start', prompt: full }) // Already handed off: say so and spend nothing. This is the guard that keeps one run to