ccqa 1.25.0 → 1.26.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/bin/ccqa.mjs CHANGED
@@ -4780,6 +4780,42 @@ function appendCostRecord(command, cost) {
4780
4780
  appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
4781
4781
  } catch {}
4782
4782
  }
4783
+ /**
4784
+ * Read back what `appendCostRecord` wrote. `null` when the file isn't there:
4785
+ * ccqa never ran, which is not the answer "ran and was billed nothing". An
4786
+ * unbilled line (`totalCostUsd: null`) adds nothing to the total but is still
4787
+ * an invocation — a killed job leaves a half-written last line, which is
4788
+ * neither.
4789
+ */
4790
+ async function readCostFileTotal(path) {
4791
+ let raw;
4792
+ try {
4793
+ raw = await readFile(path, "utf8");
4794
+ } catch (err) {
4795
+ if (err instanceof Error && err.code === "ENOENT") return null;
4796
+ throw err;
4797
+ }
4798
+ let totalUsd = 0;
4799
+ let invocations = 0;
4800
+ let unreadable = 0;
4801
+ for (const line of raw.split("\n")) {
4802
+ if (line.trim().length === 0) continue;
4803
+ let record;
4804
+ try {
4805
+ record = JSON.parse(line);
4806
+ } catch {
4807
+ unreadable++;
4808
+ continue;
4809
+ }
4810
+ invocations++;
4811
+ if (typeof record.totalCostUsd === "number") totalUsd += record.totalCostUsd;
4812
+ }
4813
+ return {
4814
+ totalUsd,
4815
+ invocations,
4816
+ unreadable
4817
+ };
4818
+ }
4783
4819
  //#endregion
4784
4820
  //#region src/cli/draft.ts
4785
4821
  const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
@@ -7231,6 +7267,35 @@ AckSchema.extend({
7231
7267
  profile: z.string(),
7232
7268
  name: z.string()
7233
7269
  });
7270
+ /**
7271
+ * What one batch of ccqa invocations spent on Claude, as the job that ran them
7272
+ * reported it (see `SpendStore`). `label` is the consumer's name for the batch
7273
+ * — its job name — and the only thing that says where the money went.
7274
+ */
7275
+ const SpendEntrySchema = z.object({
7276
+ id: z.string(),
7277
+ at: z.string(),
7278
+ costUsd: z.number(),
7279
+ label: z.string(),
7280
+ ciRunId: z.string().optional(),
7281
+ runUrl: z.string().optional()
7282
+ });
7283
+ z.object({ entries: z.array(SpendEntrySchema).default([]) });
7284
+ /** Body of `POST /projects/:project/spend` — one batch's total. `at` defaults to now. */
7285
+ const RecordSpendRequestSchema = z.object({
7286
+ costUsd: z.number().nonnegative(),
7287
+ label: z.string().min(1).max(200),
7288
+ at: z.string().refine((v) => !Number.isNaN(Date.parse(v)), "at must be an ISO-8601 instant").optional(),
7289
+ ciRunId: z.string().optional(),
7290
+ runUrl: z.string().optional()
7291
+ });
7292
+ z.object({
7293
+ project: z.string(),
7294
+ since: z.string().nullable(),
7295
+ until: z.string().nullable(),
7296
+ totalUsd: z.number(),
7297
+ entries: z.array(SpendEntrySchema)
7298
+ });
7234
7299
  //#endregion
7235
7300
  //#region src/run/hub-selection.ts
7236
7301
  /**
@@ -8245,6 +8310,19 @@ function githubRunUrl(env = process.env) {
8245
8310
  function githubRunId(env = process.env) {
8246
8311
  return env["GITHUB_RUN_ID"] ?? null;
8247
8312
  }
8313
+ /**
8314
+ * The CI provenance every hub record carries, ready to spread into a request
8315
+ * body. Empty outside Actions, so a local invocation sends neither field
8316
+ * rather than a null one.
8317
+ */
8318
+ function ciProvenance(env = process.env) {
8319
+ const ciRunId = githubRunId(env);
8320
+ const runUrl = githubRunUrl(env);
8321
+ return {
8322
+ ...ciRunId ? { ciRunId } : {},
8323
+ ...runUrl ? { runUrl } : {}
8324
+ };
8325
+ }
8248
8326
  //#endregion
8249
8327
  //#region src/cli/deploy-paths.ts
