ccqa 1.25.0 → 1.26.1

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
 
@@ -20536,7 +20747,7 @@ const CLIENT_JS = `
20536
20747
  // Shared by the runs-list row and the run-detail header — the one place
20537
20748
  // both decide whether a run's own status badge speaks drift's vocabulary.
20538
20749
  function runStatusBadge(run) {
20539
- return run.kind === "drift" ? driftFoundBadge(driftRunState(run), "drift.run.") : statusBadge(run.status);
20750
+ return answersDrift(run) ? driftFoundBadge(driftRunState(run), "drift.run.") : statusBadge(run.status);
20540
20751
  }
20541
20752
 
20542
20753
  // Which command left the run, and whether its spec counts are a tally of
@@ -20600,6 +20811,16 @@ const CLIENT_JS = `
20600
20811
  return r.status === "failed" ? "found" : "clean";
20601
20812
  }
20602
20813
 
20814
+ /**
20815
+ * Whether a run's badge should say what the audit found, rather than how the
20816
+ * run itself is going. Only once it is over: an audit still streaming its
20817
+ * rows has no summary yet, and reading that absence as "no drift" claims the
20818
+ * one answer nobody has earned.
20819
+ */
20820
+ function answersDrift(run) {
20821
+ return run.kind === "drift" && run.status !== "running";
20822
+ }
20823
+
20603
20824
  /** A whole drift run's state. Label counts beat status for the same reason. */
20604
20825
  function driftRunState(run) {
20605
20826
  var d = driftSummary(run);
@@ -20777,13 +20998,58 @@ const CLIENT_JS = `
20777
20998
 
20778
20999
  // ── runs list ────────────────────────────────────────────────────────
20779
21000
 
21001
+ var RUNS_LIMIT = 50;
21002
+ // Outlives every render, so a refresh or a language switch comes back to the
21003
+ // list the operator was looking at.
21004
+ var runsFilter = { date: "", kind: "", status: "" };
21005
+ function runsFilterActive() { return !!(runsFilter.date || runsFilter.kind || runsFilter.status); }
21006
+
21007
+ function runsQuery() {
21008
+ var q = "/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=" + RUNS_LIMIT;
21009
+ if (runsFilter.kind) q += "&kind=" + encodeURIComponent(runsFilter.kind);
21010
+ if (runsFilter.status) q += "&status=" + encodeURIComponent(runsFilter.status);
21011
+ if (runsFilter.date) {
21012
+ // The picked day becomes [local midnight, next local midnight). The API
21013
+ // takes instants and carries no timezone, so the day has to be resolved
21014
+ // here — against the clock of whoever picked it.
21015
+ var p = runsFilter.date.split("-");
21016
+ var start = new Date(+p[0], +p[1] - 1, +p[2]);
21017
+ var next = new Date(+p[0], +p[1] - 1, +p[2] + 1);
21018
+ q += "&since=" + encodeURIComponent(start.toISOString()) + "&until=" + encodeURIComponent(next.toISOString());
21019
+ }
21020
+ return q;
21021
+ }
21022
+
21023
+ // Both selects take their values from the tables that label the rows, so a
21024
+ // filter cannot name a kind or a status differently from the run it hides.
21025
+ // Called on boot and on a language switch — the only times the labels move.
21026
+ function syncRunsFilters() {
21027
+ document.getElementById("runs-f-date").value = runsFilter.date;
21028
+ fillRunsFilter("runs-f-kind", Object.keys(KINDS), function (k) { return t(kindOf(k).label); }, runsFilter.kind);
21029
+ fillRunsFilter("runs-f-status", RUN_STATUSES, function (s) { return t("status." + s); }, runsFilter.status);
21030
+ }
21031
+
21032
+ function fillRunsFilter(id, values, labelOf, selected) {
21033
+ var sel = document.getElementById(id);
21034
+ clear(sel);
21035
+ var any = el("option", null, t("runs.filter.all"));
21036
+ any.value = ""; // the empty value is what the query omits
21037
+ sel.appendChild(any);
21038
+ values.forEach(function (v) {
21039
+ var opt = el("option", null, labelOf(v));
21040
+ opt.value = v;
21041
+ sel.appendChild(opt);
21042
+ });
21043
+ sel.value = selected;
21044
+ }
21045
+
20780
21046
  // What the listed runs cost together — the accumulating number an operator
20781
21047
  // reads to decide how often CI should run, so it follows whatever filter
20782
21048
  // produced the list. Hidden when no listed run carries a cost at all, since
20783
21049
  // a "$0.0000" total would read as "CI is free" rather than "nothing measured".
20784
21050
  //
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
21051
+ // The label names the run count on purpose. The list is capped (RUNS_LIMIT),
21052
+ // so an unqualified "total" would quietly under-report a project's spend the
20787
21053
  // moment it has more runs than that — the one number this feature exists to
20788
21054
  // get right.
20789
21055
  function renderRunsTotalCost(runs) {
@@ -20805,14 +21071,38 @@ const CLIENT_JS = `
20805
21071
  }
