diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 59b819062f..4a54e56fed 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1072,6 +1072,28 @@ describe('claude adapter — a public seat gets its declared skills as prompt te expect(args[args.indexOf('--setting-sources') + 1]).toBe(''); }); + // D48 (TASK-545). A public seat has no subagent to spawn and works no board + // rows, so its fresh session carries the memory cues and not the habits. The + // legacy `internal` record reads as public, so it is held to the same line. + // Seatbelt wraps argv and spends `-p` on its profile, so the prompt is found + // by its content rather than by the flag. + test.each(['public', 'internal'])('a fresh trust=%s seat is not taught the row-working habits', async (trust) => { + const { impl, calls } = makeSpawnImpl(); + await publicSpawn(impl, { + environment: { model: 'test-model', + sandbox: { mode: 'workspace', trust }, + skills: { claude: [skillSrc] }, + }, + }); + + expect(calls[0].cmd).toBe('/usr/bin/sandbox-exec'); + const promptArg = calls[0].args.find((arg) => String(arg).includes('=== Current turn ===')); + expect(promptArg).toBeDefined(); + expect(promptArg).toContain('=== Fresh session ==='); + expect(promptArg).not.toContain('=== Working a row ==='); + expect(promptArg).not.toContain('Agent tool'); + }); + test('a private seat loads skills natively and gets no appended prompt', async () => { const { impl, calls } = makeSpawnImpl(); await claude.spawn('hi', { diff --git a/cli/__tests__/adapters.claude.test.mjs b/cli/__tests__/adapters.claude.test.mjs index 2f2d2befb1..d3fbadbe5a 100644 --- a/cli/__tests__/adapters.claude.test.mjs +++ b/cli/__tests__/adapters.claude.test.mjs @@ -170,6 +170,7 @@ describe('claude adapter — spawn()', () => { // Recovery creates an underlying session, so it must receive the same // fresh-session memory cues as an initial spawn. expect(calls[1].args[1]).toContain('=== Fresh session ==='); + expect(calls[1].args[1]).toContain('=== Working a row ==='); // newSessionId returned is the fresh one so the wrapper persists it next turn expect(res.newSessionId).not.toBe('abc-123'); expect(res.newSessionId).toMatch(/^[0-9a-f]{8}-/); @@ -215,6 +216,35 @@ describe('claude adapter — spawn()', () => { expect(promptArg).toContain("section: 'long_term'"); }); + // D48 (TASK-545) is done when a live seat's spawn argv shows these habits, so + // they are pinned on the argv here, not only on the shared helper. + test('a fresh private Claude seat is taught parallel reads and row slicing', async () => { + const { impl, calls } = makeSpawnImpl({ stdout: 'ok' }); + await claude.spawn('current message', { environment: { model: 'test-model' }, + memoryLongTerm: 'open gate: #123', + _spawnImpl: impl, + }); + + const promptArg = calls[0].args[1]; + expect(promptArg).toContain('=== Working a row ==='); + expect(promptArg).toContain('use the Agent tool'); + expect(promptArg).toContain('commonly_create_task with parentTask'); + expect(promptArg.indexOf('=== Working a row ===')).toBeLessThan(promptArg.indexOf('=== Current turn ===')); + }); + + test('a resumed Claude seat is not re-taught the habits its transcript holds', async () => { + const { impl, calls } = makeSpawnImpl({ stdout: 'ok' }); + await claude.spawn('current message', { environment: { model: 'test-model' }, + sessionId: 'sid-1', + memoryLongTerm: 'open gate: #123', + _spawnImpl: impl, + }); + + const promptArg = calls[0].args[1]; + expect(promptArg).not.toContain('=== Working a row ==='); + expect(promptArg).not.toContain('Agent tool'); + }); + // Replaces an assertion that empty memory passed the prompt verbatim. That // made two different states byte-identical from inside the session — an agent // that has never saved anything, and a runtime with no memory bridge at all — diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index e9ee9cc22f..b5b474a8a4 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -764,6 +764,69 @@ describe('codex adapter — spawn()', () => { expect(calls[0].args[calls[0].args.length - 1]).not.toContain('=== Fresh session ==='); }); + // D48 (TASK-545). Codex has no subagent tool, so it is taught row slicing + // and not parallel reads; claude.js carries that half. + test('a fresh private Codex seat is taught row slicing alone', async () => { + const { impl, calls } = makeSpawnImpl({ + stdoutChunks: ['{"type":"thread.started","thread_id":"sid-1"}\n'], + outputContents: 'ok', + }); + await codex.spawn('current message', { environment: { model: 'test-model' }, + sessionId: null, + memoryLongTerm: 'open gate: #123', + _spawnImpl: impl, + }); + + const promptArg = calls[0].args[calls[0].args.length - 1]; + expect(promptArg).toContain('=== Working a row ==='); + expect(promptArg).toContain('commonly_create_task with parentTask'); + expect(promptArg).not.toContain('Agent tool'); + }); + + test('a resumed Codex seat is not re-taught row slicing', async () => { + const { impl, calls } = makeSpawnImpl({ + stdoutChunks: ['{"type":"thread.started","thread_id":"sid-1"}\n'], + outputContents: 'ok', + }); + await codex.spawn('current message', { environment: { model: 'test-model' }, + sessionId: 'sid-1', + memoryLongTerm: 'open gate: #123', + _spawnImpl: impl, + }); + + expect(calls[0].args[calls[0].args.length - 1]).not.toContain('=== Working a row ==='); + }); + + // The legacy `internal` record reads as public, so it is held to the same line. + test.each(['public', 'internal'])('a fresh trust=%s Codex seat is not taught row slicing', async (trust) => { + const operatorHome = await mkdtemp(join(tmpdir(), 'commonly-codex-operator-home-')); + const publicHome = await mkdtemp(join(tmpdir(), 'commonly-codex-public-home-')); + try { + await writeFile(join(operatorHome, 'auth.json'), '{"test":true}', 'utf8'); + const { impl, calls } = makeSpawnImpl({ + stdoutChunks: ['{"type":"thread.started","thread_id":"sid-public"}\n'], + outputContents: 'ok', + }); + await codex.spawn('current message', { + sessionId: null, + cwd: '/tmp/public-agent-workspace', + environment: { model: 'test-model', sandbox: { trust } }, + env: { ...process.env, CODEX_HOME: operatorHome }, + agentName: 'public-work-cue-agent', + memoryLongTerm: 'open gate: #123', + _publicCodexHome: publicHome, + _spawnImpl: impl, + }); + + const promptArg = calls[0].args[calls[0].args.length - 1]; + expect(promptArg).toContain('=== Fresh session ==='); + expect(promptArg).not.toContain('=== Working a row ==='); + } finally { + await rm(operatorHome, { recursive: true, force: true }); + await rm(publicHome, { recursive: true, force: true }); + } + }); + // Same swap as the claude adapter: both delegate to `buildMemoryPreamble`, so // the empty case is a cue rather than a bare prompt. Pinned on both adapters // deliberately — a shared helper is only shared until someone re-inlines one. diff --git a/cli/__tests__/memory-bridge.test.mjs b/cli/__tests__/memory-bridge.test.mjs index 579181cbfb..98a64912e6 100644 --- a/cli/__tests__/memory-bridge.test.mjs +++ b/cli/__tests__/memory-bridge.test.mjs @@ -7,7 +7,9 @@ */ import { jest } from '@jest/globals'; -import { readLongTerm, syncBack, SOURCE_RUNTIME, buildMemoryPreamble } from '../src/lib/memory-bridge.js'; +import { + readLongTerm, syncBack, SOURCE_RUNTIME, buildMemoryPreamble, ROW_SLICING_CUE, +} from '../src/lib/memory-bridge.js'; const makeClient = ({ memoryPayload = null, getThrows = false, postThrows = false } = {}) => { const get = jest.fn(async () => { @@ -238,4 +240,34 @@ describe('buildMemoryPreamble', () => { expect(fresh).toMatch(/Do not treat it as empty or write a replacement/i); expect(fresh).not.toContain('commonly_save_my_memory'); }); + + // D48 (TASK-545). Working habits ride the memory cues' once-per-session rule, + // and only the habits an adapter passes are rendered. + it('renders work cues on a fresh session, after the read cue and before the turn', () => { + const fresh = buildMemoryPreamble('turn', 'mem', { + freshSession: true, + workCues: ['first habit', 'second habit'], + }); + expect(fresh).toContain('=== Working a row ===\n- first habit\n- second habit\n'); + expect(fresh.indexOf('=== Fresh session ===')).toBeLessThan(fresh.indexOf('=== Working a row ===')); + expect(fresh.indexOf('=== Working a row ===')).toBeLessThan(fresh.indexOf('=== Current turn ===')); + }); + + it('does not render work cues on a resumed turn, even when an adapter passes them', () => { + const resumed = buildMemoryPreamble('turn', 'mem', { workCues: ['first habit'] }); + expect(resumed).not.toContain('=== Working a row ==='); + expect(resumed).not.toContain('first habit'); + }); + + it('leaves the fresh preamble as it was when no cues are passed', () => { + const none = buildMemoryPreamble('turn', 'mem', { freshSession: true, workCues: [] }); + expect(none).toBe(buildMemoryPreamble('turn', 'mem', { freshSession: true })); + expect(none).not.toContain('=== Working a row ==='); + expect(none).toContain('prior sessions.\n\n=== Current turn ===\nturn'); + }); + + it('names the exact call and field a seat slices a row with', () => { + expect(ROW_SLICING_CUE).toContain('commonly_create_task'); + expect(ROW_SLICING_CUE).toContain('parentTask'); + }); }); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index d7e0b7abad..75a17b48dd 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -81,7 +81,7 @@ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credent import { prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir, } from '../mcp-home.js'; -import { buildMemoryPreamble } from '../memory-bridge.js'; +import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js'; import { claudeConfigDirFor, claudeContextTokens } from '../session-recycle.js'; import { skillsAsText } from '../skill-prompt.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; @@ -98,6 +98,16 @@ const DEFAULT_TIMEOUT_MS = (() => { })(); const buildPrompt = buildMemoryPreamble; + +// The working habits a private claude seat is taught on a fresh session (D48, +// TASK-545). Parallel reads live here because only this CLI has a subagent +// tool; codex is passed row slicing alone. A public seat is passed neither: its +// argv allows Read and the MCP under dontAsk, so it has no subagent to spawn, +// and it answers strangers rather than working board rows. +const PARALLEL_READS_CUE = 'Parallel reads: when a row needs several files or sources read, ' + + 'use the Agent tool. Spawn read-only subagents in parallel, brief each one with the files ' + + 'to read and the question to answer, and keep only their conclusions.'; +const WORK_CUES = [PARALLEL_READS_CUE, ROW_SLICING_CUE]; const RUNTIME_CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}'; const QUOTE_SHELL_ARGUMENT = (value) => `'${String(value).replaceAll("'", "'\\''")}'`; @@ -799,7 +809,12 @@ export default { // the only two paths that build a real prompt. buildPrompt handles // undefined and '' as absence itself; it does not need a guard, it needs // the value. - const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: !isResume }); + const publicTrust = ctx.environment?.sandbox?.trust === 'public'; + const workCues = publicTrust ? [] : WORK_CUES; + const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { + freshSession: !isResume, + workCues, + }); const sessionFlag = isResume ? '--resume' : '--session-id'; // Model pin from the ADR-008 environment spec. Absent it, claude picks its // own default — which is how a fleet of ten agents ended up running three @@ -839,7 +854,6 @@ export default { let mcpConfig = null; let publicClaudeState = null; let publicSkillArgs = []; - const publicTrust = ctx.environment?.sandbox?.trust === 'public'; const publicNativeSandbox = publicTrust && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox)); const publicBwrapSandbox = publicTrust @@ -927,7 +941,10 @@ export default { // The retry creates a new underlying CLI session. Rebuild its prompt // as fresh too: otherwise a session-recovery path is the one fresh // session that misses the durable-state cue. - const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: true }); + const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { + freshSession: true, + workCues, + }); const retryBase = ['-p', freshPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs]; const retryCtx = { diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index f55cd44100..2b417acb74 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -66,7 +66,7 @@ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js'; import { supportedGitHooks } from '../commit-attribution.js'; import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js'; import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js'; -import { buildMemoryPreamble } from '../memory-bridge.js'; +import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js'; import { resolveSkillSource, skillsAsText } from '../skill-prompt.js'; import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; @@ -650,14 +650,20 @@ export default { async spawn(prompt, ctx = {}) { const seatModel = requireSeatModel(ctx.environment, 'codex'); + const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox); // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and // `null || ''` is `''` — which routed the unreadable case straight into // the empty-memory branch and made the whole distinction unreachable from // the only two paths that build a real prompt. buildPrompt handles // undefined and '' as absence itself; it does not need a guard, it needs // the value. + // + // Codex has no subagent tool, so of D48's two habits (TASK-545) it is + // taught row slicing alone; claude.js adds parallel reads. A public seat + // answers strangers rather than working board rows, so it is taught neither. const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: !ctx.sessionId, + workCues: sandbox?.trust === 'public' ? [] : [ROW_SLICING_CUE], }); // Per-spawn temp dir for --output-last-message. Cleaned up in `finally` @@ -674,7 +680,6 @@ export default { }); try { - const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox); const mcp = buildMcpOverrideArgs(ctx.environment?.mcp, { runtimeToken: ctx.runtimeToken, instanceUrl: ctx.instanceUrl, diff --git a/cli/src/lib/memory-bridge.js b/cli/src/lib/memory-bridge.js index 813dc675c7..5f8573a2d2 100644 --- a/cli/src/lib/memory-bridge.js +++ b/cli/src/lib/memory-bridge.js @@ -22,6 +22,18 @@ export const SOURCE_RUNTIME = 'local-cli'; +/** + * Row slicing, the runtime-agnostic half of D48 (TASK-545). A row too large + * for one turn drifts when one seat carries it whole, so the seat splits it on + * the board before any code. It names the exact call and field for the reason + * the memory cue below names `long_term`: a seat adopts a habit through a + * surface it can see. Each adapter decides whether to pass it. + */ +export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, split it before ' + + 'writing any code. Post each slice as a child row (commonly_create_task with parentTask set ' + + "to the row's taskId) with one assignee, a written file or area boundary, and its own " + + 'acceptance line.'; + /** * The turn preamble, shared by every adapter so the two cannot drift. * @@ -39,8 +51,17 @@ export const SOURCE_RUNTIME = 'local-cli'; * the section whose name most invites exactly this use — succeeds, returns 200, * and is never seen again; the write and the silence are indistinguishable from * a correct round trip. Naming the section is the whole point of the cue. + * + * `workCues` are working habits an adapter teaches on a fresh session, beside + * the memory cues and under the same once-per-session rule. An adapter passes + * only the habits its CLI can act on; passing none leaves the preamble as it + * was. */ -export const buildMemoryPreamble = (prompt, memoryLongTerm, { freshSession = false } = {}) => { +export const buildMemoryPreamble = ( + prompt, + memoryLongTerm, + { freshSession = false, workCues = [] } = {}, +) => { // `null` is the UNREADABLE signal, and it is deliberately not the same value // as `''`. Telling a seat whose token was revoked that "nothing has ever been // saved here" is a false claim about its own history, and it is the same @@ -87,7 +108,10 @@ export const buildMemoryPreamble = (prompt, memoryLongTerm, { freshSession = fal + 'gates held, decisions pending, and task context, not a transcript — using ' + 'the long_term write above.'; } - return `${context}\n${freshReadCue}\n=== Current turn ===\n${prompt}\n${sessionEndCue}`; + const workCue = workCues.length + ? `\n=== Working a row ===\n${workCues.map((cue) => `- ${cue}`).join('\n')}\n` + : ''; + return `${context}\n${freshReadCue}${workCue}\n=== Current turn ===\n${prompt}\n${sessionEndCue}`; }; export const readLongTerm = async (client, { onError } = {}) => {