8250
8328
  /**
@@ -9036,6 +9114,36 @@ function describeSelection(selection, diffAvailable) {
9036
9114
  return `${values.filter((s) => s.verdict === "needed").length} needed / ${values.filter((s) => s.verdict === "unknown").length} unknown / ${values.length} specs`;
9037
9115
  }
9038
9116
  const deployCommand = new Command("deploy").description("Report deploys to the hub, the input behind `ccqa run --only-hub-rerun-needed`.").addCommand(deployRecord);
9117
+ const costPush = new Command("push").description("Record what this job spent on Claude: sum the cost file every ccqa invocation appended to ($CCQA_COST_FILE) and push the total to the hub as one spend entry. Run it last, once. A budget reads these totals INSTEAD OF summing the hub's runs: most commands that call Claude leave no run behind, and a batch already includes the ones that do, so adding both double-counts.").requiredOption("--label <name>", "What this batch was — typically the CI job's name. Required: an unlabelled entry tells a reader nothing about where the money went.").option("--from <path>", "Cost file to read. Defaults to $CCQA_COST_FILE.").option("--project <name>", "Project the spend is recorded against. Defaults to the current directory's name.").option(...cwdOption).option(...hubUrlOption).option(...hubTokenOption).action(withHubErrors(runCostPush));
9118
+ async function runCostPush(opts) {
9119
+ const path = opts.from ?? process.env.CCQA_COST_FILE;
9120
+ if (!path) {
9121
+ error("no cost file to read (--from <path> or CCQA_COST_FILE)");
9122
+ process.exit(2);
9123
+ }
9124
+ const project = resolveProject(opts);
9125
+ const hub = connect(opts);
9126
+ header("hub cost push", opts.label);
9127
+ meta("project", project);
9128
+ const total = await readCostFileTotal(path);
9129
+ if (total === null) {
9130
+ warn(`no cost file at ${path}; recorded nothing — ccqa never ran here, which is not a spend of zero`);
9131
+ return;
9132
+ }
9133
+ if (total.invocations === 0) {
9134
+ warn(`${path} holds no invocations; recorded nothing`);
9135
+ return;
9136
+ }
9137
+ await hub.recordSpend(project, {
9138
+ costUsd: total.totalUsd,
9139
+ label: opts.label,
9140
+ ...ciProvenance()
9141
+ });
9142
+ meta("invocations", String(total.invocations));
9143
+ if (total.unreadable > 0) warn(`${total.unreadable} line(s) of ${path} could not be read, so the total below is a floor: what they cost is not in it, and the hub now holds the short number`);
9144
+ info(`recorded $${total.totalUsd.toFixed(4)} of spend on the hub`);
9145
+ }
9146
+ const costCommand = new Command("cost").description("Report what a CI job spent on Claude to the hub — the number a budget reads.").addCommand(costPush);
9039
9147
  const pushCommand = new Command("push").description("Upload the report directory of a finished `ccqa run --report` to the hub as a run. Run this after `ccqa run` (use `if: always()` in CI so failing runs are pushed too).").option("--report-dir <dir>", `Report directory to push. Default: ${DEFAULT_REPORT_DIR}/`).option("--project <name>", "Logical project name for the run. Defaults to the current directory's name.").option("--branch <name>", "Branch label. Defaults to $GITHUB_HEAD_REF / $GITHUB_REF_NAME / current git branch.").option("--profile <name>", "Profile (environment) the run executed against. Recorded for display; runs are not scoped by profile.").option(...hubUrlOption).option(...hubTokenOption).option("--cwd <path>", "Directory the report dir is resolved against (defaults to the current directory).").action(withHubErrors(async (opts) => {
9040
9148
  const cwd = resolveCwd(opts.cwd);
9041
9149
  const reportDir = join(cwd, opts.reportDir ?? "ccqa-report");
@@ -9070,7 +9178,7 @@ const pushCommand = new Command("push").description("Upload the report directory
9070
9178
  meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
9071
9179
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
9072
9180
  }));
9073
- const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand);
9181
+ const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand);
9074
9182
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
9075
9183
  function isStorageStateShape(state) {
9076
9184
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -11929,8 +12037,6 @@ function requireReportToHubConnection(conn) {
11929
12037
  */
