ccqa 1.60.1 → 1.60.3

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/bin/ccqa.mjs CHANGED
@@ -1650,10 +1650,10 @@ async function saveRecording(ref, actions, cleanup = [], stepDigests) {
1650
1650
  *
1651
1651
  * One directory, not one per attempt: a project whose tests belong to its own
1652
1652
  * runner never calls `ccqa run`, so this stands in for the run report the
1653
- * evidence table reads — and what stands in for it is the attempt that passed,
1654
- * which there is only ever one of. `ccqa generate` clears it before each
1655
- * attempt and removes it when none passed, because screenshots of a failing
1656
- * run are a debugging aid and this table presents them as the case working.
1653
+ * evidence table reads — and what stands in for it is the attempt that passed
1654
+ * last, the version generation keeps. `ccqa generate` replaces it only on a
1655
+ * pass and removes it when none passed, because screenshots of a failing run
1656
+ * are a debugging aid and this table presents them as the case working.
1657
1657
  */
1658
1658
  function caseRunDir(ref) {
1659
1659
  return join(ref.dir, EVIDENCE_DIR);
@@ -6535,7 +6535,7 @@ function parseStepComment(line) {
6535
6535
  * every step then reads as deciding nothing and every rewrite is rejected.
6536
6536
  */
6537
6537
  function stepLines(source) {
6538
- return source.replace(STEP_CALL, (call) => call.replace(/\s*\n\s*/g, " ")).split("\n");
6538
+ return source.replace(STEP_CALL, (call) => "\n".repeat(call.split("\n").length - 1) + call.replace(/\s*\n\s*/g, " ")).split("\n");
6539
6539
  }
6540
6540
  /**
6541
6541
  * The step id a `test.step` title names — the block form only.
@@ -8338,6 +8338,31 @@ function exportedNames(source) {
8338
8338
  }
8339
8339
  //#endregion
8340
8340
  //#region src/targets/emitted-review.ts
8341
+ /**
8342
+ * A mechanical read of the code a generation just wrote, before it is called
8343
+ * done.
8344
+ *
8345
+ * The fix loop already runs the project's own test command and checks, so
8346
+ * what reaches review is code that compiles, lints and passes. None of that
8347
+ * answers whether it is code a reviewer would take: an assertion scoped to
8348
+ * nothing passes, a locator named for a row that matches page-wide text
8349
+ * passes, a test that checks five things the case never asked about passes.
8350
+ * Those are what this reads for, and its findings go back through the same
8351
+ * fix loop rather than to a human.
8352
+ *
8353
+ * Every rule here was calibrated against a large hand-written suite: a rule
8354
+ * that fires on code people wrote and reviewed is a rule that is wrong,
8355
+ * however reasonable it sounds. Two candidates died that way —
8356
+ * "a locator named `…Row` must use `getByRole("row")`" fired across a
8357
+ * sizable share of the hand-written page objects, because that suite names
8358
+ * elements for what they look like rather than for their ARIA role, and
8359
+ * "never `.first()` in an assertion" fired on deliberate uses that each
8360
+ * carried the comment their own guidelines ask for. A third died against
8361
+ * ccqa's own output: the step comment a generated test opens each step with
8362
+ * looked like narration, and is what the evidence table reads back to say
8363
+ * which assertion belongs to which step. Keep that bar for anything added
8364
+ * here.
8365
+ */
8341
8366
  function reviewEmittedFiles(input) {
8342
8367
  const locators = collectLocators(input.files);
8343
8368
  const said = input.caseText.join("\n");
@@ -8346,7 +8371,7 @@ function reviewEmittedFiles(input) {
8346
8371
  ...containerOfPageText(file, source),
8347
8372
  ...unjustifiedFirst(file, source),
8348
8373
  ...describeEcho(file, source),
8349
- ...unaskedAssertions(file, source, locators, said),
8374
+ ...unaskedAssertions(file, source, locators, said, input.blockSteps),
8350
8375
  ...weakerTwin(file, source),
8351
8376
  ...unreached(file, source, input)
8352
8377
  ])];
@@ -8601,9 +8626,26 @@ function assertedStrings(text, locators) {
8601
8626
  * precondition broke rather than four steps later, is worth doing and is
8602
8627
  * written down when it is.
8603
8628
  */
8604
- function unaskedAssertions(file, source, locators, said) {
8629
+ function unaskedAssertions(file, source, locators, said, blockSteps = /* @__PURE__ */ new Set()) {
8605
8630
  const all = lines(source);
8631
+ const boundaries = stepLines(source);
8632
+ let step = null;
8633
+ let depth = null;
8606
8634
  return all.flatMap((text, i) => {
8635
+ const opened = parseStepComment(boundaries[i] ?? "");
8636
+ if (opened !== null) {
8637
+ step = opened;
8638
+ depth = boundaries[i].includes("test.step(") ? 0 : null;
8639
+ }
8640
+ if (depth !== null) {
8641
+ const before = depth;
8642
+ depth += (text.match(/\{/g)?.length ?? 0) - (text.match(/\}/g)?.length ?? 0);
8643
+ if (before > 0 && depth <= 0) {
8644
+ step = null;
8645
+ depth = null;
8646
+ }
8647
+ }
8648
+ if (step !== null && blockSteps.has(step)) return [];
8607
8649
  if (!/\bexpect(?:\.soft)?\(/.test(text)) return [];
8608
8650
  const strings = assertedStrings(text, locators);
8609
8651
  if (strings.length === 0 || strings.some((s) => said.includes(s))) return [];
@@ -9302,8 +9344,12 @@ const ViolationsSchema = z.object({ ruleViolations: z.array(z.unknown()) });
9302
9344
  * treats it as one it could not obtain.
9303
9345
  */
9304
9346
  const MAX_REVIEW_TURNS = 80;
9305
- /** A wedged call, not a slow one: a review still silent after this is stuck. */
9306
- const REVIEW_TIMEOUT_MS = 10 * 6e4;
9347
+ /**
9348
+ * A wedged call, not a slow one. A review of a real suite reads the test, its
9349
+ * page objects and many of the suite's files to count conventions, which runs
9350
+ * past ten minutes; one that is still going after twenty is stuck.
9351
+ */
9352
+ const REVIEW_TIMEOUT_MS = 20 * 6e4;
9307
9353
  /** Read-only: a reviewer reads the repository and changes nothing in it. */
9308
9354
  const REVIEW_TOOLS = [
9309
9355
  "Read",
@@ -14746,7 +14792,7 @@ const LLM_GEN_ALLOWED_TOOLS = [
14746
14792
  const LlmFileSchema = z.object({
14747
14793
  path: z.string().min(1),
14748
14794
  contents: z.string(),
14749
- kind: z.string().default("test")
14795
+ kind: z.string().optional()
14750
14796
  });
14751
14797
  const LlmOutputSchema = z.object({
14752
14798
  files: z.array(LlmFileSchema),
@@ -14778,21 +14824,29 @@ function parseLlmGenOutput(raw) {
14778
14824
  const issues = res.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`);
14779
14825
  throw new Error(`reply JSON does not match {files:[{path,contents,kind}],summary}: ${issues.join("; ")}`);
14780
14826
  }
14781
- const kindWarnings = [];
14782
14827
  return {
14783
- files: res.data.files.map((f) => {
14784
- if (f.kind === "test" || f.kind === "support") return {
14785
- ...f,
14786
- kind: f.kind
14787
- };
14788
- kindWarnings.push(`file "${f.path}": unknown kind "${f.kind}" coerced to "test"`);
14789
- return {
14790
- ...f,
14791
- kind: "test"
14792
- };
14793
- }),
14794
- summary: res.data.summary,
14795
- kindWarnings
14828
+ files: res.data.files.map((f) => ({
14829
+ path: f.path,
14830
+ contents: f.contents,
14831
+ kind: f.kind === "support" ? "support" : "test"
14832
+ })),
14833
+ summary: res.data.summary
14834
+ };
14835
+ }
14836
+ /**
14837
+ * The file at the configured test path is the test and every other file is
14838
+ * support — whatever the reply labelled them. The path already says which one
14839
+ * a file is, and a model that labels a page object "test" would otherwise fail
14840
+ * the whole generation over a word.
14841
+ */
14842
+ function kindByPath(output, policy) {
14843
+ const testAbs = resolve(policy.cwd, policy.testPath);
14844
+ return {
14845
+ ...output,
14846
+ files: output.files.map((f) => ({
14847
+ ...f,
14848
+ kind: resolve(policy.cwd, f.path) === testAbs ? "test" : "support"
14849
+ }))
14796
14850
  };
14797
14851
  }
14798
14852
  /**
@@ -14952,7 +15006,6 @@ async function generateWithLlmEngine(req) {
14952
15006
  requireTestFile: true,
14953
15007
  allowEmpty: false
14954
15008
  }), "run");
14955
- warnings.push(...output.kindWarnings);
14956
15009
  return finalizeAndVerify({
14957
15010
  ctx,
14958
15011
  target: req.target,
@@ -15013,12 +15066,14 @@ async function finalizeAndVerify(p) {
15013
15066
  review
15014
15067
  };
15015
15068
  }
15069
+ const FINDINGS_LEFT_OPEN = "generated with review findings still open — the files are kept and the findings are recorded against the case, but nothing acted on them";
15016
15070
  /**
15017
15071
  * The runCommand verification loop. One round asks the project's own commands,
15018
- * then the review, then the run — and a round is over when all three are
15019
- * clean. On anything else the output goes to Claude for a corrected set and
15072
+ * then the review and the run together — and a round is over when all three
15073
+ * are clean. On anything else the output goes to Claude for a corrected set and
15020
15074
  * the next round asks all three again: a fix written for a failing run can
15021
- * break a rule the round before it cleared.
15075
+ * break a rule the round before it cleared, and one written for a finding can
15076
+ * break the run.
15022
15077
  *
15023
15078
  * `fix.mode` mirrors the agent-browser target's fix UX (which only ever
15024
15079
  * prompts inside its own fix loop, never for the first write):
@@ -15030,8 +15085,9 @@ async function finalizeAndVerify(p) {
15030
15085
  * verify decides pass/fail.
15031
15086
  *
15032
15087
  * Exhaustion (or a declined / skipped fix) keeps the files on disk and reports
15033
- * `passed: false`. Targets without a runCommand are generate-only here and
15034
- * pass trivially.
15088
+ * `passed: false` — unless an earlier round's files passed their run: those
15089
+ * are put back and the generate passes with the findings open. Targets without
15090
+ * a runCommand are generate-only here and pass trivially.
15035
15091
  */
15036
15092
  async function runVerificationLoop(p, state) {
15037
15093
  const runCommand = p.ctx.targetConfig.runCommand;
@@ -15061,33 +15117,61 @@ async function runVerificationLoop(p, state) {
15061
15117
  const askReviewer = maxRetries > 0;
15062
15118
  let reviewerGone = false;
15063
15119
  if (!askReviewer && p.reading) info("no fix round could act on a review — the reviewer is not asked; the mechanical read still runs");
15120
+ let passing = null;
15121
+ const original = /* @__PURE__ */ new Map();
15122
+ const settle = async (attempt) => {
15123
+ if (passing === null) {
15124
+ await clearCaseRun(p.ctx.ref);
15125
+ return finish(false);
15126
+ }
15127
+ if (passing.attempt !== attempt) {
15128
+ await restoreFiles(state, passing.files, original);
15129
+ review = passing.review;
15130
+ warn("the final round did not pass — restored the files of the last round that did");
15131
+ }
15132
+ warn(FINDINGS_LEFT_OPEN);
15133
+ return finish(true);
15134
+ };
15135
+ if (captures) await clearCaseRun(p.ctx.ref);
15064
15136
  for (let attempt = 0;; attempt++) {
15065
15137
  let failing = await runCheckCommands(p.ctx);
15066
15138
  let asked = [];
15067
15139
  if (failing === null) {
15068
15140
  await refreshFromDisk(state);
15069
- const [reading, mechanical] = await Promise.all([readingOfEmitted(p, state, askReviewer && !reviewerGone), reviewOfEmitted(p.ctx, state, p.writeRoots)]);
15141
+ const questions = [
15142
+ readingOfEmitted(p, state, askReviewer && !reviewerGone),
15143
+ reviewOfEmitted(p.ctx, state, p.writeRoots),
15144
+ verifyRun(p, state, runCommand, attempt, captures)
15145
+ ];
15146
+ const [reading, mechanical, run] = await Promise.all(questions).catch(async (err) => {
15147
+ await Promise.allSettled(questions);
15148
+ await clearCaseRun(p.ctx.ref);
15149
+ throw err;
15150
+ });
15070
15151
  review = reading;
15071
- if (review?.reviewerFailed) reviewerGone = true;
15072
- const unchecked = uncheckedSteps(review, spent);
15073
- const violations = guideViolations(review, spent);
15152
+ if (run.exitCode === 0) passing = {
15153
+ files: new Map(state),
15154
+ review,
15155
+ attempt
15156
+ };
15157
+ const heard = review?.reviewerFailed ? void 0 : review;
15158
+ if (review && !heard) reviewerGone = true;
15159
+ const unchecked = uncheckedSteps(heard, spent);
15160
+ const violations = guideViolations(heard, spent);
15074
15161
  const reads = allReadings(mechanical, unchecked, violations);
15075
- if (reads !== null && attempt < maxRetries) {
15076
- asked = [...unchecked?.asked ?? [], ...violations?.asked ?? []];
15077
- failing = reads;
15078
- } else {
15079
- if (reads !== null) warn("generated with review findings still open — the files are kept and the findings are recorded against the case, but nothing acted on them");
15080
- const run = await verifyRun(p, state, runCommand, attempt, captures);
15081
- if (run.exitCode === 0) return finish(true);
15082
- failing = run;
15162
+ asked = [...unchecked?.asked ?? [], ...violations?.asked ?? []];
15163
+ if (run.exitCode !== 0) failing = reads === null ? run : allReadings(run, reads) ?? run;
15164
+ else if (reads !== null && attempt < maxRetries) failing = reads;
15165
+ else {
15166
+ if (reads !== null) warn(FINDINGS_LEFT_OPEN);
15167
+ return finish(true);
15083
15168
  }
15084
15169
  }
15085
15170
  const outputTail = scrubEnvValues(tail(failing.output), outputScrub);
15086
15171
  if (attempt >= maxRetries) {
15087
- warn(`verification still failing after ${maxRetries} fix attempt(s) — generated files kept`);
15172
+ warn(`verification still failing after ${maxRetries} fix attempt(s)`);
15088
15173
  logOutputTail(outputTail, warn);
15089
- await clearCaseRun(p.ctx.ref);
15090
- return finish(false);
15174
+ return settle(attempt);
15091
15175
  }
15092
15176
  fix(`verification failed (exit ${failing.exitCode}) — requesting a fix (${attempt + 1}/${maxRetries})`);
15093
15177
  logOutputTail(outputTail, fix);
@@ -15125,9 +15209,12 @@ async function runVerificationLoop(p, state) {
15125
15209
  continue;
15126
15210
  }
15127
15211
  if (p.ctx.fix.mode === "interactive" && !await confirmFixWrite(output.files, p.ctx.cwd)) {
15128
- info("fix not applied (declined) — keeping current files");
15129
- await clearCaseRun(p.ctx.ref);
15130
- return finish(false);
15212
+ info("fix not applied (declined)");
15213
+ return settle(attempt);
15214
+ }
15215
+ for (const f of output.files) {
15216
+ const rel = relative(p.ctx.cwd, resolve(p.ctx.cwd, f.path));
15217
+ if (!state.has(rel) && !original.has(rel)) original.set(rel, await readFile(resolve(p.ctx.cwd, f.path), "utf8").catch(() => null));
15131
15218
  }
15132
15219
  await writeGeneratedFiles(p.ctx.cwd, output.files, state);
15133
15220
  review = void 0;
@@ -15135,17 +15222,12 @@ async function runVerificationLoop(p, state) {
15135
15222
  }
15136
15223
  /**
15137
15224
  * One verification run of the files as they are now, in the shape the fix loop
15138
- * consumes. The round's last question and its most expensive: it is asked only
15139
- * of files the project's own commands and the review have already cleared.
15225
+ * consumes. Asked only of files the project's own commands have cleared.
15140
15226
  */
15141
15227
  async function verifyRun(p, state, runCommand, attempt, captures) {
15142
15228
  const testFiles = [...state.entries()].filter(([, f]) => f.kind === "test").map(([rel]) => rel);
15143
15229
  const artifactsDir = await mkdtemp(join(tmpdir(), "ccqa-verify-artifacts-"));
15144
- const evidenceDir = captures ? caseRunDir(p.ctx.ref) : null;
15145
- if (evidenceDir) {
15146
- await clearCaseRun(p.ctx.ref);
15147
- await mkdir(evidenceDir, { recursive: true });
15148
- }
15230
+ const evidenceDir = captures ? await mkdtemp(join(tmpdir(), "ccqa-verify-evidence-")) : null;
15149
15231
  let command = substituteArtifactsDir(substituteRunCommandFiles(runCommand, testFiles), artifactsDir);
15150
15232
  let traceUnavailable;
15151
15233
  if (evidenceDir) {
@@ -15169,6 +15251,8 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
15169
15251
  warn: (message) => warn(`${p.ctx.ref.id}: ${message}`)
15170
15252
  });
15171
15253
  if (unavailable) warn(unavailable);
15254
+ await clearCaseRun(p.ctx.ref);
15255
+ await cp(evidenceDir, caseRunDir(p.ctx.ref), { recursive: true });
15172
15256
  }
15173
15257
  return {
15174
15258
  ...result,
@@ -15179,6 +15263,10 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
15179
15263
  recursive: true,
15180
15264
  force: true
15181
15265
  });
15266
+ if (evidenceDir) await rm(evidenceDir, {
15267
+ recursive: true,
15268
+ force: true
15269
+ });
15182
15270
  }
15183
15271
  }
15184
15272
  /**
@@ -15199,6 +15287,10 @@ async function runCheckCommands(ctx) {
15199
15287
  }
15200
15288
  return null;
15201
15289
  }
15290
+ function blockStepIds(ctx) {
15291
+ const included = new Set(collectIncludedBlockNames(ctx.spec));
15292
+ return new Set(ctx.steps.filter((s) => included.has(s.source)).map((s) => s.id));
15293
+ }
15202
15294
  /**
15203
15295
  * The mechanical read of what this attempt wrote, shaped like a failed check
15204
15296
  * so the fix loop carries it the same way.
@@ -15216,6 +15308,7 @@ async function reviewOfEmitted(ctx, state, writeRoots) {
15216
15308
  ...ctx.expectations,
15217
15309
  ...ctx.cleanupExpectations
15218
15310
  ],
15311
+ blockSteps: blockStepIds(ctx),
15219
15312
  testPath: ctx.testPath
15220
15313
  });
15221
15314
  if (findings.length === 0) return null;
@@ -15261,6 +15354,23 @@ async function refreshFromDisk(state) {
15261
15354
  }
15262
15355
  }
15263
15356
  /**
15357
+ * Put the files back as `to` holds them. A path written after `to` was taken
15358
+ * goes back to what it held before the loop wrote it, or away if it was new.
15359
+ */
15360
+ async function restoreFiles(state, to, original) {
15361
+ for (const [rel, f] of state) {
15362
+ if (to.has(rel)) continue;
15363
+ const was = original.get(rel) ?? null;
15364
+ if (was === null) await rm(f.abs, { force: true });
15365
+ else await writeFile(f.abs, was, "utf8");
15366
+ }
15367
+ state.clear();
15368
+ for (const [rel, f] of to) {
15369
+ await writeFile(f.abs, f.contents, "utf8");
15370
+ state.set(rel, f);
15371
+ }
15372
+ }
15373
+ /**
15264
15374
  * The reading's findings, shaped like a failed check so the fix loop carries
15265
15375
  * them the same way — or null when there is nothing to act on.
15266
15376
  *
@@ -15273,9 +15383,8 @@ async function refreshFromDisk(state) {
15273
15383
  *
15274
15384
  * What the round asks for is verified like any other rewrite, and that can
15275
15385
  * end red: an assertion strengthened to check what the case claims may simply
15276
- * not hold. The generate then reports failed — on the test run, not on this —
15277
- * and the files are kept. That is the honest outcome, and the alternative
15278
- * (restoring what passed weakly) would hide a case the product does not meet.
15386
+ * not hold. The loop then goes back to the last version that passed, and the
15387
+ * finding stays open on the record rather than hidden.
15279
15388
  *
15280
15389
  * A review that could not be obtained is not acted on either: spending a round
15281
15390
  * answering a question nobody asked is worse than leaving it unanswered. Nor
@@ -15421,7 +15530,7 @@ function allReadings(...reports) {
15421
15530
  const found = reports.filter((r) => r !== null);
15422
15531
  if (found.length === 0) return null;
15423
15532
  return {
15424
- exitCode: 1,
15533
+ exitCode: found[0].exitCode,
15425
15534
  command: found.map((r) => r.command).join(" + "),
15426
15535
  output: found.map((r) => `${r.command}\n\n${r.output}`).join("\n\n")
15427
15536
  };
@@ -15461,8 +15570,7 @@ async function invokeForFiles(p) {
15461
15570
  model: p.ctx.model
15462
15571
  }, () => {});
15463
15572
  if (isError) throw new Error(`Claude invocation failed: ${tail(result)}`);
15464
- const output = parseLlmGenOutput(result);
15465
- for (const w of output.kindWarnings) warn(w);
15573
+ const output = kindByPath(parseLlmGenOutput(result), p.policy);
15466
15574
  const errors = validateOutput(output, p);
15467
15575
  if (errors.length > 0) throw new Error(errors.join("; "));
15468
15576
  return output;
@@ -15505,8 +15613,7 @@ function validateOutput(output, p) {
15505
15613
  if (fileError) errors.push(fileError);
15506
15614
  }
15507
15615
  const tests = output.files.filter((f) => f.kind === "test");
15508
- if (p.requireTestFile && tests.length === 0) errors.push("output contains no \"kind\": \"test\" file");
15509
- if (tests.length > 1) errors.push(`output contains ${tests.length} "kind": "test" files; a spec has exactly one`);
15616
+ if (p.requireTestFile && tests.length === 0) errors.push(`the test file must be written to ${p.policy.testPath}; the reply has no file there`);
15510
15617
  return errors;
15511
15618
  }
15512
15619
  function toPromptResource(r) {
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
+ record: "record";
38
39
  run: "run";
39
40
  drift: "drift";
40
- record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -831,9 +831,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
831
831
  declare const RunReportDataSchema: z.ZodObject<{
832
832
  schemaVersion: z.ZodLiteral<1>;
833
833
  kind: z.ZodDefault<z.ZodEnum<{
834
+ record: "record";
834
835
  run: "run";
835
836
  drift: "drift";
836
- record: "record";
837
837
  }>>;
838
838
  createdAt: z.ZodString;
839
839
  runId: z.ZodNullable<z.ZodString>;
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
+ record: "record";
38
39
  run: "run";
39
40
  drift: "drift";
40
- record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -831,9 +831,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
831
831
  declare const RunReportDataSchema: z.ZodObject<{
832
832
  schemaVersion: z.ZodLiteral<1>;
833
833
  kind: z.ZodDefault<z.ZodEnum<{
834
+ record: "record";
834
835
  run: "run";
835
836
  drift: "drift";
836
- record: "record";
837
837
  }>>;
838
838
  createdAt: z.ZodString;
839
839
  runId: z.ZodNullable<z.ZodString>;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.60.1",
3
+ "version": "1.60.3",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.60.1",
3
+ "version": "1.60.3",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {