opencode-leetcode-realworld 0.3.0 → 0.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.
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,54 @@ 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 or web server, domain
176
+ types, service layer, wiring — where **everything works except one `TODO`**. You
177
+ implement that piece (the algorithm) and `node tests/runner.mjs` verifies the app
178
+ end-to-end.
179
+
180
+ Project mode is available for **TypeScript, Python, Rust**; the other languages use
181
+ the quick exercise.
182
+
183
+ **Pick your stack.** In project mode it also asks for a stack:
184
+
185
+ | Stack | Shape | How it's tested |
186
+ | --- | --- | --- |
187
+ | Vanilla (no framework) | CLI / library | JSON on stdin → JSON on stdout |
188
+ | Express API (Node) | HTTP server | runner starts it and sends HTTP requests |
189
+ | Next.js (React) | route handlers / API | runner starts it and sends HTTP requests |
190
+ | FastAPI (Python) | HTTP server | runner starts it and sends HTTP requests |
191
+ | Agent decides | whatever fits | inferred from the chosen shape |
192
+
193
+ The folder structure is **not hardcoded** — the agent designs a layout that fits
194
+ the stack and problem (`files`), so it feels like a real repo, not a template.
195
+
196
+ For server projects, test cases are HTTP requests
197
+ (`{ name, method, path, input, expected, status }`); the runner runs your
198
+ `installCommand`, optional `buildCommand`, boots `startCommand`, waits on
199
+ `healthPath`, then checks each request. For CLI/library projects it stays
200
+ stdin/stdout.
201
+
164
202
  The generated project looks like this:
165
203
 
166
204
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-leetcode-realworld",
3
- "version": "0.3.0",
3
+ "version": "0.5.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
@@ -8,8 +8,15 @@ import type { ScaffoldSpec } from "./types";
8
8
 
9
9
  const testCaseSchema = tool.schema.object({
10
10
  name: tool.schema.string().describe("Short description of what the case asserts"),
11
- input: tool.schema.any().describe("The single JSON-shaped payload passed to the entry function"),
12
- expected: tool.schema.any().describe("The JSON-shaped value the entry function must return"),
11
+ input: tool.schema.any().optional().describe("stdio: JSON payload. http: JSON request body."),
12
+ expected: tool.schema.any().optional().describe("Expected JSON result/response body"),
13
+ method: tool.schema.string().optional().describe("http: method, e.g. POST (default GET)"),
14
+ path: tool.schema.string().optional().describe("http: request path, e.g. /api/events"),
15
+ status: tool.schema.number().optional().describe("http: expected status code (default 200)"),
16
+ headers: tool.schema
17
+ .record(tool.schema.string(), tool.schema.string())
18
+ .optional()
19
+ .describe("http: extra request headers"),
13
20
  });
14
21
 
15
22
  export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
@@ -71,9 +78,15 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
71
78
 