11930
12038
  async function openHubRun(kind, conn, cwd, profile) {
11931
12039
  const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
11932
- const ciRunId = githubRunId();
11933
- const runUrl = githubRunUrl();
11934
12040
  try {
11935
12041
  const run = await conn.hub.openRun({
11936
12042
  project: conn.project,
@@ -11938,8 +12044,7 @@ async function openHubRun(kind, conn, cwd, profile) {
11938
12044
  ...branch ? { branch } : {},
11939
12045
  ...profile ? { profile } : {},
11940
12046
  ...gitHead ? { gitHead } : {},
11941
- ...ciRunId ? { ciRunId } : {},
11942
- ...runUrl ? { runUrl } : {}
12047
+ ...ciProvenance()
11943
12048
  });
11944
12049
  return {
11945
12050
  hub: conn.hub,
@@ -12649,16 +12754,13 @@ async function executeRun(targets, opts) {
12649
12754
  let hubPublishBroken = false;
12650
12755
  if (hubCtx != null && opts.reportToHub) try {
12651
12756
  const branch = await detectBranch(cwd);
12652
- const ciRunId = githubRunId();
12653
- const runUrl = githubRunUrl();
12654
12757
  const opened = await hubCtx.hub.openRun({
12655
12758
  project: hubCtx.project,
12656
12759
  ...branch ? { branch } : {},
12657
12760
  ...opts.hubProfile ? { profile: opts.hubProfile } : {},
12658
12761
  ...git.head ? { gitHead: git.head } : {},
12659
12762
  ...deployedSha ? { deployedSha } : {},
12660
- ...ciRunId ? { ciRunId } : {},
12661
- ...runUrl ? { runUrl } : {},
12763
+ ...ciProvenance(),
12662
12764
  kind: "run"
12663
12765
  });
12664
12766
  hubRunId = opened.id;
@@ -17032,11 +17134,15 @@ function mergeBucket(into, from) {
17032
17134
  //#endregion
17033
17135
  //#region src/hub/api/validate.ts
17034
17136
  /**
17035
- * Validators for URL path parameters that flow into the storage layer's file
17036
- * path construction (secret store scope/name, artifact relative paths).
17137
+ * Validators for the request parameters handlers read straight off the wire:
17138
+ * the URL path parameters that flow into the storage layer's file path
17139
+ * construction (secret store scope/name, artifact relative paths), and the
17140
+ * query params bounding a listing's time window.
17141
+ *
17037
17142
  * Router params come from `decodeURIComponent`-ed path segments, so a client
17038
- * can put `..`, `/`, or `\` in them — these guards reject anything that
17039
- * could escape the intended directory before it ever reaches disk I/O.
17143
+ * can put `..`, `/`, or `\` in them — the path validators below reject
17144
+ * anything that could escape the intended directory before it ever reaches
17145
+ * disk I/O.
17040
17146
  */
17041
17147
  const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
17042
17148
  /** Validate a single URL path parameter (e.g. `:profile`, `:name`) as a bare name. Throws 400 if unsafe. */
@@ -17051,6 +17157,26 @@ function requireSafeSegment(value, paramName) {
17051
17157
  function requireProfileParam(url) {
17052
17158
  return requireSafeSegment(url.searchParams.get("profile") ?? "default", "profile");
17053
17159
  }
17160
+ /**
17161
+ * The `?since=`/`?until=` window a listing takes, in the shape its store's
17162
+ * `list` takes it. Half-open on purpose: a caller asking for one day passes
17163
+ * that day's start and the next day's start, and no record is counted twice
17164
+ * at a boundary.
17165
+ */
17166
+ function requireWindowParams(url) {
17167
+ const since = requireInstant(url.searchParams.get("since"), "since");
17168
+ const until = requireInstant(url.searchParams.get("until"), "until");
17169
+ return {
17170
+ ...since ? { since } : {},
17171
+ ...until ? { until } : {}
17172
+ };
17173
+ }
17174
+ /** Rejected rather than ignored: a typo would otherwise read as "nothing that day". */
17175
+ function requireInstant(raw, name) {
17176
+ if (raw === null || raw === "") return null;
17177
+ if (Number.isNaN(Date.parse(raw))) throw new HttpError(400, "invalid_param", `invalid ${name}: must be an ISO-8601 instant`);
17178
+ return raw;
17179
+ }
17054
17180
  /** Validate a `*path`-captured relative path (multiple segments allowed) as safe to join under a root dir. Throws 400 if unsafe. */
17055
17181
  function requireSafeRelPath(relPath, paramName) {
17056
17182
  const segments = relPath.split("/");
@@ -17426,7 +17552,7 @@ function createPatchRunHandler(config) {
17426
17552
  sendJson(ctx.res, 200, updated);
17427
17553
  };
17428
17554
  }
17429
- /** GET /api/v1/runs?project&branch&status&kind&limit */
17555
+ /** GET /api/v1/runs?project&branch&status&kind&since&until&limit */
17430
17556
  function createListRunsHandler(storage) {
17431
17557
  return async (ctx) => {
17432
17558
  const project = ctx.url.searchParams.get("project");
@@ -17439,6 +17565,7 @@ function createListRunsHandler(storage) {
17439
17565
  ...branch ? { branch } : {},
17440
17566
  ...status ? { status } : {},
17441
17567
  ...kindsRaw ? { kinds: kindsRaw.split(",").map(requireKind) } : {},
17568
+ ...requireWindowParams(ctx.url),
17442
17569
  ...limitRaw ? { limit: Number(limitRaw) } : {}
17443
17570
  });
17444
17571
  sendJson(ctx.res, 200, { runs: await Promise.all(runs.map((r) => withGradedDrift(storage, r))) });
@@ -18132,7 +18259,7 @@ function createGetAuditNeedHandler(storage) {
18132
18259
  //#endregion
18133
18260
  //#region src/hub/api/handlers/locks.ts
18134
18261
  /** A spec-key list and three short strings; nothing here should approach this. */
18135
- const MAX_BODY_BYTES$2 = 1024 * 1024;
18262
+ const MAX_BODY_BYTES$3 = 1024 * 1024;
18136
18263
  /**
18137
18264
  * POST /api/v1/projects/:project/locks?profile=
18138
18265
  *
@@ -18146,7 +18273,7 @@ function createAcquireLocksHandler(storage) {
18146
18273
  return async (ctx) => {
18147
18274
  const project = requireSafeSegment(ctx.params.project, "project");
18148
18275
  const profile = requireProfileParam(ctx.url);
18149
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, AcquireLocksRequestSchema, "lock request");
18276
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, AcquireLocksRequestSchema, "lock request");
18150
18277
  let result = {
18151
18278
  granted: [],
18152
18279
  denied: []
@@ -18175,7 +18302,7 @@ function createReleaseLocksHandler(storage) {
18175
18302
  return async (ctx) => {
18176
18303
  const project = requireSafeSegment(ctx.params.project, "project");
18177
18304
  const profile = requireProfileParam(ctx.url);
18178
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, ReleaseLocksRequestSchema, "release request");
18305
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, ReleaseLocksRequestSchema, "release request");
18179
18306
  await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
18180
18307
  ctx.res.writeHead(204).end();
18181
18308
  };
@@ -18188,7 +18315,7 @@ function createReleaseLocksHandler(storage) {
18188
18315
  * 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
18189
18316
  * never answered 413 by a limit the documented bounds don't mention.
18190
18317
  */
18191
- const MAX_BODY_BYTES$1 = 8 * 1024 * 1024;
18318
+ const MAX_BODY_BYTES$2 = 8 * 1024 * 1024;
18192
18319
  function requireAckKey(ctx) {
18193
18320
  return {
18194
18321
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -18215,7 +18342,7 @@ function createGetAckHandler(storage) {
18215
18342
  function createPutAckHandler(storage) {
18216
18343
  return async (ctx) => {
18217
18344
  const key = requireAckKey(ctx);
18218
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$1, PutAckRequestSchema, "ack body");
18345
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAckRequestSchema, "ack body");
18219
18346
  const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
18220
18347
  sendJson(ctx.res, 200, {
18221
18348
  ...key,
@@ -18224,6 +18351,46 @@ function createPutAckHandler(storage) {
18224
18351
  };
18225
18352
  }
18226
18353
  //#endregion
18354
+ //#region src/hub/api/handlers/spend.ts
18355
+ /** One entry is a handful of short fields; anything larger is a malformed client. */
18356
+ const MAX_BODY_BYTES$1 = 4 * 1024;
18357
+ /**
18358
+ * POST /api/v1/projects/:project/spend
18359
+ *
18360
+ * What a batch of ccqa invocations cost, as the job that ran them reported it —
18361
+ * the number a budget reads instead of summing runs (ADR-0017).
18362
+ */
18363
+ function createRecordSpendHandler(storage) {
18364
+ return async (ctx) => {
18365
+ const project = requireSafeSegment(ctx.params.project, "project");
18366
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$1, RecordSpendRequestSchema, "spend body");
18367
+ const entry = await storage.spend.append(project, {
18368
+ id: randomUUID(),
18369
+ at: new Date(body.at ?? Date.now()).toISOString(),
18370
+ costUsd: body.costUsd,
18371
+ label: body.label,
18372
+ ...body.ciRunId ? { ciRunId: body.ciRunId } : {},
18373
+ ...body.runUrl ? { runUrl: body.runUrl } : {}
18374
+ });
18375
+ sendJson(ctx.res, 201, entry);
18376
+ };
18377
+ }
18378
+ /** GET /api/v1/projects/:project/spend?since=&until= — newest first, plus the window's total. */
18379
+ function createGetSpendHandler(storage) {
18380
+ return async (ctx) => {
18381
+ const project = requireSafeSegment(ctx.params.project, "project");
18382
+ const window = requireWindowParams(ctx.url);
18383
+ const entries = await storage.spend.list(project, window);
18384
+ sendJson(ctx.res, 200, {
18385
+ project,
18386
+ since: window.since ?? null,
18387
+ until: window.until ?? null,
18388
+ totalUsd: entries.reduce((sum, e) => sum + e.costUsd, 0),
18389
+ entries
18390
+ });
18391
+ };
18392
+ }
18393
+ //#endregion
18227
18394
  //#region src/hub/core/rerun.ts
18228
18395
  /**
18229
18396
  * When each deployed commit reached the environment. A baseline read at that
@@ -19060,10 +19227,32 @@ const HTML_BODY = `
19060
19227
  <div class="page-bar">
19061
19228
  <h1 data-i18n="runs.title">Runs</h1>
19062
19229
  <span class="total" id="runs-total-cost" hidden></span>
19230
+ <span class="total" id="runs-capped" hidden></span>
19231
+ <!-- Ruled off from the two above because it counts something else: the
19232
+ project's whole spend, not what the listed runs cost. -->
19233
+ <span class="total apart" id="runs-spend-24h" hidden></span>
19063
19234
  <div class="spacer"></div>
19064
19235
  ${refreshButton("runs-refresh")}
19065
19236
  </div>
19066
19237
  <div class="content">
19238
+ <!-- Deliberately selects and a native date input, not the .fchip
19239
+ toggles the rest of the page uses: these three refetch, and a chip
19240
+ group beside a date box would be the odd one out. Their options
19241
+ are built by syncRunsFilters. -->
19242
+ <div class="toolbar">
19243
+ <div class="fgroup">
19244
+ <label class="fgroup-label" for="runs-f-date" data-i18n="runs.filter.date">Date</label>
19245
+ <input class="fctl" type="date" id="runs-f-date">
19246
+ </div>
19247
+ <div class="fgroup">
19248
+ <label class="fgroup-label" for="runs-f-kind" data-i18n="runs.filter.kind">Kind</label>
19249
+ <select class="fctl" id="runs-f-kind"></select>
19250
+ </div>
19251
+ <div class="fgroup">
19252
+ <label class="fgroup-label" for="runs-f-status" data-i18n="runs.filter.status">Status</label>
19253
+ <select class="fctl" id="runs-f-status"></select>
19254
+ </div>
19255
+ </div>
19067
19256
  <div class="card" id="runs-card">
19068
19257
  <div class="table-wrap">
19069
19258
  <table>
@@ -19424,6 +19613,7 @@ const CSS = `
19424
19613
  .page-bar .back svg { width: 15px; height: 15px; }
19425
19614
  .page-bar .filters { display: flex; gap: 8px; margin-left: 8px; }
19426
19615
  .page-bar .total { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
19616
+ .page-bar .total.apart { padding-left: 12px; border-left: 1px solid var(--border); }
19427
19617
  .page-bar .spacer { flex: 1; }
19428
19618
  .content { padding: 18px 24px 48px; }
19429
19619
 
@@ -19852,6 +20042,13 @@ const CSS = `
19852
20042
  .fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
19853
20043
  .fchip .fcount { margin-left: 6px; font-variant-numeric: tabular-nums; color: var(--muted-2); }
19854
20044
  .fchip[aria-pressed="true"] .fcount { color: var(--accent-fg); opacity: 0.7; }
20045
+ /* A filter control that picks one value out of many (the runs bar's date box
20046
+ and selects), sized to itself — .input is the full-width form field the
20047
+ sheets use, which in a toolbar row swallows the whole line. */
20048
+ .fctl { height: 32px; padding: 0 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--fg); font: inherit; font-size: 13px; }
20049
+ /* Native chrome (the date picker's glyph, the select's arrow) takes its
20050
+ colours from the color-scheme property, not from any class of ours. */
20051
+ .dark .fctl { color-scheme: dark; }
19855
20052
 
19856
20053
  .chip.live { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
19857
20054
  .badge.ok { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
@@ -19956,7 +20153,10 @@ const CLIENT_JS = `
19956
20153
  var PREDICTED_LABELS = ${JSON.stringify(PREDICTED_LABELS)};
19957
20154
  var AGENT_BROWSER_TARGET = ${JSON.stringify(AGENT_BROWSER_TARGET)};
19958
20155
  var GUIDANCE_KINDS = ${JSON.stringify(GUIDANCE_KINDS)};
19959
- var state = { token: "", project: "", profile: "default", detailRunId: "", jobPollToken: 0 };
20156
+ // Every status a run can be in, from the contract that defines them, so the
20157
+ // runs filter offers exactly what a row's badge can say.
20158
+ var RUN_STATUSES = ${JSON.stringify(RunStatusSchema.options)};
20159
+ var state = { token: "", project: "", profile: "default", detailRunId: "", jobPollToken: 0, runsLoadToken: 0, spendLoadToken: 0 };
19960
20160
  var knownProfiles = [];
19961
20161
  var TOKEN_KEY = "ccqa-hub-token";
19962
20162
  var LANG_KEY = "ccqa-hub-lang";
@@ -19981,9 +20181,13 @@ const CLIENT_JS = `
19981
20181
  "projects.title": "Projects", "projects.new": "New project",
19982
20182
  "runs.title": "Runs", "runs.empty": "Select a project to see its runs.",
19983
20183
  "runs.none": "No runs yet for this project.", "projects.none": "No projects yet. Create one to get started.", "projects.noneShort": "No projects yet",
20184
+ "runs.noMatch": "No runs match this filter.",
19984
20185
  "runs.col.run": "Run", "runs.col.branch": "Branch", "runs.col.status": "Status",
19985
20186
  "runs.col.specs": "Specs", "runs.col.cost": "Cost", "runs.col.created": "Created",
19986
- "runs.totalCost": "Cost of these {n}:",
20187
+ "runs.totalCost": "Cost of these {n}:", "runs.capped": "showing the first {n}",
20188
+ "runs.spend24h": "All spend, last 24h:",
20189
+ "runs.filter.date": "Date", "runs.filter.kind": "Kind", "runs.filter.status": "Status",
20190
+ "runs.filter.all": "All",
19987
20191
  "detail.back": "Runs", "detail.specs": "Specs",
19988
20192
  "detail.download": "Download artifacts",
19989
20193
  "detail.triage": "Triage",
@@ -20140,9 +20344,13 @@ const CLIENT_JS = `
20140
20344
  "projects.title": "プロジェクト", "projects.new": "新規プロジェクト",
20141
20345
  "runs.title": "実行", "runs.empty": "プロジェクトを選択すると実行一覧が表示されます。",
20142
20346
  "runs.none": "このプロジェクトにはまだ実行がありません。", "projects.none": "まだプロジェクトがありません。作成して始めましょう。", "projects.noneShort": "プロジェクトなし",
20347
+ "runs.noMatch": "条件に一致する実行はありません。",
20143
20348
  "runs.col.run": "実行", "runs.col.branch": "ブランチ", "runs.col.status": "ステータス",
20144
20349
  "runs.col.specs": "スペック", "runs.col.cost": "コスト", "runs.col.created": "作成",
20145
- "runs.totalCost": "この {n} 件のコスト:",
20350
+ "runs.totalCost": "この {n} 件のコスト:", "runs.capped": "先頭 {n} 件のみ表示",
20351
+ "runs.spend24h": "直近 24 時間の全支出:",
20352
+ "runs.filter.date": "日付", "runs.filter.kind": "種類", "runs.filter.status": "結果",
20353
+ "runs.filter.all": "すべて",
20146
20354
  "detail.back": "実行", "detail.specs": "スペック",
20147
20355
  "detail.download": "アーティファクトをダウンロード",
20148
20356
  "detail.triage": "トリアージ",
@@ -20310,6 +20518,9 @@ const CLIENT_JS = `
20310
20518
  for (var i = 0; i < nodes.length; i++) { nodes[i].textContent = t(nodes[i].getAttribute("data-i18n")); }
20311
20519
  var phs = document.querySelectorAll("[data-i18n-ph]");
20312
20520
  for (var j = 0; j < phs.length; j++) { phs[j].placeholder = t(phs[j].getAttribute("data-i18n-ph")); }
20521
+ // The runs filters' options are built rather than marked up, so they are
20522
+ // not reached by the two loops above.
20523
+ syncRunsFilters();
20313
20524
  document.documentElement.lang = lang;
20314
20525
  }
20315
20526
 
@@ -20777,13 +20988,58 @@ const CLIENT_JS = `
20777
20988
 
20778
20989
  // ── runs list ────────────────────────────────────────────────────────
20779
20990
 
20991
+ var RUNS_LIMIT = 50;
20992
+ // Outlives every render, so a refresh or a language switch comes back to the
20993
+ // list the operator was looking at.
20994
+ var runsFilter = { date: "", kind: "", status: "" };
20995
+ function runsFilterActive() { return !!(runsFilter.date || runsFilter.kind || runsFilter.status); }
20996
+
20997
+ function runsQuery() {
20998
+ var q = "/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=" + RUNS_LIMIT;
20999
+ if (runsFilter.kind) q += "&kind=" + encodeURIComponent(runsFilter.kind);
21000
+ if (runsFilter.status) q += "&status=" + encodeURIComponent(runsFilter.status);
21001
+ if (runsFilter.date) {
21002
+ // The picked day becomes [local midnight, next local midnight). The API
21003
+ // takes instants and carries no timezone, so the day has to be resolved
21004
+ // here — against the clock of whoever picked it.
21005
+ var p = runsFilter.date.split("-");
21006
+ var start = new Date(+p[0], +p[1] - 1, +p[2]);
21007
+ var next = new Date(+p[0], +p[1] - 1, +p[2] + 1);
21008
+ q += "&since=" + encodeURIComponent(start.toISOString()) + "&until=" + encodeURIComponent(next.toISOString());
21009
+ }
21010
+ return q;
21011
+ }
21012
+
21013
+ // Both selects take their values from the tables that label the rows, so a
21014
+ // filter cannot name a kind or a status differently from the run it hides.
21015
+ // Called on boot and on a language switch — the only times the labels move.
21016
+ function syncRunsFilters() {
21017
+ document.getElementById("runs-f-date").value = runsFilter.date;
21018
+ fillRunsFilter("runs-f-kind", Object.keys(KINDS), function (k) { return t(kindOf(k).label); }, runsFilter.kind);
21019
+ fillRunsFilter("runs-f-status", RUN_STATUSES, function (s) { return t("status." + s); }, runsFilter.status);
21020
+ }
21021
+
21022
+ function fillRunsFilter(id, values, labelOf, selected) {
21023
+ var sel = document.getElementById(id);
21024
+ clear(sel);
21025
+ var any = el("option", null, t("runs.filter.all"));
21026
+ any.value = ""; // the empty value is what the query omits
21027
+ sel.appendChild(any);
21028
+ values.forEach(function (v) {
21029
+ var opt = el("option", null, labelOf(v));
21030
+ opt.value = v;
21031
+ sel.appendChild(opt);
21032
+ });
21033
+ sel.value = selected;
21034
+ }
21035
+
20780
21036
  // What the listed runs cost together — the accumulating number an operator
20781
21037
  // reads to decide how often CI should run, so it follows whatever filter
20782
21038
  // produced the list. Hidden when no listed run carries a cost at all, since
20783
21039
  // a "$0.0000" total would read as "CI is free" rather than "nothing measured".
20784
21040
  //
20785
- // The label names the run count on purpose. The list is capped (limit=50), so
20786
- // an unqualified "total" would quietly under-report a project's spend the
21041
+ // The label names the run count on purpose. The list is capped (RUNS_LIMIT),
21042
+ // so an unqualified "total" would quietly under-report a project's spend the
20787
21043
  // moment it has more runs than that — the one number this feature exists to
20788
21044
  // get right.
20789
21045
  function renderRunsTotalCost(runs) {
@@ -20805,14 +21061,38 @@ const CLIENT_JS = `
20805
21061
  }
20806
21062
  }
20807
21063
 
21064
+ // The project's whole spend over the last 24 hours — deliberately not the
21065
+ // list's window or filter: the two numbers differ by everything that calls
21066
+ // Claude without leaving a run behind, which is why both are here. Hidden
21067
+ // when nothing was reported, since "$0.0000" would claim a free day.
21068
+ function loadRunsSpend() {
21069
+ var span = document.getElementById("runs-spend-24h");
21070
+ span.hidden = true;
21071
+ var token = ++state.spendLoadToken;
21072
+ var since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
21073
+ apiFetch("/api/v1/projects/" + encodeURIComponent(state.project) + "/spend?since=" + encodeURIComponent(since))
21074
+ .then(function (data) {
21075
+ if (token !== state.spendLoadToken) return;
21076
+ if (!data.entries.length) return;
21077
+ span.hidden = false;
21078
+ span.textContent = t("runs.spend24h") + " " + costText(data.totalUsd);
21079
+ })
21080
+ .catch(function () { /* the runs list is the page; a missing total must not replace it with an error */ });
21081
+ }
21082
+
20808
21083
  function renderRunsList(runs) {
20809
21084
  var tbody = document.getElementById("runs-tbody");
20810
21085
  clear(tbody);
20811
21086
  renderRunsTotalCost(runs);
21087
+ // A full page is almost certainly a truncated one, and under a date filter
21088
+ // that turns the total beside it into a day's spend that stops at the cap.
21089
+ var capped = document.getElementById("runs-capped");
21090
+ capped.hidden = runs.length < RUNS_LIMIT;
21091
+ capped.textContent = t("runs.capped").replace("{n}", RUNS_LIMIT);
20812
21092
  var empty = document.getElementById("runs-empty");
20813
21093
  if (runs.length === 0) {
20814
21094
  empty.hidden = false;
20815
- empty.textContent = t("runs.none");
21095
+ empty.textContent = t(runsFilterActive() ? "runs.noMatch" : "runs.none");
20816
21096
  return;
20817
21097
  }
20818
21098
  empty.hidden = true;
@@ -20879,19 +21159,33 @@ const CLIENT_JS = `
20879
21159
  });
20880
21160
  }
20881
21161
 
20882
- function loadRuns() {
20883
- var empty = document.getElementById("runs-empty");
20884
- empty.hidden = true;
20885
- apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=50")
20886
- .then(function (data) { renderRunsList(data.runs); })
21162
+ // Compared against the live token before painting, so a slower earlier
21163
+ // response cannot land last: holding an arrow key down in the date box fires
21164
+ // one request per day passed, and the table would end up on the wrong one.
21165
+ function loadRunsList() {
21166
+ var token = ++state.runsLoadToken;
21167
+ document.getElementById("runs-empty").hidden = true;
21168
+ apiFetch(runsQuery())
21169
+ .then(function (data) {
21170
+ if (token !== state.runsLoadToken) return;
21171
+ renderRunsList(data.runs);
21172
+ })
20887
21173
  .catch(function (err) {
20888
- clear(document.getElementById("runs-tbody"));
20889
- renderRunsTotalCost([]);
21174
+ if (token !== state.runsLoadToken) return;
21175
+ renderRunsList([]);
21176
+ var empty = document.getElementById("runs-empty");
20890
21177
  empty.hidden = false;
20891
21178
  empty.textContent = "Error loading runs: " + err.message;
20892
21179
  });
20893
21180
  }
20894
21181
 
21182
+ // Entering the view or refreshing it. The spend readout is a fixed window,
21183
+ // so a filter change reloads the list alone.
21184
+ function loadRuns() {
21185
+ loadRunsSpend();
21186
+ loadRunsList();
21187
+ }
21188
+
20895
21189
  // ── run detail: header ──────────────────────────────────────────────
20896
21190
 
20897
21191
  function renderRunHead(run) {
@@ -23720,6 +24014,14 @@ const CLIENT_JS = `
23720
24014
 
23721
24015
  document.getElementById("detail-back").addEventListener("click", function () { location.hash = "#/runs"; });
23722
24016
  document.getElementById("runs-refresh").addEventListener("click", loadRuns);
24017
+ // Every control refetches: the window and the kinds are the server's to
24018
+ // apply, so filtering client-side would only narrow the same capped page.
24019
+ [["runs-f-date", "date"], ["runs-f-kind", "kind"], ["runs-f-status", "status"]].forEach(function (pair) {
24020
+ document.getElementById(pair[0]).addEventListener("change", function (e) {
24021
+ runsFilter[pair[1]] = e.target.value;
24022
+ loadRunsList();
24023
+ });
24024
+ });
23723
24025
  document.getElementById("learn-run").addEventListener("click", startLearn);
23724
24026
  document.getElementById("jobs-refresh").addEventListener("click", loadJobs);
23725
24027
  // Wrap so the click PointerEvent isn't passed as loadSecrets' statusAfter
@@ -24192,6 +24494,8 @@ function registerRoutes(router, config, queue) {
24192
24494
  router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
24193
24495
  router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
24194
24496
  router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
24497
+ router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
24498
+ router.get("/api/v1/projects/:project/spend", createGetSpendHandler(storage));
24195
24499
  const sessionConfig = {
24196
24500
  store: storage.sessions,
24197
24501
  encryptionKey: config.encryptionKey
@@ -24376,6 +24680,7 @@ function isNotFound(err) {
24376
24680
  * deploys/<project>/<profile>/log.json (DeployLog, ring-buffered)
24377
24681
  * deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
24378
24682
  * acks/<project>/<profile>/<name>.json (Ack: a consumer's acted-on keys)
24683
+ * spend/<project>.json (SpendLog, pruned to its retention window)
24379
24684
  *
24380
24685
  * IDs and names are validated by their callers (run ids are server-minted
24381
24686
  * UUIDs; project/profile/name come from validated request params) before
@@ -24470,6 +24775,9 @@ function specLocksPath(root, project, profile) {
24470
24775
  function ackPath(root, project, profile, name) {
24471
24776
  return join(root, "acks", project, profile, `${name}.json`);
24472
24777
  }
24778
+ function spendPath(root, project) {
24779
+ return join(root, "spend", `${project}.json`);
24780
+ }
24473
24781
  //#endregion
24474
24782
  //#region src/hub/core/storage/file/ack-store.ts
24475
24783
  function assertSafeKey(project, profile, name) {
@@ -24752,6 +25060,22 @@ function createFilePromptStore(root) {
24752
25060
  };
24753
25061
  }
24754
25062
  //#endregion
25063
+ //#region src/hub/core/storage/file/time-window.ts
25064
+ /**
25065
+ * The half-open `[since, until)` window the run and spend listings both take,
25066
+ * as a predicate over a record's ISO-8601 timestamp. Compared as instants, not
25067
+ * as strings: the ends come off the wire in whatever offset the caller wrote
25068
+ * them in, while the stored field is always UTC.
25069
+ */
25070
+ function windowFilter(q) {
25071
+ const from = q.since === void 0 ? null : Date.parse(q.since);
25072
+ const to = q.until === void 0 ? null : Date.parse(q.until);
25073
+ return (at) => {
25074
+ const instant = Date.parse(at);
25075
+ return (from === null || instant >= from) && (to === null || instant < to);
25076
+ };
25077
+ }
25078
+ //#endregion
24755
25079
  //#region src/hub/core/storage/file/run-store.ts
24756
25080
  /**
24757
25081
  * Read one run record for an aggregate scan, tolerating a bad entry: a
@@ -24784,8 +25108,12 @@ function createFileRunStore(root) {
24784
25108
  };
24785
25109
  });
24786
25110
  },
24787
- async list({ project, branch, status, kinds, limit }) {
25111
+ async list({ project, branch, status, kinds, since, until, limit }) {
24788
25112
  const ids = await listSubdirsOrEmpty(runsDir(root));
25113
+ const inWindow = windowFilter({
25114
+ since,
25115
+ until
25116
+ });
24789
25117
  const runs = [];
24790
25118
  for (const id of ids) {
24791
25119
  const run = await readRunOrSkip(root, id);
@@ -24794,6 +25122,7 @@ function createFileRunStore(root) {
24794
25122
  if (branch !== void 0 && run.branch !== branch) continue;
24795
25123
  if (status !== void 0 && run.status !== status) continue;
24796
25124
  if (kinds !== void 0 && !kinds.includes(run.kind)) continue;
25125
+ if (!inWindow(run.createdAt)) continue;
24797
25126
  runs.push(run);
24798
25127
  }
24799
25128
  runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -24866,6 +25195,55 @@ function createFileSecretStore(root, kind) {
24866
25195
  }
24867
25196
  };
24868
25197
  }
25198
+ /** Spend storage: one JSON document per project, pruned as it is appended to. */
25199
+ function createFileSpendStore(root) {
25200
+ return {
25201
+ async append(project, entry) {
25202
+ assertSafeName(project, "project");
25203
+ const path = spendPath(root, project);
25204
+ const cutoff = Date.now() - 2160 * 60 * 60 * 1e3;
25205
+ await updateJson(path, (current) => {
25206
+ return { entries: [...readEntries(current, path).filter((e) => Date.parse(e.at) >= cutoff && !supersededBy(e, entry)), entry] };
25207
+ });
25208
+ return entry;
25209
+ },
25210
+ async list(project, window) {
25211
+ assertSafeName(project, "project");
25212
+ const path = spendPath(root, project);
25213
+ const inWindow = windowFilter(window);
25214
+ return readEntries(await readJson(path), path).filter((e) => inWindow(e.at)).sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
25215
+ }
25216
+ };
25217
+ }
25218
+ /**
25219
+ * A second push from the same CI run under the same label replaces the first
25220
+ * rather than adding to it: a retried job really does spend again, but it also
25221
+ * rewrites its cost file from scratch, so its new total is the whole of it.
25222
+ */
25223
+ function supersededBy(stored, incoming) {
25224
+ return incoming.ciRunId !== void 0 && stored.ciRunId === incoming.ciRunId && stored.label === incoming.label;
25225
+ }
25226
+ /**
25227
+ * Entries parsed one at a time, keeping the survivors: a whole-document parse
25228
+ * would answer "this project spent nothing" for one bad entry, and a budget
25229
+ * reads that as zero rather than as an error. What is lost is logged, since
25230
+ * nothing else would ever say so.
25231
+ */
25232
+ function readEntries(raw, path) {
25233
+ if (raw === null || raw === void 0) return [];
25234
+ const stored = raw.entries;
25235
+ if (!Array.isArray(stored)) {
25236
+ console.error(`hub: spend log at ${path} is not a spend document; ignoring what it holds`);
25237
+ return [];
25238
+ }
25239
+ const entries = [];
25240
+ for (const value of stored) {
25241
+ const parsed = SpendEntrySchema.safeParse(value);
25242
+ if (parsed.success) entries.push(parsed.data);
25243
+ }
25244
+ if (entries.length < stored.length) console.error(`hub: skipping ${stored.length - entries.length} unreadable spend entries in ${path}`);
25245
+ return entries;
25246
+ }
24869
25247
  //#endregion
24870
25248
  //#region src/hub/core/storage/file/triage-store.ts
24871
25249
  function createFileTriageStore(root) {
@@ -24906,7 +25284,8 @@ function createFileHubStorage(dataDir) {
24906
25284
  driftLedger: createFileDriftLedgerStore(dataDir),
24907
25285
  deploys: createFileDeployStore(dataDir),
24908
25286
  locks: createFileLockStore(dataDir),
24909
- acks: createFileAckStore(dataDir)
25287
+ acks: createFileAckStore(dataDir),
25288
+ spend: createFileSpendStore(dataDir)
24910
25289
  };
24911
25290
  }
24912
25291
  //#endregion
@@ -396,6 +396,49 @@ declare const DriftLedgerResponseSchema: z.ZodObject<{
396
396
  }, z.core.$strip>>;
397
397
  }, z.core.$strip>;
398
398
  type DriftLedgerResponse = z.infer<typeof DriftLedgerResponseSchema>;
399
+ /**
400
+ * What one batch of ccqa invocations spent on Claude, as the job that ran them
401
+ * reported it (see `SpendStore`). `label` is the consumer's name for the batch
402
+ * — its job name — and the only thing that says where the money went.
403
+ */
404
+ declare const SpendEntrySchema: z.ZodObject<{
405
+ id: z.ZodString;
406
+ at: z.ZodString;
407
+ costUsd: z.ZodNumber;
408
+ label: z.ZodString;
409
+ ciRunId: z.ZodOptional<z.ZodString>;
410
+ runUrl: z.ZodOptional<z.ZodString>;
411
+ }, z.core.$strip>;
412
+ type SpendEntry = z.infer<typeof SpendEntrySchema>;
413
+ /** Body of `POST /projects/:project/spend` — one batch's total. `at` defaults to now. */
414
+ declare const RecordSpendRequestSchema: z.ZodObject<{
415
+ costUsd: z.ZodNumber;
416
+ label: z.ZodString;
417
+ at: z.ZodOptional<z.ZodString>;
418
+ ciRunId: z.ZodOptional<z.ZodString>;
419
+ runUrl: z.ZodOptional<z.ZodString>;
420
+ }, z.core.$strip>;
421
+ type RecordSpendRequest = z.infer<typeof RecordSpendRequestSchema>;
422
+ /**
423
+ * Body of `GET /projects/:project/spend?since=&until=`, newest first. `since`
424
+ * and `until` echo the window that was asked for (null for an open end), so a
425
+ * reader of `totalUsd` can tell what it totals.
426
+ */
427
+ declare const SpendLogResponseSchema: z.ZodObject<{
428
+ project: z.ZodString;
429
+ since: z.ZodNullable<z.ZodString>;
430
+ until: z.ZodNullable<z.ZodString>;
431
+ totalUsd: z.ZodNumber;
432
+ entries: z.ZodArray<z.ZodObject<{
433
+ id: z.ZodString;
434
+ at: z.ZodString;
435
+ costUsd: z.ZodNumber;
436
+ label: z.ZodString;
437
+ ciRunId: z.ZodOptional<z.ZodString>;
438
+ runUrl: z.ZodOptional<z.ZodString>;
439
+ }, z.core.$strip>>;
440
+ }, z.core.$strip>;
441
+ type SpendLogResponse = z.infer<typeof SpendLogResponseSchema>;
399
442
  //#endregion
400
443
  //#region src/prompts/prompt-names.d.ts
401
444
  /**
@@ -950,6 +993,16 @@ interface HubClient {
950
993
  profile: string;
951
994
  limit?: number;
952
995
  }): Promise<DeployLogResponse>;
996
+ /**
997
+ * Report what one batch of ccqa invocations cost (`ccqa hub cost push`).
998
+ * Read instead of a sum over runs, never alongside one (ADR-0017).
999
+ */
1000
+ recordSpend(project: string, body: RecordSpendRequest): Promise<SpendEntry>;
1001
+ /** The project's spend over `[since, until)`, newest first, with the window's total. */
1002
+ getSpend(project: string, q?: {
1003
+ since?: string;
1004
+ until?: string;
1005
+ }): Promise<SpendLogResponse>;
953
1006
  putSession(project: string, profile: string, name: string, storageState: unknown): Promise<void>;
954
1007
  getSession(project: string, profile: string, name: string): Promise<unknown>;
955
1008
  listSessions(project: string, profile: string): Promise<{
@@ -201,6 +201,19 @@ function createHubClient(opts) {
201
201
  limit: q.limit
202
202
  })}`);
203
203
  },
204
+ recordSpend(project, body) {
205
+ return json(spendPath(project), {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json" },
208
+ body: JSON.stringify(body)
209
+ });
210
+ },
211
+ getSpend(project, q = {}) {
212
+ return json(`${spendPath(project)}?${queryString({
213
+ since: q.since,
214
+ until: q.until
215
+ })}`);
216
+ },
204
217
  async listProjects() {
205
218
  const { projects } = await json("/api/v1/projects");
206
219
  return projects;
@@ -286,6 +299,9 @@ function promptsPath(project) {
286
299
  function deploysPath(project) {
287
300
  return `/api/v1/projects/${encodeURIComponent(project)}/deploys`;
288
301
  }
302
+ function spendPath(project) {
303
+ return `/api/v1/projects/${encodeURIComponent(project)}/spend`;
304
+ }
289
305
  function locksPath(project) {
290
306
  return `/api/v1/projects/${encodeURIComponent(project)}/locks`;
291
307
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
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.25.0",
3
+ "version": "1.26.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {