@vibe-interviewing/core 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -1,3 +1,18 @@
1
+ import {
2
+ AIToolNotFoundError,
3
+ GitCloneError,
4
+ InvalidSessionCodeError,
5
+ ScenarioFetchError,
6
+ ScenarioNotFoundError,
7
+ ScenarioValidationError,
8
+ SessionNotFoundError,
9
+ SetupError,
10
+ VibeError,
11
+ decodeSessionCode,
12
+ encodeSessionCode,
13
+ isCloudSessionCode
14
+ } from "./chunk-CI3BD2WQ.js";
15
+
1
16
  // src/scenario/types.ts
2
17
  import { z } from "zod";
3
18
  var AIRulesSchema = z.object({
@@ -14,6 +29,24 @@ var EvaluationSchema = z.object({
14
29
  /** Description of the expected fix */
15
30
  expected_fix: z.string().optional()
16
31
  });
32
+ var KeySignalSchema = z.object({
33
+ /** What behavior or skill this signal measures */
34
+ signal: z.string(),
35
+ /** What a strong candidate does (green flag) */
36
+ positive: z.string(),
37
+ /** What a weak candidate does (red flag) */
38
+ negative: z.string()
39
+ });
40
+ var InterviewerGuideSchema = z.object({
41
+ /** High-level summary of what this scenario evaluates and why */
42
+ overview: z.string(),
43
+ /** Specific behaviors to watch for, with green/red flag indicators */
44
+ key_signals: z.array(KeySignalSchema).default([]),
45
+ /** Common mistakes candidates make */
46
+ common_pitfalls: z.array(z.string()).default([]),
47
+ /** Questions to ask the candidate after the session */
48
+ debrief_questions: z.array(z.string()).default([])
49
+ });
17
50
  var PatchSchema = z.object({
18
51
  /** Path to the file relative to repo root */
19
52
  file: z.string(),
@@ -22,11 +55,14 @@ var PatchSchema = z.object({
22
55
  /** The replacement text (with the bug) */
23
56
  replace: z.string()
24
57
  });
58
+ var ScenarioTypeSchema = z.enum(["debug", "feature", "refactor"]).default("debug");
25
59
  var ScenarioConfigSchema = z.object({
26
60
  /** Scenario display name */
27
61
  name: z.string(),
28
- /** One-line description */
62
+ /** One-line description (candidate-visible — describe symptoms/task, never the root cause or solution) */
29
63
  description: z.string(),
64
+ /** Scenario type: debug (find a bug), feature (build something), refactor (improve code) */
65
+ type: ScenarioTypeSchema,
30
66
  /** Difficulty level */
31
67
  difficulty: z.enum(["easy", "medium", "hard"]),
32
68
  /** Estimated time (e.g., "30-45m") */
@@ -41,14 +77,20 @@ var ScenarioConfigSchema = z.object({
41
77
  setup: z.array(z.string()).default([]),
42
78
  /** Find-and-replace patches to inject the bug after cloning */
43
79
  patch: z.array(PatchSchema).default([]),
80
+ /** Files or directories to delete after cloning (globs relative to repo root) */
81
+ delete_files: z.array(z.string()).default([]),
44
82
  /** Briefing shown to the candidate (written like a team lead message) */
45
83
  briefing: z.string(),
46
84
  /** AI behavioral rules (injected via system prompt, hidden from candidate) */
47
85
  ai_rules: AIRulesSchema,
48
- /** Interviewer reference — what the fix looks like */
49
- solution: z.string(),
86
+ /** Interviewer reference — what the fix/implementation looks like */
87
+ solution: z.string().optional(),
88
+ /** Acceptance criteria for feature scenarios (concrete, testable requirements) */
89
+ acceptance_criteria: z.array(z.string()).optional(),
50
90
  /** Evaluation rubric */
51
91
  evaluation: EvaluationSchema.optional(),
92
+ /** Structured interviewer guide — what to watch for, common pitfalls, debrief questions */
93
+ interviewer_guide: InterviewerGuideSchema.optional(),
52
94
  /** License of the original project */
53
95
  license: z.string().optional()
54
96
  });
@@ -56,90 +98,76 @@ var ScenarioConfigSchema = z.object({
56
98
  // src/scenario/loader.ts
57
99
  import { readFile } from "fs/promises";
58
100
  import { parse as parseYaml } from "yaml";
59
-
60
- // src/errors.ts
61
- var VibeError = class extends Error {
62
- constructor(message, code, hint) {
63
- super(message);
64
- this.code = code;
65
- this.hint = hint;
66
- this.name = "VibeError";
67
- }
68
- };
69
- var ScenarioNotFoundError = class extends VibeError {
70
- constructor(name) {
71
- super(
72
- `Scenario not found: ${name}`,
73
- "SCENARIO_NOT_FOUND",
74
- "Run `vibe-interviewing list` to see available scenarios"
75
- );
76
- }
77
- };
78
- var ScenarioValidationError = class extends VibeError {
79
- constructor(message, issues) {
80
- super(`Invalid scenario config: ${message}`, "SCENARIO_VALIDATION_ERROR", issues.join("\n"));
81
- this.issues = issues;
82
- }
83
- };
84
- var AIToolNotFoundError = class _AIToolNotFoundError extends VibeError {
85
- static installHints = {
86
- "claude-code": "Install Claude Code: npm install -g @anthropic-ai/claude-code"
87
- };
88
- constructor(tool) {
89
- super(
90
- `${tool} is not installed`,
91
- "AI_TOOL_NOT_FOUND",
92
- _AIToolNotFoundError.installHints[tool] ?? `Install ${tool} and try again`
93
- );
101
+ import { existsSync } from "fs";
102
+ var MAX_RESPONSE_BYTES = 1048576;
103
+ var FETCH_TIMEOUT_MS = 15e3;
104
+ function isUrl(input) {
105
+ return input.startsWith("http://") || input.startsWith("https://");
106
+ }
107
+ function toGitHubRawUrl(url) {
108
+ const match = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/(.+)$/);
109
+ if (match) {
110
+ return `https://raw.githubusercontent.com/${match[1]}/${match[2]}`;
94
111
  }
95
- };
96
- var SessionNotFoundError = class extends VibeError {
97
- constructor(id) {
98
- super(
99
- `Session not found: ${id}`,
100
- "SESSION_NOT_FOUND",
101
- "Run `vibe-interviewing list` to see active sessions"
102
- );
112
+ return url;
113
+ }
114
+ async function fetchScenarioYaml(url) {
115
+ const rawUrl = toGitHubRawUrl(url);
116
+ let response;
117
+ try {
118
+ response = await fetch(rawUrl, {
119
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
120
+ headers: { Accept: "text/plain, application/x-yaml, */*" }
121
+ });
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : "unknown error";
124
+ throw new ScenarioFetchError(url, message);
103
125
  }
104
- };
105
- var GitCloneError = class extends VibeError {
106
- constructor(repo, reason) {
107
- super(
108
- `Failed to clone repository: ${repo}${reason ? ` \u2014 ${reason}` : ""}`,
109
- "GIT_CLONE_FAILED",
110
- "Check the repo URL and your network connection"
111
- );
126
+ if (!response.ok) {
127
+ throw new ScenarioFetchError(url, `HTTP ${response.status} ${response.statusText}`);
112
128
  }
113
- };
114
- var SetupError = class extends VibeError {
115
- constructor(command, reason) {
116
- super(
117
- `Setup command failed: ${command}${reason ? ` \u2014 ${reason}` : ""}`,
118
- "SETUP_FAILED",
119
- "Check the scenario setup commands and try again"
120
- );
129
+ const contentLength = response.headers.get("content-length");
130
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
131
+ throw new ScenarioFetchError(url, "response too large (>1 MB)");
121
132
  }
122
- };
123
-
124
- // src/scenario/loader.ts
125
- import { existsSync } from "fs";
126
- async function loadScenarioConfig(configPath) {
127
- if (!existsSync(configPath)) {
128
- throw new ScenarioNotFoundError(configPath);
133
+ const text = await response.text();
134
+ if (text.length > MAX_RESPONSE_BYTES) {
135
+ throw new ScenarioFetchError(url, "response too large (>1 MB)");
129
136
  }
130
- const raw = await readFile(configPath, "utf-8");
137
+ return text;
138
+ }
139
+ function parseScenarioYaml(raw, source) {
131
140
  const parsed = parseYaml(raw);
132
141
  const result = ScenarioConfigSchema.safeParse(parsed);
133
142
  if (!result.success) {
134
143
  const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
135
- throw new ScenarioValidationError("validation failed", issues);
144
+ throw new ScenarioValidationError(`validation failed (${source})`, issues);
136
145
  }
137
146
  return result.data;
138
147
  }
148
+ async function loadScenarioConfig(pathOrUrl) {
149
+ if (isUrl(pathOrUrl)) {
150
+ const raw2 = await fetchScenarioYaml(pathOrUrl);
151
+ return parseScenarioYaml(raw2, pathOrUrl);
152
+ }
153
+ if (!existsSync(pathOrUrl)) {
154
+ throw new ScenarioNotFoundError(pathOrUrl);
155
+ }
156
+ const raw = await readFile(pathOrUrl, "utf-8");
157
+ return parseScenarioYaml(raw, pathOrUrl);
158
+ }
139
159
  function generateSystemPrompt(config) {
140
160
  const lines = [];
141
161
  lines.push(`# Interview Scenario: ${config.name}`);
142
162
  lines.push("");
163
+ const typeDescriptions = {
164
+ debug: "The candidate is debugging a bug in this codebase. Guide them through the debugging process without revealing the answer.",
165
+ feature: "The candidate is building a new feature. Help them understand the requirements, plan their approach, and implement it. Offer architectural guidance but let them drive the implementation.",
166
+ refactor: "The candidate is improving existing code. Help them identify issues and plan improvements. Encourage them to explain their reasoning for changes."
167
+ };
168
+ lines.push("## Scenario Type");
169
+ lines.push(typeDescriptions[config.type] ?? typeDescriptions["debug"]);
170
+ lines.push("");
143
171
  lines.push("## Your Role");
144
172
  lines.push(config.ai_rules.role.trim());
145
173
  lines.push("");
@@ -148,13 +176,18 @@ function generateSystemPrompt(config) {
148
176
  lines.push(`- ${rule}`);
149
177
  }
150
178
  lines.push("");
151
- lines.push("## Knowledge (DO NOT share directly with the candidate)");
179
+ const knowledgeHeaders = {
180
+ debug: "Knowledge (DO NOT share directly with the candidate)",
181
+ feature: "Implementation Context (DO NOT share directly with the candidate)",
182
+ refactor: "Improvement Context (DO NOT share directly with the candidate)"
183
+ };
184
+ lines.push(`## ${knowledgeHeaders[config.type] ?? knowledgeHeaders["debug"]}`);
152
185
  lines.push(config.ai_rules.knowledge.trim());
153
186
  return lines.join("\n");
154
187
  }
155
188
 
156
189
  // src/scenario/validator.ts
157
- async function validateScenario(config) {
190
+ function validateScenario(config) {
158
191
  const warnings = [];
159
192
  const errors = [];
160
193
  if (!config.briefing.trim()) {
@@ -168,24 +201,46 @@ async function validateScenario(config) {
168
201
  }
169
202
  if (!config.commit.trim()) {
170
203
  errors.push("commit cannot be empty \u2014 pin to a specific commit SHA for reproducibility");
204
+ } else if (!/^[0-9a-f]{7,40}$/i.test(config.commit.trim())) {
205
+ errors.push(
206
+ "commit must be a hex SHA (7-40 characters) \u2014 branch/tag names are not allowed for reproducibility"
207
+ );
171
208
  }
172
209
  if (config.ai_rules.rules.length === 0) {
173
210
  warnings.push("ai_rules.rules is empty \u2014 the AI will have no behavioral constraints");
174
211
  }
175
- if (!config.solution.trim()) {
176
- warnings.push("solution is empty \u2014 interviewers will have no solution reference");
212
+ if (config.type === "debug") {
213
+ if (config.patch.length === 0) {
214
+ warnings.push("debug scenario has no patches \u2014 a bug must be injected via patch");
215
+ }
216
+ if (!config.solution?.trim()) {
217
+ warnings.push("solution is empty \u2014 interviewers will have no solution reference");
218
+ }
219
+ }
220
+ if (config.type === "feature") {
221
+ const hasCriteria = config.acceptance_criteria && config.acceptance_criteria.length > 0 || config.evaluation && config.evaluation.criteria && config.evaluation.criteria.length > 0;
222
+ if (!hasCriteria) {
223
+ warnings.push(
224
+ "feature scenario has no acceptance_criteria or evaluation.criteria \u2014 candidates need a definition of done"
225
+ );
226
+ }
177
227
  }
178
228
  if (!config.evaluation) {
179
229
  warnings.push("No evaluation criteria defined");
180
230
  }
231
+ if (!config.interviewer_guide) {
232
+ warnings.push(
233
+ "No interviewer_guide defined \u2014 interviewers will have limited context during the session"
234
+ );
235
+ }
181
236
  return {
182
237
  valid: errors.length === 0,
183
238
  warnings,
184
239
  errors
185
240
  };
186
241
  }
187
- async function validateScenarioOrThrow(config) {
188
- const result = await validateScenario(config);
242
+ function validateScenarioOrThrow(config) {
243
+ const result = validateScenario(config);
189
244
  if (!result.valid) {
190
245
  throw new ScenarioValidationError("scenario validation failed", result.errors);
191
246
  }
@@ -230,6 +285,10 @@ async function discoverBuiltInScenarios() {
230
285
  config,
231
286
  builtIn: true
232
287
  });
288
+ } else {
289
+ console.warn(
290
+ `Warning: scenario "${entry.name}" listed in registry but ${scenarioConfigPath} not found`
291
+ );
233
292
  }
234
293
  }
235
294
  return scenarios;
@@ -326,6 +385,9 @@ var SessionRecorder = class _SessionRecorder {
326
385
  static fromJSON(data) {
327
386
  const recorder = new _SessionRecorder();
328
387
  Object.defineProperty(recorder, "startedAt", { value: data.startedAt });
388
+ Object.defineProperty(recorder, "startTime", {
389
+ value: new Date(data.startedAt).getTime()
390
+ });
329
391
  for (const event of data.events) {
330
392
  recorder.events.push({ ...event });
331
393
  }
@@ -496,17 +558,7 @@ async function listActiveSessions() {
496
558
 
497
559
  // src/session/types.ts
498
560
  function toStoredSession(session) {
499
- return {
500
- id: session.id,
501
- scenarioName: session.scenarioName,
502
- status: session.status,
503
- workdir: session.workdir,
504
- systemPromptPath: session.systemPromptPath,
505
- aiTool: session.aiTool,
506
- createdAt: session.createdAt,
507
- startedAt: session.startedAt,
508
- completedAt: session.completedAt
509
- };
561
+ return { ...session };
510
562
  }
511
563
 
512
564
  // src/session/manager.ts
@@ -520,12 +572,13 @@ var SessionManager = class {
520
572
  * Flow:
521
573
  * 1. Clone the repo at a pinned commit
522
574
  * 2. Apply bug patches (find/replace in source files)
523
- * 3. Wipe git history so the candidate can't diff to find the bug
524
- * 4. Remove scenario.yaml from workspace (interviewer-only)
525
- * 5. Write BRIEFING.md and system prompt
526
- * 6. Run setup commands (npm install, etc.)
575
+ * 3. Delete files excluded by the scenario (e.g., tests that reveal the bug)
576
+ * 4. Wipe git history so the candidate can't diff to find the bug
577
+ * 5. Remove scenario.yaml from workspace (interviewer-only)
578
+ * 6. Write BRIEFING.md and system prompt
579
+ * 7. Run setup commands (npm install, etc.)
527
580
  */
528
- async createSession(config, workdir, onProgress) {
581
+ async createSession(config, workdir, onProgress, options) {
529
582
  const id = randomBytes(4).toString("hex");
530
583
  const sessionDir = workdir ?? join5(homedir3(), "vibe-sessions", `${config.name}-${id}`);
531
584
  const session = {
@@ -542,7 +595,7 @@ var SessionManager = class {
542
595
  for (const p of config.patch) {
543
596
  const filePath = join5(sessionDir, p.file);
544
597
  const content = await readFile6(filePath, "utf-8");
545
- const patched = content.replace(p.find, p.replace);
598
+ const patched = content.replaceAll(p.find, p.replace);
546
599
  if (patched === content) {
547
600
  throw new SetupError(
548
601
  `patch ${p.file}`,
@@ -551,12 +604,18 @@ var SessionManager = class {
551
604
  }
552
605
  await writeFile3(filePath, patched);
553
606
  }
607
+ if (config.delete_files.length > 0) {
608
+ onProgress?.("Removing excluded files...");
609
+ for (const target of config.delete_files) {
610
+ await rm(join5(sessionDir, target), { recursive: true, force: true });
611
+ }
612
+ }
554
613
  onProgress?.("Preparing workspace...");
555
614
  await rm(join5(sessionDir, ".git"), { recursive: true, force: true });
556
- execSync('git init && git add -A && git commit -m "initial"', {
557
- cwd: sessionDir,
558
- stdio: "ignore"
559
- });
615
+ execSync(
616
+ 'git init && git add -A && git -c user.name=vibe -c user.email=vibe@local commit -m "initial"',
617
+ { cwd: sessionDir, stdio: "ignore" }
618
+ );
560
619
  await rm(join5(sessionDir, "scenario.yaml"), { force: true });
561
620
  await writeFile3(join5(sessionDir, "BRIEFING.md"), `# Interview Briefing
562
621
 
@@ -566,13 +625,15 @@ ${config.briefing}`);
566
625
  const systemPromptPath = join5(promptDir, `${id}.md`);
567
626
  await writeFile3(systemPromptPath, generateSystemPrompt(config));
568
627
  session.systemPromptPath = systemPromptPath;
569
- session.status = "setting-up";
570
- for (const cmd of config.setup) {
571
- onProgress?.(`Running: ${cmd}`);
572
- try {
573
- execSync(cmd, { cwd: sessionDir, stdio: "pipe", timeout: 3e5 });
574
- } catch (err) {
575
- throw new SetupError(cmd, err instanceof Error ? err.message : String(err));
628
+ if (!options?.skipSetup) {
629
+ session.status = "setting-up";
630
+ for (const cmd of config.setup) {
631
+ onProgress?.(`Running: ${cmd}`);
632
+ try {
633
+ execSync(cmd, { cwd: sessionDir, stdio: "pipe", timeout: 3e5 });
634
+ } catch (err) {
635
+ throw new SetupError(cmd, err instanceof Error ? err.message : String(err));
636
+ }
576
637
  }
577
638
  }
578
639
  session.status = "running";
@@ -614,7 +675,9 @@ export {
614
675
  AIToolNotFoundError,
615
676
  ClaudeCodeLauncher,
616
677
  GitCloneError,
678
+ InvalidSessionCodeError,
617
679
  ScenarioConfigSchema,
680
+ ScenarioFetchError,
618
681
  ScenarioNotFoundError,
619
682
  ScenarioValidationError,
620
683
  SessionManager,
@@ -622,14 +685,18 @@ export {
622
685
  SessionRecorder,
623
686
  SetupError,
624
687
  VibeError,
688
+ decodeSessionCode,
625
689
  deleteSession,
626
690
  detectInstalledTools,
627
691
  discoverAllScenarios,
628
692
  discoverBuiltInScenarios,
693
+ encodeSessionCode,
629
694
  generateSystemPrompt,
630
695
  getAllLaunchers,
631
696
  getLauncher,
632
697
  importRepo,
698
+ isCloudSessionCode,
699
+ isUrl,
633
700
  listActiveSessions,
634
701
  listSessions,
635
702
  loadScenarioConfig,