taskchef 8.4.2 → 8.5.1

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.
@@ -154,6 +154,8 @@ test("Settings uses the same capped content layout as List", () => {
154
154
  const { container } = render(<DashboardApp connect={false} />);
155
155
  const settingsPage = container.querySelector(".taskchef-settings-page");
156
156
  expect(settingsPage).toHaveClass("taskchef-content");
157
+ expect(container.querySelector(".taskchef-header")).not.toHaveClass("taskchef-list-layout");
158
+ expect(container.querySelector(".taskchef-main")).not.toHaveClass("taskchef-list-layout");
157
159
  expect(within(settingsPage as HTMLElement).getByRole("link", { name: "Back to tasks" })).toBeInTheDocument();
158
160
  window.location.hash = "";
159
161
  });
@@ -208,6 +210,8 @@ test("places the filtered task count below the toolbar and updates it from live
208
210
  const summary = document.querySelector<HTMLElement>(".taskchef-results-summary");
209
211
  const taskList = screen.getByRole("region", { name: "Tasks" });
210
212
 
213
+ expect(header.querySelector(".taskchef-header")).toHaveClass("taskchef-list-layout");
214
+ expect(document.querySelector(".taskchef-main")).toHaveClass("taskchef-list-layout");
211
215
  expect(projectFilter).toHaveValue("Another project");
212
216
  expect(screen.getByRole("combobox", { name: "Updated" })).toHaveValue("All time");
213
217
  expect(toolbar?.firstElementChild?.firstElementChild).toContainElement(viewSwitch);
@@ -248,6 +252,8 @@ test("Board ignores the List status filter and restores it on return", () => {
248
252
  render(<DashboardApp connect={false} initialFilters={{ status: "completed" }} initialTasks={tasks} />);
249
253
  expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
250
254
  fireEvent.click(screen.getByRole("radio", { name: "Board" }));
255
+ expect(document.querySelector(".taskchef-header")).not.toHaveClass("taskchef-list-layout");
256
+ expect(document.querySelector(".taskchef-main")).not.toHaveClass("taskchef-list-layout");
251
257
  const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
252
258
  expect(toolbar?.firstElementChild?.firstElementChild).toContainElement(screen.getByRole("radiogroup", { name: "View" }));
253
259
  expect(screen.getByRole("radiogroup", { name: "View" }).compareDocumentPosition(screen.getByRole("combobox", { name: "Project" })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -256,6 +262,8 @@ test("Board ignores the List status filter and restores it on return", () => {
256
262
  expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Working task");
257
263
  expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Completed task");
258
264
  fireEvent.click(screen.getByRole("radio", { name: "List" }));
265
+ expect(document.querySelector(".taskchef-header")).toHaveClass("taskchef-list-layout");
266
+ expect(document.querySelector(".taskchef-main")).toHaveClass("taskchef-list-layout");
259
267
  expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
260
268
  expect(screen.getByText("Tasks: 1 of 2")).toBeInTheDocument();
261
269
  });
@@ -239,6 +239,7 @@ export function DashboardApp({
239
239
  const visible = useMemo(() => filterTasks(tasks, { project, status, date, now }), [tasks, project, status, date, now]);
240
240
  const boardTasks = useMemo(() => filterTasks(tasks, { project, date, now }), [tasks, project, date, now]);
241
241
  const board = preferredView === "board";
242
+ const listLayout = !board && !settings;
242
243
  const counts = useMemo(() => statusFilterCounts(tasks, { project, date, now }), [tasks, project, date, now]);
243
244
  const statusData = STATUS_FILTERS.map(({ label, value }: { label: string; value: string }) => ({
244
245
  label: counts[value] > 0 ? `${label} ${counts[value]}` : label,
@@ -322,7 +323,7 @@ export function DashboardApp({
322
323
  <RelativeTimeProvider now={now}>
323
324
  <AppShell className="taskchef-shell" padding={0}>
324
325
  <Box className="taskchef-header-shell" component="header">
325
- <Container className={`taskchef-header${board ? " taskchef-header-board" : ""}`} size="100%">
326
+ <Container className={`taskchef-header${board ? " taskchef-header-board" : ""}${listLayout ? " taskchef-list-layout" : ""}`} size="100%">
326
327
  <Group className="taskchef-header-layout" justify="space-between" wrap="nowrap">
327
328
  <Group className="taskchef-header-copy" gap="xs" wrap="nowrap">
328
329
  <BrandIcon />
@@ -344,7 +345,7 @@ export function DashboardApp({
344
345
  </Box>
345
346
 
346
347
  <AppShell.Main>
347
- <Container className={`taskchef-main${board ? " taskchef-main-board" : ""}`} pb={80} size="100%">
348
+ <Container className={`taskchef-main${board ? " taskchef-main-board" : ""}${listLayout ? " taskchef-list-layout" : ""}`} pb={80} size="100%">
348
349
  {projectIndex?.status === "unavailable" && (
349
350
  <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Project index unavailable">
350
351
  Task history is still visible. Verify the index and inspect backups before making project changes.
@@ -186,7 +186,7 @@ export function TaskDetail({
186
186
  opened={opened}
187
187
  overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
188
188
  scrollAreaComponent={ScrollArea.Autosize}
189
- size="min(820px, calc(100vw - 32px))"
189
+ size="min(600px, calc(100vw - 32px))"
190
190
  title={<span className="taskchef-visually-hidden">Task details</span>}
191
191
  trapFocus
192
192
  zIndex={300}
@@ -30,6 +30,7 @@ body {
30
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; }
31
31
  .taskchef-brand-icon, .taskchef-brand-icon img { display: block; flex: none; width: 28px; height: 28px; }
32
32
  .taskchef-main { max-width: var(--taskchef-page-max-width); padding-top: 8px; padding-inline: var(--taskchef-page-gutter); }
33
+ .taskchef-list-layout { --taskchef-content-max-width: 600px; max-width: calc(var(--taskchef-content-max-width) + 2 * var(--taskchef-page-gutter)); }
33
34
  .taskchef-content { width: 100%; max-width: var(--taskchef-content-max-width); }
34
35
  .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; }
35
36
  .taskchef-toolbar-filters { width: min(100%, 488px); min-width: 0; }
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
  {