handzon-core 0.12.1 → 0.13.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/package.json +1 -1
- package/src/collections.ts +298 -61
- package/src/components/Sidebar.astro +8 -0
- package/src/components/StepHeroMedia.astro +77 -0
- package/src/components/TrackSelector.tsx +62 -0
- package/src/components/ai/ChatButton.tsx +28 -4
- package/src/components/ai/ChatPanel.tsx +10 -0
- package/src/components/home/TutorialCard.astro +77 -3
- package/src/components/mdx/Track.astro +11 -0
- package/src/layouts/BaseLayout.astro +7 -0
- package/src/layouts/TutorialLayout.astro +34 -0
- package/src/lib/ai/context.ts +12 -2
- package/src/lib/ai/prompts.ts +8 -1
- package/src/lib/heroMedia.ts +37 -0
- package/src/lib/mdx-components.ts +2 -0
- package/src/lib/progress/types.ts +1 -0
- package/src/lib/track-scoped.ts +24 -0
- package/src/lib/track-source.ts +10 -0
- package/src/lib/tracks.ts +32 -0
- package/src/pages/TutorialLanding.astro +132 -27
- package/src/server/auth/config.ts +13 -4
- package/src/server/mcp/startTutorial.ts +70 -12
- package/src/server/mcp/tools.ts +36 -3
- package/src/server/mcp/tracks.ts +43 -0
- package/src/server/mcp/writeTools.ts +34 -4
- package/styles/components/track-selector.css +44 -0
- package/styles/components.css +1 -0
|
@@ -24,6 +24,12 @@ interface AuthConfigOptions {
|
|
|
24
24
|
* builds and for `pnpm build` on a fresh checkout without a database.
|
|
25
25
|
*/
|
|
26
26
|
db: Parameters<typeof DrizzleAdapter>[0] | null;
|
|
27
|
+
/**
|
|
28
|
+
* Override auth-astro's route prefix when a fronting proxy rewrites
|
|
29
|
+
* paths before the request reaches the Handzon app. Defaults to
|
|
30
|
+
* Astro's configured `base` plus `/api/auth`.
|
|
31
|
+
*/
|
|
32
|
+
authPrefix?: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -56,13 +62,14 @@ function resolveAuthUrl(): string | undefined {
|
|
|
56
62
|
return undefined;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
function resolveAuthPrefix(): string {
|
|
65
|
+
function resolveAuthPrefix(authPrefix?: string): string {
|
|
66
|
+
if (authPrefix) return authPrefix;
|
|
60
67
|
const base = import.meta.env.BASE_URL;
|
|
61
68
|
const normalizedBase = base === "/" ? "" : base.replace(/\/$/, "");
|
|
62
69
|
return `${normalizedBase}/api/auth`;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
export function createAuthConfig({ db }: AuthConfigOptions) {
|
|
72
|
+
export function createAuthConfig({ db, authPrefix }: AuthConfigOptions) {
|
|
66
73
|
// Auth.js v5 reads `AUTH_URL` from process.env on each request, so
|
|
67
74
|
// we resolve once and write it back. Idempotent: if AUTH_URL was
|
|
68
75
|
// already a full URL, this is a no-op aside from the trailing-slash
|
|
@@ -72,18 +79,20 @@ export function createAuthConfig({ db }: AuthConfigOptions) {
|
|
|
72
79
|
process.env.AUTH_URL = resolvedUrl;
|
|
73
80
|
}
|
|
74
81
|
|
|
82
|
+
const prefix = resolveAuthPrefix(authPrefix);
|
|
83
|
+
|
|
75
84
|
if (!db) {
|
|
76
85
|
// No database → no adapter → no providers. auth-astro logs a warning
|
|
77
86
|
// and any sign-in attempt fails gracefully instead of crashing the
|
|
78
87
|
// build.
|
|
79
|
-
return defineConfig({ providers: [] });
|
|
88
|
+
return defineConfig({ prefix, providers: [] });
|
|
80
89
|
}
|
|
81
90
|
return defineConfig({
|
|
82
91
|
// auth-astro defaults to /api/auth, but Astro apps mounted with
|
|
83
92
|
// `base` receive requests at /<base>/api/auth. Use the same base
|
|
84
93
|
// that powers app links and API calls so the catch-all auth route
|
|
85
94
|
// returns a Response instead of falling through with undefined.
|
|
86
|
-
prefix
|
|
95
|
+
prefix,
|
|
87
96
|
adapter: DrizzleAdapter(db, {
|
|
88
97
|
usersTable: users,
|
|
89
98
|
accountsTable: accounts,
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { StarterSpec, VerifySpec } from "../../collections.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isStarterSpec,
|
|
4
|
+
isVerifySpec,
|
|
5
|
+
resolveForTrack,
|
|
6
|
+
type TrackScoped,
|
|
7
|
+
} from "../../lib/track-scoped.ts";
|
|
8
|
+
import type { TrackOption } from "../../lib/tracks.ts";
|
|
9
|
+
import { type McpContext, type McpTool, text } from "./protocol.ts";
|
|
10
|
+
import { resolveMcpTrack } from "./tracks.ts";
|
|
3
11
|
|
|
4
12
|
interface StartTutorialStep {
|
|
5
13
|
slug: string;
|
|
@@ -7,7 +15,7 @@ interface StartTutorialStep {
|
|
|
7
15
|
title: string;
|
|
8
16
|
summary?: string;
|
|
9
17
|
duration?: string;
|
|
10
|
-
verify?: VerifySpec | null;
|
|
18
|
+
verify?: TrackScoped<VerifySpec> | null;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
interface StartTutorialInput {
|
|
@@ -17,14 +25,18 @@ interface StartTutorialInput {
|
|
|
17
25
|
description: string;
|
|
18
26
|
difficulty: string;
|
|
19
27
|
tags: string[];
|
|
20
|
-
|
|
28
|
+
tracks?: TrackOption[];
|
|
29
|
+
defaultTrack?: string;
|
|
30
|
+
starter?: TrackScoped<StarterSpec>;
|
|
21
31
|
};
|
|
22
32
|
steps: StartTutorialStep[];
|
|
23
33
|
workspaceName?: string;
|
|
34
|
+
resolvedTrack?: string;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export type LoadStartTutorial = (
|
|
27
38
|
slug: string,
|
|
39
|
+
ctx: McpContext,
|
|
28
40
|
) => Promise<Omit<StartTutorialInput, "workspaceName"> | null>;
|
|
29
41
|
|
|
30
42
|
export interface StartTutorialPayload {
|
|
@@ -34,6 +46,8 @@ export interface StartTutorialPayload {
|
|
|
34
46
|
description: string;
|
|
35
47
|
difficulty: string;
|
|
36
48
|
tags: string[];
|
|
49
|
+
tracks: TrackOption[];
|
|
50
|
+
track?: string | null;
|
|
37
51
|
};
|
|
38
52
|
starter: StarterSpec | null;
|
|
39
53
|
workspace: {
|
|
@@ -80,19 +94,39 @@ function buildCommands(starter: StarterSpec | undefined, targetDir: string, open
|
|
|
80
94
|
return commands;
|
|
81
95
|
}
|
|
82
96
|
|
|
97
|
+
function resolveStarterForTrack(
|
|
98
|
+
starter: TrackScoped<StarterSpec> | undefined,
|
|
99
|
+
trackId: string | undefined,
|
|
100
|
+
) {
|
|
101
|
+
return resolveForTrack(starter, trackId, isStarterSpec as (v: unknown) => v is StarterSpec);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveVerifyForTrack(
|
|
105
|
+
verify: TrackScoped<VerifySpec> | undefined,
|
|
106
|
+
trackId: string | undefined,
|
|
107
|
+
) {
|
|
108
|
+
return resolveForTrack(verify, trackId, isVerifySpec as (v: unknown) => v is VerifySpec);
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
export function buildStartTutorialPayload({
|
|
84
112
|
tutorial,
|
|
85
113
|
steps,
|
|
86
114
|
workspaceName,
|
|
115
|
+
resolvedTrack,
|
|
87
116
|
}: StartTutorialInput): StartTutorialPayload {
|
|
88
|
-
const
|
|
117
|
+
const resolvedSteps = steps.map((step) => ({
|
|
118
|
+
...step,
|
|
119
|
+
verify: resolveVerifyForTrack(step.verify ?? undefined, resolvedTrack) ?? null,
|
|
120
|
+
}));
|
|
121
|
+
const [firstStep] = [...resolvedSteps].sort((a, b) => a.order - b.order);
|
|
89
122
|
if (!firstStep) {
|
|
90
123
|
throw new Error(`Tutorial ${tutorial.slug} has no steps.`);
|
|
91
124
|
}
|
|
92
125
|
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
126
|
+
const starter = resolveStarterForTrack(tutorial.starter, resolvedTrack);
|
|
127
|
+
const targetDir = resolveTargetDir(tutorial.slug, starter, workspaceName);
|
|
128
|
+
const openPath = resolveOpenPath(targetDir, starter);
|
|
129
|
+
const commands = buildCommands(starter, targetDir, openPath);
|
|
96
130
|
|
|
97
131
|
return {
|
|
98
132
|
tutorial: {
|
|
@@ -101,8 +135,10 @@ export function buildStartTutorialPayload({
|
|
|
101
135
|
description: tutorial.description,
|
|
102
136
|
difficulty: tutorial.difficulty,
|
|
103
137
|
tags: tutorial.tags,
|
|
138
|
+
tracks: tutorial.tracks ?? [],
|
|
139
|
+
track: resolvedTrack ?? null,
|
|
104
140
|
},
|
|
105
|
-
starter:
|
|
141
|
+
starter: starter ?? null,
|
|
106
142
|
workspace: { targetDir, openPath },
|
|
107
143
|
commands,
|
|
108
144
|
firstStep,
|
|
@@ -131,20 +167,42 @@ export function createStartTutorialTool(load: LoadStartTutorial): McpTool {
|
|
|
131
167
|
description:
|
|
132
168
|
"Optional local directory name to use instead of the tutorial's default targetDir.",
|
|
133
169
|
},
|
|
170
|
+
track: {
|
|
171
|
+
type: "string",
|
|
172
|
+
minLength: 1,
|
|
173
|
+
description:
|
|
174
|
+
"Optional tutorial track id. Overrides the learner's persisted prefs.track for this call.",
|
|
175
|
+
},
|
|
134
176
|
},
|
|
135
177
|
required: ["slug"],
|
|
136
178
|
additionalProperties: false,
|
|
137
179
|
},
|
|
138
|
-
handler: async (args) => {
|
|
139
|
-
const { slug, workspaceName } = args as {
|
|
140
|
-
|
|
180
|
+
handler: async (args, ctx) => {
|
|
181
|
+
const { slug, workspaceName, track } = args as {
|
|
182
|
+
slug: string;
|
|
183
|
+
workspaceName?: string;
|
|
184
|
+
track?: string;
|
|
185
|
+
};
|
|
186
|
+
const loaded = await load(slug, ctx);
|
|
141
187
|
if (!loaded) {
|
|
142
188
|
return {
|
|
143
189
|
content: [{ type: "text", text: `No tutorial with slug "${slug}".` }],
|
|
144
190
|
isError: true,
|
|
145
191
|
};
|
|
146
192
|
}
|
|
147
|
-
|
|
193
|
+
const resolvedTrack = await resolveMcpTrack({
|
|
194
|
+
tracks: loaded.tutorial.tracks,
|
|
195
|
+
defaultTrack: loaded.tutorial.defaultTrack,
|
|
196
|
+
explicitTrack: track,
|
|
197
|
+
learnerId: ctx.learnerId,
|
|
198
|
+
});
|
|
199
|
+
return text(
|
|
200
|
+
JSON.stringify(
|
|
201
|
+
buildStartTutorialPayload({ ...loaded, workspaceName, resolvedTrack }),
|
|
202
|
+
null,
|
|
203
|
+
2,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
148
206
|
},
|
|
149
207
|
};
|
|
150
208
|
}
|
package/src/server/mcp/tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { eq } from "drizzle-orm";
|
|
2
|
+
import type { VerifySpec } from "../../collections.ts";
|
|
2
3
|
import {
|
|
3
4
|
getStep,
|
|
4
5
|
getStepsForTutorial,
|
|
@@ -6,10 +7,13 @@ import {
|
|
|
6
7
|
getTutorials,
|
|
7
8
|
parseStepId,
|
|
8
9
|
} from "../../lib/content.ts";
|
|
10
|
+
import { isVerifySpec, resolveForTrack, type TrackScoped } from "../../lib/track-scoped.ts";
|
|
11
|
+
import { stripInactiveTrackBlocks } from "../../lib/track-source.ts";
|
|
9
12
|
import { getDb } from "../db/client.ts";
|
|
10
13
|
import { progressEntries } from "../db/schema.ts";
|
|
11
14
|
import { type McpTool, text } from "./protocol.ts";
|
|
12
15
|
import { createStartTutorialTool } from "./startTutorial.ts";
|
|
16
|
+
import { resolveMcpTrack } from "./tracks.ts";
|
|
13
17
|
import { progressWriteTools, verificationTools } from "./writeTools.ts";
|
|
14
18
|
|
|
15
19
|
/**
|
|
@@ -24,6 +28,13 @@ function extractCheckpointLabel(body: string): string | undefined {
|
|
|
24
28
|
return m[1] ?? m[2] ?? m[3];
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
function resolveVerifyForTrack(
|
|
32
|
+
verify: TrackScoped<VerifySpec> | undefined,
|
|
33
|
+
trackId: string | undefined,
|
|
34
|
+
) {
|
|
35
|
+
return resolveForTrack(verify, trackId, isVerifySpec as (v: unknown) => v is VerifySpec);
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
/**
|
|
28
39
|
* Catalog read tools. No auth required beyond a valid bearer token —
|
|
29
40
|
* agents browsing what's available before deciding which tutorial to
|
|
@@ -42,6 +53,8 @@ export const catalogReadTools: McpTool[] = [
|
|
|
42
53
|
description: t.data.description,
|
|
43
54
|
difficulty: t.data.difficulty,
|
|
44
55
|
tags: t.data.tags,
|
|
56
|
+
tracks: t.data.tracks,
|
|
57
|
+
defaultTrack: t.data.defaultTrack,
|
|
45
58
|
}));
|
|
46
59
|
return text(JSON.stringify({ tutorials: rows }, null, 2));
|
|
47
60
|
},
|
|
@@ -73,6 +86,8 @@ export const catalogReadTools: McpTool[] = [
|
|
|
73
86
|
difficulty: tutorial.data.difficulty,
|
|
74
87
|
tags: tutorial.data.tags,
|
|
75
88
|
gated: tutorial.data.gated,
|
|
89
|
+
tracks: tutorial.data.tracks,
|
|
90
|
+
defaultTrack: tutorial.data.defaultTrack,
|
|
76
91
|
starter: tutorial.data.starter ?? null,
|
|
77
92
|
steps: steps.map((s) => {
|
|
78
93
|
const { stepSlug, order } = parseStepId(s.id);
|
|
@@ -99,6 +114,8 @@ export const catalogReadTools: McpTool[] = [
|
|
|
99
114
|
description: tutorial.data.description,
|
|
100
115
|
difficulty: tutorial.data.difficulty,
|
|
101
116
|
tags: tutorial.data.tags,
|
|
117
|
+
tracks: tutorial.data.tracks,
|
|
118
|
+
defaultTrack: tutorial.data.defaultTrack,
|
|
102
119
|
starter: tutorial.data.starter,
|
|
103
120
|
},
|
|
104
121
|
steps: steps.map((s) => {
|
|
@@ -122,12 +139,18 @@ export const catalogReadTools: McpTool[] = [
|
|
|
122
139
|
properties: {
|
|
123
140
|
tutorial: { type: "string", minLength: 1 },
|
|
124
141
|
step: { type: "string", minLength: 1 },
|
|
142
|
+
track: {
|
|
143
|
+
type: "string",
|
|
144
|
+
minLength: 1,
|
|
145
|
+
description:
|
|
146
|
+
"Optional tutorial track id. Overrides the learner's persisted prefs.track for this call.",
|
|
147
|
+
},
|
|
125
148
|
},
|
|
126
149
|
required: ["tutorial", "step"],
|
|
127
150
|
additionalProperties: false,
|
|
128
151
|
},
|
|
129
152
|
handler: async (args) => {
|
|
130
|
-
const { tutorial, step } = args as { tutorial: string; step: string };
|
|
153
|
+
const { tutorial, step, track } = args as { tutorial: string; step: string; track?: string };
|
|
131
154
|
const tut = await getTutorialBySlug(tutorial);
|
|
132
155
|
if (!tut) {
|
|
133
156
|
return {
|
|
@@ -143,12 +166,22 @@ export const catalogReadTools: McpTool[] = [
|
|
|
143
166
|
};
|
|
144
167
|
}
|
|
145
168
|
const { stepSlug, order } = parseStepId(stepEntry.id);
|
|
146
|
-
const
|
|
147
|
-
|
|
169
|
+
const activeTrack = await resolveMcpTrack({
|
|
170
|
+
tracks: tut.data.tracks,
|
|
171
|
+
defaultTrack: tut.data.defaultTrack,
|
|
172
|
+
explicitTrack: track,
|
|
173
|
+
learnerId: ctx.learnerId,
|
|
174
|
+
});
|
|
175
|
+
const body = stripInactiveTrackBlocks(stepEntry.body ?? "", activeTrack);
|
|
176
|
+
const verifySpec = resolveVerifyForTrack(
|
|
177
|
+
(stepEntry.data as { verify?: TrackScoped<VerifySpec> }).verify,
|
|
178
|
+
activeTrack,
|
|
179
|
+
);
|
|
148
180
|
const payload = {
|
|
149
181
|
tutorial,
|
|
150
182
|
slug: stepSlug,
|
|
151
183
|
order,
|
|
184
|
+
track: activeTrack ?? null,
|
|
152
185
|
title: stepEntry.data.title,
|
|
153
186
|
summary: stepEntry.data.summary,
|
|
154
187
|
duration: stepEntry.data.duration,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { resolveActiveTrack, type TrackOption } from "../../lib/tracks.ts";
|
|
3
|
+
import { getDb } from "../db/client.ts";
|
|
4
|
+
import { progressEntries } from "../db/schema.ts";
|
|
5
|
+
|
|
6
|
+
export async function readPersistedTrack(
|
|
7
|
+
learnerId: string | undefined,
|
|
8
|
+
): Promise<string | undefined> {
|
|
9
|
+
if (!learnerId) return undefined;
|
|
10
|
+
const db = getDb();
|
|
11
|
+
const [row] = await db
|
|
12
|
+
.select({ value: progressEntries.value })
|
|
13
|
+
.from(progressEntries)
|
|
14
|
+
.where(
|
|
15
|
+
and(
|
|
16
|
+
eq(progressEntries.learnerId, learnerId),
|
|
17
|
+
eq(progressEntries.kind, "pref"),
|
|
18
|
+
eq(progressEntries.scope, "global"),
|
|
19
|
+
eq(progressEntries.key, "track"),
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
.limit(1);
|
|
23
|
+
return typeof row?.value === "string" ? row.value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function resolveMcpTrack({
|
|
27
|
+
tracks,
|
|
28
|
+
defaultTrack,
|
|
29
|
+
explicitTrack,
|
|
30
|
+
learnerId,
|
|
31
|
+
}: {
|
|
32
|
+
tracks?: TrackOption[];
|
|
33
|
+
defaultTrack?: string;
|
|
34
|
+
explicitTrack?: string;
|
|
35
|
+
learnerId?: string;
|
|
36
|
+
}): Promise<string | undefined> {
|
|
37
|
+
return resolveActiveTrack({
|
|
38
|
+
tracks,
|
|
39
|
+
explicitTrack,
|
|
40
|
+
preferredTrack: await readPersistedTrack(learnerId),
|
|
41
|
+
defaultTrack,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { VerifySpec } from "../../collections.ts";
|
|
2
|
+
import { getStep, getTutorialBySlug } from "../../lib/content.ts";
|
|
3
|
+
import { isVerifySpec, resolveForTrack, type TrackScoped } from "../../lib/track-scoped.ts";
|
|
2
4
|
import { getDb } from "../db/client.ts";
|
|
3
5
|
import { helpRequests } from "../db/schema.ts";
|
|
4
6
|
import { writeProgressEntries } from "../progress.ts";
|
|
5
7
|
import { type CheckObservation, evaluate } from "../verify/evaluator.ts";
|
|
6
8
|
import { errorResult, type McpTool, text } from "./protocol.ts";
|
|
9
|
+
import { resolveMcpTrack } from "./tracks.ts";
|
|
7
10
|
|
|
8
11
|
const SCOPE = "progress:write";
|
|
9
12
|
|
|
13
|
+
function resolveVerifyForTrack(
|
|
14
|
+
verify: TrackScoped<VerifySpec> | undefined,
|
|
15
|
+
trackId: string | undefined,
|
|
16
|
+
) {
|
|
17
|
+
return resolveForTrack(verify, trackId, isVerifySpec as (v: unknown) => v is VerifySpec);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
function requireLearner(learnerId: string | undefined) {
|
|
11
21
|
if (!learnerId) {
|
|
12
22
|
throw new Error("No resolved learner — bearer token required.");
|
|
@@ -312,6 +322,12 @@ export const verificationTools: McpTool[] = [
|
|
|
312
322
|
properties: {
|
|
313
323
|
tutorial: { type: "string", minLength: 1 },
|
|
314
324
|
step: { type: "string", minLength: 1 },
|
|
325
|
+
track: {
|
|
326
|
+
type: "string",
|
|
327
|
+
minLength: 1,
|
|
328
|
+
description:
|
|
329
|
+
"Optional tutorial track id. Overrides the learner's persisted prefs.track for this verification.",
|
|
330
|
+
},
|
|
315
331
|
observations: {
|
|
316
332
|
type: "array",
|
|
317
333
|
description:
|
|
@@ -326,16 +342,28 @@ export const verificationTools: McpTool[] = [
|
|
|
326
342
|
const a = args as {
|
|
327
343
|
tutorial: string;
|
|
328
344
|
step: string;
|
|
345
|
+
track?: string;
|
|
329
346
|
observations: CheckObservation[];
|
|
330
347
|
};
|
|
331
348
|
const learnerId = requireLearner(ctx.learnerId);
|
|
349
|
+
const tutorialEntry = await getTutorialBySlug(a.tutorial);
|
|
350
|
+
if (!tutorialEntry) {
|
|
351
|
+
return errorResult(`No tutorial "${a.tutorial}".`);
|
|
352
|
+
}
|
|
332
353
|
const stepEntry = await getStep(a.tutorial, a.step);
|
|
333
354
|
if (!stepEntry) {
|
|
334
355
|
return errorResult(`No step "${a.step}" in "${a.tutorial}".`);
|
|
335
356
|
}
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
357
|
+
const activeTrack = await resolveMcpTrack({
|
|
358
|
+
tracks: tutorialEntry.data.tracks,
|
|
359
|
+
defaultTrack: tutorialEntry.data.defaultTrack,
|
|
360
|
+
explicitTrack: a.track,
|
|
361
|
+
learnerId,
|
|
362
|
+
});
|
|
363
|
+
const spec = resolveVerifyForTrack(
|
|
364
|
+
(stepEntry.data as { verify?: TrackScoped<VerifySpec> }).verify,
|
|
365
|
+
activeTrack,
|
|
366
|
+
);
|
|
339
367
|
if (!spec) {
|
|
340
368
|
return errorResult(
|
|
341
369
|
`Step ${a.tutorial}/${a.step} has no verify block. Use complete_checkpoint for prose-fallback verification.`,
|
|
@@ -356,6 +384,7 @@ export const verificationTools: McpTool[] = [
|
|
|
356
384
|
source: "verify",
|
|
357
385
|
tutorial: a.tutorial,
|
|
358
386
|
step: a.step,
|
|
387
|
+
track: activeTrack ?? null,
|
|
359
388
|
results: a.observations,
|
|
360
389
|
ts,
|
|
361
390
|
},
|
|
@@ -383,6 +412,7 @@ export const verificationTools: McpTool[] = [
|
|
|
383
412
|
key: spec.id,
|
|
384
413
|
value: {
|
|
385
414
|
pass: false,
|
|
415
|
+
track: activeTrack ?? null,
|
|
386
416
|
failingCheckIndex: verdict.failingCheckIndex,
|
|
387
417
|
reason: verdict.reason,
|
|
388
418
|
hint: verdict.hint,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
.track-selector {
|
|
2
|
+
display: grid;
|
|
3
|
+
gap: 0.5rem;
|
|
4
|
+
margin-top: 1rem;
|
|
5
|
+
padding-top: 1rem;
|
|
6
|
+
border-top: var(--border-default, 2px) solid var(--color-border);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.track-selector-label {
|
|
10
|
+
font-family: var(--font-mono);
|
|
11
|
+
font-size: 0.72em;
|
|
12
|
+
color: var(--color-muted);
|
|
13
|
+
text-transform: uppercase;
|
|
14
|
+
letter-spacing: 0.08em;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.track-selector-list {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
gap: 0.4rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.track-selector-option {
|
|
24
|
+
appearance: none;
|
|
25
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
26
|
+
background: var(--color-bg);
|
|
27
|
+
color: var(--color-fg);
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
font: inherit;
|
|
30
|
+
font-family: var(--font-mono);
|
|
31
|
+
font-size: 0.75em;
|
|
32
|
+
line-height: 1;
|
|
33
|
+
padding: 0.45rem 0.6rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.track-selector-option:hover {
|
|
37
|
+
border-color: var(--color-accent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.track-selector-option[data-active="true"] {
|
|
41
|
+
background: var(--color-accent);
|
|
42
|
+
border-color: var(--color-accent);
|
|
43
|
+
color: var(--color-accent-fg);
|
|
44
|
+
}
|