@algolia/wizard 0.24.0-rc.111.205 → 0.25.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/main.js +480 -321
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,8 +12,22 @@ npx @algolia/wizard
12
12
 
13
13
  # run a specific workflow by id
14
14
  npx @algolia/wizard <workflow-id>
15
+
16
+ # see all options
17
+ npx @algolia/wizard --help
15
18
  ```
16
19
 
20
+ ### Options
21
+
22
+ | Flag | Effect |
23
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | `--seed <step-id>` | Start the workflow at the step with this id (e.g. `--seed ingestion`), with the earlier steps pre-filled with test data. Pass with no value to print the step ids. |
25
+ | `--no-telemetry` | Send no telemetry or analytics for this run. |
26
+ | `--reset-on-run` | Wipe this project's wizard state (run state, AI-changes consent, worktrees) before starting, so the run behaves like a first-ever run. Credentials are untouched. |
27
+ | `-h`, `--help` | Print usage. |
28
+
29
+ `--seed` pre-fills the earlier steps with fabricated data so a single step can be exercised without running the whole workflow — useful for testing a step, not for a real implementation. It replaces any in-progress run for that workflow and pre-grants the AI-changes consent. See [Starting mid-workflow](CONTRIBUTING.md#starting-mid-workflow---seed).
30
+
17
31
  On first run, Wizard checks whether you're signed in to the Algolia CLI. If you aren't, it runs `algolia auth login --non-interactive` — the browser opens for sign-in, and no prompts land in the wizard's terminal. It then asks which Algolia application to work in (skipping the question when the account has only one) and makes it current with `algolia application select`.
18
32
 
19
33
  You'll also be asked once to consent to AI-authored changes to the repository.
package/dist/main.js CHANGED
@@ -251,7 +251,6 @@ var useWizard = create((set, get) => ({
251
251
  _noticeTimer: null,
252
252
  cliOutput: [],
253
253
  targetIndex: null,
254
- writtenFiles: [],
255
254
  logs: [],
256
255
  error: null,
257
256
  inputReq: null,
@@ -345,8 +344,6 @@ var useWizard = create((set, get) => ({
345
344
  })),
346
345
  clearCliOutput: () => set({ cliOutput: [] }),
347
346
  setTargetIndex: (index) => set({ targetIndex: index }),
348
- recordWrittenFile: (path) => set((s) => ({ writtenFiles: [...s.writtenFiles, path] })),
349
- clearWrittenFiles: () => set({ writtenFiles: [] }),
350
347
  logStart: (kind, name, input) => {
351
348
  const id = nanoid();
352
349
  set((s) => ({
@@ -394,7 +391,6 @@ var useWizard = create((set, get) => ({
394
391
  notices: [],
395
392
  cliOutput: [],
396
393
  targetIndex: null,
397
- writtenFiles: [],
398
394
  logs: [],
399
395
  error: null,
400
396
  inputReq: null,
@@ -1263,7 +1259,7 @@ var accessItems = [
1263
1259
  {
1264
1260
  tag: "WRITE",
1265
1261
  title: "Code changes",
1266
- description: "creates & edits files (search UI, config) directly in your branch."
1262
+ description: "creates & edits files (search UI, config) in a throwaway git worktree \u2014 your checkout is never touched."
1267
1263
  },
1268
1264
  {
1269
1265
  tag: "EXEC",
@@ -1278,7 +1274,7 @@ var accessItems = [
1278
1274
  {
1279
1275
  tag: "KEY",
1280
1276
  title: "Credentials",
1281
- description: "writes your Algolia app id and a search-only key (safe to expose) to .env in your project."
1277
+ description: "writes your Algolia app id and a search-only key (safe to expose) to .env in the worktree."
1282
1278
  }
1283
1279
  ];
1284
1280
  var neverItems = [
@@ -2558,7 +2554,6 @@ function writeFileTool(ctx) {
2558
2554
  }
2559
2555
  await mkdir3(dirname4(resolved2.target), { recursive: true });
2560
2556
  await writeFile3(resolved2.target, content, "utf8");
2561
- useWizard.getState().recordWrittenFile(resolved2.target);
2562
2557
  return `Wrote to ${filePath}`;
2563
2558
  } catch (err) {
2564
2559
  return `Error writing ${filePath}: ${err.message}`;
@@ -3183,6 +3178,10 @@ function storeApproval(root) {
3183
3178
  return answer === "approve" ? "approve" : "reject";
3184
3179
  };
3185
3180
  }
3181
+ var EXPLORATORY_COMMANDS = /* @__PURE__ */ new Set(["ls", "find", "tree", "dir"]);
3182
+ function isExploratoryCommand(command) {
3183
+ return command.split(/&&|;|\|/).map((segment) => segment.trim().split(/\s+/)[0]).some((word) => word !== void 0 && EXPLORATORY_COMMANDS.has(word));
3184
+ }
3186
3185
  async function approveAndRun(ctx, command, cwd, explanation) {
3187
3186
  const decision = await ctx.shell.approve({ command, cwd, explanation });
3188
3187
  if (decision === "reject") {
@@ -3227,7 +3226,7 @@ async function approveAndRun(ctx, command, cwd, explanation) {
3227
3226
  }
3228
3227
  function runShellTool(ctx) {
3229
3228
  return tool8({
3230
- description: "Run a shell command in the project. Use this for anything the project needs done in its own ecosystem: installing dependencies, running a script you wrote, running the project's lint/typecheck/test commands. The user sees and approves every command before it runs, so write a clear `explanation`. If the user rejects a command, do not retry it \u2014 propose a different approach.",
3229
+ description: "Run a shell command in the project. Use this for anything the project needs done in its own ecosystem: installing dependencies, running a script you wrote, running the project's lint/typecheck/test commands. To inspect the project, use listFiles or searchFiles instead of ls/find \u2014 this tool refuses those. The user sees and approves every command before it runs, so write a clear `explanation`. If the user rejects a command, do not retry it \u2014 propose a different approach.",
3231
3230
  inputSchema: z15.object({
3232
3231
  command: z15.string().describe(
3233
3232
  "The command to run, exactly as it would be typed in a shell. Pipes, && and redirects are allowed."
@@ -3245,6 +3244,9 @@ function runShellTool(ctx) {
3245
3244
  }
3246
3245
  const resolved2 = resolveInRoot(ctx, cwd ?? ".");
3247
3246
  if (!resolved2.ok) return resolved2.error;
3247
+ if (isExploratoryCommand(command)) {
3248
+ return "Refused: use listFiles or searchFiles to inspect the project instead of ls/find/tree.";
3249
+ }
3248
3250
  logger.info({ command, cwd: resolved2.target }, "called runShell tool");
3249
3251
  return serializePrompt(
3250
3252
  () => approveAndRun(ctx, command, resolved2.target, explanation)
@@ -3339,7 +3341,7 @@ function defaultCreateModel() {
3339
3341
  }
3340
3342
  function generateRecordTool(ctx, createModel = defaultCreateModel) {
3341
3343
  return tool10({
3342
- description: "Generate realistic sample records for an entity and write them to a JSON file. Provide the entity name and its attributes; this tool asks a model to invent varied, realistic values, each with a unique objectID, and returns the file path to read them from at runtime. Do not invent the record values or objectIDs yourself, and do not inline the returned records into the script \u2014 call this tool and read the file it writes.",
3344
+ description: "Generate realistic sample records for an entity and write them to a JSON file in the worktree. Provide the entity name and its attributes; this tool asks a model to invent varied, realistic values, each with a unique objectID, and returns the file path to read them from at runtime. Do not invent the record values or objectIDs yourself, and do not inline the returned records into the script \u2014 call this tool and read the file it writes.",
3343
3345
  inputSchema: z17.object({
3344
3346
  entityName: z17.string().describe("Name of the entity to generate records for."),
3345
3347
  attributes: z17.array(z17.string()).describe("Attribute names each record must contain."),
@@ -3495,7 +3497,30 @@ var MODEL_BY_SIZE = {
3495
3497
  medium: "claude-sonnet-4-6",
3496
3498
  large: "claude-opus-4-8"
3497
3499
  };
3500
+ var MISSING_REPORT_STATUS_ERROR_MESSAGE = "Agent finished without calling reportStatus";
3501
+ var MISSING_REPORT_USER_MESSAGE = "This step ran into a problem finishing. Run the wizard again to retry it.";
3502
+ var REPORT_STATUS_RETRIES = 2;
3498
3503
  async function runAgent(req) {
3504
+ for (let attempt = 0; attempt <= REPORT_STATUS_RETRIES; attempt++) {
3505
+ try {
3506
+ return await runAgentAttempt(req, attempt);
3507
+ } catch (err) {
3508
+ const isMissingReport = err instanceof Error && err.message === MISSING_REPORT_STATUS_ERROR_MESSAGE;
3509
+ if (!isMissingReport) {
3510
+ throw err;
3511
+ }
3512
+ if (attempt === REPORT_STATUS_RETRIES) {
3513
+ throw new Error(MISSING_REPORT_USER_MESSAGE);
3514
+ }
3515
+ logger.warn(
3516
+ { attempt: attempt + 1 },
3517
+ "retrying runAgent after missing reportStatus"
3518
+ );
3519
+ }
3520
+ }
3521
+ throw new Error("unreachable");
3522
+ }
3523
+ async function runAgentAttempt(req, attempt) {
3499
3524
  const start = Date.now();
3500
3525
  logger.info({ startedAt: new Date(start).toISOString() }, "runAgent started");
3501
3526
  const token = getAuthToken();
@@ -3515,7 +3540,13 @@ async function runAgent(req) {
3515
3540
  ...hasReadTools ? [
3516
3541
  "When you need to read or search multiple files, issue those tool calls together in one step rather than one at a time."
3517
3542
  ] : [],
3518
- "Call notifyUser whenever you start a new phase of work or your focus shifts (e.g. moving from reading code to writing files) \u2014 a short, plain-language, high-level update. Do not call it for every tool use."
3543
+ "Call notifyUser whenever you start a new phase of work or your focus shifts (e.g. moving from reading code to writing files) \u2014 a short, plain-language, high-level update. Do not call it for every tool use.",
3544
+ // Last so a retry does not bust the cached tools+system prefix from
3545
+ // the first attempt. Inspect-and-continue, not start over: implement
3546
+ // shares the worktree and toolContext across attempts.
3547
+ ...attempt > 0 ? [
3548
+ "This run is a retry after a previous attempt that did not finish. Files, shell commands, or ingestion may already have been applied in this workspace \u2014 inspect what is already there and continue from it rather than repeating that work."
3549
+ ] : []
3519
3550
  ];
3520
3551
  const agent = new ToolLoopAgent({
3521
3552
  model: anthropic(MODEL_BY_SIZE[req.modelSize ?? "medium"]),
@@ -3582,7 +3613,18 @@ async function runAgent(req) {
3582
3613
  const toolResults = await stream.toolResults;
3583
3614
  const report = [...toolResults].reverse().find((r) => r.toolName === "reportStatus");
3584
3615
  if (!report) {
3585
- throw new Error("Agent finished without calling reportStatus");
3616
+ const steps = await stream.steps;
3617
+ const lastStep = steps[steps.length - 1];
3618
+ logger.error(
3619
+ {
3620
+ finishReason: lastStep?.finishReason,
3621
+ rawFinishReason: lastStep?.rawFinishReason,
3622
+ stepCount: steps.length,
3623
+ lastStepToolCalls: lastStep?.toolCalls?.map((c) => c.toolName)
3624
+ },
3625
+ "Agent finished without calling reportStatus"
3626
+ );
3627
+ throw new Error(MISSING_REPORT_STATUS_ERROR_MESSAGE);
3586
3628
  }
3587
3629
  const result = report.output;
3588
3630
  if (result.status !== "success") {
@@ -3705,7 +3747,7 @@ async function runAnalysis(mode, extraInstructions = []) {
3705
3747
  // package.json
3706
3748
  var package_default = {
3707
3749
  name: "@algolia/wizard",
3708
- version: "0.24.0-rc.111.205",
3750
+ version: "0.25.0",
3709
3751
  description: "Magically implement Algolia functionality in your codebase",
3710
3752
  type: "module",
3711
3753
  engines: {
@@ -4072,10 +4114,11 @@ ${JSON.stringify(s.output, null, 2)}`
4072
4114
  function formatReviewSummary(result) {
4073
4115
  const nextStepLines = result.nextSteps.map((step) => {
4074
4116
  const isIngestCommand = step.includes(".algolia-wizard/ingest.sh");
4117
+ const isWorktreeCommand = step.includes("/worktrees/");
4075
4118
  return {
4076
4119
  text: `\u2192 ${step}`,
4077
- color: isIngestCommand ? COLORS.brand : void 0,
4078
- bold: isIngestCommand
4120
+ color: isIngestCommand ? COLORS.brand : isWorktreeCommand ? COLORS.secondary : void 0,
4121
+ bold: isIngestCommand || isWorktreeCommand
4079
4122
  };
4080
4123
  });
4081
4124
  return [
@@ -4110,13 +4153,15 @@ ${formatCompletedSteps(ctx.completedSteps)}`,
4110
4153
 
4111
4154
  // src/actions/implement.ts
4112
4155
  import z29 from "zod";
4113
- import { join as join12, relative as relative6 } from "node:path";
4156
+ import { join as join12 } from "node:path";
4114
4157
 
4115
- // src/lib/git.ts
4158
+ // src/lib/worktree.ts
4116
4159
  import { execFile as execFile2 } from "node:child_process";
4117
- import { copyFile, mkdir as mkdir6, readFile as readFile8, stat as stat3, writeFile as writeFile7 } from "node:fs/promises";
4160
+ import { copyFile, mkdir as mkdir6, readdir as readdir3, readFile as readFile8, stat as stat3, writeFile as writeFile7 } from "node:fs/promises";
4118
4161
  import { basename as basename2, dirname as dirname7, isAbsolute as isAbsolute2, join as join10, resolve as resolve3 } from "node:path";
4119
4162
  var MAX_BUFFER = 32 * 1024 * 1024;
4163
+ var MAX_WIZARD_WORKTREES = 3;
4164
+ var WIZARD_BRANCH_PREFIX = "wizard/implement-";
4120
4165
  function git(args) {
4121
4166
  return new Promise((resolve4, reject) => {
4122
4167
  execFile2("git", args, { maxBuffer: MAX_BUFFER }, (err, stdout, stderr) => {
@@ -4139,7 +4184,44 @@ async function assertGitRepoWithHead(repoRoot) {
4139
4184
  );
4140
4185
  }
4141
4186
  }
4142
- async function copyUploadIntoProject(repoRoot, ingestDir, sourcePath) {
4187
+ async function isWorkingTreeDirty(repoRoot) {
4188
+ const out = await git(["-C", repoRoot, "status", "--porcelain"]);
4189
+ return out.trim().length > 0;
4190
+ }
4191
+ async function pruneOldWorktrees(repoRoot) {
4192
+ const dir = join10(stateDir(repoRoot), "worktrees");
4193
+ const stale = (await readdir3(dir).catch(() => [])).filter((name) => /^wizard-implement-\d+$/.test(name)).sort().reverse().slice(MAX_WIZARD_WORKTREES - 1);
4194
+ for (const slug of stale) {
4195
+ const branch = slug.replace("wizard-implement-", WIZARD_BRANCH_PREFIX);
4196
+ try {
4197
+ await git([
4198
+ "-C",
4199
+ repoRoot,
4200
+ "worktree",
4201
+ "remove",
4202
+ "--force",
4203
+ join10(dir, slug)
4204
+ ]);
4205
+ await git(["-C", repoRoot, "branch", "-D", branch]);
4206
+ } catch (err) {
4207
+ logger.warn(
4208
+ { branch, err: err.message },
4209
+ "createWorktree: failed to prune a stale wizard worktree; continuing"
4210
+ );
4211
+ }
4212
+ }
4213
+ }
4214
+ async function createWorktree(repoRoot) {
4215
+ const branch = `${WIZARD_BRANCH_PREFIX}${Date.now()}`;
4216
+ const dirSlug = branch.replace(/\//g, "-");
4217
+ const path = join10(stateDir(repoRoot), "worktrees", dirSlug);
4218
+ await git(["-C", repoRoot, "worktree", "prune"]);
4219
+ await pruneOldWorktrees(repoRoot);
4220
+ await mkdir6(dirname7(path), { recursive: true });
4221
+ await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
4222
+ return { path, branch };
4223
+ }
4224
+ async function copyUploadIntoWorktree(repoRoot, worktreePath, ingestDir, sourcePath) {
4143
4225
  const trimmed = sourcePath.trim();
4144
4226
  if (!trimmed) {
4145
4227
  return { ok: false, reason: "no file path was provided" };
@@ -4153,10 +4235,7 @@ async function copyUploadIntoProject(repoRoot, ingestDir, sourcePath) {
4153
4235
  return { ok: false, reason: `"${sourcePath}" does not exist` };
4154
4236
  }
4155
4237
  const relPath = join10(ingestDir, basename2(source));
4156
- const dest = join10(repoRoot, relPath);
4157
- if (resolve3(source) === resolve3(dest)) {
4158
- return { ok: true, relPath };
4159
- }
4238
+ const dest = join10(worktreePath, relPath);
4160
4239
  try {
4161
4240
  await mkdir6(dirname7(dest), { recursive: true });
4162
4241
  await copyFile(source, dest);
@@ -4171,10 +4250,10 @@ async function copyUploadIntoProject(repoRoot, ingestDir, sourcePath) {
4171
4250
  function hasEnvVar(content, name) {
4172
4251
  return new RegExp(`^(\\s*(?:export\\s+)?${name})\\s*=`, "m").test(content);
4173
4252
  }
4174
- async function readEnvVar(repoRoot, name) {
4253
+ async function readEnvVar(worktreePath, name) {
4175
4254
  let content;
4176
4255
  try {
4177
- content = await readFile8(join10(repoRoot, ".env"), "utf8");
4256
+ content = await readFile8(join10(worktreePath, ".env"), "utf8");
4178
4257
  } catch (err) {
4179
4258
  if (err.code !== "ENOENT") throw err;
4180
4259
  return void 0;
@@ -4188,8 +4267,8 @@ async function readEnvVar(repoRoot, name) {
4188
4267
  if (!value || value.startsWith("<")) return void 0;
4189
4268
  return value;
4190
4269
  }
4191
- async function writeSearchEnvValues(repoRoot, vars) {
4192
- const target = join10(repoRoot, ".env");
4270
+ async function writeSearchEnvValues(worktreePath, vars) {
4271
+ const target = join10(worktreePath, ".env");
4193
4272
  let existing = "";
4194
4273
  try {
4195
4274
  existing = await readFile8(target, "utf8");
@@ -4204,27 +4283,61 @@ async function writeSearchEnvValues(repoRoot, vars) {
4204
4283
  await writeFile7(target, existing + prefix + lines, "utf8");
4205
4284
  return missing.map((v) => v.name);
4206
4285
  }
4286
+ async function listChangedFiles(worktreePath) {
4287
+ const raw = await git(["-C", worktreePath, "status", "--porcelain", "-z"]);
4288
+ const entries = raw.split("\0");
4289
+ const files = [];
4290
+ for (let i = 0; i < entries.length; i += 1) {
4291
+ const entry = entries[i];
4292
+ if (!entry) continue;
4293
+ files.push(entry.slice(3));
4294
+ if (["R", "C"].includes(entry[0]) || ["R", "C"].includes(entry[1])) i += 1;
4295
+ }
4296
+ return files;
4297
+ }
4207
4298
  function normalizeFindingPaths(findings) {
4208
4299
  return {
4209
4300
  ...findings,
4210
4301
  ingestionAnalysis: findings.ingestionAnalysis?.map((e) => ({
4211
4302
  ...e,
4212
- paths: e.paths.map(toRootRelative)
4303
+ paths: e.paths.map(toWorktreeRelative)
4213
4304
  })),
4214
4305
  searchImplementationAnalysis: findings.searchImplementationAnalysis ? normalizeSearchLocation(findings.searchImplementationAnalysis) : void 0,
4215
4306
  confirmedEntities: findings.confirmedEntities?.map((e) => ({
4216
4307
  ...e,
4217
- paths: e.paths.map(toRootRelative)
4308
+ paths: e.paths.map(toWorktreeRelative)
4218
4309
  }))
4219
4310
  };
4220
4311
  }
4221
4312
  function normalizeSearchLocation(path) {
4222
- const normalized = path ? toRootRelative(path).trim() : "";
4313
+ const normalized = path ? toWorktreeRelative(path).trim() : "";
4223
4314
  return normalized && normalized.toLowerCase() !== "unknown" ? normalized : void 0;
4224
4315
  }
4225
- function toRootRelative(p) {
4316
+ function toWorktreeRelative(p) {
4226
4317
  return p.replace(/^\/+/, "");
4227
4318
  }
4319
+ async function confirmDirtyWorkingTree(ctx, repoRoot) {
4320
+ const MAX_LISTED_DIRTY_FILES = 10;
4321
+ const dirty = await listChangedFiles(repoRoot);
4322
+ const shown = dirty.slice(0, MAX_LISTED_DIRTY_FILES);
4323
+ const overflow = dirty.length - shown.length;
4324
+ const answer = await ctx.requestUserInput({
4325
+ prompt: "Proceed using HEAD only? Uncommitted changes will NOT be included in the generated implementation.",
4326
+ promptType: "acceptReject",
4327
+ options: [],
4328
+ messages: [
4329
+ `${dirty.length} uncommitted change(s) detected. The wizard builds an isolated worktree from HEAD, so these are ignored:`,
4330
+ ...shown.map((file) => ` \u2022 ${file}`),
4331
+ ...overflow > 0 ? [` \u2022 \u2026and ${overflow} more`] : [],
4332
+ "Commit or stash them first to include them in the implementation."
4333
+ ]
4334
+ });
4335
+ if (answer !== true) {
4336
+ throw new Error(
4337
+ "implement aborted: commit or stash your changes so they are built into the worktree, then re-run the wizard."
4338
+ );
4339
+ }
4340
+ }
4228
4341
 
4229
4342
  // src/lib/algoliaDocs.ts
4230
4343
  import { readFileSync, readdirSync, existsSync } from "node:fs";
@@ -4284,6 +4397,11 @@ function getFrameworkSpecificDoc(frameworks) {
4284
4397
  return loadAlgoliaDoc("js");
4285
4398
  }
4286
4399
 
4400
+ // src/lib/shell.ts
4401
+ function shellQuote(value) {
4402
+ return "'" + value.replace(/'/g, "'\\''") + "'";
4403
+ }
4404
+
4287
4405
  // src/actions/resolveEnvVarPrefix.ts
4288
4406
  import z28 from "zod";
4289
4407
  var resolveEnvVarPrefixSchema = z28.object({
@@ -4303,7 +4421,9 @@ var resolveEnvVarPrefix = (frameworkName) => runAgent({
4303
4421
 
4304
4422
  // src/actions/implement.ts
4305
4423
  var implementSchema = z29.object({
4424
+ filesChanged: z29.array(z29.string()),
4306
4425
  summary: z29.string(),
4426
+ worktreePath: z29.string().optional(),
4307
4427
  ingestCommand: z29.string().optional(),
4308
4428
  ingestScriptRan: z29.boolean().optional(),
4309
4429
  ingestRecordCount: z29.number().optional(),
@@ -4356,6 +4476,8 @@ function frameworksForDoc(language) {
4356
4476
  }
4357
4477
  function baseInstructions(input) {
4358
4478
  return [
4479
+ // Agents have renamed this (e.g. appending the project name), which the
4480
+ // index-scoped keys then reject with a 403.
4359
4481
  `Target Algolia index, to be used exactly as written \u2014 never renamed, re-cased, prefixed, or suffixed: "${input.targetIndex}"`,
4360
4482
  `Project languages and frameworks: ${JSON.stringify(input.language)}`,
4361
4483
  "Make minimal, idiomatic changes; do not touch unrelated code.",
@@ -4373,14 +4495,14 @@ function sourceSpecificInstructions(input) {
4373
4495
  "Add env vars for any local source access (DB URL, API base, file paths) that is not a fixed repo path."
4374
4496
  ],
4375
4497
  fileUpload: [
4376
- `Records come from the developer's file, already copied into the project at "${input.uploadFilePath}". Read and parse that exact file.`,
4498
+ `Records come from the developer's file, already copied into the worktree at "${input.uploadFilePath}". Read and parse that exact file.`,
4377
4499
  "Parse by extension: JSON = array of objects; CSV/TSV = header row maps to keys.",
4378
4500
  "Map parsed columns/fields to the confirmed entity attributes.",
4379
4501
  "Never fabricate, hardcode, or substitute a different file."
4380
4502
  ],
4381
4503
  generated: [
4382
4504
  "No real data source exists; use sample records for each confirmed entity.",
4383
- "Call the generateRecord tool once per entity (entityName, attributes, count 20-50); it invents the values and unique objectIDs and writes them to a JSON file, returning the file path. Do not write records or objectIDs yourself.",
4505
+ "Call the generateRecord tool once per entity (entityName, attributes, count 20-50); it invents the values and unique objectIDs and writes them to a JSON file in the worktree, returning the file path. Do not write records or objectIDs yourself.",
4384
4506
  "In the script, read and parse each returned file path at runtime using your language's standard JSON support, instead of inlining the records as literals.",
4385
4507
  "Add a prominent TODO where the developer swaps the generated records (and the JSON file under `.algolia-wizard/data/`) for their real record source."
4386
4508
  ]
@@ -4429,11 +4551,14 @@ function searchInstructions(input) {
4429
4551
  "If a search box already exists, replace it with yours.",
4430
4552
  `Read the index name from the ${publicIndexNameVar(input.publicEnvVarPrefix)} env var, which the wizard sets to "${input.targetIndex}". Never hardcode an index name or derive one from the project, file, or component name.`,
4431
4553
  "Read the App ID, the search-only API key, and the index name from env vars; never hardcode them. A search-only key is safe to expose client-side.",
4432
- // The key is provisioned only after verification passes, and the wizard
4433
- // reads .env to decide whether a key already exists an agent-invented
4434
- // value there would be reused as if it were real.
4554
+ // The key is provisioned only after verification passes, so the agent never
4555
+ // sees one. It must also leave .env alone: the wizard reads that file to
4556
+ // decide whether a key already exists, and an agent-invented value there
4557
+ // would be reused as if it were real.
4435
4558
  `Add Algolia App ID "${input.appId}"; leave the search-only key as a placeholder. Do not create or edit .env \u2014 the wizard writes the resolved key there itself.`,
4436
- // The wizard writes these exact names into .env right after this step.
4559
+ // Not the agent's to rename: the wizard writes these exact names into
4560
+ // ".env" right after this step, so a renamed prefix would leave the code
4561
+ // reading a var the wizard never wrote.
4437
4562
  `Use exactly these env var names in the code: ${input.searchEnvVars.map(({ name }) => name).join(", ")}.`,
4438
4563
  "Install any Algolia packages you import with the project's own package manager via runShell, and declare them in the project's dependency manifest.",
4439
4564
  "Match the styles of the application as closely as possible.",
@@ -4442,10 +4567,10 @@ function searchInstructions(input) {
4442
4567
  }
4443
4568
  function verificationInstructions(input) {
4444
4569
  return [
4445
- "Verify the Algolia implementation changes.",
4570
+ "Verify the Algolia implementation changes in the current worktree.",
4446
4571
  `Verification tools found in the codebase: ${JSON.stringify(input.findings.verification ?? [])}.`,
4447
4572
  "Run the project's own checks (lint, type check, tests) via runShell, using the commands the project actually defines \u2014 its task runner, manifest scripts, or Makefile. Run every check that applies, not just the first.",
4448
- "If a check fails because packages or modules are missing, install the dependencies via runShell and re-run it rather than changing the code.",
4573
+ "This worktree starts with no installed dependencies. If a check fails because packages or modules are missing, install the dependencies via runShell and re-run it rather than changing the code.",
4449
4574
  "For issues caused by the implementation, make minimal fixes with writeFile and re-run the checks.",
4450
4575
  "Do not make speculative fixes when no checks exist, a check cannot run, or failures are unrelated to these changes \u2014 note the limitation in your summary.",
4451
4576
  "Do not add new Algolia functionality here; only validate and make minimal correctness fixes.",
@@ -4540,11 +4665,11 @@ function ingestFailure(attempt, executions) {
4540
4665
  }
4541
4666
  return { reason: `it failed (exit ${attempt.exitCode ?? "unknown"})`, detail };
4542
4667
  }
4543
- function makeToolContext(root, env = async () => ({})) {
4668
+ function makeToolContext(worktree, env = async () => ({})) {
4544
4669
  return createToolContext(
4545
4670
  DEFAULT_TOOL_LIMITS,
4546
- root,
4547
- createShellContext({ env, approve: storeApproval(root) })
4671
+ worktree,
4672
+ createShellContext({ env, approve: storeApproval(worktree) })
4548
4673
  );
4549
4674
  }
4550
4675
  function verificationRetryInstructions(verification) {
@@ -4552,7 +4677,7 @@ function verificationRetryInstructions(verification) {
4552
4677
  `Implementation insufficient. Address these findings before reporting completion: ${verification.additionalInstructions ?? verification.summary}`
4553
4678
  ];
4554
4679
  }
4555
- async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4680
+ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES, existingWorktreePath) {
4556
4681
  const repoRoot = process.cwd();
4557
4682
  const scan = ctx.getStepOutput("project-scan");
4558
4683
  const entities = ctx.getStepOutput(
@@ -4633,6 +4758,9 @@ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4633
4758
  const targetIndex = selected?.selection;
4634
4759
  useWizard.getState().setTargetIndex(targetIndex ?? null);
4635
4760
  await assertGitRepoWithHead(repoRoot);
4761
+ if (await isWorkingTreeDirty(repoRoot)) {
4762
+ await confirmDirtyWorkingTree(ctx, repoRoot);
4763
+ }
4636
4764
  const normalized = normalizeFindingPaths(findings);
4637
4765
  const confirmed2 = normalized.confirmedEntities;
4638
4766
  const searchLocation = normalized.searchImplementationAnalysis;
@@ -4644,303 +4772,328 @@ async function implement(ctx, useCases = DEFAULT_IMPLEMENT_USE_CASES) {
4644
4772
  if (useCases.includes("ingestion")) {
4645
4773
  ingestAppId = appId ?? (await requireApplication()).id;
4646
4774
  }
4647
- let uploadFilePath;
4648
- let uploadWarning;
4649
- if (ingestionSource === "fileUpload") {
4650
- const copied = await copyUploadIntoProject(
4651
- repoRoot,
4652
- INGEST_DIR,
4653
- uploadSourcePath ?? ""
4654
- );
4655
- if (copied.ok) {
4656
- uploadFilePath = copied.relPath;
4657
- } else {
4658
- ingestionSource = "generated";
4659
- uploadWarning = `\u26A0\uFE0F Could not use the uploaded file (${copied.reason}); generating sample records instead.`;
4660
- logger.warn(
4661
- { reason: copied.reason },
4662
- "implement: file upload unavailable; falling back to generated sample records"
4663
- );
4664
- }
4665
- }
4666
- const publicEnvVarPrefix = await publicEnvVarPrefixPromise;
4667
- const input = {
4668
- findings: normalized,
4669
- confirmed: confirmed2,
4670
- searchLocation,
4671
- targetIndex,
4672
- language,
4673
- publicEnvVarPrefix,
4674
- appId,
4675
- searchEnvVars: publicSearchEnvVars(publicEnvVarPrefix, targetIndex, appId),
4676
- ingestDir: INGEST_DIR,
4677
- ingestionSource,
4678
- uploadFilePath,
4679
- searchUiTarget: searchUiTarget(language)
4680
- };
4681
- const summaries = [];
4682
- if (uploadWarning) summaries.push(uploadWarning);
4683
- let envSearchKey;
4684
- let envAppIdMismatch = false;
4685
- if (useCases.includes("search") && appId) {
4686
- const envAppId = await readEnvVar(
4687
- repoRoot,
4688
- publicAppIdVar(publicEnvVarPrefix)
4689
- );
4690
- if (envAppId === appId) {
4691
- envSearchKey = await readEnvVar(
4775
+ const worktree = existingWorktreePath ?? (await createWorktree(repoRoot)).path;
4776
+ try {
4777
+ process.chdir(worktree);
4778
+ let uploadFilePath;
4779
+ let uploadWarning;
4780
+ if (ingestionSource === "fileUpload") {
4781
+ const copied = await copyUploadIntoWorktree(
4692
4782
  repoRoot,
4693
- publicSearchKeyVar(publicEnvVarPrefix)
4694
- );
4695
- } else if (envAppId) {
4696
- envAppIdMismatch = true;
4697
- const appIdVarName = publicAppIdVar(publicEnvVarPrefix);
4698
- const searchKeyVarName = publicSearchKeyVar(publicEnvVarPrefix);
4699
- summaries.push(
4700
- `\u26A0\uFE0F .env already sets ${appIdVarName}=${envAppId}, but the active Algolia application is ${appId}. The wizard left those values alone \u2014 update ${appIdVarName} and ${searchKeyVarName} by hand, or searches will fail.`
4701
- );
4702
- logger.warn(
4703
- { envAppId, appId },
4704
- "implement: .env holds credentials for a different Algolia application; not reusing its search key"
4783
+ worktree,
4784
+ INGEST_DIR,
4785
+ uploadSourcePath ?? ""
4705
4786
  );
4787
+ if (copied.ok) {
4788
+ uploadFilePath = copied.relPath;
4789
+ } else {
4790
+ ingestionSource = "generated";
4791
+ uploadWarning = `\u26A0\uFE0F Could not use the uploaded file (${copied.reason}); generating sample records instead.`;
4792
+ logger.warn(
4793
+ { reason: copied.reason },
4794
+ "implement: file upload unavailable; falling back to generated sample records"
4795
+ );
4796
+ }
4706
4797
  }
4707
- }
4708
- let finalSearchEnvVars = input.searchEnvVars;
4709
- let agentRuns = 0;
4710
- let ingestCommand;
4711
- let ingestScriptRan = false;
4712
- let ingestRecordCount;
4713
- let ingestDurationMs;
4714
- let ingestOutcomeMessage;
4715
- const ingestKeyAppId = ingestAppId;
4716
- const ingestionTools = ingestKeyAppId ? makeToolContext(repoRoot, async () => ({
4717
- [APP_ID_VAR]: ingestKeyAppId,
4718
- [API_KEY_VAR]: (await resolveWriteKey(targetIndex, ingestKeyAppId)).key,
4719
- [INDEX_NAME_VAR]: targetIndex
4720
- })) : void 0;
4721
- const searchTools = makeToolContext(repoRoot);
4722
- async function runImplementationUseCase(currentUseCase, extraInstructions = []) {
4723
- if (agentRuns > 0) ctx.recordStepExecution();
4724
- agentRuns += 1;
4725
- return runAgent({
4726
- instructions: buildAgentInstructions(
4727
- currentUseCase,
4728
- input,
4729
- extraInstructions
4798
+ const publicEnvVarPrefix = await publicEnvVarPrefixPromise;
4799
+ const input = {
4800
+ findings: normalized,
4801
+ confirmed: confirmed2,
4802
+ searchLocation,
4803
+ targetIndex,
4804
+ language,
4805
+ publicEnvVarPrefix,
4806
+ appId,
4807
+ searchEnvVars: publicSearchEnvVars(
4808
+ publicEnvVarPrefix,
4809
+ targetIndex,
4810
+ appId
4730
4811
  ),
4731
- tools: toolsForUseCase(currentUseCase, input.ingestionSource),
4732
- outputSchema: implementationOutputSchema,
4733
- toolContext: currentUseCase === "ingestion" ? ingestionTools ?? searchTools : searchTools
4734
- });
4735
- }
4736
- async function runVerificationUseCase() {
4737
- if (agentRuns > 0) ctx.recordStepExecution();
4738
- agentRuns += 1;
4739
- return runAgent({
4740
- instructions: buildAgentInstructions("verification", input),
4741
- tools: toolsForUseCase("verification"),
4742
- outputSchema: verificationOutputSchema,
4743
- toolContext: searchTools
4744
- });
4745
- }
4746
- if (useCases.includes("ingestion")) {
4747
- let ingestFailureDetail;
4748
- const result = await runImplementationUseCase("ingestion");
4749
- summaries.push(formatSummary("ingestion", result.summary));
4750
- ingestCommand = result.ingestCommand;
4751
- const ingestionContext = ingestionTools ?? searchTools;
4752
- const executions = ingestionContext.shell.executions;
4753
- const {
4754
- run: ingestRun,
4755
- attempt: ingestAttempt,
4756
- recordCount
4757
- } = ingestOutcome(executions, ingestCommand);
4758
- ingestScriptRan = ingestRun != null;
4759
- ingestRecordCount = recordCount;
4760
- ingestDurationMs = ingestRun?.durationMs;
4761
- if (ingestScriptRan && ingestionContext.reviewed.length === 0) {
4762
- summaries.push(
4763
- "\u26A0\uFE0F The ingestion script ran without being shown to you for review. Read it in your project before trusting the index contents."
4764
- );
4765
- logger.warn(
4766
- { ingestCommand },
4767
- "implement: ingestion ran without a reviewScript call"
4812
+ ingestDir: INGEST_DIR,
4813
+ ingestionSource,
4814
+ uploadFilePath,
4815
+ searchUiTarget: searchUiTarget(language)
4816
+ };
4817
+ const summaries = [];
4818
+ if (uploadWarning) summaries.push(uploadWarning);
4819
+ let envSearchKey;
4820
+ let envAppIdMismatch = false;
4821
+ if (useCases.includes("search") && appId) {
4822
+ const envAppId = await readEnvVar(
4823
+ worktree,
4824
+ publicAppIdVar(publicEnvVarPrefix)
4768
4825
  );
4769
- }
4770
- if (ingestScriptRan) {
4771
- ingestOutcomeMessage = `\u2705 Ingestion succeeded${ingestRecordCount != null ? ` \u2014 ${ingestRecordCount} record(s) indexed.` : "."}`;
4772
- if (ingestRecordCount != null) {
4773
- track("AI Wizard Ingest Successful", {
4774
- entity_name: confirmed2?.map((e) => e.name).join(", ") || "unknown",
4775
- record_count: ingestRecordCount,
4776
- duration_ms: ingestDurationMs ?? 0
4777
- });
4826
+ if (envAppId === appId) {
4827
+ envSearchKey = await readEnvVar(
4828
+ worktree,
4829
+ publicSearchKeyVar(publicEnvVarPrefix)
4830
+ );
4831
+ } else if (envAppId) {
4832
+ envAppIdMismatch = true;
4833
+ const appIdVarName = publicAppIdVar(publicEnvVarPrefix);
4834
+ const searchKeyVarName = publicSearchKeyVar(publicEnvVarPrefix);
4835
+ summaries.push(
4836
+ `\u26A0\uFE0F .env already sets ${appIdVarName}=${envAppId}, but the active Algolia application is ${appId}. The wizard left those values alone \u2014 update ${appIdVarName} and ${searchKeyVarName} by hand, or searches will fail.`
4837
+ );
4838
+ logger.warn(
4839
+ { envAppId, appId },
4840
+ "implement: .env holds credentials for a different Algolia application; not reusing its search key"
4841
+ );
4778
4842
  }
4779
- } else {
4780
- const { reason, detail } = ingestFailure(ingestAttempt, executions);
4781
- ingestOutcomeMessage = `\u26A0\uFE0F Records were not indexed \u2014 ${reason}.${ingestCommand ? " Run the command below when you are ready." : ""}`;
4782
- ingestFailureDetail = detail;
4783
- summaries.push(
4784
- `\u26A0\uFE0F Ingestion did not complete: ${reason}.${detail ? `
4785
- ${detail}` : ""}`
4786
- );
4787
- logger.warn(
4788
- {
4789
- ingestCommand,
4790
- reason,
4791
- approved: ingestAttempt?.approved,
4792
- exitCode: ingestAttempt?.exitCode,
4793
- timedOut: ingestAttempt?.timedOut,
4794
- commandsRun: executions.length
4795
- },
4796
- "implement: ingestion script did not complete successfully"
4797
- );
4798
- track("Error", {
4799
- step: "Push Data",
4800
- error: `ingestion did not complete: ${reason}`,
4801
- product_area: "AI Wizard"
4843
+ }
4844
+ let finalSearchEnvVars = input.searchEnvVars;
4845
+ let agentRuns = 0;
4846
+ let ingestCommand;
4847
+ let ingestScriptRan = false;
4848
+ let ingestRecordCount;
4849
+ let ingestDurationMs;
4850
+ let ingestOutcomeMessage;
4851
+ const ingestKeyAppId = ingestAppId;
4852
+ const ingestionTools = ingestKeyAppId ? makeToolContext(worktree, async () => ({
4853
+ [APP_ID_VAR]: ingestKeyAppId,
4854
+ [API_KEY_VAR]: (await resolveWriteKey(targetIndex, ingestKeyAppId)).key,
4855
+ [INDEX_NAME_VAR]: targetIndex
4856
+ })) : void 0;
4857
+ const searchTools = makeToolContext(worktree);
4858
+ async function runImplementationUseCase(currentUseCase, extraInstructions = []) {
4859
+ if (agentRuns > 0) ctx.recordStepExecution();
4860
+ agentRuns += 1;
4861
+ return runAgent({
4862
+ instructions: buildAgentInstructions(
4863
+ currentUseCase,
4864
+ input,
4865
+ extraInstructions
4866
+ ),
4867
+ tools: toolsForUseCase(currentUseCase, input.ingestionSource),
4868
+ outputSchema: implementationOutputSchema,
4869
+ toolContext: currentUseCase === "ingestion" ? ingestionTools ?? searchTools : searchTools
4802
4870
  });
4803
4871
  }
4804
- const commandMessages = ingestCommand ? [`Ingestion command: ${ingestCommand}`] : [];
4805
- await ctx.requestUserInput({
4806
- prompt: "",
4807
- promptType: "enterToContinue",
4808
- options: [],
4809
- // One message per line: Notices.tsx counts a message as one wrapped
4810
- // line, so an embedded newline overflows the panel's height accounting.
4811
- messages: [
4812
- ingestOutcomeMessage,
4813
- ...ingestFailureDetail?.split("\n").filter((l) => l.trim()) ?? [],
4814
- ...commandMessages
4815
- ]
4816
- });
4817
- }
4818
- if (useCases.includes("search")) {
4819
- let extraInstructions = [];
4820
- useWizard.getState().clearWrittenFiles();
4821
- for (let attempt = 1; attempt <= MAX_IMPLEMENT_VERIFICATION_ATTEMPTS; attempt++) {
4822
- if (attempt > 1) {
4823
- logger.info(
4824
- {
4825
- attempt,
4826
- maxAttempts: MAX_IMPLEMENT_VERIFICATION_ATTEMPTS,
4827
- extraInstructions
4828
- },
4829
- "implement: retrying search implementation after failed verification"
4872
+ async function runVerificationUseCase() {
4873
+ if (agentRuns > 0) ctx.recordStepExecution();
4874
+ agentRuns += 1;
4875
+ return runAgent({
4876
+ instructions: buildAgentInstructions("verification", input),
4877
+ tools: toolsForUseCase("verification"),
4878
+ outputSchema: verificationOutputSchema,
4879
+ toolContext: searchTools
4880
+ });
4881
+ }
4882
+ if (useCases.includes("ingestion")) {
4883
+ let ingestFailureDetail;
4884
+ const result = await runImplementationUseCase("ingestion");
4885
+ summaries.push(formatSummary("ingestion", result.summary));
4886
+ ingestCommand = result.ingestCommand;
4887
+ const ingestionContext = ingestionTools ?? searchTools;
4888
+ const executions = ingestionContext.shell.executions;
4889
+ const {
4890
+ run: ingestRun,
4891
+ attempt: ingestAttempt,
4892
+ recordCount
4893
+ } = ingestOutcome(executions, ingestCommand);
4894
+ ingestScriptRan = ingestRun != null;
4895
+ ingestRecordCount = recordCount;
4896
+ ingestDurationMs = ingestRun?.durationMs;
4897
+ if (ingestScriptRan && ingestionContext.reviewed.length === 0) {
4898
+ summaries.push(
4899
+ "\u26A0\uFE0F The ingestion script ran without being shown to you for review. Read it in the worktree before trusting the index contents."
4830
4900
  );
4831
- }
4832
- const { summary } = await runImplementationUseCase(
4833
- "search",
4834
- extraInstructions
4835
- );
4836
- summaries.push(formatSummary("search", summary));
4837
- const verification = await runVerificationUseCase();
4838
- summaries.push(formatSummary("verification", verification.summary));
4839
- if (verification.sufficient) {
4840
- ctx.setUserInput("implementation", "success");
4841
- const searchFilesChanged = [
4842
- ...new Set(useWizard.getState().writtenFiles)
4843
- ].map((file) => relative6(repoRoot, file));
4844
- track("AI Wizard Frontend Component Generated", {
4845
- filePaths: searchFilesChanged
4846
- });
4847
- track("AI Wizard Wired to UI", {
4848
- location_heuristic: searchLocation ?? "unknown"
4849
- });
4850
- break;
4851
- }
4852
- if (attempt === MAX_IMPLEMENT_VERIFICATION_ATTEMPTS) {
4853
- ctx.setUserInput("implementation", "fail");
4854
- throw new Error(
4855
- `Implementation verification failed after ${MAX_IMPLEMENT_VERIFICATION_ATTEMPTS} attempts: ${verification.additionalInstructions ?? verification.summary}`
4901
+ logger.warn(
4902
+ { ingestCommand },
4903
+ "implement: ingestion ran without a reviewScript call"
4856
4904
  );
4857
4905
  }
4858
- extraInstructions = verificationRetryInstructions(verification);
4859
- }
4860
- let searchKey;
4861
- let searchKeyError;
4862
- if (appId) {
4863
- try {
4864
- const resolved2 = await resolveSearchOnlyKey(
4865
- targetIndex,
4866
- appId,
4867
- envSearchKey
4868
- );
4869
- searchKey = resolved2.key;
4906
+ if (ingestScriptRan) {
4907
+ ingestOutcomeMessage = `\u2705 Ingestion succeeded${ingestRecordCount != null ? ` \u2014 ${ingestRecordCount} record(s) indexed.` : "."}`;
4908
+ if (ingestRecordCount != null) {
4909
+ track("AI Wizard Ingest Successful", {
4910
+ entity_name: confirmed2?.map((e) => e.name).join(", ") || "unknown",
4911
+ record_count: ingestRecordCount,
4912
+ duration_ms: ingestDurationMs ?? 0
4913
+ });
4914
+ }
4915
+ } else {
4916
+ const { reason, detail } = ingestFailure(ingestAttempt, executions);
4917
+ ingestOutcomeMessage = `\u26A0\uFE0F Records were not indexed \u2014 ${reason}.${ingestCommand ? " Run the command below when you are ready." : ""}`;
4918
+ ingestFailureDetail = detail;
4870
4919
  summaries.push(
4871
- resolved2.source === "created" ? `Created a new search-only Algolia API key for the "${targetIndex}" index in app ${appId} \u2014 safe to expose in frontend code.` : `Reused the existing search-only Algolia API key for the "${targetIndex}" index in app ${appId}.`
4920
+ `\u26A0\uFE0F Ingestion did not complete: ${reason}.${detail ? `
4921
+ ${detail}` : ""}`
4872
4922
  );
4873
- } catch (err) {
4874
- searchKeyError = err.message;
4875
4923
  logger.warn(
4876
- { err: searchKeyError },
4877
- "implement: could not provision a search-only API key; the .env value stays a placeholder"
4924
+ {
4925
+ ingestCommand,
4926
+ reason,
4927
+ approved: ingestAttempt?.approved,
4928
+ exitCode: ingestAttempt?.exitCode,
4929
+ timedOut: ingestAttempt?.timedOut,
4930
+ commandsRun: executions.length
4931
+ },
4932
+ "implement: ingestion script did not complete successfully"
4878
4933
  );
4934
+ track("Error", {
4935
+ step: "Push Data",
4936
+ error: `ingestion did not complete: ${reason}`,
4937
+ product_area: "AI Wizard"
4938
+ });
4879
4939
  }
4880
- }
4881
- finalSearchEnvVars = publicSearchEnvVars(
4882
- publicEnvVarPrefix,
4883
- targetIndex,
4884
- appId,
4885
- searchKey
4886
- );
4887
- const resolvedSearchEnvVars = finalSearchEnvVars.filter(
4888
- (v) => !v.value.startsWith("<")
4889
- );
4890
- if (resolvedSearchEnvVars.length > 0) {
4891
- const written = await writeSearchEnvValues(
4892
- repoRoot,
4893
- resolvedSearchEnvVars
4894
- );
4895
- if (written.length > 0) {
4896
- summaries.push(`Wrote ${written.join(", ")} to .env.`);
4940
+ const commandMessages = [`Open the worktree: cd ${shellQuote(worktree)}`];
4941
+ if (ingestCommand) {
4942
+ commandMessages.push(`Ingestion command: ${ingestCommand}`);
4897
4943
  }
4898
- const ignored = await ensureGitIgnored(repoRoot, join12(repoRoot, ".env"));
4899
- if (ignored === "added") {
4900
- summaries.push("Added .env to .gitignore.");
4901
- } else if (ignored === "tracked") {
4902
- summaries.push(
4903
- '\u26A0\uFE0F .env is tracked by git, so a .gitignore rule cannot un-stage it. Run "git rm --cached .env" before committing, or the credentials go into history.'
4944
+ await ctx.requestUserInput({
4945
+ prompt: "",
4946
+ promptType: "enterToContinue",
4947
+ options: [],
4948
+ // The wizard never streams command output, so a failed run's tail is the
4949
+ // only place the developer sees why it failed. One message per line:
4950
+ // the panel's height accounting counts a message as one wrapped line
4951
+ // (see Notices.tsx), so an embedded newline overflows it.
4952
+ messages: [
4953
+ ingestOutcomeMessage,
4954
+ ...ingestFailureDetail?.split("\n").filter((l) => l.trim()) ?? [],
4955
+ ...commandMessages
4956
+ ]
4957
+ });
4958
+ }
4959
+ if (useCases.includes("search")) {
4960
+ let extraInstructions = [];
4961
+ const preSearchFiles = new Set(await listChangedFiles(worktree));
4962
+ for (let attempt = 1; attempt <= MAX_IMPLEMENT_VERIFICATION_ATTEMPTS; attempt++) {
4963
+ if (attempt > 1) {
4964
+ logger.info(
4965
+ {
4966
+ attempt,
4967
+ maxAttempts: MAX_IMPLEMENT_VERIFICATION_ATTEMPTS,
4968
+ extraInstructions
4969
+ },
4970
+ "implement: retrying search implementation after failed verification"
4971
+ );
4972
+ }
4973
+ const { summary } = await runImplementationUseCase(
4974
+ "search",
4975
+ extraInstructions
4904
4976
  );
4977
+ summaries.push(formatSummary("search", summary));
4978
+ const verification = await runVerificationUseCase();
4979
+ summaries.push(formatSummary("verification", verification.summary));
4980
+ if (verification.sufficient) {
4981
+ ctx.setUserInput("implementation", "success");
4982
+ const searchFilesChanged = (await listChangedFiles(worktree)).filter(
4983
+ (file) => !preSearchFiles.has(file)
4984
+ );
4985
+ track("AI Wizard Frontend Component Generated", {
4986
+ filePaths: searchFilesChanged
4987
+ });
4988
+ track("AI Wizard Wired to UI", {
4989
+ location_heuristic: searchLocation ?? "unknown"
4990
+ });
4991
+ break;
4992
+ }
4993
+ if (attempt === MAX_IMPLEMENT_VERIFICATION_ATTEMPTS) {
4994
+ ctx.setUserInput("implementation", "fail");
4995
+ throw new Error(
4996
+ `Implementation verification failed after ${MAX_IMPLEMENT_VERIFICATION_ATTEMPTS} attempts: ${verification.additionalInstructions ?? verification.summary}`
4997
+ );
4998
+ }
4999
+ extraInstructions = verificationRetryInstructions(verification);
4905
5000
  }
4906
- const stale = [];
4907
- for (const v of resolvedSearchEnvVars) {
4908
- if (written.includes(v.name)) continue;
4909
- const current = await readEnvVar(repoRoot, v.name);
4910
- if (current && current !== v.value) stale.push(v);
5001
+ let searchKey;
5002
+ let searchKeyError;
5003
+ if (appId) {
5004
+ try {
5005
+ const resolved2 = await resolveSearchOnlyKey(
5006
+ targetIndex,
5007
+ appId,
5008
+ envSearchKey
5009
+ );
5010
+ searchKey = resolved2.key;
5011
+ summaries.push(
5012
+ resolved2.source === "created" ? `Created a new search-only Algolia API key for the "${targetIndex}" index in app ${appId} \u2014 safe to expose in frontend code.` : `Reused the existing search-only Algolia API key for the "${targetIndex}" index in app ${appId}.`
5013
+ );
5014
+ } catch (err) {
5015
+ searchKeyError = err.message;
5016
+ logger.warn(
5017
+ { err: searchKeyError },
5018
+ "implement: could not provision a search-only API key; the .env value stays a placeholder"
5019
+ );
5020
+ }
4911
5021
  }
4912
- if (stale.length > 0 && !envAppIdMismatch) {
4913
- summaries.push(
4914
- `\u26A0\uFE0F .env already assigns a different value to ${stale.map((v) => `${v.name} (should be ${v.value})`).join(", ")} \u2014 the wizard left it alone. Fix it by hand, or searches will fail.`
5022
+ finalSearchEnvVars = publicSearchEnvVars(
5023
+ publicEnvVarPrefix,
5024
+ targetIndex,
5025
+ appId,
5026
+ searchKey
5027
+ );
5028
+ const resolvedSearchEnvVars = finalSearchEnvVars.filter(
5029
+ (v) => !v.value.startsWith("<")
5030
+ );
5031
+ if (resolvedSearchEnvVars.length > 0) {
5032
+ const written = await writeSearchEnvValues(
5033
+ worktree,
5034
+ resolvedSearchEnvVars
4915
5035
  );
4916
- logger.warn(
4917
- { vars: stale.map((v) => v.name) },
4918
- "implement: .env holds different values for the resolved search credentials; not overwriting them"
5036
+ if (written.length > 0) {
5037
+ summaries.push(`Wrote ${written.join(", ")} to .env.`);
5038
+ }
5039
+ const ignored = await ensureGitIgnored(worktree, join12(worktree, ".env"));
5040
+ if (ignored === "added") {
5041
+ summaries.push("Added .env to .gitignore.");
5042
+ } else if (ignored === "tracked") {
5043
+ summaries.push(
5044
+ '\u26A0\uFE0F .env is tracked by git, so a .gitignore rule cannot un-stage it. Run "git rm --cached .env" before committing, or the credentials go into history.'
5045
+ );
5046
+ }
5047
+ const stale = [];
5048
+ for (const v of resolvedSearchEnvVars) {
5049
+ if (written.includes(v.name)) continue;
5050
+ const current = await readEnvVar(worktree, v.name);
5051
+ if (current && current !== v.value) stale.push(v);
5052
+ }
5053
+ if (stale.length > 0 && !envAppIdMismatch) {
5054
+ summaries.push(
5055
+ `\u26A0\uFE0F .env already assigns a different value to ${stale.map((v) => `${v.name} (should be ${v.value})`).join(", ")} \u2014 the wizard left it alone. Fix it by hand, or searches will fail.`
5056
+ );
5057
+ logger.warn(
5058
+ { vars: stale.map((v) => v.name) },
5059
+ "implement: .env holds different values for the resolved search credentials; not overwriting them"
5060
+ );
5061
+ }
5062
+ }
5063
+ const unresolvedSearchEnvVars = finalSearchEnvVars.filter(
5064
+ (v) => v.value.startsWith("<")
5065
+ );
5066
+ if (unresolvedSearchEnvVars.length > 0) {
5067
+ summaries.push(
5068
+ `Could not resolve a value for ${unresolvedSearchEnvVars.map((v) => v.name).join(", ")} \u2014 fill it in manually in .env.` + // Without the reason the line is a dead end.
5069
+ (searchKeyError ? ` Reason: ${searchKeyError}` : "")
4919
5070
  );
4920
5071
  }
5072
+ } else {
5073
+ ctx.setUserInput("implementation", "success");
4921
5074
  }
4922
- const unresolvedSearchEnvVars = finalSearchEnvVars.filter(
4923
- (v) => v.value.startsWith("<")
4924
- );
4925
- if (unresolvedSearchEnvVars.length > 0) {
4926
- summaries.push(
4927
- `Could not resolve a value for ${unresolvedSearchEnvVars.map((v) => v.name).join(", ")} \u2014 fill it in manually in .env.` + (searchKeyError ? ` Reason: ${searchKeyError}` : "")
5075
+ const filesChanged = await listChangedFiles(worktree);
5076
+ if (filesChanged.length === 0) {
5077
+ logger.warn(
5078
+ "implement: agent reported success but no files changed in the worktree"
4928
5079
  );
4929
5080
  }
4930
- } else {
4931
- ctx.setUserInput("implementation", "success");
5081
+ return {
5082
+ ingestionSource,
5083
+ filesChanged,
5084
+ summary: summaries.join("\n\n"),
5085
+ worktreePath: worktree,
5086
+ ...useCases.includes("ingestion") && ingestCommand ? {
5087
+ ingestCommand,
5088
+ ingestScriptRan,
5089
+ ...ingestRecordCount != null ? { ingestRecordCount } : {},
5090
+ ...ingestDurationMs != null ? { ingestDurationMs } : {}
5091
+ } : {},
5092
+ ...useCases.includes("search") ? { searchEnvVars: finalSearchEnvVars } : {}
5093
+ };
5094
+ } finally {
5095
+ process.chdir(repoRoot);
4932
5096
  }
4933
- return {
4934
- ingestionSource,
4935
- summary: summaries.join("\n\n"),
4936
- ...useCases.includes("ingestion") && ingestCommand ? {
4937
- ingestCommand,
4938
- ingestScriptRan,
4939
- ...ingestRecordCount != null ? { ingestRecordCount } : {},
4940
- ...ingestDurationMs != null ? { ingestDurationMs } : {}
4941
- } : {},
4942
- ...useCases.includes("search") ? { searchEnvVars: finalSearchEnvVars } : {}
4943
- };
4944
5097
  }
4945
5098
 
4946
5099
  // src/workflows/default.ts
@@ -5012,7 +5165,10 @@ var defaultWorkflow = {
5012
5165
  ctx.notify({
5013
5166
  messages: ["Building your Algolia search experience\u2026"]
5014
5167
  });
5015
- return implement(ctx, ["search"]);
5168
+ const ingestion2 = ctx.getStepOutput(
5169
+ "ingestion"
5170
+ );
5171
+ return implement(ctx, ["search"], ingestion2?.worktreePath);
5016
5172
  }
5017
5173
  }),
5018
5174
  defineStep({
@@ -5027,8 +5183,9 @@ var defaultWorkflow = {
5027
5183
  "ingestion"
5028
5184
  );
5029
5185
  return reviewStep(ctx, {
5030
- // ingestCommand was already shown verbatim as a notice; an
5031
- // LLM-paraphrased restatement in nextSteps risks being wrong.
5186
+ // The ingestion step already showed the user the exact `ingestCommand`
5187
+ // and worktree path as a notice, so nextSteps must not restate it —
5188
+ // an LLM-paraphrased command risks being wrong.
5032
5189
  nextStepsGuidance: ingestion2?.ingestScriptRan ? "The wizard already ran the ingestion script and records are in the index. Do NOT tell the user to run it again; instead point them at the target index to confirm the records. Do not restate the ingestion command \u2014 the wizard already showed it to them." : "Tell the user to run the ingestion script; do not restate the exact command \u2014 the wizard already showed it to them above."
5033
5190
  });
5034
5191
  }
@@ -5075,6 +5232,7 @@ var selectIndex = {
5075
5232
  selection: "wizard_seed_products"
5076
5233
  };
5077
5234
  var ingestion = {
5235
+ filesChanged: ["algolia/ingest.mjs", "algolia/records.json", "package.json"],
5078
5236
  summary: "Generated sample Product records and an ingestion script that pushes them to the target index with algoliasearch.",
5079
5237
  ingestCommand: "node algolia/ingest.mjs",
5080
5238
  ingestScriptRan: true,
@@ -5086,6 +5244,7 @@ var confirmFramework2 = {
5086
5244
  frameworks: projectScan2.frameworks
5087
5245
  };
5088
5246
  var search = {
5247
+ filesChanged: ["src/components/Search.tsx", "src/components/Header.tsx", ".env"],
5089
5248
  summary: "Added an InstantSearch-powered search box and results list, mounted in the shared header component.",
5090
5249
  ingestionSource: "generated",
5091
5250
  searchEnvVars: [
@@ -5098,7 +5257,7 @@ var review = {
5098
5257
  "Ingested 25 generated Product records into wizard_seed_products.",
5099
5258
  "Added an InstantSearch search experience to the shared header."
5100
5259
  ],
5101
- reviewPrompt: "Review the Algolia ingestion and search changes.",
5260
+ reviewPrompt: "Review the Algolia ingestion and search changes in this worktree.",
5102
5261
  nextSteps: ["Point the ingestion script at your real product data."]
5103
5262
  };
5104
5263
  var SEEDS = {
@@ -5215,9 +5374,9 @@ Options:
5215
5374
  steps pre-filled with test data. Pass with no value to print
5216
5375
  the step ids. See CONTRIBUTING.md.
5217
5376
  --no-telemetry Send no telemetry or analytics for this run.
5218
- --reset-on-run Wipe this project's wizard state (run state, AI consent)
5219
- before starting, so the run behaves like a first-ever
5220
- run. Also drops every API key the wizard has
5377
+ --reset-on-run Wipe this project's wizard state (run state, AI consent,
5378
+ worktrees) before starting, so the run behaves like a
5379
+ first-ever run. Also drops every API key the wizard has
5221
5380
  stored in your keychain (or, where the platform has none,
5222
5381
  the encrypted file it falls back to \u2014 see CONTRIBUTING.md),
5223
5382
  for this project and any other, so later runs create new
@@ -5257,7 +5416,7 @@ function parseCliArgs(argv) {
5257
5416
  }
5258
5417
 
5259
5418
  // src/lib/resetState.ts
5260
- import { readdir as readdir3, rm as rm2 } from "node:fs/promises";
5419
+ import { readdir as readdir4, rm as rm2 } from "node:fs/promises";
5261
5420
  import { join as join13 } from "node:path";
5262
5421
  var KEEP = ["wizard.log"];
5263
5422
  async function resetProjectState() {
@@ -5265,7 +5424,7 @@ async function resetProjectState() {
5265
5424
  await forgetResolvedKeys();
5266
5425
  let entries;
5267
5426
  try {
5268
- entries = await readdir3(dir);
5427
+ entries = await readdir4(dir);
5269
5428
  } catch {
5270
5429
  return { dir, removed: [] };
5271
5430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@algolia/wizard",
3
- "version": "0.24.0-rc.111.205",
3
+ "version": "0.25.0",
4
4
  "description": "Magically implement Algolia functionality in your codebase",
5
5
  "type": "module",
6
6
  "engines": {