taskchef 8.4.1 → 8.5.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.
@@ -32,6 +32,7 @@ vi.mock("@tabler/icons-react", async () => {
32
32
  IconAlertTriangle: Icon,
33
33
  IconCircleFilled: Icon,
34
34
  IconMoon: Icon,
35
+ IconRefresh: Icon,
35
36
  IconSun: Icon,
36
37
  };
37
38
  });
@@ -45,6 +46,7 @@ vi.mock("@mantine/core", async () => {
45
46
  fw: _fontWeight,
46
47
  gap: _gap,
47
48
  justify: _justify,
49
+ leftSection: _leftSection,
48
50
  mb: _marginBottom,
49
51
  mt: _marginTop,
50
52
  p: _padding,
@@ -146,6 +148,16 @@ beforeEach(() => {
146
148
  apiMocks.dashboardVersion.mockResolvedValue("7.25.1");
147
149
  });
148
150
 
151
+ test("Settings uses the same capped content layout as List", () => {
152
+ window.location.hash = "#settings";
153
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [] }) }));
154
+ const { container } = render(<DashboardApp connect={false} />);
155
+ const settingsPage = container.querySelector(".taskchef-settings-page");
156
+ expect(settingsPage).toHaveClass("taskchef-content");
157
+ expect(within(settingsPage as HTMLElement).getByRole("link", { name: "Back to tasks" })).toBeInTheDocument();
158
+ window.location.hash = "";
159
+ });
160
+
149
161
  test("places the filtered task count below the toolbar and updates it from live snapshots", async () => {
150
162
  const recent = new Date().toISOString();
151
163
  const tasks = [
@@ -205,6 +217,8 @@ test("places the filtered task count below the toolbar and updates it from live
205
217
  expect(toolbar).not.toContainElement(summary);
206
218
  expect(toolbar?.nextElementSibling).toBe(summary);
207
219
  expect(taskList).toHaveAttribute("aria-describedby", summary?.id);
220
+ expect(taskList).toHaveClass("taskchef-content");
221
+ expect(summary).toHaveClass("taskchef-content");
208
222
 
209
223
  act(() => {
210
224
  onSnapshot?.({
@@ -68,6 +68,7 @@ const theme = createTheme({
68
68
  components: {
69
69
  Button: { defaultProps: { radius: "md" } },
70
70
  Paper: { defaultProps: { radius: "md" } },
71
+ Select: { defaultProps: { size: "xs" } },
71
72
  },
72
73
  });
73
74
  const styleNonce = document.querySelector<HTMLMetaElement>('meta[name="taskchef-style-nonce"]')?.content;
@@ -94,7 +95,7 @@ export function TaskResultsSummary({
94
95
  visibleCount: number;
95
96
  }) {
96
97
  return (
97
- <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">
98
+ <Text aria-live="polite" className="taskchef-results-summary taskchef-content" id="task-results-summary">
98
99
  Tasks: {visibleCount} of {totalCount}
99
100
  </Text>
100
101
  );
@@ -358,15 +359,15 @@ export function DashboardApp({
358
359
  ))}
359
360
  </Alert>
360
361
  )}
361
- {settings ? <Stack gap="md"><Button component="a" href="#" variant="subtle" size="sm" leftSection={<IconArrowLeft size={16} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
362
+ {settings ? <Stack className="taskchef-settings-page taskchef-content" gap="sm"><Button component="a" href="#" variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
362
363
  <Paper className="taskchef-toolbar" radius={0}>
363
364
  <Stack gap="sm">
364
365
  <Group className="taskchef-toolbar-view">
365
366
  <SegmentedControl aria-label="View" data={[{ label: "Board", value: "board" }, { label: "List", value: "list" }]} onChange={changeView} size="xs" value={preferredView} withItemsBorders={false} />
366
367
  </Group>
367
368
  <Group className="taskchef-toolbar-filters" gap="sm" wrap="nowrap">
368
- <Select aria-label="Project" data={projects} onChange={(value) => { setProject(value ?? ""); setCompletedLimit(5); }} size="xs" value={project} />
369
- <Select aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} size="xs" value={date} />
369
+ <Select aria-label="Project" data={projects} onChange={(value) => { setProject(value ?? ""); setCompletedLimit(5); }} value={project} />
370
+ <Select aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} value={date} />
370
371
  </Group>
371
372
  {!board && <Box className="taskchef-toolbar-status">
372
373
  <SegmentedControl aria-label="Status" data={statusData} onChange={setStatus} size="xs" value={status} withItemsBorders={false} />
@@ -391,7 +392,7 @@ export function DashboardApp({
391
392
  onOpenCodex={handleOpenCodex}
392
393
  onOpenDetail={(value) => void loadDetail(value)}
393
394
  tasks={boardTasks}
394
- /> : <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list" component="section" gap="sm" mt="xs">
395
+ /> : <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list taskchef-content" component="section" gap="sm" mt="xs">
395
396
  {visible.map((task: Task) => (
396
397
  <TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
397
398
  ))}
@@ -8,7 +8,7 @@ vi.mock('../api', () => apiMocks);
8
8
 
9
9
  vi.mock('@mantine/core', async () => {
10
10
  const { createElement } = await import('react');
11
- const block = ({ children }: { children?: import('react').ReactNode }) => createElement('div', null, children);
11
+ const block = ({ children, className, 'aria-label': ariaLabel }: { children?: import('react').ReactNode; className?: string; 'aria-label'?: string }) => createElement('div', { className, 'aria-label': ariaLabel }, children);
12
12
  return {
13
13
  MantineProvider: block, Tooltip: block, Badge: block, Group: block, Paper: block, SimpleGrid: block, Stack: block, Text: block, Title: block,
14
14
  Select: ({ 'aria-busy': busy, 'aria-label': label, data = [], disabled, onChange, readOnly, value }: { 'aria-busy'?: boolean; 'aria-label': string; data?: Array<string | { value: string; label: string; disabled?: boolean }>; disabled?: boolean; onChange: (value: string | null) => void; readOnly?: boolean; value: string | null }) => createElement('select', { 'aria-busy': busy, 'aria-label': label, disabled, 'aria-readonly': readOnly, value: value ?? '', onChange: (event: { currentTarget: { value: string } }) => onChange(event.currentTarget.value) }, [createElement('option', { key: '', value: '' }), ...data.map((item) => typeof item === 'string' ? createElement('option', { key: item, value: item }, item) : createElement('option', { key: item.value, value: item.value, disabled: item.disabled }, item.label))]),
@@ -34,6 +34,22 @@ test('shows the ccusage version pinned by TaskChef without an availability statu
34
34
  expect(screen.queryByText('available')).not.toBeInTheDocument();
35
35
  });
36
36
 
37
+ test('uses the shared dashboard content and section treatment', async () => {
38
+ const role = { role: 'planner', source: null, model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
39
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
40
+ ok: true,
41
+ json: async () => ({
42
+ profiles: [{ id: 'personal', roles: [role], problems: [], modelOptions: [{ value: 'gpt-one', label: 'GPT One', efforts: ['low'] }] }],
43
+ usageProvider: { provider: 'ccusage', status: 'available', version: '20.0.21' },
44
+ }),
45
+ }));
46
+ const { container } = render(<MantineProvider><ModelSettings /></MantineProvider>);
47
+ expect(await screen.findByRole('combobox', { name: 'planner model' })).toBeInTheDocument();
48
+ expect(container.querySelector('[aria-label="Model settings"]')).toHaveClass('taskchef-settings');
49
+ expect(container.querySelector('.taskchef-settings-fields')).toBeInTheDocument();
50
+ expect(container.querySelectorAll('.taskchef-section')).toHaveLength(2);
51
+ });
52
+
37
53
  test('refresh shows configuration failures and does not label them honored', async () => {
38
54
  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[] };
39
55
  const profile = { id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null };
@@ -59,9 +59,9 @@ export function ModelSettings() {
59
59
  setSaving(null);
60
60
  }
61
61
  };
62
- return <Stack gap="md" aria-label="Model settings">
62
+ return <Stack className="taskchef-settings" gap="sm" aria-label="Model settings">
63
63
  <Group justify="space-between">
64
- <Title order={2} size="h3">Model roles</Title>
64
+ <Title order={2} size="h5">Model roles</Title>
65
65
  <ActionIcon aria-label="Refresh" aria-busy={loading} aria-disabled={loading || saving !== null} className="taskchef-icon-button" color="gray" variant="subtle" size="lg" onClick={() => {
66
66
  if (loading || saving) return;
67
67
  setLoading(true);
@@ -72,10 +72,10 @@ export function ModelSettings() {
72
72
  {error && <Alert color="red" role="alert">{error}</Alert>}
73
73
  {profile && <>
74
74
  {profile.problems.map((problem) => <Alert color="yellow" key={problem}>{problem}</Alert>)}
75
- {profile.roles.map((role) => <Paper key={role.role} withBorder p="md">
75
+ {profile.roles.map((role) => <Paper className="taskchef-section" key={role.role} withBorder p="sm">
76
76
  <Stack gap="xs">
77
- <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>
78
- <SimpleGrid cols={{ base: 1, sm: 2 }}>
77
+ <Group justify="space-between"><Title order={3} size="h5" tt="capitalize">{role.role}</Title><Badge color={['invalid', 'unavailable'].includes(role.status) ? 'red' : role.status === 'missing' ? 'gray' : 'teal'}>{role.status}</Badge></Group>
78
+ <SimpleGrid className="taskchef-settings-fields" cols={2}>
79
79
  <Select label="Model" aria-label={`${role.role} model`} aria-busy={loading || saving !== null} data={profile.modelOptions ?? []} value={typeof role.model === 'string' ? role.model : null} readOnly={loading || saving !== null} allowDeselect={false} searchable onChange={(model) => {
80
80
  if (loading || saving || !model) return;
81
81
  const option = profile.modelOptions?.find((candidate) => candidate.value === model);
@@ -95,10 +95,10 @@ export function ModelSettings() {
95
95
  </Stack>
96
96
  </Paper>)}
97
97
  </>}
98
- {usageProvider && <Paper withBorder p="md">
98
+ {usageProvider && <Paper className="taskchef-section" withBorder p="sm">
99
99
  <Stack gap="xs">
100
- <Title order={3} size="h4">Usage provider</Title>
101
- <Text>{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
100
+ <Title order={3} size="h5">Usage provider</Title>
101
+ <Text size="sm">{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
102
102
  <Text c="dimmed" size="sm">Version pinned by TaskChef for token and cost estimates.</Text>
103
103
  </Stack>
104
104
  </Paper>}
@@ -5,6 +5,9 @@
5
5
  --taskchef-text: light-dark(#202521, #edf1ee);
6
6
  --taskchef-muted: light-dark(#657068, #aab4ad);
7
7
  --taskchef-accent: light-dark(#087f5b, #38d9a9);
8
+ --taskchef-page-max-width: 1510px;
9
+ --taskchef-content-max-width: 1080px;
10
+ --taskchef-page-gutter: clamp(16px, 3vw, 48px);
8
11
  }
9
12
 
10
13
  * { box-sizing: border-box; }
@@ -17,7 +20,7 @@ body {
17
20
 
18
21
  .taskchef-shell { min-height: 100vh; background: var(--taskchef-page); }
19
22
  .taskchef-header-shell { border-bottom: 1px solid var(--taskchef-border); }
20
- .taskchef-header { max-width: 1510px; padding: 10px clamp(12px, 3vw, 48px); }
23
+ .taskchef-header { max-width: var(--taskchef-page-max-width); padding: 10px var(--taskchef-page-gutter); }
21
24
  .taskchef-header-layout { min-width: 0; min-height: 36px; gap: 8px; }
22
25
  .taskchef-header-copy { min-width: 0; }
23
26
  .taskchef-header-actions { flex: none; }
@@ -26,15 +29,23 @@ body {
26
29
  .taskchef-brand-title { min-width: 0; color: var(--taskchef-accent); font-size: 0.875rem; font-weight: 750; letter-spacing: 0.035em; line-height: 1; text-transform: uppercase; white-space: nowrap; }
27
30
  .taskchef-version { margin-inline-start: 0.45rem; color: var(--taskchef-muted); font-size: 0.78em; font-weight: 600; letter-spacing: 0; text-transform: none; }
28
31
  .taskchef-brand-icon, .taskchef-brand-icon img { display: block; flex: none; width: 28px; height: 28px; }
29
- .taskchef-main { max-width: 1510px; padding-top: 8px; padding-inline: clamp(16px, 3vw, 48px); }
32
+ .taskchef-main { max-width: var(--taskchef-page-max-width); padding-top: 8px; padding-inline: var(--taskchef-page-gutter); }
33
+ .taskchef-content { width: 100%; max-width: var(--taskchef-content-max-width); }
30
34
  .taskchef-toolbar { max-width: none; margin-bottom: 22px; padding: 8px 0 var(--mantine-spacing-md); border: 0; border-bottom: 1px solid var(--taskchef-border); background: transparent; box-shadow: none; }
31
35
  .taskchef-toolbar-filters { width: min(100%, 488px); min-width: 0; }
32
36
  .taskchef-toolbar-filters .mantine-InputWrapper-root { flex: 1 1 0; width: 0; min-width: 0; max-width: 240px; }
33
37
  .taskchef-toolbar .mantine-InputWrapper-label, .taskchef-filter-label { font-size: var(--mantine-font-size-sm); font-weight: 600; line-height: var(--mantine-line-height-sm); }
34
38
  .taskchef-toolbar .mantine-SegmentedControl-root { max-width: 650px; }
35
- .taskchef-results-summary { max-width: 1080px; margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
36
- .taskchef-list { max-width: 1080px; }
37
- .taskchef-task-row { container-type: inline-size; background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
39
+ .taskchef-results-summary { max-width: var(--taskchef-content-max-width); margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
40
+ .taskchef-list { max-width: var(--taskchef-content-max-width); }
41
+ .taskchef-settings-fields {
42
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
43
+ gap: var(--mantine-spacing-sm);
44
+ width: min(100%, 36rem);
45
+ }
46
+ .taskchef-task-row { container-type: inline-size; }
47
+ .taskchef-task-row,
48
+ .taskchef-section { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
38
49
  .taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
39
50
  .taskchef-task-main { min-width: 0; flex: 1; }
40
51
  .taskchef-card-heading,
@@ -73,7 +84,7 @@ body {
73
84
  .taskchef-stat + .taskchef-stat { border-inline-start: 1px solid var(--taskchef-border); }
74
85
  .taskchef-empty { border-style: dashed; background: transparent; }
75
86
 
76
- .taskchef-toolbar .mantine-Select-input { width: 100%; height: 30px; min-height: 30px; padding-inline-start: 12px; font-size: var(--mantine-font-size-xs); }
87
+ .taskchef-toolbar .mantine-Select-input { width: 100%; }
77
88
  .taskchef-toolbar-view .mantine-SegmentedControl-root, .taskchef-toolbar-status .mantine-SegmentedControl-root { width: max-content; max-width: 100%; height: 30px; padding: 0; }
78
89
  .taskchef-toolbar-view .mantine-SegmentedControl-label, .taskchef-toolbar-status .mantine-SegmentedControl-label { display: flex; align-items: center; height: 100%; padding-inline: 12px; font-size: var(--mantine-font-size-xs); text-align: start; }
79
90
  .taskchef-board {
package/src/github.js CHANGED
@@ -74,8 +74,9 @@ export function normalizeGithubRepositories(
74
74
  return repositories;
75
75
  }
76
76
 
77
- export function matchProjectForGithubUrl(value, projects) {
77
+ export function matchProjectForGithubUrl(value, projects, routingHints = []) {
78
78
  if (!Array.isArray(projects)) throw new Error("projects must be an array");
79
+ if (!Array.isArray(routingHints)) throw new Error("routingHints must be an array");
79
80
  let parsed;
80
81
  try {
81
82
  parsed = parseGithubUrl(value, "GitHub URL", { allowIssueOrPull: true });
@@ -84,15 +85,17 @@ export function matchProjectForGithubUrl(value, projects) {
84
85
  }
85
86
  const repository = `https://github.com/${parsed.owner}/${parsed.repository}`;
86
87
  const key = repository.toLowerCase();
87
- const matches = projects.filter((project) =>
88
- Array.isArray(project?.githubRepos)
89
- && project.githubRepos.some((candidate) => {
88
+ const matches = projects.filter((project) => {
89
+ const learned = routingHints.find((hint) => hint?.path === project?.path)?.githubRepos ?? [];
90
+ return [...(Array.isArray(project?.githubRepos) ? project.githubRepos : []), ...learned]
91
+ .some((candidate) => {
90
92
  try {
91
93
  return canonicalGithubRepository(candidate).toLowerCase() === key;
92
94
  } catch {
93
95
  return false;
94
96
  }
95
- }));
97
+ });
98
+ });
96
99
  if (matches.length === 1) {
97
100
  return { status: "matched", repository, project: matches[0], projects: matches };
98
101
  }
package/src/mcp.js CHANGED
@@ -8,7 +8,10 @@ import {
8
8
  reportTaskState,
9
9
  reportTaskResult,
10
10
  dashboardAutostartEnabled,
11
+ includeProject,
11
12
  readConfig,
13
+ reconcileProjects,
14
+ updateProjectHint,
12
15
  } from "./workspace.js";
13
16
  import { parseTaskChefMarker } from "./delegation.js";
14
17
  import { createDashboardManager } from "./dashboard-manager.js";
@@ -105,6 +108,60 @@ const preparationSchema = z.object({
105
108
  modelRoles: z.record(z.string(), z.unknown()).optional(),
106
109
  projectCount: z.number(),
107
110
  projects: z.array(projectSchema),
111
+ routingHints: z.array(z.object({
112
+ path: z.string(),
113
+ aliases: z.array(z.string()),
114
+ githubRepos: z.array(z.string()),
115
+ responsibilities: z.array(z.string()),
116
+ })).optional(),
117
+ });
118
+
119
+ const nativeProjectSnapshotSchema = z.object({
120
+ schemaVersion: z.literal(2),
121
+ projects: z.array(z.object({
122
+ projectId: z.string().min(1),
123
+ projectKind: z.string().min(1),
124
+ label: z.string().min(1),
125
+ path: z.string().nullable().optional(),
126
+ hostId: z.string().nullable().optional(),
127
+ hostDisplayName: z.string().nullable().optional(),
128
+ isGitRepository: z.boolean().nullable().optional(),
129
+ }).passthrough()),
130
+ }).passthrough();
131
+
132
+ const routingProvenanceSchema = z.object({
133
+ kind: z.enum(["explicit_user", "verified_repository", "accepted_terminal_report"]),
134
+ evidence: z.string().min(1).max(240),
135
+ taskId: z.string().min(1).nullable(),
136
+ threadId: z.string().min(1).nullable(),
137
+ turnRef: z.string().min(1).max(256).nullable(),
138
+ repositoryPath: z.string().min(1).nullable(),
139
+ });
140
+
141
+ const reconciliationSchema = z.object({
142
+ schemaVersion: z.literal(1),
143
+ changed: z.boolean(),
144
+ projectCount: z.number().int().nonnegative(),
145
+ addedCount: z.number().int().nonnegative(),
146
+ boundCount: z.number().int().nonnegative(),
147
+ available: z.array(z.object({
148
+ hostId: z.string(),
149
+ projectId: z.string(),
150
+ path: z.string(),
151
+ })),
152
+ diagnostics: z.array(z.object({
153
+ projectId: z.string(),
154
+ code: z.string(),
155
+ message: z.string(),
156
+ })),
157
+ });
158
+
159
+ const hintUpdateSchema = z.object({
160
+ changed: z.boolean(),
161
+ suppressed: z.boolean(),
162
+ project: z.string(),
163
+ kind: z.enum(["alias", "githubRepo", "responsibility"]),
164
+ value: z.string(),
108
165
  });
109
166
 
110
167
  const dashboardSchema = z.object({
@@ -166,6 +223,9 @@ export function createTaskChefMcpServer({
166
223
  reportResult = reportTaskResult,
167
224
  reportState = reportTaskState,
168
225
  link = linkTask,
226
+ reconcile = reconcileProjects,
227
+ updateHint = updateProjectHint,
228
+ include = includeProject,
169
229
  dashboardManager = createDashboardManager({ workspace }),
170
230
  readConfiguration = readConfig,
171
231
  logDashboardDiagnostic,
@@ -223,6 +283,75 @@ export function createTaskChefMcpServer({
223
283
  },
224
284
  );
225
285
 
286
+ server.registerTool(
287
+ "reconcile_projects",
288
+ {
289
+ title: "Reconcile TaskChef projects",
290
+ description:
291
+ "Atomically reconcile an agent-supplied native Codex list_projects schema-2 snapshot into the local TaskChef project index. Only eligible local projects on hostId local are considered; existing curated metadata and missing entries are preserved.",
292
+ inputSchema: { snapshot: nativeProjectSnapshotSchema },
293
+ outputSchema: { reconciliation: reconciliationSchema },
294
+ annotations: {
295
+ readOnlyHint: false,
296
+ destructiveHint: false,
297
+ openWorldHint: false,
298
+ },
299
+ },
300
+ async ({ snapshot }) => {
301
+ const result = await reconcile(workspace, snapshot);
302
+ const reconciliation = {
303
+ schemaVersion: 1,
304
+ changed: result.changed,
305
+ projectCount: result.projectCount,
306
+ addedCount: result.added.length,
307
+ boundCount: result.bound.length,
308
+ available: result.available,
309
+ diagnostics: result.diagnostics,
310
+ };
311
+ return toolResult(
312
+ "reconciliation",
313
+ reconciliation,
314
+ `Reconciled ${reconciliation.projectCount} TaskChef project(s).`,
315
+ );
316
+ },
317
+ );
318
+
319
+ server.registerTool(
320
+ "include_project",
321
+ {
322
+ title: "Include a previously excluded TaskChef project",
323
+ description:
324
+ "Remove an explicit reconciliation exclusion by exact local native projectId or canonical path. Reconciliation must run afterward before the project becomes a routing target again.",
325
+ inputSchema: {
326
+ projectId: z.string().min(1).optional(),
327
+ path: z.string().min(1).optional(),
328
+ },
329
+ outputSchema: {
330
+ result: z.object({ changed: z.boolean(), remainingExclusionCount: z.number().int().nonnegative() }),
331
+ },
332
+ annotations: {
333
+ readOnlyHint: false,
334
+ destructiveHint: false,
335
+ openWorldHint: false,
336
+ },
337
+ },
338
+ async ({ projectId, path: projectPath }) => {
339
+ if (Boolean(projectId) === Boolean(projectPath)) {
340
+ throw new Error("include_project requires exactly one projectId or path");
341
+ }
342
+ const updated = await include(workspace, projectId
343
+ ? { projectId, hostId: "local" }
344
+ : { path: projectPath });
345
+ const result = {
346
+ changed: updated.changed,
347
+ remainingExclusionCount: updated.exclusions.length,
348
+ };
349
+ return toolResult("result", result, result.changed
350
+ ? "Removed the TaskChef project exclusion."
351
+ : "No matching TaskChef project exclusion exists.");
352
+ },
353
+ );
354
+
226
355
  server.registerTool(
227
356
  "prepare_dispatch",
228
357
  {
@@ -243,6 +372,43 @@ export function createTaskChefMcpServer({
243
372
  },
244
373
  );
245
374
 
375
+ server.registerTool(
376
+ "update_project_hint",
377
+ {
378
+ title: "Remember or forget TaskChef project routing",
379
+ description:
380
+ "Remember or forget one bounded routing fact for an exact configured project. Aliases and forgetting require explicit user evidence; repository ownership requires verified origin evidence; report-derived responsibilities require the accepted current terminal task/turn identity.",
381
+ inputSchema: {
382
+ action: z.enum(["remember", "correct", "forget"]),
383
+ project: z.string().min(1),
384
+ kind: z.enum(["alias", "githubRepo", "responsibility"]),
385
+ value: z.string().min(1).max(256),
386
+ provenance: routingProvenanceSchema,
387
+ },
388
+ outputSchema: { result: hintUpdateSchema },
389
+ annotations: {
390
+ readOnlyHint: false,
391
+ destructiveHint: true,
392
+ openWorldHint: false,
393
+ },
394
+ },
395
+ async (input) => {
396
+ const updated = await updateHint(workspace, input);
397
+ const result = {
398
+ changed: updated.changed,
399
+ suppressed: updated.suppressed,
400
+ project: updated.project,
401
+ kind: updated.kind,
402
+ value: updated.value,
403
+ };
404
+ return toolResult(
405
+ "result",
406
+ result,
407
+ `${result.changed ? "Updated" : "Kept"} TaskChef project routing.`,
408
+ );
409
+ },
410
+ );
411
+
246
412
  server.registerTool(
247
413
  "record_task",
248
414
  {