opencode-leetcode-realworld 0.3.0 → 0.4.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/README.md CHANGED
@@ -76,6 +76,7 @@ Options can be given in any order; the command figures out which is which.
76
76
  | `tags` | One or more topic tags, comma-separated (e.g. `graph` or `dynamic-programming,sliding-window`) | any | Nudges which underlying algorithm and real-world domain you get. |
77
77
  | `<id-or-slug>` | A problem number or slug (e.g. `1`, `two-sum`) | random | Derive the assignment from one specific source problem. |
78
78
  | `language` | A supported language or alias (see below) | **asks you** | Language of the generated project and tests. |
79
+ | `scope` | `single` \| `mini` \| `full` | **asks you** | How big the assignment is (see Two modes). |
79
80
 
80
81
  If you leave the language (or difficulty) out, opencode **asks you** with a
81
82
  multiple-choice question before generating anything.
@@ -150,17 +151,34 @@ they tend to produce:
150
151
  ```
151
152
  /practice
152
153
  │
153
- ├─ (agent) if language/difficulty are missing, ask you via the `question` tool
154
+ ├─ (agent) ask you for language/difficulty, then how you want to start
154
155
  │
155
156
  ├─ leetcode_fetch fetch a problem from the LeetCode API (private reasoning input)
156
157
  │
157
158
  ├─ (agent) derive the principle -> invent a real-world scenario
158
159
  │
159
- ├─ leetcode_scaffold write README / PROJECT / starter / tests, reject any leak
160
+ ├─ leetcode_scaffold write the exercises(s): one function, or a multi-file app
160
161
  │
161
162
  └─ guard hooks block reading hidden tests + provenance, keep the exercise honest
162
163
  ```
163
164
 
165
+ ## Two modes
166
+
167
+ `/practice` asks **how you want to start**, and the answer changes what gets built:
168
+
169
+ | Scope | What you get |
170
+ | --- | --- |
171
+ | **Quick exercise** | One entry point with a JSON-in / JSON-out contract (any language). |
172
+ | **Mini project** | A small multi-file app (~3-6 files) with the algorithm behind a single `TODO` (TypeScript, Python, Rust). |
173
+ | **Fuller project** | A multi-file app with more realistic layers (~6-15 files) and the same single `TODO`. |
174
+
175
+ In project mode you get a real little codebase — CLI entry, domain types, service
176
+ layer, wiring — where **everything works except one `TODO`**. You implement that
177
+ piece (the algorithm) and `node tests/runner.mjs` verifies the app end-to-end.
178
+
179
+ Project mode is available for **TypeScript, Python, Rust**; the other languages use
180
+ the quick exercise.
181
+
164
182
  The generated project looks like this:
165
183
 
166
184
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-leetcode-realworld",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin that turns a LeetCode problem into a realistic software-engineering assignment, so you practice applying the underlying algorithm instead of solving the puzzle.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -71,9 +71,15 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
71
71
 