20806
21072
  }
20807
21073
 
21074
+ // The project's whole spend over the last 24 hours — deliberately not the
21075
+ // list's window or filter: the two numbers differ by everything that calls
21076
+ // Claude without leaving a run behind, which is why both are here. Hidden
21077
+ // when nothing was reported, since "$0.0000" would claim a free day.
21078
+ function loadRunsSpend() {
21079
+ var span = document.getElementById("runs-spend-24h");
21080
+ span.hidden = true;
21081
+ var token = ++state.spendLoadToken;
21082
+ var since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
21083
+ apiFetch("/api/v1/projects/" + encodeURIComponent(state.project) + "/spend?since=" + encodeURIComponent(since))
21084
+ .then(function (data) {
21085
+ if (token !== state.spendLoadToken) return;
21086
+ if (!data.entries.length) return;
21087
+ span.hidden = false;
21088
+ span.textContent = t("runs.spend24h") + " " + costText(data.totalUsd);
21089
+ })
21090
+ .catch(function () { /* the runs list is the page; a missing total must not replace it with an error */ });
21091
+ }
21092
+
20808
21093
  function renderRunsList(runs) {
20809
21094
  var tbody = document.getElementById("runs-tbody");
20810
21095
  clear(tbody);
20811
21096
  renderRunsTotalCost(runs);
21097
+ // A full page is almost certainly a truncated one, and under a date filter
21098
+ // that turns the total beside it into a day's spend that stops at the cap.
21099
+ var capped = document.getElementById("runs-capped");
21100
+ capped.hidden = runs.length < RUNS_LIMIT;
21101
+ capped.textContent = t("runs.capped").replace("{n}", RUNS_LIMIT);
20812
21102
  var empty = document.getElementById("runs-empty");
20813
21103
  if (runs.length === 0) {
20814
21104
  empty.hidden = false;
20815
- empty.textContent = t("runs.none");
21105
+ empty.textContent = t(runsFilterActive() ? "runs.noMatch" : "runs.none");
20816
21106
  return;
20817
21107
  }
20818
21108
  empty.hidden = true;
@@ -20879,19 +21169,33 @@ const CLIENT_JS = `
20879
21169
  });
20880
21170
  }
20881
21171
 
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); })
21172
+ // Compared against the live token before painting, so a slower earlier
21173
+ // response cannot land last: holding an arrow key down in the date box fires
21174
+ // one request per day passed, and the table would end up on the wrong one.
21175
+ function loadRunsList() {
21176
+ var token = ++state.runsLoadToken;
21177
+ document.getElementById("runs-empty").hidden = true;
21178
+ apiFetch(runsQuery())
21179
+ .then(function (data) {
21180
+ if (token !== state.runsLoadToken) return;
21181
+ renderRunsList(data.runs);
21182
+ })
20887
21183
  .catch(function (err) {
20888
- clear(document.getElementById("runs-tbody"));
20889
- renderRunsTotalCost([]);
21184
+ if (token !== state.runsLoadToken) return;
21185
+ renderRunsList([]);
21186
+ var empty = document.getElementById("runs-empty");
20890
21187
  empty.hidden = false;
20891
21188
  empty.textContent = "Error loading runs: " + err.message;
20892
21189
  });
20893
21190
  }
20894
21191
 
21192
+ // Entering the view or refreshing it. The spend readout is a fixed window,
21193
+ // so a filter change reloads the list alone.
21194
+ function loadRuns() {
21195
+ loadRunsSpend();
21196
+ loadRunsList();
21197
+ }
21198
+
20895
21199
  // ── run detail: header ──────────────────────────────────────────────
20896
21200
 
20897
21201
  function renderRunHead(run) {
@@ -23720,6 +24024,14 @@ const CLIENT_JS = `
23720
24024
 
23721
24025
  document.getElementById("detail-back").addEventListener("click", function () { location.hash = "#/runs"; });
23722
24026
  document.getElementById("runs-refresh").addEventListener("click", loadRuns);
24027
+ // Every control refetches: the window and the kinds are the server's to
24028
+ // apply, so filtering client-side would only narrow the same capped page.
24029
+ [["runs-f-date", "date"], ["runs-f-kind", "kind"], ["runs-f-status", "status"]].forEach(function (pair) {
24030
+ document.getElementById(pair[0]).addEventListener("change", function (e) {
24031
+ runsFilter[pair[1]] = e.target.value;
24032
+ loadRunsList();
24033
+ });
24034
+ });
23723
24035
  document.getElementById("learn-run").addEventListener("click", startLearn);
23724
24036
  document.getElementById("jobs-refresh").addEventListener("click", loadJobs);
23725
24037
  // Wrap so the click PointerEvent isn't passed as loadSecrets' statusAfter
@@ -24192,6 +24504,8 @@ function registerRoutes(router, config, queue) {
24192
24504
  router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
24193
24505
  router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
24194
24506
  router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
24507
+ router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
24508
+ router.get("/api/v1/projects/:project/spend", createGetSpendHandler(storage));
24195
24509
  const sessionConfig = {
24196
24510
  store: storage.sessions,
24197
24511
  encryptionKey: config.encryptionKey
@@ -24376,6 +24690,7 @@ function isNotFound(err) {
24376
24690
  * deploys/<project>/<profile>/log.json (DeployLog, ring-buffered)
24377
24691
  * deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
24378
24692
  * acks/<project>/<profile>/<name>.json (Ack: a consumer's acted-on keys)
24693
+ * spend/<project>.json (SpendLog, pruned to its retention window)
24379
24694
  *
24380
24695
  * IDs and names are validated by their callers (run ids are server-minted
24381
24696
  * UUIDs; project/profile/name come from validated request params) before
@@ -24470,6 +24785,9 @@ function specLocksPath(root, project, profile) {
24470
24785
  function ackPath(root, project, profile, name) {
24471
24786
  return join(root, "acks", project, profile, `${name}.json`);
24472
24787
  }
24788
+ function spendPath(root, project) {
24789
+ return join(root, "spend", `${project}.json`);
24790
+ }
24473
24791
  //#endregion
24474
24792
  //#region src/hub/core/storage/file/ack-store.ts
24475
24793
  function assertSafeKey(project, profile, name) {
@@ -24752,6 +25070,22 @@ function createFilePromptStore(root) {
24752
25070
  };
24753
25071
  }
24754
25072
  //#endregion
25073
+ //#region src/hub/core/storage/file/time-window.ts
25074
+ /**
25075
+ * The half-open `[since, until)` window the run and spend listings both take,
25076
+ * as a predicate over a record's ISO-8601 timestamp. Compared as instants, not
25077
+ * as strings: the ends come off the wire in whatever offset the caller wrote
25078
+ * them in, while the stored field is always UTC.
25079
+ */
25080
+ function windowFilter(q) {
25081
+ const from = q.since === void 0 ? null : Date.parse(q.since);
25082
+ const to = q.until === void 0 ? null : Date.parse(q.until);
25083
+ return (at) => {
25084
+ const instant = Date.parse(at);
25085
+ return (from === null || instant >= from) && (to === null || instant < to);
25086
+ };
25087
+ }
25088
+ //#endregion
24755
25089
  //#region src/hub/core/storage/file/run-store.ts
24756
25090
  /**
24757
25091
  * Read one run record for an aggregate scan, tolerating a bad entry: a
@@ -24784,8 +25118,12 @@ function createFileRunStore(root) {
24784
25118
  };
24785
25119
  });
24786
25120
  },
24787
- async list({ project, branch, status, kinds, limit }) {
25121
+ async list({ project, branch, status, kinds, since, until, limit }) {
24788
25122
  const ids = await listSubdirsOrEmpty(runsDir(root));
25123
+ const inWindow = windowFilter({
25124
+ since,
25125
+ until
25126
+ });
24789
25127
  const runs = [];
24790
25128
  for (const id of ids) {
24791
25129
  const run = await readRunOrSkip(root, id);
@@ -24794,6 +25132,7 @@ function createFileRunStore(root) {
24794
25132
  if (branch !== void 0 && run.branch !== branch) continue;
24795
25133
  if (status !== void 0 && run.status !== status) continue;
24796
25134
  if (kinds !== void 0 && !kinds.includes(run.kind)) continue;
25135
+ if (!inWindow(run.createdAt)) continue;
24797
25136
  runs.push(run);
24798
25137
  }
24799
25138
  runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -24866,6 +25205,55 @@ function createFileSecretStore(root, kind) {
24866
25205
  }
24867
25206
  };
24868
25207
  }
25208
+ /** Spend storage: one JSON document per project, pruned as it is appended to. */
25209
+ function createFileSpendStore(root) {
25210
+ return {
25211
+ async append(project, entry) {
25212
+ assertSafeName(project, "project");
25213
+ const path = spendPath(root, project);
25214
+ const cutoff = Date.now() - 2160 * 60 * 60 * 1e3;
25215
+ await updateJson(path, (current) => {
25216
+ return { entries: [...readEntries(current, path).filter((e) => Date.parse(e.at) >= cutoff && !supersededBy(e, entry)), entry] };
25217
+ });
25218
+ return entry;
25219
+ },
25220
+ async list(project, window) {
25221
+ assertSafeName(project, "project");
25222
+ const path = spendPath(root, project);
25223
+ const inWindow = windowFilter(window);
25224
+ return readEntries(await readJson(path), path).filter((e) => inWindow(e.at)).sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
25225
+ }
25226
+ };
25227
+ }
25228
+ /**
25229
+ * A second push from the same CI run under the same label replaces the first
25230
+ * rather than adding to it: a retried job really does spend again, but it also
25231
+ * rewrites its cost file from scratch, so its new total is the whole of it.
25232
+ */
25233
+ function supersededBy(stored, incoming) {
25234
+ return incoming.ciRunId !== void 0 && stored.ciRunId === incoming.ciRunId && stored.label === incoming.label;
25235
+ }
25236
+ /**
25237
+ * Entries parsed one at a time, keeping the survivors: a whole-document parse
25238
+ * would answer "this project spent nothing" for one bad entry, and a budget
25239
+ * reads that as zero rather than as an error. What is lost is logged, since
25240
+ * nothing else would ever say so.
25241
+ */
25242
+ function readEntries(raw, path) {
25243
+ if (raw === null || raw === void 0) return [];
25244
+ const stored = raw.entries;
25245
+ if (!Array.isArray(stored)) {
25246
+ console.error(`hub: spend log at ${path} is not a spend document; ignoring what it holds`);
25247
+ return [];
25248
+ }
25249
+ const entries = [];
25250
+ for (const value of stored) {
25251
+ const parsed = SpendEntrySchema.safeParse(value);
25252
+ if (parsed.success) entries.push(parsed.data);
25253
+ }
25254
+ if (entries.length < stored.length) console.error(`hub: skipping ${stored.length - entries.length} unreadable spend entries in ${path}`);
25255
+ return entries;
25256
+ }
24869
25257
  //#endregion
24870
25258
  //#region src/hub/core/storage/file/triage-store.ts
24871
25259
  function createFileTriageStore(root) {
@@ -24906,7 +25294,8 @@ function createFileHubStorage(dataDir) {
24906
25294
  driftLedger: createFileDriftLedgerStore(dataDir),
24907
25295
  deploys: createFileDeployStore(dataDir),
24908
25296
  locks: createFileLockStore(dataDir),
24909
- acks: createFileAckStore(dataDir)
25297
+ acks: createFileAckStore(dataDir),
25298
+ spend: createFileSpendStore(dataDir)
24910
25299
  };
24911
25300
  }
24912
25301
  //#endregion
@@ -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;
@@ -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
  /**
@@ -572,9 +615,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
572
615
  declare const RunReportDataSchema: z.ZodObject<{
573
616
  schemaVersion: z.ZodLiteral<1>;
574
617
  kind: z.ZodDefault<z.ZodEnum<{
618
+ record: "record";
575
619
  run: "run";
576
620
  drift: "drift";
577
- record: "record";
578
621
  }>>;
579
622
  createdAt: z.ZodString;
580
623
  runId: z.ZodNullable<z.ZodString>;
@@ -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.1",
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.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {