taskchef 7.27.2 → 7.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/roles/resolve_roles.py +172 -0
- package/skills/taskchef-bootstrap/SKILL.md +26 -0
- package/skills/taskchef-delegate/SKILL.md +7 -0
- package/skills/taskchef-delegate/references/model-roles.md +49 -0
- package/src/dashboard/dist/app.js +39 -39
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/react/App.test.tsx +1 -0
- package/src/dashboard/react/App.tsx +11 -1
- package/src/dashboard/react/components/ModelSettings.test.tsx +53 -0
- package/src/dashboard/react/components/ModelSettings.tsx +61 -0
- package/src/dashboard.js +11 -0
- package/src/mcp.js +2 -0
- package/src/model-roles.js +44 -0
- package/src/workspace.js +5 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { ModelSettings } from "./components/ModelSettings";
|
|
1
2
|
import {
|
|
2
3
|
Alert,
|
|
3
4
|
ActionIcon,
|
|
4
5
|
AppShell,
|
|
5
6
|
Box,
|
|
7
|
+
Button,
|
|
6
8
|
CloseButton,
|
|
7
9
|
Container,
|
|
8
10
|
Group,
|
|
@@ -100,6 +102,12 @@ export function DashboardApp({
|
|
|
100
102
|
initialFilters?: { date?: string; project?: string; status?: string };
|
|
101
103
|
initialTasks?: Task[];
|
|
102
104
|
}) {
|
|
105
|
+
const [settings, setSettings] = useState(() => window.location.hash === '#settings');
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const update = () => setSettings(window.location.hash === '#settings');
|
|
108
|
+
window.addEventListener('hashchange', update);
|
|
109
|
+
return () => window.removeEventListener('hashchange', update);
|
|
110
|
+
}, []);
|
|
103
111
|
const [tasks, setTasks] = useState<Task[]>(initialTasks);
|
|
104
112
|
const [connected, setConnected] = useState(false);
|
|
105
113
|
const [message, setMessage] = useState<string | null>(null);
|
|
@@ -307,13 +315,14 @@ export function DashboardApp({
|
|
|
307
315
|
<IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
|
|
308
316
|
<Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
|
|
309
317
|
</Group>
|
|
310
|
-
<ThemeToggle
|
|
318
|
+
<Group gap="xs"><Button component="a" href={settings ? '#' : '#settings'} variant="subtle" size="xs">{settings ? 'Tasks' : 'Settings'}</Button><ThemeToggle /></Group>
|
|
311
319
|
</Stack>
|
|
312
320
|
</Group>
|
|
313
321
|
</Container>
|
|
314
322
|
|
|
315
323
|
<AppShell.Main>
|
|
316
324
|
<Container className="taskchef-main" pb={80} size={1080}>
|
|
325
|
+
{settings ? <ModelSettings /> : <>
|
|
317
326
|
<Paper className="taskchef-toolbar" p="sm" radius="md" withBorder>
|
|
318
327
|
<Stack gap="sm">
|
|
319
328
|
<Group align="end" gap="sm">
|
|
@@ -356,6 +365,7 @@ export function DashboardApp({
|
|
|
356
365
|
</Paper>
|
|
357
366
|
)}
|
|
358
367
|
</Stack>
|
|
368
|
+
</>}
|
|
359
369
|
</Container>
|
|
360
370
|
</AppShell.Main>
|
|
361
371
|
</AppShell>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { MantineProvider } from '@mantine/core';
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
4
|
+
import { ModelSettings } from './ModelSettings';
|
|
5
|
+
|
|
6
|
+
vi.mock('@mantine/core', async () => {
|
|
7
|
+
const { createElement } = await import('react');
|
|
8
|
+
const block = ({ children }: { children?: import('react').ReactNode }) => createElement('div', null, children);
|
|
9
|
+
return {
|
|
10
|
+
MantineProvider: block, Badge: block, Group: block, Paper: block, Stack: block, Text: block, Title: block,
|
|
11
|
+
Select: () => null,
|
|
12
|
+
Alert: ({ children }: { children?: import('react').ReactNode }) => createElement('div', { role: 'alert' }, children),
|
|
13
|
+
Button: ({ children, loading, onClick }: { children?: import('react').ReactNode; loading: boolean; onClick: () => void }) => createElement('button', { disabled: loading, onClick }, children),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
|
18
|
+
|
|
19
|
+
test('refresh shows configuration failures and does not label them honored', async () => {
|
|
20
|
+
const role = { role: 'reviewer', source: '/fixture/reviewer.toml', effectiveSource: '/fixture/reviewer.toml', model: 'fixture', effort: 'low', status: 'configured', availability: 'not checked', fallback: 'inherit parent settings', problems: [] as string[] };
|
|
21
|
+
const profile = { id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null };
|
|
22
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [profile] }) })
|
|
23
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [{ ...profile, roles: [{ ...role, status: 'invalid', problems: ['Unsupported effort'] }] }] }) });
|
|
24
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
25
|
+
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
26
|
+
expect(await screen.findByText('configured')).toBeInTheDocument();
|
|
27
|
+
const refresh = screen.getByText('Refresh').closest('button')!;
|
|
28
|
+
await waitFor(() => expect(refresh).toBeEnabled());
|
|
29
|
+
await act(async () => { fireEvent.click(refresh); });
|
|
30
|
+
expect(await screen.findByText('Unsupported effort')).toBeInTheDocument();
|
|
31
|
+
expect(screen.getByText(/Overrides blocked/)).toBeInTheDocument();
|
|
32
|
+
expect(screen.queryByText(/Requested effective settings/)).not.toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('request failure remains visible and can be retried', async () => {
|
|
36
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false }).mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [] }) }));
|
|
37
|
+
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
38
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('could not be loaded');
|
|
39
|
+
const refresh = screen.getByText('Refresh').closest('button')!;
|
|
40
|
+
await waitFor(() => expect(refresh).toBeEnabled());
|
|
41
|
+
await act(async () => { fireEvent.click(refresh); });
|
|
42
|
+
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
test('non-string TOML values show diagnostics without crashing settings', async () => {
|
|
47
|
+
const role = { role: 'reviewer', source: '/fixture/reviewer.toml', model: { slug: 'bad' }, effort: ['bad'], status: 'invalid', availability: 'not checked', fallback: 'inherit parent settings', problems: ['model must be a nonempty string'] };
|
|
48
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [{ id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null }] }) }));
|
|
49
|
+
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
50
|
+
expect(await screen.findByText(/Invalid model value/)).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByText(/Invalid effort value/)).toBeInTheDocument();
|
|
52
|
+
expect(screen.getByText('model must be a nonempty string')).toBeInTheDocument();
|
|
53
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Alert, Badge, Button, Group, Paper, Select, Stack, Text, Title } from '@mantine/core';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
type Role = { role: string; source: string | null; effectiveSource: string | null; model: unknown; effort: unknown; status: string; availability: string; fallback: string; problems: string[] };
|
|
5
|
+
type Profile = { id: string; project: string; roles: Role[]; problems: string[]; precedence?: string; scope?: string; catalogSource: string | null };
|
|
6
|
+
|
|
7
|
+
function setting(value: unknown, name: string) {
|
|
8
|
+
return typeof value === 'string' ? value : value == null ? `${name} omitted` : `Invalid ${name.toLowerCase()} value`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ModelSettings() {
|
|
12
|
+
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
13
|
+
const [selection, setSelection] = useState('personal');
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
const [loading, setLoading] = useState(true);
|
|
16
|
+
const [revision, setRevision] = useState(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
setLoading(true);
|
|
20
|
+
setError(null);
|
|
21
|
+
fetch('/api/settings', { signal: controller.signal }).then(async (response) => {
|
|
22
|
+
if (!response.ok) throw new Error('Settings preview could not be loaded.');
|
|
23
|
+
const data = await response.json() as { profiles: Profile[] };
|
|
24
|
+
setProfiles(data.profiles);
|
|
25
|
+
}).catch((failure: Error) => {
|
|
26
|
+
if (!controller.signal.aborted) setError(failure.message);
|
|
27
|
+
}).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
|
28
|
+
return () => controller.abort();
|
|
29
|
+
}, [revision]);
|
|
30
|
+
const profile = profiles.find((item) => item.id === selection) ?? profiles[0];
|
|
31
|
+
return <Stack gap="md" aria-label="Model settings">
|
|
32
|
+
<Group justify="space-between">
|
|
33
|
+
<Title order={2} size="h3">Model roles</Title>
|
|
34
|
+
<Button variant="light" loading={loading} onClick={() => setRevision((value) => value + 1)}>Refresh</Button>
|
|
35
|
+
</Group>
|
|
36
|
+
<Text c="dimmed" size="sm">Preview user-owned Codex agent preferences for planning, implementation, and independent review.</Text>
|
|
37
|
+
{error && <Alert color="red" role="alert">{error}</Alert>}
|
|
38
|
+
{loading && <Text role="status">Loading model preferences…</Text>}
|
|
39
|
+
{profile && <>
|
|
40
|
+
<Select label="Profile scope" data={profiles.map((item) => ({ value: item.id, label: item.project }))} value={profile.id} onChange={(value) => setSelection(value ?? 'personal')} />
|
|
41
|
+
{profile.problems.map((problem) => <Alert color="yellow" key={problem}>{problem}</Alert>)}
|
|
42
|
+
{profile.roles.map((role) => <Paper key={role.role} withBorder p="md">
|
|
43
|
+
<Stack gap="xs">
|
|
44
|
+
<Group justify="space-between"><Title order={3} size="h4" tt="capitalize">{role.role}</Title><Badge color={['invalid', 'unavailable'].includes(role.status) ? 'red' : role.status === 'missing' ? 'gray' : 'teal'}>{role.status}</Badge></Group>
|
|
45
|
+
<Text fw={600}>{setting(role.model, 'Model')} · {setting(role.effort, 'Effort')}</Text>
|
|
46
|
+
<Text size="sm" style={{ overflowWrap: 'anywhere' }}>Source: {role.source ?? 'No role file'}</Text>
|
|
47
|
+
<Text size="sm">{role.status === 'configured' ? 'Requested effective settings; passed as explicit overrides after native-tool validation.' : role.status === 'missing' ? `Effective fallback: ${role.fallback}.` : 'Overrides blocked until the configuration problem is resolved or a valid explicit user choice is supplied.'}</Text>
|
|
48
|
+
<Text c="dimmed" size="sm">When missing: {role.fallback}.</Text>
|
|
49
|
+
{typeof role.model === 'string' && <Text c="dimmed" size="sm">Availability: {role.availability}.</Text>}
|
|
50
|
+
{role.problems.map((problem) => <Alert key={problem} color="red">{problem}</Alert>)}
|
|
51
|
+
</Stack>
|
|
52
|
+
</Paper>)}
|
|
53
|
+
<Paper withBorder p="md"><Stack gap="xs">
|
|
54
|
+
<Text fw={600}>Precedence and validation</Text>
|
|
55
|
+
<Text size="sm">{profile.precedence}</Text>
|
|
56
|
+
<Text size="sm">{profile.scope}</Text>
|
|
57
|
+
<Text c="dimmed" size="sm" style={{ overflowWrap: 'anywhere' }}>Catalog: {profile.catalogSource ?? 'Unavailable'}. This preview does not prove that a future run will honor the profile. The current native interface validates model availability.</Text>
|
|
58
|
+
</Stack></Paper>
|
|
59
|
+
</>}
|
|
60
|
+
</Stack>;
|
|
61
|
+
}
|
package/src/dashboard.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveModelRoles } from "./model-roles.js";
|
|
1
2
|
import { EventEmitter } from "node:events";
|
|
2
3
|
import { randomBytes } from "node:crypto";
|
|
3
4
|
import { constants, watch } from "node:fs";
|
|
@@ -1178,6 +1179,16 @@ export async function createDashboardServer({
|
|
|
1178
1179
|
return;
|
|
1179
1180
|
}
|
|
1180
1181
|
|
|
1182
|
+
if (url.pathname === "/api/settings" && method === "GET") {
|
|
1183
|
+
const config = await readConfig(workspace);
|
|
1184
|
+
const profiles = [{ id: "personal", project: "Personal", ...await resolveModelRoles() }];
|
|
1185
|
+
for (const project of config.projects) {
|
|
1186
|
+
profiles.push({ id: project.path, project: project.name, ...await resolveModelRoles(project.path) });
|
|
1187
|
+
}
|
|
1188
|
+
sendJson(response, 200, { profiles });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1181
1192
|
if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
|
|
1182
1193
|
if (method === "HEAD") {
|
|
1183
1194
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
package/src/mcp.js
CHANGED
|
@@ -102,6 +102,8 @@ const preparationSchema = z.object({
|
|
|
102
102
|
taskId: z.string(),
|
|
103
103
|
preparedAt: z.string(),
|
|
104
104
|
marker: z.string(),
|
|
105
|
+
modelRoles: z.record(z.string(), z.unknown()).optional(),
|
|
106
|
+
projectModelRoles: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
105
107
|
projectCount: z.number(),
|
|
106
108
|
projects: z.array(projectSchema),
|
|
107
109
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCallback);
|
|
9
|
+
const resolver = fileURLToPath(new URL('../scripts/roles/resolve_roles.py', import.meta.url));
|
|
10
|
+
|
|
11
|
+
async function hasAgentFiles(directory) {
|
|
12
|
+
try { return (await readdir(directory)).some((name) => name.endsWith('.toml')); }
|
|
13
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function missingRoles() {
|
|
17
|
+
return {
|
|
18
|
+
roles: ['planner', 'implementer', 'reviewer'].map((role) => ({
|
|
19
|
+
role, source: null, effectiveSource: null, model: null, effort: null,
|
|
20
|
+
status: 'missing', availability: 'No role files', problems: [],
|
|
21
|
+
taskOverrides: {}, subagentOverrides: {},
|
|
22
|
+
fallback: role === 'reviewer' ? 'inherit parent settings' : 'native new-task default (not guaranteed dispatcher inheritance)',
|
|
23
|
+
})),
|
|
24
|
+
problems: [], catalogSource: null,
|
|
25
|
+
precedence: 'explicit user model (and its explicit effort) > project role > personal role > native defaults; explicit effort alone overrides role effort',
|
|
26
|
+
scope: 'Model and effort only; agent instructions, tools, permissions, and other TOML keys are not applied by this adapter.',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function resolveModelRoles(project = null, { run = execFile, env = process.env } = {}) {
|
|
31
|
+
try {
|
|
32
|
+
const home = env.HOME || os.homedir();
|
|
33
|
+
const codexHome = (env.CODEX_HOME || path.join(home, '.codex')).replace(/^~(?=\/|$)/, home);
|
|
34
|
+
const directories = [path.join(codexHome, 'agents'), ...(project ? [path.join(project, '.codex', 'agents')] : [])];
|
|
35
|
+
const configured = await Promise.all(directories.map(hasAgentFiles));
|
|
36
|
+
if (!configured.some(Boolean)) return missingRoles();
|
|
37
|
+
const { stdout } = await run('python3', [resolver, ...(project ? ['--project', project] : [])], {
|
|
38
|
+
env, timeout: 5000, maxBuffer: 128 * 1024,
|
|
39
|
+
});
|
|
40
|
+
return JSON.parse(stdout);
|
|
41
|
+
} catch {
|
|
42
|
+
return { roles: [], problems: ['Model role preview unavailable. Python 3.11+ and readable agent configuration are required when agent files exist; configured roles have not been honored.'], catalogSource: null };
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/workspace.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveModelRoles } from "./model-roles.js";
|
|
1
2
|
import { execFile as execFileCallback } from "node:child_process";
|
|
2
3
|
import {
|
|
3
4
|
access,
|
|
@@ -704,6 +705,10 @@ export async function prepareDispatch(workspaceRoot, {
|
|
|
704
705
|
marker,
|
|
705
706
|
projectCount: projects.length,
|
|
706
707
|
projects,
|
|
708
|
+
modelRoles: await resolveModelRoles(),
|
|
709
|
+
projectModelRoles: await Promise.all(projects.map(async (project) => ({
|
|
710
|
+
project: project.name, path: project.path, ...await resolveModelRoles(project.path),
|
|
711
|
+
}))),
|
|
707
712
|
};
|
|
708
713
|
}
|
|
709
714
|
|