Skip to content

Commit da2706e

Browse files
gewenyu99releaser-wizard[bot]claude
authored
feat(pi): orchestrator runTask — per-task pi sessions with in-process queue tools (#853)
Co-authored-by: releaser-wizard[bot] <251022448+releaser-wizard[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d9c016d commit da2706e

27 files changed

Lines changed: 1505 additions & 246 deletions

src/lib/__tests__/wizard-tools.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
__test,
1010
ensureGitignoreCoverage,
1111
evaluateAskCap,
12+
fetchSkillMenu,
1213
mergeEnvValues,
1314
parseEnvKeys,
1415
resolveEnvPath,
@@ -490,3 +491,47 @@ describe('downloadWithRetry', () => {
490491
).rejects.toThrow(/attempt 1.*attempt 2.*attempt 3/s);
491492
});
492493
});
494+
495+
describe('fetchSkillMenu', () => {
496+
const noSleep = () => Promise.resolve();
497+
const menu = { categories: { integration: [] } };
498+
const menuResponse = () =>
499+
Promise.resolve({
500+
ok: true,
501+
status: 200,
502+
statusText: 'OK',
503+
json: () => Promise.resolve(menu),
504+
});
505+
506+
it('retries a flaky menu fetch before succeeding', async () => {
507+
let attempts = 0;
508+
509+
const result = await fetchSkillMenu('http://localhost:8765', {
510+
fetchImpl: (() => {
511+
attempts += 1;
512+
if (attempts < 3) return Promise.reject(new Error('reset'));
513+
return menuResponse();
514+
}) as any,
515+
sleepImpl: noSleep,
516+
});
517+
518+
expect(attempts).toBe(3);
519+
expect(result).toEqual(menu);
520+
});
521+
522+
it('returns null after exhausting retries', async () => {
523+
let attempts = 0;
524+
525+
const result = await fetchSkillMenu('http://localhost:8765', {
526+
fetchImpl: (() => {
527+
attempts += 1;
528+
return Promise.reject(new Error('network down'));
529+
}) as any,
530+
sleepImpl: noSleep,
531+
maxAttempts: 3,
532+
});
533+
534+
expect(attempts).toBe(3);
535+
expect(result).toBeNull();
536+
});
537+
});

src/lib/agent/__tests__/__snapshots__/commandments.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
exports[`getWizardCommandments > matches the published commandment list 1`] = `
44
"Never hallucinate a PostHog project token, host, or any other secret. Always use the real values that have been configured for this project (for example via environment variables).
5+
Never substitute an empty string or placeholder for the project token when its source is missing — an empty key silently disables analytics with no error. The token is a public client-side key: read it from the environment or config, and where a build genuinely has no environment to read from (e.g. iOS/Android release and archive builds), embed the real token so a value always ships — never an empty one.
56
Never write API keys, access tokens, or other secrets directly into source code. Always reference environment variables instead, and rely on the wizard-tools MCP server (check_env_keys / set_env_values) to create or update .env files.
67
Always use the detect_package_manager tool from the wizard-tools MCP server to determine the package manager. Do not guess based on lockfiles or hard-code npm, yarn, pnpm, bun, pip, etc.
78
Before writing to any file, you MUST read that exact file immediately beforehand using the Read tool, even if you have already read it earlier in the run. This avoids tool failures and stale edits.

src/lib/agent/__tests__/agent-prompt-loader.test.ts

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
assembleTaskPrompt,
77
buildRegistry,
88
parseAgentPrompt,
9+
promptModelFor,
910
resolveTask,
10-
taskModel,
11+
taskModelSpec,
1112
type AgentPrompt,
1213
type AgentRegistry,
1314
type OrchestratorPromptContext,
@@ -29,7 +30,9 @@ function registryOf(prompts: AgentPrompt[]): AgentRegistry {
2930
describe('parseAgentPrompt', () => {
3031
const sample = `---
3132
type: instrument-events
32-
model: claude-sonnet-4-6 # cheapest model that succeeds
33+
model_pi: openai/gpt-5.6-terra # per-profile model targets
34+
effort_pi: medium
35+
model_sdk: claude-sonnet-4-6
3336
skills: [instrument-events]
3437
allowedTools: [Read, Edit, Grep, Glob, Bash]
3538
disallowedTools: [enqueue_task]
@@ -43,16 +46,54 @@ Add at least one capture call.
4346
it('parses frontmatter scalars and inline arrays', () => {
4447
const p = parseAgentPrompt(sample, 'fallback');
4548
expect(p.type).toBe('instrument-events');
46-
expect(p.model).toBe('claude-sonnet-4-6');
49+
expect(p.modelPi).toBe('openai/gpt-5.6-terra');
50+
expect(p.effortPi).toBe('medium');
51+
expect(p.modelSdk).toBe('claude-sonnet-4-6');
4752
expect(p.skills).toEqual(['instrument-events']);
4853
expect(p.allowedTools).toEqual(['Read', 'Edit', 'Grep', 'Glob', 'Bash']);
4954
expect(p.disallowedTools).toEqual(['enqueue_task']);
5055
expect(p.dependsOn).toEqual(['init']);
5156
});
5257

58+
it('resolves the per-harness model + effort, not 1:1 across providers', () => {
59+
const p = parseAgentPrompt(sample, 'fallback');
60+
expect(promptModelFor(p, 'pi')).toEqual({
61+
model: 'openai/gpt-5.6-terra',
62+
effort: 'medium',
63+
});
64+
expect(promptModelFor(p, 'anthropic')).toEqual({
65+
model: 'claude-sonnet-4-6',
66+
effort: undefined,
67+
});
68+
});
69+
70+
it('drops an effort that is not a ThinkingLevel — remote typos never reach a session', () => {
71+
const p = parseAgentPrompt(
72+
'---\nmodel_pi: m\neffort_pi: mediun\neffort_sdk: high\n---\nx',
73+
'capture',
74+
);
75+
expect(p.effortPi).toBeUndefined();
76+
expect(p.effortSdk).toBe('high');
77+
});
78+
79+
it('falls back to the menu entry flow when frontmatter omits it', () => {
80+
const p = parseAgentPrompt(
81+
'---\ntype: install\n---\nx',
82+
'install',
83+
'my-flow',
84+
);
85+
expect(p.flow).toBe('my-flow');
86+
const declared = parseAgentPrompt(
87+
'---\nflow: audit\n---\nx',
88+
'install',
89+
'my-flow',
90+
);
91+
expect(declared.flow).toBe('audit');
92+
});
93+
5394
it('strips inline comments and keeps the body', () => {
5495
const p = parseAgentPrompt(sample, 'fallback');
55-
expect(p.model).not.toContain('#');
96+
expect(p.modelPi).not.toContain('#');
5697
expect(p.body).toContain('## Goal');
5798
expect(p.body).not.toContain('---');
5899
});
@@ -76,9 +117,10 @@ Add at least one capture call.
76117
);
77118
});
78119