72
72
  const scaffoldTool = tool({
73
73
  description:
74
- "Create a real-world practice project from a spec (scenario, requirements, starter, tests). " +
74
+ "Create a real-world practice project from a spec. " +
75
+ "Use mode \"single\" for one JSON-in/out function (any language) or mode \"project\" " +
76
+ "(typescript, python, rust) for a multi-file app where one TODO must be implemented. " +
75
77
  "Rejects any spec that leaks the original problem, its title, slug, or a coding-practice site name.",
76
78
  args: {
79
+ mode: tool.schema
80
+ .enum(["single", "project"])
81
+ .default("single")
82
+ .describe("single = one function; project = multi-file app (ts/python/rust)"),
77
83
  title: tool.schema.string().describe("Real-world project title (no coding-practice words)"),
78
84
  scenario: tool.schema.string().describe("Business context, written like a real ticket"),
79
85
  pattern: tool.schema.string().describe("Underlying algorithmic principle (internal only)"),
@@ -102,6 +108,22 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
102
108
  outDir: tool.schema.string().optional().describe("Target directory, relative to the session directory"),
103
109
  sourceSlug: tool.schema.string().optional().describe("Provenance only; never surfaced"),
104
110
  sourceTitle: tool.schema.string().optional().describe("Provenance only; never surfaced"),
111
+ task: tool.schema
112
+ .string()
113
+ .optional()
114
+ .describe("Project mode: markdown brief telling the learner what to implement and where"),
115
+ files: tool.schema
116
+ .array(tool.schema.object({ path: tool.schema.string(), content: tool.schema.string() }))
117
+ .optional()
118
+ .describe("Project mode: the full file tree, leaving one TODO to implement"),
119
+ runCommand: tool.schema
120
+ .string()
121
+ .optional()
122
+ .describe("Project mode: command that runs one case (JSON on stdin, JSON on stdout)"),
123
+ buildCommand: tool.schema
124
+ .string()
125
+ .optional()
126
+ .describe("Project mode: optional one-time build command (compiled languages)"),
105
127
  },
106
128
  async execute(rawArgs, context) {
107
129
  const spec = rawArgs as ScaffoldSpec;
@@ -131,6 +153,7 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
131
153
  `Created a real-world practice project at ${relative}`,
132
154
  "",
133
155
  `Title: ${result.title}`,
156
+ `Mode: ${result.mode}${result.mode === "project" ? " (implement the marked TODO)" : ""}`,
134
157
  `Language: ${result.languageName} (${result.runtime})`,
135
158
  `Files (${result.files.length}):`,
136
159
  ...result.files.map((file) => ` - ${file}`),
package/src/patterns.ts CHANGED
@@ -92,11 +92,12 @@ function textsFromSpec(spec: ScaffoldSpec): Array<[string, string]> {
92
92
  push("scenario", spec.scenario);
93
93
  push("pattern", spec.pattern);
94
94
  push("functionName", spec.functionName);
95
- push("starterCode", spec.starterCode);
95
+ push("task", spec.task);
96
96
  (spec.requirements ?? []).forEach((req, i) => push(`requirements[${i}]`, req));
97
97
  (spec.edgeCases ?? []).forEach((edge, i) => push(`edgeCases[${i}]`, edge));
98
98
  (spec.publicTests ?? []).forEach((test, i) => push(`publicTests[${i}].name`, test.name));
99
99
  (spec.hiddenTests ?? []).forEach((test, i) => push(`hiddenTests[${i}].name`, test.name));
100
+ (spec.files ?? []).forEach((file, i) => push(`files[${i}] (${file.path})`, file.content));
100
101
  return entries;
101
102
  }
102
103
 
package/src/prompt.ts CHANGED
@@ -13,6 +13,9 @@ export const SUPPORTED_LANGUAGES = [
13
13
  "Swift",
14
14
  ];
15
15
 
16
+ /** Languages that can receive a full multi-file project. */
17
+ export const PROJECT_LANGUAGES = ["TypeScript", "Python", "Rust"];
18
+
16
19
  /**
17
20
  * The `/practice` command prompt. It orchestrates the two plugin tools and is
18
21
  * deliberately opinionated: the whole value of this plugin is that the learner
@@ -32,6 +35,7 @@ Recognise any of these (all optional):
32
35
  - tags: topic names or slugs, e.g. "graph" or "dynamic-programming,sliding-window"
33
36
  - a specific problem id or slug, e.g. "two-sum" or "1"
34
37
  - language: a supported language name or alias (below)
38
+ - scope: "single" | "mini" | "full" (how they want to start)
35
39
 
36
40
  Mapping rules:
37
41
  - A word that names a topic (array, string, graph, tree, dp/dynamic-programming,
@@ -42,82 +46,97 @@ Mapping rules:
42
46
  - When the user gives a tag, it MUST be respected. After fetching, if the problem's
43
47
  topics do not include the requested tag, the fetch is wrong: retry with the tag.
44
48
 
45
- ## 2. Ask when unclear (use the \`question\` tool)
46
- Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
49
+ ## 2. Ask how they want to start (use the \`question\` tool)
50
+
51
+ Step A — if the language and/or difficulty are missing, ask for them in one
52
+ \`question\` call. Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
53
+ Offer difficulty options "Easy", "Medium" (recommended), "Hard".
47
54
 
48
- - If the user did NOT specify a language, call the \`question\` tool and ask which
49
- language they want to practice, offering these as options:
50
- ${SUPPORTED_LANGUAGES.map((name) => `"${name}"`).join(", ")}.
51
- - If the user did NOT specify a difficulty, include a second question in the SAME
52
- \`question\` call offering "Easy", "Medium" (recommended), and "Hard".
53
- - Wait for the answers. Do not guess and do not default silently.
55
+ Step B — decide the *scope*. Full multi-file projects are only available for
56
+ ${PROJECT_LANGUAGES.join(", ")}.
57
+ - If the chosen language is one of those AND scope was not given, make a SECOND
58
+ \`question\` call titled "How do you want to start?" with these options:
59
+ - "Quick exercise" — one focused function with a JSON-in/JSON-out contract.
60
+ - "Mini project" — a small multi-file app (~3-6 files) with the algorithm at its core.
61
+ - "Fuller project" — a multi-file app with more realistic layers (~6-15 files).
62
+ - Otherwise (other language, or scope already given) skip this question.
54
63
 
55
- If the user did specify values, skip the corresponding question. Only ask at all
56
- when something is missing.
64
+ Wait for the answers. Do not guess.
57
65
 
58
66
  ## 3. Fetch the source problem (private)
59
67
  Call the \`leetcode_fetch\` tool with the difficulty, and pass any requested topic
60
- as \`tags\` (array of slugs) — not as \`idOrSlug\`. Verify the returned topics
61
- include every requested tag; if not, retry. The fetched statement is for YOUR
62
- reasoning only. Never paste it, paraphrase it closely, or name its source anywhere
63
- in the generated project.
68
+ as \`tags\` (array of slugs). Verify the returned topics include every requested
69
+ tag; if not, retry. The fetched statement is for YOUR reasoning only. Never paste
70
+ it, paraphrase it closely, or name its source anywhere in the generated project.
64
71
 
65
72
  ## 4. Derive the principle
66
73
  Identify the underlying algorithmic idea (e.g. sliding window, heap-based
67
74
  scheduling, union-find, DP over intervals). Do not carry over the problem's
68
75
  fiction (arrays of "nums", "target", etc.).
69
76
 
70
- ## 5. Invent a real engineering scenario
77
+ ## 5. Build the assignment
71
78
  Design a plausible product/business task that genuinely needs that principle. Good
72
- domains: observability pipelines, payments/ledgering, logistics, rate limiting,
73
- access control, search, feature flags, data reconciliation, scheduling.
74
- The scenario must read like a ticket from a real team: who needs it, why, and what
75
- "correct" means.
76
-
77
- The assignment has ONE entry point with a JSON-in / JSON-out contract:
78
- - It receives a single JSON value (usually an object describing a request).
79
- - It returns a single JSON value (a response or result).
80
- This is how real services, CLIs and jobs exchange data, and it lets the same test
81
- harness verify any language.
82
-
83
- Call the tool \`leetcode_scaffold\` with a spec containing:
84
- - title: real-world project title (no coding-practice words)
85
- - scenario: 1-3 paragraphs of business context
86
- - pattern: the underlying principle (internal only)
87
- - language: the language the user chose (use the canonical id, e.g. "typescript",
88
- "javascript", "python", "ruby", "php", "go", "rust", "csharp", "swift")
79
+ domains: observability, payments/ledgering, logistics, rate limiting, access
80
+ control, search, feature flags, data reconciliation, scheduling.
81
+
82
+ Then call \`leetcode_scaffold\`. There are two shapes:
83
+
84
+ ### A) mode = "single" (Quick exercise)
85
+ - One entry point with a JSON-in / JSON-out contract.
86
+ - Do NOT provide \`starterCode\`: the plugin generates a typed starter (type-safe
87
+ languages) or a documented one (dynamic languages) inferred from your test cases,
88
+ so make the first few cases representative (vary fields to expose optionals).
89
+ - Spec: mode, title, scenario, pattern, difficulty, language, requirements,
90
+ edgeCases, publicTests (5-8), hiddenTests (6-12 incl. empty + large), task optional,
91
+ sourceSlug, sourceTitle.
92
+
93
+ ### B) mode = "project" (only ${PROJECT_LANGUAGES.join(", ")})
94
+ Build a small but real application where the algorithm is the piece the learner
95
+ implements. You author the whole tree and pass it as \`files\`.
96
+ - Provide a working CLI entry (e.g. \`src/cli.ts\`, \`src/cli.py\`, or \`src/main.rs\`)
97
+ that reads ONE JSON value on stdin and writes ONE JSON value on stdout. Tests
98
+ drive this entry end-to-end.
99
+ - Put the algorithm behind a single, clearly marked \`TODO\` in a domain/service file
100
+ (e.g. \`// TODO: implement ...\`). Everything else — argument/stdin parsing, domain
101
+ models/types, wiring, config, a repository or helper — must be complete and runnable
102
+ so that the ONLY thing missing is that TODO.
103
+ - Keep the surrounding code real but not algorithm-heavy: parsing, types, plumbing.
104
+ - Respect the requested size: mini ~3-6 files, fuller ~6-15 files.
105
+ - Provide \`runCommand\` (runs one case) and, for compiled languages, \`buildCommand\`
106
+ (e.g. Rust: \`cargo build\`; then \`runCommand: ./target/debug/<bin>\`).
107
+ - Provide \`task\`: a short markdown brief naming the file and function to implement.
108
+ - Include everything needed to build/run (package.json/tsconfig, Cargo.toml, etc.).
109
+ - Import/runtime correctness (the tests run your \`runCommand\`):
110
+ - TypeScript: relative imports MUST include the \`.ts\` extension
111
+ (\`import { x } from "./service.ts"\`) because the app runs under Node ESM type
112
+ stripping, which does not resolve extensionless specifiers.
113
+ - Python: make imports work when the entry runs directly (\`python3 src/cli.py\`);
114
+ same-directory modules or a small \`sys.path\` adjustment are both fine.
115
+ - Rust: declare internal modules with \`mod ...;\` and keep them under \`src/\`.
116
+ - Do NOT implement the TODO and do not reveal the algorithm in comments.
117
+
118
+ Both shapes:
89
119
  - requirements: concrete, testable bullet points
90
120
  - edgeCases: tricky situations the solution must handle
91
- - Do NOT provide starterCode. Omit it so the plugin can generate a starter that
92
- reflects the real types: type-safe languages get typed interfaces/structs and a
93
- typed signature; dynamic languages get a documented input/output format comment.
94
- The generated types are inferred from your publicTests/hiddenTests, so make the
95
- first few cases representative of the full input shape (and vary fields so
96
- optional ones are detected).
97
- - publicTests: 5-8 visible cases that illustrate the contract
98
- - hiddenTests: 6-12 acceptance cases, including edge cases, boundary values, an
99
- empty/degenerate input, and at least one larger input
100
- - sourceSlug / sourceTitle: for internal provenance ONLY (never surfaced)
101
-
102
- Test cases must be JSON-serialisable: \`{ "name": string, "input": any, "expected": any }\`.
103
- Make sure every \`expected\` value is actually correct for the described rules, and
104
- that public and hidden cases do not overlap.
121
+ - publicTests / hiddenTests: JSON-serialisable \`{ "name": string, "input": any, "expected": any }\`.
122
+ Every \`expected\` must be correct for the described rules; public and hidden must not overlap.
123
+ - sourceSlug / sourceTitle: provenance ONLY, never surfaced.
105
124
 
106
125
  ## 6. Rules (non-negotiable)
107
- - The generated title, scenario, requirements, comments, and test names MUST NOT
108
- mention LeetCode, its title, its slug, or any coding-practice site. The
109
- \`leetcode_scaffold\` tool will reject the spec if it does.
110
- - DO NOT implement the solution. Your job ends when the scaffold is written. The
111
- learner implements the solution file themselves.
126
+ - Titles, scenarios, requirements, comments, file contents and test names MUST NOT
127
+ mention LeetCode, its title, its slug, or any coding-practice site. The tool
128
+ rejects leaks (including inside \`files\`).
129
+ - NEVER implement the algorithm (single mode: leave the entry unimplemented;
130
+ project mode: leave the TODO unimplemented). The learner does that.
112
131
  - Do not reveal the fetched problem or the original examples.
113
- - If the tool reports a leak, rewrite the offending fields and retry.
114
132
 
115
133
  ## 7. After scaffolding
116
134
  Report back with, in order:
117
135
  1. One short paragraph describing the assignment as a real task (no source spoilers).
118
- 2. The project path and chosen language.
136
+ 2. The project path, mode, and language.
119
137
  3. The exact command to run the tests (\`node tests/runner.mjs\`).
120
- 4. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
138
+ 4. For project mode: which file/function holds the TODO.
139
+ 5. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
121
140
  Then stop. If the learner asks for help, give guiding hints and ask questions
122
141
  rather than writing the algorithm for them.
123
142
  `;
package/src/scaffold.ts CHANGED
@@ -232,9 +232,15 @@ tests/hidden/
232
232
  .practice-meta.json
233
233
  `;
234
234
 
235
- function metaFor(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
235
+ function metaFor(
236
+ spec: ScaffoldSpec,
237
+ names: ResolvedNames,
238
+ language: LanguageId,
239
+ mode: "single" | "project" = "single",
240
+ ): string {
236
241
  return (
237
242
  json({
243
+ mode,
238
244
  title: names.title,
239
245
  functionName: names.functionName,
240
246
  language,
@@ -289,6 +295,119 @@ function buildFiles(spec: ScaffoldSpec, names: ResolvedNames, language: Language
289
295
  return files;
290
296
  }
291
297
 
298
+ /** Languages that support full multi-file project mode. */
299
+ export const PROJECT_LANGUAGES = new Set<LanguageId>(["typescript", "python", "rust"]);
300
+
301
+ function sanitizeRelative(value: string): string {
302
+ const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
303
+ if (normalized.includes("..") || /^[a-zA-Z]:/.test(normalized)) {
304
+ throw new Error(`Refusing to write outside the project directory: "${value}"`);
305
+ }
306
+ return normalized;
307
+ }
308
+
309
+ function projectReadme(
310
+ names: ResolvedNames,
311
+ spec: ScaffoldSpec,
312
+ language: LanguageId,
313
+ ): string {
314
+ const adapter = getLanguage(language);
315
+ const fileList = (spec.files ?? [])
316
+ .map((file) => ` ${sanitizeRelative(file.path)}`)
317
+ .join("\n");
318
+ return `# ${names.title}
319
+
320
+ This is a small application, not a single function. Read \`PROJECT.md\` for the
321
+ brief, find the marked \`TODO\`, and implement the behaviour it needs.
322
+
323
+ ## Run the checks
324
+
325
+ \`\`\`bash
326
+ node tests/runner.mjs # all cases
327
+ node tests/runner.mjs --public # visible cases only
328
+ \`\`\`
329
+
330
+ Requires ${adapter.runtime}.
331
+
332
+ The app is invoked once per case as \`${spec.runCommand}\`: it reads **one JSON value
333
+ on stdin** and must write **one JSON value on stdout**. Debug output goes to stderr.
334
+
335
+ ## Files
336
+
337
+ \`\`\`
338
+ ${fileList}
339
+ \`\`\`
340
+ `;
341
+ }
342
+
343
+ function projectModeDoc(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
344
+ const task = spec.task?.trim() || "Find the `TODO` in the source and implement it.";
345
+ return `# ${names.title}
346
+
347
+ ## Scenario
348
+
349
+ ${spec.scenario.trim()}
350
+
351
+ ${exampleSection(spec)}
352
+ ${requirementsSection(spec)}
353
+ ${edgeCaseSection(spec)}
354
+ ## What to build
355
+
356
+ ${task}
357
+
358
+ ## Interface
359
+
360
+ The application is invoked as \`${spec.runCommand}\`. It reads **one JSON value from
361
+ stdin** and must write **one JSON value to stdout**. Only stdout is compared; use
362
+ stderr for logs.
363
+
364
+ ## Definition of done
365
+
366
+ - The full test suite passes: \`node tests/runner.mjs\`.
367
+ - The provided code is understood and reused, not bypassed.
368
+ - Edge cases above are handled, not just the happy path.
369
+ `;
370
+ }
371
+
372
+ function buildProjectFiles(
373
+ spec: ScaffoldSpec,
374
+ names: ResolvedNames,
375
+ language: LanguageId,
376
+ ): FileEntry[] {
377
+ const adapter = getLanguage(language);
378
+ const cases = [...(spec.publicTests ?? []), ...(spec.hiddenTests ?? [])];
379
+ const first = cases[0];
380
+ const ctx: LanguageContext = {
381
+ projectName: names.projectName,
382
+ functionName: names.functionName,
383
+ title: names.title,
384
+ input: schemaFromSamples(cases.map((test) => test.input)),
385
+ output: schemaFromSamples(cases.map((test) => test.expected)),
386
+ inputExample: first?.input ?? null,
387
+ outputExample: first?.expected ?? null,
388
+ };
389
+
390
+ const files: FileEntry[] = [
391
+ { relative: "README.md", contents: projectReadme(names, spec, language) },
392
+ { relative: "PROJECT.md", contents: projectModeDoc(spec, names, language) },
393
+ { relative: ".gitignore", contents: PROJECT_GITIGNORE },
394
+ { relative: ".practice-meta.json", contents: metaFor(spec, names, language, "project") },
395
+ {
396
+ relative: "harness.json",
397
+ contents:
398
+ json({ build: spec.buildCommand ?? adapter.build(ctx), run: spec.runCommand }) + "\n",
399
+ },
400
+ { relative: "tests/runner.mjs", contents: RUNNER },
401
+ { relative: "tests/public/cases.json", contents: casesFile(spec.publicTests ?? []) },
402
+ { relative: "tests/hidden/cases.json", contents: casesFile(spec.hiddenTests ?? []) },
403
+ ];
404
+
405
+ for (const file of spec.files ?? []) {
406
+ files.push({ relative: sanitizeRelative(file.path), contents: file.content });
407
+ }
408
+ return files;
409
+ }
410
+
292
411
  export interface ScaffoldOptions {
293
412
  /** Base directory to resolve relative paths against (the session directory). */
294
413
  directory: string;
@@ -316,13 +435,35 @@ export async function scaffold(
316
435
 
317
436
  assertNoLeak(normalized);
318
437
 
438
+ const mode = normalized.mode === "project" ? "project" : "single";
439
+
319
440
  const adapter = getLanguage(language);
320
441
  const names = resolveNames(normalized);
442
+
443
+ if (mode === "project") {
444
+ if (!PROJECT_LANGUAGES.has(language)) {
445
+ throw new Error(
446
+ `Project mode is only supported for: ${[...PROJECT_LANGUAGES].join(", ")}. ` +
447
+ `Use mode "single" for ${adapter.name}.`,
448
+ );
449
+ }
450
+ if (!normalized.files || normalized.files.length === 0) {
451
+ throw new Error("Project mode requires a `files` list (the full file tree).");
452
+ }
453
+ if (!normalized.runCommand) {
454
+ throw new Error("Project mode requires a `runCommand` (run one case, JSON on stdin/stdout).");
455
+ }
456
+ }
457
+
321
458
  const target = normalized.outDir
322
459
  ? path.resolve(options.directory, normalized.outDir)
323
460
  : path.resolve(options.directory, ".practice", names.projectName);
324
461
 
325
- const files = buildFiles(normalized, names, language);
462
+ const files =
463
+ mode === "project"
464
+ ? buildProjectFiles(normalized, names, language)
465
+ : buildFiles(normalized, names, language);
466
+
326
467
  for (const file of files) {
327
468
  const destination = path.join(target, file.relative);
328
469
  await mkdir(path.dirname(destination), { recursive: true });
@@ -336,6 +477,7 @@ export async function scaffold(
336
477
  language,
337
478
  languageName: adapter.name,
338
479
  runtime: adapter.runtime,
480
+ mode,
339
481
  files: files.map((file) => file.relative).sort(),
340
482
  runCommand: check,
341
483
  checkCommand: check,
package/src/types.ts CHANGED
@@ -49,12 +49,22 @@ export interface FileEntry {
49
49
  contents: string;
50
50
  }
51
51
 
52
+ /** A file the agent authored for a multi-file project. */
53
+ export interface ProjectFile {
54
+ path: string;
55
+ content: string;
56
+ }
57
+
58
+ export type PracticeMode = "single" | "project";
59
+
52
60
  /**
53
61
  * The contract the agent fills in after it has decided on a real-world scenario.
54
62
  * None of these fields may reference LeetCode: `scaffold()` calls `assertNoLeak`
55
63
  * before writing anything to disk.
56
64
  */
57
65
  export interface ScaffoldSpec {
66
+ /** `single` = one JSON-in/out function. `project` = a multi-file app. */
67
+ mode?: PracticeMode;
58
68
  /** kebab-case directory name. Derived from `title` when omitted. */
59
69
  projectName?: string;
60
70
  /** Human title of the *real-world* project, e.g. "Realtime Dedup Pipeline". */
@@ -69,7 +79,7 @@ export interface ScaffoldSpec {
69
79
  functionName?: string;
70
80
  requirements?: string[];
71
81
  edgeCases?: string[];
72
- /** Optional starter implementation. A TODO stub is generated when omitted. */
82
+ /** Optional starter implementation (single mode). A TODO stub is generated when omitted. */
73
83
  starterCode?: string;
74
84
  publicTests?: TestCase[];
75
85
  hiddenTests?: TestCase[];
@@ -78,6 +88,16 @@ export interface ScaffoldSpec {
78
88
  /** Internal provenance, stored in `.practice-meta.json` only. Never leaked. */
79
89
  sourceSlug?: string;
80
90
  sourceTitle?: string;
91
+
92
+ // --- project mode -------------------------------------------------------
93
+ /** The full file tree for a project-mode assignment. */
94
+ files?: ProjectFile[];
95
+ /** Markdown brief for the learner: what to implement and where. */
96
+ task?: string;
97
+ /** Command that runs one test case (JSON on stdin, JSON on stdout). */
98
+ runCommand?: string;
99
+ /** Optional one-time build command (compiled languages). */
100
+ buildCommand?: string;
81
101
  }
82
102
 
83
103
  export interface ScaffoldResult {
@@ -86,6 +106,7 @@ export interface ScaffoldResult {
86
106
  language: LanguageId;
87
107
  languageName: string;
88
108
  runtime: string;
109
+ mode: PracticeMode;
89
110
  files: string[];
90
111
  runCommand: string;
91
112
  checkCommand: string;