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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cli/__tests__/adapters.claude.environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
30 changes: 30 additions & 0 deletions cli/__tests__/adapters.claude.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}-/);
Expand Down Expand Up @@ -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 —
Expand Down
63 changes: 63 additions & 0 deletions cli/__tests__/adapters.codex.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 33 additions & 1 deletion cli/__tests__/memory-bridge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
25 changes: 21 additions & 4 deletions cli/src/lib/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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("'", "'\\''")}'`;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
9 changes: 7 additions & 2 deletions cli/src/lib/adapters/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`
Expand All @@ -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,
Expand Down
28 changes: 26 additions & 2 deletions cli/src/lib/memory-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Expand Down Expand Up @@ -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 } = {}) => {
Expand Down
Loading