72
79
  const scaffoldTool = tool({
73
80
  description:
74
- "Create a real-world practice project from a spec (scenario, requirements, starter, tests). " +
81
+ "Create a real-world practice project from a spec. " +
82
+ "Use mode \"single\" for one JSON-in/out function (any language) or mode \"project\" " +
83
+ "(typescript, python, rust) for a multi-file app where one TODO must be implemented. " +
75
84
  "Rejects any spec that leaks the original problem, its title, slug, or a coding-practice site name.",
76
85
  args: {
86
+ mode: tool.schema
87
+ .enum(["single", "project"])
88
+ .default("single")
89
+ .describe("single = one function; project = multi-file app (ts/python/rust)"),
77
90
  title: tool.schema.string().describe("Real-world project title (no coding-practice words)"),
78
91
  scenario: tool.schema.string().describe("Business context, written like a real ticket"),
79
92
  pattern: tool.schema.string().describe("Underlying algorithmic principle (internal only)"),
@@ -102,6 +115,39 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
102
115
  outDir: tool.schema.string().optional().describe("Target directory, relative to the session directory"),
103
116
  sourceSlug: tool.schema.string().optional().describe("Provenance only; never surfaced"),
104
117
  sourceTitle: tool.schema.string().optional().describe("Provenance only; never surfaced"),
118
+ task: tool.schema
119
+ .string()
120
+ .optional()
121
+ .describe("Project mode: markdown brief telling the learner what to implement and where"),
122
+ files: tool.schema
123
+ .array(tool.schema.object({ path: tool.schema.string(), content: tool.schema.string() }))
124
+ .optional()
125
+ .describe("Project mode: the full file tree, leaving one TODO to implement"),
126
+ runCommand: tool.schema
127
+ .string()
128
+ .optional()
129
+ .describe("stdio project: command that runs one case (JSON on stdin, JSON on stdout)"),
130
+ buildCommand: tool.schema
131
+ .string()
132
+ .optional()
133
+ .describe("Project mode: optional one-time build command (e.g. `cargo build`)"),
134
+ installCommand: tool.schema
135
+ .string()
136
+ .optional()
137
+ .describe("Project mode: optional one-time install command (e.g. `npm install`)"),
138
+ startCommand: tool.schema
139
+ .string()
140
+ .optional()
141
+ .describe("http project: command that starts the server (enables HTTP test mode)"),
142
+ port: tool.schema.number().optional().describe("http project: server port (default 3000)"),
143
+ healthPath: tool.schema
144
+ .string()
145
+ .optional()
146
+ .describe("http project: readiness path the runner polls (default /)"),
147
+ stack: tool.schema
148
+ .string()
149
+ .optional()
150
+ .describe("Free-form stack label, e.g. express, nextjs, fastapi, vanilla"),
105
151
  },
106
152
  async execute(rawArgs, context) {
107
153
  const spec = rawArgs as ScaffoldSpec;
@@ -131,6 +177,7 @@ export const LeetCodeRealWorld: Plugin = async ({ client, directory }) => {
131
177
  `Created a real-world practice project at ${relative}`,
132
178
  "",
133
179
  `Title: ${result.title}`,
180
+ `Mode: ${result.mode}${result.mode === "project" ? ` (${result.kind}${result.stack ? `, ${result.stack}` : ""})` : ""}`,
134
181
  `Language: ${result.languageName} (${result.runtime})`,
135
182
  `Files (${result.files.length}):`,
136
183
  ...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,18 @@ 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
+
19
+ /** Stacks offered in project mode. */
20
+ export const STACKS = [
21
+ "Vanilla (no framework)",
22
+ "Express API (Node)",
23
+ "Next.js (React)",
24
+ "FastAPI (Python)",
25
+ "Agent decides",
26
+ ];
27
+
16
28
  /**
17
29
  * The `/practice` command prompt. It orchestrates the two plugin tools and is
18
30
  * deliberately opinionated: the whole value of this plugin is that the learner
@@ -32,92 +44,111 @@ Recognise any of these (all optional):
32
44
  - tags: topic names or slugs, e.g. "graph" or "dynamic-programming,sliding-window"
33
45
  - a specific problem id or slug, e.g. "two-sum" or "1"
34
46
  - language: a supported language name or alias (below)
47
+ - scope: "single" | "mini" | "full"
48
+ - stack: a stack name, e.g. "express", "nextjs", "fastapi", "vanilla"
35
49
 
36
50
  Mapping rules:
37
51
  - A word that names a topic (array, string, graph, tree, dp/dynamic-programming,
38
52
  sliding-window, two-pointers, greedy, ...) is a TAG. Pass it to \`leetcode_fetch\`
39
- as \`tags\` (an array of slugs, e.g. \`["array"]\`).
40
- - Only pass \`idOrSlug\` when the argument is a number or a known problem slug
41
- (e.g. "1", "two-sum"). Never pass a topic name as \`idOrSlug\`.
42
- - When the user gives a tag, it MUST be respected. After fetching, if the problem's
43
- topics do not include the requested tag, the fetch is wrong: retry with the tag.
53
+ as \`tags\` (array of slugs).
54
+ - Only pass \`idOrSlug\` for a number or known slug (e.g. "1", "two-sum").
55
+ - When a tag is given it MUST be respected; if the fetched topics lack it, retry.
56
+
57
+ ## 2. Ask how they want to start (use the \`question\` tool)
58
+
59
+ Step A — if language and/or difficulty are missing, ask in one \`question\` call.
60
+ Languages: ${SUPPORTED_LANGUAGES.join(", ")}. Difficulty options: "Easy",
61
+ "Medium" (recommended), "Hard".
44
62
 
45
- ## 2. Ask when unclear (use the \`question\` tool)
46
- Supported languages: ${SUPPORTED_LANGUAGES.join(", ")}.
63
+ Step B — scope. Full projects are only for ${PROJECT_LANGUAGES.join(", ")}.
64
+ - If the language is one of those AND scope was not given, ask a SECOND question
65
+ "How do you want to start?" with: "Quick exercise" (one function),
66
+ "Mini project" (~3-6 files), "Fuller project" (~6-15 files).
67
+ - Otherwise skip it.
47
68
 
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.
69
+ Step C — stack. Only when the scope is a project (not "Quick exercise"):
70
+ - If the stack was not given, ask a THIRD \`question\`: "Which stack?" with:
71
+ ${STACKS.map((s) => `"${s}"`).join(", ")}.
72
+ - Pick sensible stacks: Express/Next.js for TypeScript, FastAPI for Python,
73
+ "Vanilla" or an HTTP service for Rust.
54
74
 
55
- If the user did specify values, skip the corresponding question. Only ask at all
56
- when something is missing.
75
+ Wait for answers. Do not guess.
57
76
 
58
77
  ## 3. Fetch the source problem (private)
59
- 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.
78
+ Call \`leetcode_fetch\` with the difficulty, passing topics as \`tags\`. Verify the
79
+ returned topics include every requested tag; retry if not. The statement is for
80
+ YOUR reasoning only — never paste it or name its source anywhere.
64
81
 
65
82
  ## 4. Derive the principle
66
- Identify the underlying algorithmic idea (e.g. sliding window, heap-based
67
- scheduling, union-find, DP over intervals). Do not carry over the problem's
68
- fiction (arrays of "nums", "target", etc.).
69
-
70
- ## 5. Invent a real engineering scenario
71
- 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")
89
- - requirements: concrete, testable bullet points
90
- - 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.
83
+ Identify the underlying algorithmic idea. Do not carry over the problem's fiction.
84
+
85
+ ## 5. Build the assignment
86
+ When the spec is ready, call the \`leetcode_scaffold\` tool. The two shapes differ:
87
+
88
+ ### A) scope = Quick exercise -> mode "single"
89
+ One entry point, JSON-in / JSON-out. Do NOT provide \`starterCode\` (the plugin
90
+ generates typed/documented starters from your test cases). Spec: mode, title,
91
+ scenario, pattern, difficulty, language, requirements, edgeCases, publicTests
92
+ (5-8), hiddenTests (6-12), sourceSlug, sourceTitle.
93
+
94
+ ### B) scope = Mini/Fuller project -> mode "project" (${PROJECT_LANGUAGES.join(", ")})
95
+ Build a REAL app in the chosen stack. The folder structure is entirely up to you
96
+ — it is fluid, there is no template. The learner must implement ONE marked TODO
97
+ that contains the algorithm; the rest must build and run.
98
+
99
+ Design:
100
+ - Create a genuine feature the algorithm powers (an endpoint, command, job, or
101
+ module), embedded in a believable app with a few real layers (entry, routing or
102
+ CLI, domain/service, models/types, small helpers). Respect the size: mini ~3-6
103
+ files, fuller ~6-15 files.
104
+ - Leave exactly one \`TODO\` where the algorithm goes. It must be the ONLY thing
105
+ missing. Everything else compiles/runs.
106
+ - Choose the interface based on the stack:
107
+
108
+ * Web/API stacks (Express, Next.js, FastAPI, any HTTP server): provide
109
+ \`startCommand\`, \`port\`, \`healthPath\` (a route that returns 200), and use
110
+ **HTTP test cases**. Every case is: { name, method, path, input (JSON body),
111
+ expected (JSON response body), status? }. Add install/build commands as needed
112
+ (\`installCommand\`, e.g. "npm install" / "python -m pip install -r requirements.txt";
113
+ \`buildCommand\` only if a build is required). The runner starts the server,
114
+ polls the health path, then sends the requests.
115
+ * Non-web stacks (CLI/library): provide \`runCommand\` (reads ONE JSON value on
116
+ stdin, writes ONE JSON value on stdout) and use stdio cases
117
+ { name, input, expected }.
118
+
119
+ - Include everything needed to build/run you write as \`files\` (manifests, config,
120
+ source). No hidden dependencies beyond \`installCommand\`.
121
+ - Import/runtime correctness:
122
+ - TypeScript under Node ESM type-stripping: relative imports MUST include the
123
+ \`.ts\` extension (\`import { x } from "./service.ts"\`). If you use a bundler/dev
124
+ server (Next.js), follow that stack's conventions instead.
125
+ - Python: make imports work when the entry runs as given by \`startCommand\`/\`runCommand\`.
126
+ - Rust: internal modules via \`mod ...;\` under \`src/\`.
127
+ - Provide \`task\`: a short markdown brief naming the file(s) and function/route to implement.
128
+ - Do NOT implement the TODO and do not reveal the algorithm in comments or commit messages.
129
+
130
+ Both shapes:
131
+ - requirements / edgeCases: concrete and testable
132
+ - publicTests (5-8) and hiddenTests (6-12) as JSON-serialisable objects. Every
133
+ \`expected\` must be correct; public and hidden must not overlap. Include an
134
+ empty/degenerate case and a larger case.
135
+ - \`sourceSlug\` / \`sourceTitle\`: provenance ONLY, never surfaced.
105
136
 
106
137
  ## 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.
138
+ - Titles, scenarios, requirements, comments, file contents and test names MUST NOT
139
+ mention LeetCode, its title, its slug, or any coding-practice site. The tool
140
+ rejects leaks, including inside \`files\`.
141
+ - NEVER implement the algorithm (single: leave the entry unimplemented; project:
142
+ leave the TODO unimplemented).
112
143
  - Do not reveal the fetched problem or the original examples.
113
- - If the tool reports a leak, rewrite the offending fields and retry.
114
144
 
115
145
  ## 7. After scaffolding
116
146
  Report back with, in order:
117
- 1. One short paragraph describing the assignment as a real task (no source spoilers).
118
- 2. The project path and chosen language.
147
+ 1. One short paragraph describing the assignment as a real task (no spoilers).
148
+ 2. The project path, mode, stack, and language.
119
149
  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.
150
+ 4. For project mode: the file(s) and function/route holding the TODO.
151
+ 5. Note that \`tests/hidden/cases.json\` holds extra acceptance cases they should not edit.
121
152
  Then stop. If the learner asks for help, give guiding hints and ask questions
122
153
  rather than writing the algorithm for them.
123
154
  `;
package/src/scaffold.ts CHANGED
@@ -37,7 +37,7 @@ function json(value: unknown, indent = 2): string {
37
37
  }
38
38
 
39
39
  const RUNNER = String.raw`#!/usr/bin/env node
40
- import { spawnSync } from "node:child_process";
40
+ import { spawn, spawnSync } from "node:child_process";
41
41
  import { readFileSync } from "node:fs";
42
42
  import path from "node:path";
43
43
  import { fileURLToPath } from "node:url";
@@ -45,6 +45,8 @@ import { fileURLToPath } from "node:url";
45
45
  const testsDir = path.dirname(fileURLToPath(import.meta.url));
46
46
  const root = path.resolve(testsDir, "..");
47
47
  const harness = JSON.parse(readFileSync(path.join(root, "harness.json"), "utf8"));
48
+ const kind = harness.kind === "http" ? "http" : "stdio";
49
+ const baseUrl = () => "http://127.0.0.1:" + (harness.port || 3000);
48
50
 
49
51
  function canonical(value) {
50
52
  if (Array.isArray(value)) return value.map(canonical);
@@ -60,12 +62,20 @@ function same(a, b) {
60
62
  return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
61
63
  }
62
64
 
63
- function runCase(input) {
65
+ function runOnce(command) {
66
+ const result = spawnSync(command, { cwd: root, shell: true, stdio: "inherit" });
67
+ if (result.status !== 0) {
68
+ console.error("\nCommand failed: " + command);
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ function runStdio(input) {
64
74
  const result = spawnSync(harness.run, {
65
75
  cwd: root,
66
76
  shell: true,
67
77
  encoding: "utf8",
68
- input: JSON.stringify(input),
78
+ input: JSON.stringify(input === undefined ? null : input),
69
79
  timeout: 30000,
70
80
  });
71
81
  if (result.error) throw result.error;
@@ -77,46 +87,108 @@ function runCase(input) {
77
87
  return text === "" ? null : JSON.parse(text);
78
88
  }
79
89
 
80
- if (harness.build) {
81
- const build = spawnSync(harness.build, { cwd: root, shell: true, stdio: "inherit" });
82
- if (build.status !== 0) {
83
- console.error("\nBuild failed.");
84
- process.exit(1);
90
+ async function waitForServer() {
91
+ const health = harness.healthPath || "/";
92
+ const deadline = Date.now() + 40000;
93
+ while (Date.now() < deadline) {
94
+ try {
95
+ const response = await fetch(baseUrl() + health);
96
+ if (response.status < 500) return;
97
+ } catch (error) {
98
+ /* server not up yet */
99
+ }
100
+ await new Promise((resolve) => setTimeout(resolve, 400));
85
101
  }
102
+ throw new Error("server did not become ready at " + baseUrl() + health);
86
103
  }
87
104
 
88
- const onlyPublic = process.argv.includes("--public");
89
- const suites = onlyPublic ? ["public"] : ["public", "hidden"];
90
- let passed = 0;
91
- let failed = 0;
105
+ async function runHttp(testCase) {
106
+ const method = (testCase.method || "GET").toUpperCase();
107
+ const init = {
108
+ method: method,
109
+ headers: Object.assign({ "content-type": "application/json" }, testCase.headers || {}),
110
+ };
111
+ if (testCase.input !== undefined && method !== "GET" && method !== "HEAD") {
112
+ init.body = JSON.stringify(testCase.input);
113
+ }
114
+ const response = await fetch(baseUrl() + (testCase.path || "/"), init);
115
+ const expectedStatus = testCase.status || 200;
116
+ if (response.status !== expectedStatus) {
117
+ throw new Error("expected HTTP " + expectedStatus + " but got " + response.status);
118
+ }
119
+ const text = (await response.text()).trim();
120
+ return text === "" ? null : JSON.parse(text);
121
+ }
92
122
 
93
- for (const suite of suites) {
94
- const cases = JSON.parse(readFileSync(path.join(root, "tests", suite, "cases.json"), "utf8"));
95
- console.log("\n" + suite + " (" + cases.length + " cases)");
96
- for (const testCase of cases) {
123
+ function stopServer(server) {
124
+ if (server && server.pid) {
97
125
  try {
98
- const received = runCase(testCase.input);
99
- if (same(received, testCase.expected)) {
100
- passed += 1;
101
- console.log(" PASS " + testCase.name);
102
- } else {
103
- failed += 1;
104
- console.log(" FAIL " + testCase.name);
105
- console.log(" expected: " + JSON.stringify(testCase.expected));
106
- console.log(" received: " + JSON.stringify(received));
126
+ process.kill(-server.pid, "SIGTERM");
127
+ } catch (error) {
128
+ try {
129
+ server.kill();
130
+ } catch (inner) {
131
+ /* ignore */
107
132
  }
133
+ }
134
+ }
135
+ }
136
+
137
+ async function main() {
138
+ if (harness.install) runOnce(harness.install);
139
+ if (harness.build) runOnce(harness.build);
140
+
141
+ let server;
142
+ if (kind === "http") {
143
+ server = spawn(harness.start, { cwd: root, shell: true, stdio: "inherit", detached: true });
144
+ try {
145
+ await waitForServer();
108
146
  } catch (error) {
109
- failed += 1;
110
- console.log(" ERROR " + testCase.name);
111
- for (const line of String(error.message).split("\n")) {
112
- console.log(" " + line);
147
+ console.error(String(error.message));
148
+ stopServer(server);
149
+ process.exit(1);
150
+ }
151
+ }
152
+
153
+ const onlyPublic = process.argv.includes("--public");
154
+ const suites = onlyPublic ? ["public"] : ["public", "hidden"];
155
+ let passed = 0;
156
+ let failed = 0;
157
+
158
+ try {
159
+ for (const suite of suites) {
160
+ const cases = JSON.parse(readFileSync(path.join(root, "tests", suite, "cases.json"), "utf8"));
161
+ console.log("\n" + suite + " (" + cases.length + " cases)");
162
+ for (const testCase of cases) {
163
+ try {
164
+ const received = kind === "http" ? await runHttp(testCase) : runStdio(testCase.input);
165
+ if (same(received, testCase.expected)) {
166
+ passed += 1;
167
+ console.log(" PASS " + testCase.name);
168
+ } else {
169
+ failed += 1;
170
+ console.log(" FAIL " + testCase.name);
171
+ console.log(" expected: " + JSON.stringify(testCase.expected));
172
+ console.log(" received: " + JSON.stringify(received));
173
+ }
174
+ } catch (error) {
175
+ failed += 1;
176
+ console.log(" ERROR " + testCase.name);
177
+ for (const line of String(error.message).split("\n")) {
178
+ console.log(" " + line);
179
+ }
180
+ }
113
181
  }
114
182
  }
183
+ } finally {
184
+ stopServer(server);
115
185
  }
186
+
187
+ console.log("\n" + passed + " passed, " + failed + " failed");
188
+ process.exit(failed === 0 ? 0 : 1);
116
189
  }
117
190
 
118
- console.log("\n" + passed + " passed, " + failed + " failed");
119
- process.exit(failed === 0 ? 0 : 1);
191
+ main();
120
192
  `;
121
193
 
122
194
  function requirementsSection(spec: ScaffoldSpec): string {
@@ -232,9 +304,15 @@ tests/hidden/
232
304
  .practice-meta.json
233
305
  `;
234
306
 
235
- function metaFor(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
307
+ function metaFor(
308
+ spec: ScaffoldSpec,
309
+ names: ResolvedNames,
310
+ language: LanguageId,
311
+ mode: "single" | "project" = "single",
312
+ ): string {
236
313
  return (
237
314
  json({
315
+ mode,
238
316
  title: names.title,
239
317
  functionName: names.functionName,
240
318
  language,
@@ -289,6 +367,125 @@ function buildFiles(spec: ScaffoldSpec, names: ResolvedNames, language: Language
289
367
  return files;
290
368
  }
291
369
 
370
+ /** Languages that support full multi-file project mode. */
371
+ export const PROJECT_LANGUAGES = new Set<LanguageId>(["typescript", "python", "rust"]);
372
+
373
+ function sanitizeRelative(value: string): string {
374
+ const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
375
+ if (normalized.includes("..") || /^[a-zA-Z]:/.test(normalized)) {
376
+ throw new Error(`Refusing to write outside the project directory: "${value}"`);
377
+ }
378
+ return normalized;
379
+ }
380
+
381
+ function projectReadme(
382
+ names: ResolvedNames,
383
+ spec: ScaffoldSpec,
384
+ language: LanguageId,
385
+ ): string {
386
+ const adapter = getLanguage(language);
387
+ const fileList = (spec.files ?? [])
388
+ .map((file) => ` ${sanitizeRelative(file.path)}`)
389
+ .join("\n");
390
+ return `# ${names.title}
391
+
392
+ This is a small application, not a single function. Read \`PROJECT.md\` for the
393
+ brief, find the marked \`TODO\`, and implement the behaviour it needs.
394
+
395
+ ## Run the checks
396
+
397
+ \`\`\`bash
398
+ node tests/runner.mjs # all cases
399
+ node tests/runner.mjs --public # visible cases only
400
+ \`\`\`
401
+
402
+ Requires ${adapter.runtime}.
403
+
404
+ The tests ${spec.startCommand ? `start the server (\`${spec.startCommand}\`) and send HTTP requests to port ${spec.port ?? 3000}` : `invoke the app once per case as \`${spec.runCommand}\``}. ${spec.startCommand ? "Each case is an HTTP request; the JSON response body is compared." : "It reads **one JSON value on stdin** and must write **one JSON value on stdout**."} Debug output goes to stderr.
405
+
406
+ ## Files
407
+
408
+ \`\`\`
409
+ ${fileList}
410
+ \`\`\`
411
+ `;
412
+ }
413
+
414
+ function projectModeDoc(spec: ScaffoldSpec, names: ResolvedNames, language: LanguageId): string {
415
+ const task = spec.task?.trim() || "Find the `TODO` in the source and implement it.";
416
+ return `# ${names.title}
417
+
418
+ ## Scenario
419
+
420
+ ${spec.scenario.trim()}
421
+
422
+ ${exampleSection(spec)}
423
+ ${requirementsSection(spec)}
424
+ ${edgeCaseSection(spec)}
425
+ ## What to build
426
+
427
+ ${task}
428
+
429
+ ## Interface
430
+
431
+ ${spec.startCommand ? `The application is a server. It is started with \`${spec.startCommand}\` on port ${spec.port ?? 3000}. The tests send HTTP requests (method + path + JSON body) and compare the JSON response body.` : `The application is invoked as \`${spec.runCommand}\`. It reads **one JSON value from stdin** and must write **one JSON value to stdout**.`} Only the JSON is compared; use stderr for logs.
432
+
433
+ ## Definition of done
434
+
435
+ - The full test suite passes: \`node tests/runner.mjs\`.
436
+ - The provided code is understood and reused, not bypassed.
437
+ - Edge cases above are handled, not just the happy path.
438
+ `;
439
+ }
440
+
441
+ function buildProjectFiles(
442
+ spec: ScaffoldSpec,
443
+ names: ResolvedNames,
444
+ language: LanguageId,
445
+ ): FileEntry[] {
446
+ const adapter = getLanguage(language);
447
+ const cases = [...(spec.publicTests ?? []), ...(spec.hiddenTests ?? [])];
448
+ const first = cases[0];
449
+ const ctx: LanguageContext = {
450
+ projectName: names.projectName,
451
+ functionName: names.functionName,
452
+ title: names.title,
453
+ input: schemaFromSamples(cases.map((test) => test.input)),
454
+ output: schemaFromSamples(cases.map((test) => test.expected)),
455
+ inputExample: first?.input ?? null,
456
+ outputExample: first?.expected ?? null,
457
+ };
458
+
459
+ const files: FileEntry[] = [
460
+ { relative: "README.md", contents: projectReadme(names, spec, language) },
461
+ { relative: "PROJECT.md", contents: projectModeDoc(spec, names, language) },
462
+ { relative: ".gitignore", contents: PROJECT_GITIGNORE },
463
+ { relative: ".practice-meta.json", contents: metaFor(spec, names, language, "project") },
464
+ {
465
+ relative: "harness.json",
466
+ contents:
467
+ json({
468
+ kind: spec.startCommand ? "http" : "stdio",
469
+ stack: spec.stack ?? null,
470
+ install: spec.installCommand ?? null,
471
+ build: spec.buildCommand ?? (spec.startCommand ? null : adapter.build(ctx)),
472
+ run: spec.startCommand ? null : spec.runCommand,
473
+ start: spec.startCommand ?? null,
474
+ port: spec.startCommand ? spec.port ?? 3000 : null,
475
+ healthPath: spec.startCommand ? spec.healthPath ?? "/" : null,
476
+ }) + "\n",
477
+ },
478
+ { relative: "tests/runner.mjs", contents: RUNNER },
479
+ { relative: "tests/public/cases.json", contents: casesFile(spec.publicTests ?? []) },
480
+ { relative: "tests/hidden/cases.json", contents: casesFile(spec.hiddenTests ?? []) },
481
+ ];
482
+
483
+ for (const file of spec.files ?? []) {
484
+ files.push({ relative: sanitizeRelative(file.path), contents: file.content });
485
+ }
486
+ return files;
487
+ }
488
+
292
489
  export interface ScaffoldOptions {
293
490
  /** Base directory to resolve relative paths against (the session directory). */
294
491
  directory: string;
@@ -316,13 +513,37 @@ export async function scaffold(
316
513
 
317
514
  assertNoLeak(normalized);
318
515
 
516
+ const mode = normalized.mode === "project" ? "project" : "single";
517
+
319
518
  const adapter = getLanguage(language);
320
519
  const names = resolveNames(normalized);
520
+
521
+ if (mode === "project") {
522
+ if (!PROJECT_LANGUAGES.has(language)) {
523
+ throw new Error(
524
+ `Project mode is only supported for: ${[...PROJECT_LANGUAGES].join(", ")}. ` +
525
+ `Use mode "single" for ${adapter.name}.`,
526
+ );
527
+ }
528
+ if (!normalized.files || normalized.files.length === 0) {
529
+ throw new Error("Project mode requires a `files` list (the full file tree).");
530
+ }
531
+ if (!normalized.startCommand && !normalized.runCommand) {
532
+ throw new Error(
533
+ "Project mode requires either `runCommand` (stdio) or `startCommand` (http server).",
534
+ );
535
+ }
536
+ }
537
+
321
538
  const target = normalized.outDir
322
539
  ? path.resolve(options.directory, normalized.outDir)
323
540
  : path.resolve(options.directory, ".practice", names.projectName);
324
541
 
325
- const files = buildFiles(normalized, names, language);
542
+ const files =
543
+ mode === "project"
544
+ ? buildProjectFiles(normalized, names, language)
545
+ : buildFiles(normalized, names, language);
546
+
326
547
  for (const file of files) {
327
548
  const destination = path.join(target, file.relative);
328
549
  await mkdir(path.dirname(destination), { recursive: true });
@@ -336,6 +557,9 @@ export async function scaffold(
336
557
  language,
337
558
  languageName: adapter.name,
338
559
  runtime: adapter.runtime,
560
+ mode,
561
+ kind: normalized.startCommand ? "http" : "stdio",
562
+ stack: normalized.stack,
339
563
  files: files.map((file) => file.relative).sort(),
340
564
  runCommand: check,
341
565
  checkCommand: check,
package/src/types.ts CHANGED
@@ -40,21 +40,45 @@ export interface NormalizedProblem {
40
40
 
41
41
  export interface TestCase {
42
42
  name: string;
43
- input: unknown;
44
- expected: unknown;
43
+ /** stdio: the JSON payload. http: the request body (ignored for GET/HEAD). */
44
+ input?: unknown;
45
+ /** stdio: expected result. http: expected JSON response body. */
46
+ expected?: unknown;
47
+ /** http only: HTTP method (default GET). */
48
+ method?: string;
49
+ /** http only: request path, e.g. "/api/events" (default "/"). */
50
+ path?: string;
51
+ /** http only: expected status code (default 200). */
52
+ status?: number;
53
+ /** http only: extra request headers. */
54
+ headers?: Record<string, string>;
45
55
  }
46
56
 
57
+ export type HarnessKind = "stdio" | "http";
58
+
47
59
  export interface FileEntry {
48
60
  relative: string;
49
61
  contents: string;
50
62
  }
51
63
 
64
+ /** A file the agent authored for a multi-file project. */
65
+ export interface ProjectFile {
66
+ path: string;
67
+ content: string;
68
+ }
69
+
70
+ export type PracticeMode = "single" | "project";
71
+
52
72
  /**
53
73
  * The contract the agent fills in after it has decided on a real-world scenario.
54
74
  * None of these fields may reference LeetCode: `scaffold()` calls `assertNoLeak`
55
75
  * before writing anything to disk.
56
76
  */
57
77
  export interface ScaffoldSpec {
78
+ /** `single` = one JSON-in/out function. `project` = a multi-file app. */
79
+ mode?: PracticeMode;
80
+ /** Free-form stack label, e.g. "express", "nextjs", "fastapi", "vanilla". */
81
+ stack?: string;
58
82
  /** kebab-case directory name. Derived from `title` when omitted. */
59
83
  projectName?: string;
60
84
  /** Human title of the *real-world* project, e.g. "Realtime Dedup Pipeline". */
@@ -69,7 +93,7 @@ export interface ScaffoldSpec {
69
93
  functionName?: string;
70
94
  requirements?: string[];
71
95
  edgeCases?: string[];
72
- /** Optional starter implementation. A TODO stub is generated when omitted. */
96
+ /** Optional starter implementation (single mode). A TODO stub is generated when omitted. */
73
97
  starterCode?: string;
74
98
  publicTests?: TestCase[];
75
99
  hiddenTests?: TestCase[];
@@ -78,6 +102,24 @@ export interface ScaffoldSpec {
78
102
  /** Internal provenance, stored in `.practice-meta.json` only. Never leaked. */
79
103
  sourceSlug?: string;
80
104
  sourceTitle?: string;
105
+
106
+ // --- project mode -------------------------------------------------------
107
+ /** The full file tree for a project-mode assignment. */
108
+ files?: ProjectFile[];
109
+ /** Markdown brief for the learner: what to implement and where. */
110
+ task?: string;
111
+ /** stdio project: command that runs one case (JSON stdin -> JSON stdout). */
112
+ runCommand?: string;
113
+ /** Optional one-time install command (e.g. `npm install`, `pip install -r req.txt`). */
114
+ installCommand?: string;
115
+ /** Optional one-time build command (e.g. `cargo build`, `npm run build`). */
116
+ buildCommand?: string;
117
+ /** http project: command that starts the server. */
118
+ startCommand?: string;
119
+ /** http project: port the server listens on (default 3000). */
120
+ port?: number;
121
+ /** http project: path used to poll readiness (default "/"). */
122
+ healthPath?: string;
81
123
  }
82
124
 
83
125
  export interface ScaffoldResult {
@@ -86,6 +128,9 @@ export interface ScaffoldResult {
86
128
  language: LanguageId;
87
129
  languageName: string;
88
130
  runtime: string;
131
+ mode: PracticeMode;
132
+ kind: HarnessKind;
133
+ stack?: string;
89
134
  files: string[];
90
135
  runCommand: string;
91
136
  checkCommand: string;