79-
it('defaults missing array fields to empty and model to undefined', () => {
120+
it('defaults missing array fields to empty and models to undefined', () => {
80121
const p = parseAgentPrompt('no frontmatter at all', 'stub');
81-
expect(p.model).toBeUndefined();
122+
expect(p.modelPi).toBeUndefined();
123+
expect(p.modelSdk).toBeUndefined();
82124
expect(p.skills).toEqual([]);
83125
expect(p.dependsOn).toEqual([]);
84126
expect(p.body).toBe('no frontmatter at all');
@@ -121,7 +163,7 @@ describe('buildRegistry', () => {
121163
[
122164
prompt({ type: 'plan-audit', flow: 'audit', seed: true }),
123165
prompt({ type: 'fix-events', flow: 'audit' }),
124-
prompt({ type: 'install', flow: 'posthog-integration' }),
166+
prompt({ type: 'install', flow: 'integration-v2' }),
125167
prompt({ type: 'example' }),
126168
],
127169
'audit',
@@ -162,7 +204,9 @@ describe('resolveTask', () => {
162204
const prompt: AgentPrompt = {
163205
type: 'capture',
164206
seed: false,
165-
model: 'claude-haiku-4-5-20251001',
207+
modelPi: 'openai/gpt-5.6-luna',
208+
effortPi: 'low',
209+
modelSdk: 'claude-haiku-4-5-20251001',
166210
skills: ['instrument-events'],
167211
allowedTools: ['Read', 'Edit'],
168212
disallowedTools: ['enqueue_task'],
@@ -176,21 +220,32 @@ describe('resolveTask', () => {
176220
expect(() => resolveTask(registry, task, store)).toThrow(/capture/);
177221
});
178222

179-
it('resolves model, tools, and skills from the prompt', () => {
223+
it('resolves tools and skills from the prompt', () => {
180224
const registry = registryOf([prompt]);
181225
const task = store.enqueue({ type: 'capture' });
182226
const resolved = resolveTask(registry, task, store);
183-
expect(resolved.model).toBe('claude-haiku-4-5-20251001');
184227
expect(resolved.skills).toEqual(['instrument-events']);
185228
expect(resolved.disallowedTools).toEqual([
186229
'mcp__posthog-wizard__enqueue_task',
187230
]);
188231
});
189232

233+
it('resolves per-harness model + effort from the prompt', () => {
234+
const registry = registryOf([prompt]);
235+
const task = store.enqueue({ type: 'capture' });
236+
expect(taskModelSpec(registry, task, 'pi')).toEqual({
237+
model: 'openai/gpt-5.6-luna',
238+
effort: 'low',
239+
});
240+
expect(taskModelSpec(registry, task, 'anthropic').model).toBe(
241+
'claude-haiku-4-5-20251001',
242+
);
243+
});
244+
190245
it('prefers the enqueue model override over the prompt model', () => {
191246
const registry = registryOf([prompt]);
192247
const task = store.enqueue({ type: 'capture', model: 'override-x' });
193-
expect(resolveTask(registry, task, store).model).toBe('override-x');
248+
expect(taskModelSpec(registry, task, 'pi').model).toBe('override-x');
194249
});
195250

196251
it("appends upstream dependencies' handoffs as context", () => {
@@ -260,20 +315,29 @@ describe('resolveTask', () => {
260315
});
261316
});
262317

263-
describe('taskModel', () => {
318+
describe('taskModelSpec', () => {
264319
const prompt = parseAgentPrompt(
265-
'---\nmodel: prompt-model\n---\nx',
320+
'---\nmodel_pi: prompt-model\n---\nx',
266321
'capture',
267322
);
268323

269-
it('prefers the enqueue override, then the prompt, then the default', () => {
324+
it('prefers the enqueue override, then the prompt; the switchboard pick is the caller fallback', () => {
270325
const registry = registryOf([prompt]);
271326
const task = { type: 'capture' };
272-
expect(taskModel(registry, { ...task, model: 'override' } as never)).toBe(
273-
'override',
327+
expect(
328+
taskModelSpec(registry, { ...task, model: 'override' } as never, 'pi')
329+
.model,
330+
).toBe('override');
331+
expect(taskModelSpec(registry, task as never, 'pi').model).toBe(
332+
'prompt-model',
274333
);
275-
expect(taskModel(registry, task as never)).toBe('prompt-model');
276-
expect(taskModel(registryOf([]), task as never)).toBe('claude-sonnet-4-6');
334+
// An empty column stays undefined — the caller falls back to its switchboard pick.
335+
expect(
336+
taskModelSpec(registry, task as never, 'anthropic').model,
337+
).toBeUndefined();
338+
expect(
339+
taskModelSpec(registryOf([]), task as never, 'pi').model,
340+
).toBeUndefined();
277341
});
278342
});
279343

src/lib/agent/__tests__/variant-gating.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ describe('isOrchestratorEnabled', () => {
2929
describe('pi + orchestrator gating', () => {
3030
const program = 'posthog-integration' as const;
3131

32-
it('clamps the sequence to linear when both flags select pi + orchestrator', () => {
33-
// pi has no runTask — the clamp forces linear.
32+
it('runs the orchestrator on pi when both flags select pi + orchestrator', () => {
33+
// pi implements runTask — the capability clamp passes and the flag stands.
3434
const binding = resolveBinding({
3535
program,
3636
flags: {
@@ -39,7 +39,7 @@ describe('pi + orchestrator gating', () => {
3939
},
4040
});
4141
expect(binding.harness).toBe(Harness.pi);
42-
expect(binding.sequence).toBe(Sequence.linear);
42+
expect(binding.sequence).toBe(Sequence.orchestrator);
4343
});
4444

4545
it('leaves the orchestrator flag effective for the anthropic harness', () => {

src/lib/agent/agent-interface.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,11 +412,16 @@ export function wizardCanUseTool(
412412
};
413413
}
414414

415-
// Block direct reads/writes of .env files — use wizard-tools MCP instead
415+
// Block direct reads/writes of real .env files — use wizard-tools MCP instead.
416+
// Example/template files (.env.example, .env.sample, .env.template, .env.dist)
417+
// carry no secrets and are meant to be committed, so they stay writable.
416418
if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') {
417419
const filePath = typeof input.file_path === 'string' ? input.file_path : '';
418420
const basename = path.basename(filePath);
419-
if (basename.startsWith('.env')) {
421+
const isEnvExample = /^\.env\.(example|sample|template|dist)$/.test(
422+
basename,
423+
);
424+
if (basename.startsWith('.env') && !isEnvExample) {
420425
logToFile(`Denying ${toolName} on env file: ${filePath}`);
421426
return {
422427
behavior: 'deny',

0 commit comments

Comments
 (0)