ccqa 1.7.0 → 1.8.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
@@ -789,10 +789,26 @@ async function runPool(items, concurrency, fn) {
789
789
  //#endregion
790
790
  //#region src/drift/auth.ts
791
791
  /**
792
+ * Claude Code can also run against AWS Bedrock / Google Vertex AI, selected by
793
+ * these env toggles. Credentials then come from the cloud SDK's own chain
794
+ * (instance/task roles, gcloud auth, …), so none of the Anthropic-side probes
795
+ * below apply — a set toggle counts as auth being available. ccqa forwards the
796
+ * toggle verbatim; only "0"/"false" (any case) is treated as explicitly off.
797
+ */
798
+ const CLOUD_PROVIDER_ENV_KEYS = ["CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"];
799
+ function cloudProviderEnabled() {
800
+ return CLOUD_PROVIDER_ENV_KEYS.some((key) => {
801
+ const value = process.env[key]?.trim().toLowerCase();
802
+ return value !== void 0 && value !== "" && value !== "0" && value !== "false";
803
+ });
804
+ }
805
+ /**
792
806
  * Probe whether the host has any credential the Anthropic SDK can pick up:
793
807
  * 1. ANTHROPIC_API_KEY env var (CI / scripted use)
794
- * 2. ~/.claude/.credentials.json (Claude Code login, file-based platforms)
795
- * 3. macOS Keychain item "Claude Code-credentials" (Claude Code login on
808
+ * 2. CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX (cloud-provider
809
+ * endpoints authenticated by the cloud SDK's credential chain)
810
+ * 3. ~/.claude/.credentials.json (Claude Code login, file-based platforms)
811
+ * 4. macOS Keychain item "Claude Code-credentials" (Claude Code login on
796
812
  * darwin stores the OAuth credentials in the Keychain, not on disk)
797
813
  *
798
814
  * Claude-driven hooks are opt-in, so the caller only consults this after the
@@ -802,11 +818,12 @@ async function runPool(items, concurrency, fn) {
802
818
  function driftAuthAvailable() {
803
819
  const key = process.env["ANTHROPIC_API_KEY"];
804
820
  if (typeof key === "string" && key.length > 0) return { ok: true };
821
+ if (cloudProviderEnabled()) return { ok: true };
805
822
  if (existsSync(join(homedir(), ".claude", ".credentials.json"))) return { ok: true };
806
823
  if (process.platform === "darwin" && keychainHasClaudeCredentials()) return { ok: true };
807
824
  return {
808
825
  ok: false,
809
- reason: "no ANTHROPIC_API_KEY / claude login"
826
+ reason: "no ANTHROPIC_API_KEY / Bedrock or Vertex env / claude login"
810
827
  };
811
828
  }
812
829
  /**
@@ -2425,22 +2442,22 @@ function toAgentBrowserArgs(action) {
2425
2442
  if (!action.locator) return null;
2426
2443
  return [
2427
2444
  lit("select"),
2428
- lit(locatorToSelector(action.locator)),
2445
+ val(locatorToSelector(action.locator)),
2429
2446
  val(action.value ?? "")
2430
2447
  ];
2431
2448
  case "drag":
2432
2449
  if (!action.locator || !action.target) return null;
2433
2450
  return [
2434
2451
  lit("drag"),
2435
- lit(locatorToSelector(action.locator)),
2436
- lit(locatorToSelector(action.target))
2452
+ val(locatorToSelector(action.locator)),
2453
+ val(locatorToSelector(action.target))
2437
2454
  ];
2438
2455
  case "upload": {
2439
2456
  const files = action.files ?? [];
2440
2457
  if (!action.locator || files.length === 0) return null;
2441
2458
  return [
2442
2459
  lit("upload"),
2443
- lit(locatorToSelector(action.locator)),
2460
+ val(locatorToSelector(action.locator)),
2444
2461
  ...files.map(val)
2445
2462
  ];
2446
2463
  }
@@ -2480,7 +2497,7 @@ function interactionToArgs(action) {
2480
2497
  const abAction = action.action === "type" ? "fill" : action.action;
2481
2498
  const takesInput = action.action === "fill" || action.action === "type";
2482
2499
  if (!(loc.by !== "css" || action.index !== void 0 || action.action === "focus")) {
2483
- const args = [lit(abAction), lit(loc.value)];
2500
+ const args = [lit(abAction), val(loc.value)];
2484
2501
  if (takesInput) args.push(val(action.value ?? ""));
2485
2502
  return args;
2486
2503
  }
@@ -2490,7 +2507,7 @@ function interactionToArgs(action) {
2490
2507
  if (loc.by !== "css") return null;
2491
2508
  if (action.index === "first" || action.index === "last") out.push(lit(action.index));
2492
2509
  else out.push(lit("nth"), lit(String(action.index)));
2493
- out.push(lit(loc.value));
2510
+ out.push(val(loc.value));
2494
2511
  } else {
2495
2512
  if (loc.by === "css") return null;
2496
2513
  out.push(lit(loc.by), val(loc.value));
@@ -3016,6 +3033,7 @@ const ReportSpecResultSchema = z.object({
3016
3033
  assertions: z.array(ReportAssertionSchema).nullable(),
3017
3034
  analysis: FailureAnalysisSchema.nullable(),
3018
3035
  analysisSkipped: z.string().nullable(),
3036
+ customPromptVersion: z.string().optional(),
3019
3037
  analysisBase: z.object({
3020
3038
  ref: z.string(),
3021
3039
  sha: z.string()
@@ -3055,6 +3073,7 @@ const RunReportDataSchema = z.object({
3055
3073
  kind: z.enum(["run", "drift"]).default("run"),
3056
3074
  createdAt: z.string(),
3057
3075
  runId: z.string().nullable(),
3076
+ runUrl: z.string().nullable().optional(),
3058
3077
  git: GitEnvelopeSchema,
3059
3078
  model: z.string().nullable(),
3060
3079
  language: z.string().nullable().default(null),
@@ -3097,14 +3116,54 @@ const LabelsExportSchema = z.object({
3097
3116
  * names and failure signals) lives here and on the hub — never hard-coded
3098
3117
  * into ccqa itself.
3099
3118
  */
3119
+ /**
3120
+ * One per-target overlay: the same learned-note fields as the top-level, minus
3121
+ * `basePromptVersion` (shared across the whole document — the base analysis
3122
+ * prompt is target-agnostic).
3123
+ */
3124
+ const AnalysisCustomPromptOverlaySchema = z.object({
3125
+ customPromptVersion: z.string(),
3126
+ generatedAt: z.string(),
3127
+ guidance: z.string()
3128
+ });
3100
3129
  const AnalysisCustomPromptSchema = z.object({
3101
3130
  schemaVersion: z.literal(1),
3102
3131
  basePromptVersion: z.string(),
3103
3132
  customPromptVersion: z.string(),
3104
3133
  generatedAt: z.string(),
3105
- guidance: z.string()
3134
+ guidance: z.string(),
3135
+ byTarget: z.record(z.string(), AnalysisCustomPromptOverlaySchema).optional()
3106
3136
  });
3107
3137
  /**
3138
+ * Lift one overlay into a standalone single-target `AnalysisCustomPrompt`: the
3139
+ * overlay's own note fields plus the document-wide `schemaVersion` /
3140
+ * `basePromptVersion`, and never a `byTarget` map. Passing the document itself
3141
+ * as the overlay yields the un-scoped top-level note as a clean single prompt.
3142
+ */
3143
+ function overlayAsPrompt(base, overlay) {
3144
+ return {
3145
+ schemaVersion: base.schemaVersion,
3146
+ basePromptVersion: base.basePromptVersion,
3147
+ customPromptVersion: overlay.customPromptVersion,
3148
+ generatedAt: overlay.generatedAt,
3149
+ guidance: overlay.guidance
3150
+ };
3151
+ }
3152
+ /**
3153
+ * The effective single overlay for one target: its `byTarget` entry when it has
3154
+ * usable guidance, else the un-scoped top-level note when THAT has guidance,
3155
+ * else null. The returned value is a plain single-target `AnalysisCustomPrompt`
3156
+ * (no `byTarget`), so every downstream consumer — the prompt block and the
3157
+ * recorded `customPromptVersion` — sees exactly what was injected for the row.
3158
+ */
3159
+ function resolveCustomPromptForTarget(cp, target) {
3160
+ if (!cp) return null;
3161
+ const scoped = cp.byTarget?.[target];
3162
+ if (scoped && scoped.guidance.trim()) return overlayAsPrompt(cp, scoped);
3163
+ if (cp.guidance.trim()) return overlayAsPrompt(cp, cp);
3164
+ return null;
3165
+ }
3166
+ /**
3108
3167
  * Render the custom prompt as a prompt section, or "" when there's nothing to add.
3109
3168
  * Returning "" for the empty/absent case is what keeps the base prompt
3110
3169
  * byte-for-byte identical when no custom prompt is supplied (backward compatibility).
@@ -5694,6 +5753,7 @@ function createFailureAnalysisPass(deps) {
5694
5753
  warnedDiffUnavailable = true;
5695
5754
  info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
5696
5755
  }
5756
+ const customPrompt = resolveCustomPromptForTarget(deps.customPrompt, input.target);
5697
5757
  const fields = {
5698
5758
  analysis: null,
5699
5759
  analysisSkipped: null,
@@ -5733,7 +5793,7 @@ function createFailureAnalysisPass(deps) {
5733
5793
  ...input.artifactsDir ? { artifactsDir: input.artifactsDir } : {},
5734
5794
  ...deps.language ? { outputLanguage: deps.language } : {},
5735
5795
  ...deps.triageUserPrompt ? { triageUserPrompt: deps.triageUserPrompt } : {},
5736
- ...deps.customPrompt ? { customPrompt: deps.customPrompt } : {}
5796
+ ...customPrompt ? { customPrompt } : {}
5737
5797
  }, {
5738
5798
  ...deps.model ? { model: deps.model } : {},
5739
5799
  cwd: deps.cwd,
@@ -5746,7 +5806,8 @@ function createFailureAnalysisPass(deps) {
5746
5806
  printAnalysis(featureName, specName, outcome.analysis);
5747
5807
  return {
5748
5808
  ...fields,
5749
- analysis: outcome.analysis
5809
+ analysis: outcome.analysis,
5810
+ ...customPrompt ? { customPromptVersion: customPrompt.customPromptVersion } : {}
5750
5811
  };
5751
5812
  } };
5752
5813
  }
@@ -5849,6 +5910,7 @@ async function analyzeExternalRows(rows, run) {
5849
5910
  readScript: () => readGeneratedTestSources(ref, deps.cwd),
5850
5911
  failureLog: row.failureLogExcerpt ?? "",
5851
5912
  specYaml: row.specYaml,
5913
+ target: row.target ?? "agent-browser",
5852
5914
  driftIssues,
5853
5915
  artifactsDir: readableArtifactsDir(ref, deps)
5854
5916
  });
@@ -7967,7 +8029,8 @@ function analysisFieldsFor(a, status) {
7967
8029
  analysisSkipped: a.analysisSkipped,
7968
8030
  failureLogExcerpt: a.failureLogExcerpt,
7969
8031
  diffExcerpt: a.diffExcerpt,
7970
- ...a.analysisBase ? { analysisBase: a.analysisBase } : {}
8032
+ ...a.analysisBase ? { analysisBase: a.analysisBase } : {},
8033
+ ...a.customPromptVersion ? { customPromptVersion: a.customPromptVersion } : {}
7971
8034
  };
7972
8035
  if (status === "failed") return { analysisSkipped: ANALYSIS_DISABLED };
7973
8036
  return {};
@@ -8192,6 +8255,7 @@ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts,
8192
8255
  diffExcerpt: null
8193
8256
  };
8194
8257
  if (specDiff.error) info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
8258
+ const customPrompt = resolveCustomPromptForTarget(opts.customPrompt, AGENT_BROWSER_TARGET);
8195
8259
  const outcome = await analyzeFailure({
8196
8260
  liveTranscriptExcerpt: excerpt,
8197
8261
  specYaml: r.specYaml,
@@ -8203,7 +8267,7 @@ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts,
8203
8267
  driftIssues: driftForSpec,
8204
8268
  ...opts.language ? { outputLanguage: opts.language } : {},
8205
8269
  ...opts.triageUserPrompt ? { triageUserPrompt: opts.triageUserPrompt } : {},
8206
- ...opts.customPrompt ? { customPrompt: opts.customPrompt } : {}
8270
+ ...customPrompt ? { customPrompt } : {}
8207
8271
  }, {
8208
8272
  ...opts.model ? { model: opts.model } : {},
8209
8273
  cwd,
@@ -8220,7 +8284,8 @@ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts,
8220
8284
  analysisBase: {
8221
8285
  ref: specDiff.base.ref,
8222
8286
  sha: specDiff.base.sha
8223
- }
8287
+ },
8288
+ ...customPrompt ? { customPromptVersion: customPrompt.customPromptVersion } : {}
8224
8289
  };
8225
8290
  }
8226
8291
  function count(steps, target) {
@@ -8536,27 +8601,27 @@ function actionToLine$1(action) {
8536
8601
  if (val) assertLine = `abAssertNotVisible(${jExpr$1("text=" + val)}, 180_000);`;
8537
8602
  break;
8538
8603
  case "element_visible":
8539
- if (sel) assertLine = `abAssertVisible(${j$1(sel)});`;
8604
+ if (sel) assertLine = `abAssertVisible(${jExpr$1(sel)});`;
8540
8605
  break;
8541
8606
  case "element_not_visible":
8542
- if (sel) assertLine = `abAssertNotVisible(${j$1(sel)});`;
8607
+ if (sel) assertLine = `abAssertNotVisible(${jExpr$1(sel)});`;
8543
8608
  break;
8544
8609
  case "url_contains":
8545
8610
  if (val) assertLine = `abAssertUrl(${jExpr$1(val)});`;
8546
8611
  break;
8547
8612
  case "element_enabled":
8548
8613
  if (isStateSelector(sel)) return tautologicalStateAssertMarker(action, sel);
8549
- if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertEnabled(${j$1(sel)});`;
8614
+ if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertEnabled(${jExpr$1(sel)});`;
8550
8615
  break;
8551
8616
  case "element_disabled":
8552
8617
  if (isStateSelector(sel)) return tautologicalStateAssertMarker(action, sel);
8553
- if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertDisabled(${j$1(sel)});`;
8618
+ if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertDisabled(${jExpr$1(sel)});`;
8554
8619
  break;
8555
8620
  case "element_checked":
8556
- if (sel) assertLine = `abAssertChecked(${j$1(sel)});`;
8621
+ if (sel) assertLine = `abAssertChecked(${jExpr$1(sel)});`;
8557
8622
  break;
8558
8623
  case "element_unchecked":
8559
- if (sel) assertLine = `abAssertUnchecked(${j$1(sel)});`;
8624
+ if (sel) assertLine = `abAssertUnchecked(${jExpr$1(sel)});`;
8560
8625
  break;
8561
8626
  }
8562
8627
  if (comment && assertLine) return `${comment}\n ${assertLine}`;
@@ -8571,8 +8636,9 @@ function actionToLine$1(action) {
8571
8636
  }
8572
8637
  /**
8573
8638
  * Render one argv token into TS source: env-expandable tokens (fill values,
8574
- * URLs, find texts) become template literals via `jExpr`; everything else
8575
- * (command words, flags, raw CSS selectors) is a plain string literal.
8639
+ * URLs, find texts, CSS/selector-engine strings) become template literals via
8640
+ * `jExpr` when they carry a `${VAR}` / `$VAR` ref; command words and flags are
8641
+ * plain string literals.
8576
8642
  */
8577
8643
  function renderToken(token) {
8578
8644
  return token.expandsEnv ? jExpr$1(token.text) : j$1(token.text);
@@ -8605,7 +8671,7 @@ const j$1 = (s) => JSON.stringify(s);
8605
8671
  * emits them as `${process.env.VAR ?? ""}` template-literal substitutions
8606
8672
  * instead of baking the literal `$VAR` string into the script. Used for
8607
8673
  * values that came from a spec or block param: form fills, opened URLs,
8608
- * assertion texts/URLs.
8674
+ * assertion texts/URLs, and selector strings carrying a `${RUN_ID}`-style ref.
8609
8675
  */
8610
8676
  const jExpr$1 = (s) => envRefsToJsExpression(s);
8611
8677
  //#endregion
@@ -9487,6 +9553,9 @@ function emitPlaywrightDraft(input) {
9487
9553
  * Render a locator (plus positional pick) as a Playwright locator expression.
9488
9554
  * Semantic strategies map 1:1 onto the getBy* family; `by: "css"` keeps its
9489
9555
  * raw selector-engine string (locator() accepts `text=...` forms verbatim).
9556
+ * Every locator value — css included — goes through `jExpr`, so a `${VAR}` /
9557
+ * `$VAR` ref in a recorded selector expands to a `process.env` template
9558
+ * literal instead of baking the literal ref text into the selector.
9490
9559
  */
9491
9560
  function locatorToPlaywright(locator, index) {
9492
9561
  let expr;
@@ -9518,7 +9587,7 @@ function locatorToPlaywright(locator, index) {
9518
9587
  expr = `page.getByTestId(${jExpr(locator.value)})`;
9519
9588
  break;
9520
9589
  case "css":
9521
- expr = `page.locator(${j(locator.value)})`;
9590
+ expr = `page.locator(${jExpr(locator.value)})`;
9522
9591
  break;
9523
9592
  }
9524
9593
  if (index === "first") return `${expr}.first()`;
@@ -10061,6 +10130,25 @@ function createIncrementalReport(reportDir, envelope, sink) {
10061
10130
  };
10062
10131
  }
10063
10132
  //#endregion
10133
+ //#region src/run/github-run.ts
10134
+ /**
10135
+ * The GitHub Actions run URL for the current job, built from the standard
10136
+ * Actions environment variables. Returns null unless all three are present,
10137
+ * so nothing is ever invented for a local run — the same "only when in CI"
10138
+ * contract the report envelope's `runId` (GITHUB_RUN_ID) already follows.
10139
+ */
10140
+ function githubRunUrl(env = process.env) {
10141
+ const server = env["GITHUB_SERVER_URL"];
10142
+ const repo = env["GITHUB_REPOSITORY"];
10143
+ const runId = githubRunId(env);
10144
+ if (!server || !repo || !runId) return null;
10145
+ return `${server}/${repo}/actions/runs/${runId}`;
10146
+ }
10147
+ /** The current GitHub Actions run id (GITHUB_RUN_ID); null outside Actions. */
10148
+ function githubRunId(env = process.env) {
10149
+ return env["GITHUB_RUN_ID"] ?? null;
10150
+ }
10151
+ //#endregion
10064
10152
  //#region src/prompts/agent-update.ts
10065
10153
  /**
10066
10154
  * Build the prompts used by `--update-agent-prompt` to refresh
@@ -10582,11 +10670,15 @@ async function executeRun(targets, opts) {
10582
10670
  let hubSink;
10583
10671
  if (hubCtx != null && opts.pushReport) try {
10584
10672
  const branch = await detectBranch(cwd);
10673
+ const ciRunId = githubRunId();
10674
+ const runUrl = githubRunUrl();
10585
10675
  const opened = await hubCtx.hub.openRun({
10586
10676
  project: hubCtx.project,
10587
10677
  ...branch ? { branch } : {},
10588
10678
  ...opts.profile ? { profile: opts.profile } : {},
10589
10679
  ...git.head ? { gitHead: git.head } : {},
10680
+ ...ciRunId ? { ciRunId } : {},
10681
+ ...runUrl ? { runUrl } : {},
10590
10682
  kind: "run"
10591
10683
  });
10592
10684
  hubRunId = opened.id;
@@ -10932,6 +11024,7 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
10932
11024
  readScript: () => readScriptSafe(s.scriptFile),
10933
11025
  failureLog,
10934
11026
  specYaml,
11027
+ target: AGENT_BROWSER_TARGET,
10935
11028
  driftIssues
10936
11029
  });
10937
11030
  results.push({
@@ -10940,6 +11033,7 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
10940
11033
  analysis: fields.analysis,
10941
11034
  analysisSkipped: fields.analysisSkipped,
10942
11035
  ...fields.analysisBase ? { analysisBase: fields.analysisBase } : {},
11036
+ ...fields.customPromptVersion ? { customPromptVersion: fields.customPromptVersion } : {},
10943
11037
  driftIssues,
10944
11038
  failureLogExcerpt: failureLog.length > 0 ? failureLog : null,
10945
11039
  diffExcerpt: fields.diffExcerpt,
@@ -10958,11 +11052,13 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
10958
11052
  */
10959
11053
  function buildReportEnvelope(args) {
10960
11054
  const { git, customPromptVersion, triageUserPromptHash, opts } = args;
11055
+ const runUrl = githubRunUrl();
10961
11056
  return {
10962
11057
  schemaVersion: 1,
10963
11058
  kind: "run",
10964
11059
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
10965
- runId: process.env["GITHUB_RUN_ID"] ?? null,
11060
+ runId: githubRunId(),
11061
+ ...runUrl !== null ? { runUrl } : {},
10966
11062
  git: {
10967
11063
  head: git.head,
10968
11064
  base: git.base?.ref ?? null,
@@ -14427,6 +14523,7 @@ z.object({
14427
14523
  gitHead: z.string().nullable(),
14428
14524
  promptVersion: z.string(),
14429
14525
  ciRunId: z.string().nullable(),
14526
+ runUrl: z.string().nullable().optional(),
14430
14527
  reportCreatedAt: z.string(),
14431
14528
  createdAt: z.string()
14432
14529
  });
@@ -14592,6 +14689,7 @@ function createPushRunHandler(config) {
14592
14689
  gitHead: report.git.head,
14593
14690
  promptVersion: report.promptVersion,
14594
14691
  ciRunId: report.runId,
14692
+ runUrl: report.runUrl ?? null,
14595
14693
  reportCreatedAt: report.createdAt,
14596
14694
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
14597
14695
  };
@@ -14619,6 +14717,8 @@ function createOpenRunHandler(config) {
14619
14717
  return async (ctx) => {
14620
14718
  const { project, branch, profile, kind } = parseRunScope(ctx);
14621
14719
  const gitHead = ctx.url.searchParams.get("gitHead");
14720
+ const ciRunId = ctx.url.searchParams.get("ciRunId");
14721
+ const runUrl = ctx.url.searchParams.get("runUrl");
14622
14722
  const now = (/* @__PURE__ */ new Date()).toISOString();
14623
14723
  const run = {
14624
14724
  id: randomUUID(),
@@ -14635,7 +14735,8 @@ function createOpenRunHandler(config) {
14635
14735
  },
14636
14736
  gitHead: gitHead || null,
14637
14737
  promptVersion: "",
14638
- ciRunId: null,
14738
+ ciRunId: ciRunId || null,
14739
+ runUrl: runUrl || null,
14639
14740
  reportCreatedAt: now,
14640
14741
  createdAt: now
14641
14742
  };
@@ -14654,6 +14755,7 @@ const PatchRunRequestSchema = z.object({
14654
14755
  language: z.string().nullable().optional(),
14655
14756
  promptVersion: z.string().optional(),
14656
14757
  customPromptVersion: z.string().nullable().optional(),
14758
+ runUrl: z.string().nullable().optional(),
14657
14759
  triageUserPromptHash: z.string().optional()
14658
14760
  }).partial().optional()
14659
14761
  });
@@ -14754,6 +14856,7 @@ function createPatchRunHandler(config) {
14754
14856
  ...reportMeta?.language !== void 0 ? { language: reportMeta.language } : {},
14755
14857
  ...reportMeta?.promptVersion !== void 0 ? { promptVersion: reportMeta.promptVersion } : {},
14756
14858
  ...reportMeta?.customPromptVersion !== void 0 ? { customPromptVersion: reportMeta.customPromptVersion } : {},
14859
+ ...reportMeta?.runUrl !== void 0 ? { runUrl: reportMeta.runUrl } : {},
14757
14860
  ...reportMeta?.triageUserPromptHash !== void 0 ? { triageUserPromptHash: reportMeta.triageUserPromptHash } : {}
14758
14861
  };
14759
14862
  const merged = mergeResults(current?.results ?? [], rows);
@@ -15684,6 +15787,9 @@ ${HTML_BODY}
15684
15787
  </body>
15685
15788
  </html>`;
15686
15789
  }
15790
+ function refreshButton(id) {
15791
+ return `<button class="btn ghost sm" id="${id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg> <span data-i18n="common.refresh">Refresh</span></button>`;
15792
+ }
15687
15793
  const HTML_BODY = `
15688
15794
  <div id="login" class="login" hidden>
15689
15795
  <div class="login-card">
@@ -15743,9 +15849,7 @@ const HTML_BODY = `
15743
15849
  <div class="page-bar">
15744
15850
  <h1 data-i18n="projects.title">Projects</h1>
15745
15851
  <div class="spacer"></div>
15746
- <button class="btn ghost sm" id="projects-refresh">
15747
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg> Refresh
15748
- </button>
15852
+ ${refreshButton("projects-refresh")}
15749
15853
  <button class="btn primary sm" id="projects-new">
15750
15854
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg> <span data-i18n="projects.new">New project</span>
15751
15855
  </button>
@@ -15761,9 +15865,7 @@ const HTML_BODY = `
15761
15865
  <div class="page-bar">
15762
15866
  <h1 data-i18n="runs.title">Runs</h1>
15763
15867
  <div class="spacer"></div>
15764
- <button class="btn ghost sm" id="runs-refresh">
15765
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg> Refresh
15766
- </button>
15868
+ ${refreshButton("runs-refresh")}
15767
15869
  </div>
15768
15870
  <div class="content">
15769
15871
  <div class="card" id="runs-card">
@@ -15784,7 +15886,7 @@ const HTML_BODY = `
15784
15886
  <h1 data-i18n="perspectives.title">Perspectives</h1>
15785
15887
  <span class="updated" id="persp-updated"></span>
15786
15888
  <div class="spacer"></div>
15787
- <button class="btn ghost sm" id="persp-refresh" data-i18n="common.refresh">Refresh</button>
15889
+ ${refreshButton("persp-refresh")}
15788
15890
  </div>
15789
15891
  <div class="content">
15790
15892
  <p id="persp-status" class="empty-note" hidden></p>
@@ -15854,7 +15956,7 @@ const HTML_BODY = `
15854
15956
  <div class="page-bar">
15855
15957
  <h1 data-i18n="learning.title">Learning</h1>
15856
15958
  <div class="spacer"></div>
15857
- <button class="btn ghost sm" id="jobs-refresh" data-i18n="common.refresh">Refresh</button>
15959
+ ${refreshButton("jobs-refresh")}
15858
15960
  </div>
15859
15961
  <div class="content">
15860
15962
  <p id="jobs-status" class="empty-note" hidden></p>
@@ -15877,7 +15979,7 @@ const HTML_BODY = `
15877
15979
  <div class="proj-menu" id="sec-profile-menu" role="menu" hidden></div>
15878
15980
  </div>
15879
15981
  <div class="spacer"></div>
15880
- <button class="btn ghost sm" id="sec-load" data-i18n="common.refresh">Refresh</button>
15982
+ ${refreshButton("sec-load")}
15881
15983
  </div>
15882
15984
  <div class="content">
15883
15985
  <div class="scope-note">
@@ -15902,7 +16004,7 @@ const HTML_BODY = `
15902
16004
  <div class="page-bar">
15903
16005
  <h1 data-i18n="prompts.title">Prompts</h1>
15904
16006
  <div class="spacer"></div>
15905
- <button class="btn ghost sm" id="pr-load" data-i18n="common.refresh">Refresh</button>
16007
+ ${refreshButton("pr-load")}
15906
16008
  </div>
15907
16009
  <div class="content">
15908
16010
  <p id="prompts-status" class="empty-note" hidden></p>
@@ -16143,6 +16245,8 @@ const CSS = `
16143
16245
  .subline { margin-top: 3px; }
16144
16246
  .ci-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-family: var(--mono); color: var(--muted); background: var(--surface-3); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; }
16145
16247
  .ci-badge.local { color: var(--muted-2); }
16248
+ a.ci-badge { text-decoration: none; }
16249
+ a.ci-badge:hover { color: var(--fg); border-color: var(--fg-dim); }
16146
16250
 
16147
16251
  .badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px 2px 7px; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500; border: 1px solid transparent; }
16148
16252
  .badge .d { width: 6px; height: 6px; border-radius: 50%; }
@@ -16606,6 +16710,7 @@ const CLIENT_JS = `
16606
16710
  "prompt.runnAgent.hint": "Notes ccqa keeps for itself while generating runn runbooks, refined automatically. Read-only — ccqa regenerates it.",
16607
16711
  "prompt.triageUser.hint": "Rules you write for how failure causes are classified — e.g. which kinds of changes count as a spec change on this project. Applied on every failure analysis.",
16608
16712
  "prompt.customPrompt.hint": "Learned from your triage grades to make ccqa classify failure causes the way you do. Read-only — a learning job creates it.",
16713
+ "prompt.customPrompt.fallback": "Un-scoped (fallback)",
16609
16714
  "prompt.readonly": "read-only",
16610
16715
  "prompt.notSet": "Not set. Type guidance and Save to store it on the hub.",
16611
16716
  "prompt.notSetRo": "Not set yet — ccqa fills this in as it runs.",
@@ -16623,7 +16728,7 @@ const CLIENT_JS = `
16623
16728
  "jobs.failed": "The learning job failed.", "jobs.newCustomPrompt": "New custom prompt:", "jobs.empty": "No learning jobs yet. Grade failing specs on a run, then Learn."
16624
16729
  },
16625
16730
  ja: {
16626
- "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "Perspectives", "nav.secrets": "シークレット",
16731
+ "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "テスト観点", "nav.secrets": "シークレット",
16627
16732
  "nav.prompts": "プロンプト", "nav.learning": "学習",
16628
16733
  "app.project": "プロジェクト", "app.profile": "プロファイル", "app.disconnect": "切断", "app.noProject": "プロジェクト未選択",
16629
16734
  "app.newProfile": "新規プロファイル",
@@ -16666,15 +16771,15 @@ const CLIENT_JS = `
16666
16771
  "learn.cta.desc": "採点した内容をもとに、ccqaが次回から同じように失敗の原因を分類できるよう学習します。",
16667
16772
  "learn.cta.run": "学習",
16668
16773
  "secrets.title": "シークレット", "prompts.title": "プロンプト", "learning.title": "学習",
16669
- "perspectives.title": "Perspectives",
16774
+ "perspectives.title": "テスト観点",
16670
16775
  "perspectives.search": "ケースを検索…",
16671
16776
  "perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
16672
16777
  "perspectives.filter.live": "ライブ", "perspectives.filter.norec": "未recordのみ",
16673
16778
  "perspectives.col.case": "ケース", "perspectives.col.mode": "モード", "perspectives.col.status": "状態",
16674
16779
  "perspectives.noHit": "該当するケースがありません。",
16675
16780
  "perspectives.updated": "最終更新:",
16676
- "perspectives.empty": "まだperspectivesがありません。ccqa perspectives を実行するか、recordすると自動作成されます。",
16677
- "perspectives.loadFailed": "perspectivesの読み込みに失敗しました",
16781
+ "perspectives.empty": "まだテスト観点がありません。ccqa perspectives を実行するか、recordすると自動作成されます。",
16782
+ "perspectives.loadFailed": "テスト観点の読み込みに失敗しました",
16678
16783
  "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
16679
16784
  "perspectives.status.runnable": "実行可能", "perspectives.status.notRecorded": "未record",
16680
16785
  "perspectives.metric.features": "機能", "perspectives.metric.cases": "テストケース",
@@ -16703,6 +16808,7 @@ const CLIENT_JS = `
16703
16808
  "prompt.runnAgent.hint": "runnランブック生成中にccqaが自分用に書き留め、自動で洗練していくメモです。読み取り専用 — ccqaが再生成します。",
16704
16809
  "prompt.triageUser.hint": "失敗原因を分類するときのルールを自分で書きます(例: どの変更をこのプロジェクトで仕様変更として扱うか)。失敗分析のたびに適用されます。",
16705
16810
  "prompt.customPrompt.hint": "あなたの採点から学習し、ccqaがあなたと同じように失敗の原因を分類できるようにします。読み取り専用 — 学習ジョブが生成します。",
16811
+ "prompt.customPrompt.fallback": "共通(フォールバック)",
16706
16812
  "prompt.readonly": "読み取り専用",
16707
16813
  "prompt.notSet": "未設定。指示を入力して保存するとハブに保存されます。",
16708
16814
  "prompt.notSetRo": "未設定 — ccqaが実行しながら自動で書き込みます。",
@@ -16910,9 +17016,21 @@ const CLIENT_JS = `
16910
17016
  }
16911
17017
 
16912
17018
  function ciBadge(run) {
16913
- return run.ciRunId
16914
- ? el("span", "ci-badge", "Actions #" + run.ciRunId)
16915
- : el("span", "ci-badge local", "local run");
17019
+ if (!run.ciRunId) return el("span", "ci-badge local", "local run");
17020
+ var text = "Actions #" + run.ciRunId;
17021
+ // A link to the GitHub Actions run when the URL was recorded; same chip
17022
+ // style otherwise (plain text).
17023
+ if (run.runUrl) {
17024
+ var a = el("a", "ci-badge", text);
17025
+ a.href = run.runUrl;
17026
+ a.target = "_blank";
17027
+ a.rel = "noopener";
17028
+ // The runs-list row is itself clickable (opens the run); opening the CI
17029
+ // link must not also navigate the row.
17030
+ a.addEventListener("click", function (e) { e.stopPropagation(); });
17031
+ return a;
17032
+ }
17033
+ return el("span", "ci-badge", text);
16916
17034
  }
16917
17035
 
16918
17036
  function labelChip(label) {
@@ -18636,13 +18754,29 @@ const CLIENT_JS = `
18636
18754
  }
18637
18755
 
18638
18756
  // The custom prompt body is JSON (schemaVersion/basePromptVersion/customPromptVersion/
18639
- // generatedAt/guidance); the textarea only ever shows the learned guidance
18640
- // text, never the raw JSON. A parse failure falls back to the raw text so a
18641
- // malformed custom prompt still shows something instead of leaving the UI stuck.
18757
+ // generatedAt/guidance, plus an optional per-target byTarget map); the textarea
18758
+ // only ever shows the learned guidance text, never the raw JSON. When byTarget
18759
+ // is present, the slot shows the un-scoped fallback (when it has guidance) plus
18760
+ // each per-target overlay under a short header, so it reflects the whole learned
18761
+ // set. A parse failure falls back to the raw text so a malformed custom prompt
18762
+ // still shows something instead of leaving the UI stuck.
18642
18763
  function customPromptDisplayText(text) {
18643
18764
  if (text == null) return "";
18765
+ var NL = "\\n";
18644
18766
  try {
18645
18767
  var parsed = JSON.parse(text);
18768
+ var byTarget = parsed && parsed.byTarget;
18769
+ if (byTarget && typeof byTarget === "object") {
18770
+ var parts = [];
18771
+ var top = typeof parsed.guidance === "string" ? parsed.guidance.trim() : "";
18772
+ if (top) parts.push("[" + t("prompt.customPrompt.fallback") + "]" + NL + top);
18773
+ Object.keys(byTarget).sort().forEach(function (tg) {
18774
+ var entry = byTarget[tg];
18775
+ var g = entry && typeof entry.guidance === "string" ? entry.guidance.trim() : "";
18776
+ if (g) parts.push("[" + tg + "]" + NL + g);
18777
+ });
18778
+ if (parts.length) return parts.join(NL + NL);
18779
+ }
18646
18780
  return typeof parsed.guidance === "string" ? parsed.guidance : text;
18647
18781
  } catch (e) {
18648
18782
  // A malformed custom prompt shouldn't blank the panel; show the raw body but
@@ -19268,6 +19402,22 @@ function evidenceSignalFor(headline, note) {
19268
19402
  }
19269
19403
  function createLearningWorker(deps) {
19270
19404
  const { storage, invoke = invokeClaudeStreaming, authCheck = driftAuthAvailable } = deps;
19405
+ /**
19406
+ * One Claude call turning a batch of graded cases into a calibration note.
19407
+ * Returns null when nothing usable came back so the caller can drop that
19408
+ * group's overlay without failing the whole job.
19409
+ */
19410
+ const learnGuidance = async (cases) => {
19411
+ const { result, isError } = await invoke({
19412
+ prompt: buildLearningUserPrompt(cases.slice(0, LEARNING_MAX_CASES)),
19413
+ systemPrompt: LEARNING_SYSTEM_PROMPT,
19414
+ allowedTools: [],
19415
+ disableBuiltinTools: true,
19416
+ maxTurns: 1
19417
+ }, () => {});
19418
+ const guidance = result?.trim();
19419
+ return isError || !guidance ? null : guidance;
19420
+ };
19271
19421
  return async function runLearningJob(job) {
19272
19422
  const auth = authCheck();
19273
19423
  if (!auth.ok) throw new Error(`triage learning needs Claude auth on the hub: ${auth.reason}`);
@@ -19285,7 +19435,8 @@ function createLearningWorker(deps) {
19285
19435
  predicted: r.predicted.label,
19286
19436
  actualCause: actual,
19287
19437
  evidenceSignal: evidenceSignalFor(r.predicted.headline, r.note),
19288
- matches: r.predicted.label === actual
19438
+ matches: r.predicted.label === actual,
19439
+ ...r.target ? { target: r.target } : {}
19289
19440
  });
19290
19441
  }
19291
19442
  }
@@ -19294,32 +19445,47 @@ function createLearningWorker(deps) {
19294
19445
  runLimit,
19295
19446
  casesConsidered: cases.length
19296
19447
  } });
19297
- const { result, isError } = await invoke({
19298
- prompt: buildLearningUserPrompt(cases.slice(0, LEARNING_MAX_CASES)),
19299
- systemPrompt: LEARNING_SYSTEM_PROMPT,
19300
- allowedTools: [],
19301
- disableBuiltinTools: true,
19302
- maxTurns: 1
19303
- }, () => {});
19304
- const guidance = result?.trim();
19305
- if (isError || !guidance) throw new Error("triage learning: Claude returned no usable calibration note");
19306
- const prevCustomPrompt = await loadStoredCustomPrompt(storage, job.project);
19448
+ const fallbackCases = [];
19449
+ const targetCases = /* @__PURE__ */ new Map();
19450
+ for (const c of cases) {
19451
+ if (!c.target) {
19452
+ fallbackCases.push(c);
19453
+ continue;
19454
+ }
19455
+ const list = targetCases.get(c.target) ?? [];
19456
+ list.push(c);
19457
+ targetCases.set(c.target, list);
19458
+ }
19307
19459
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
19460
+ const sortedTargets = [...targetCases.keys()].sort((a, b) => a.localeCompare(b));
19461
+ const [fallbackGuidance, ...targetGuidances] = await Promise.all([fallbackCases.length > 0 ? learnGuidance(fallbackCases) : Promise.resolve(null), ...sortedTargets.map((target) => learnGuidance(targetCases.get(target)))]);
19462
+ const byTarget = {};
19463
+ sortedTargets.forEach((target, i) => {
19464
+ const guidance = targetGuidances[i];
19465
+ if (guidance) byTarget[target] = {
19466
+ customPromptVersion: `${generatedAt}-${target}-c${targetCases.get(target).length}`,
19467
+ generatedAt,
19468
+ guidance
19469
+ };
19470
+ });
19471
+ if (!fallbackGuidance && Object.keys(byTarget).length === 0) throw new Error("triage learning: Claude returned no usable calibration note");
19472
+ const prevCustomPrompt = await loadStoredCustomPrompt(storage, job.project);
19308
19473
  const customPrompt = {
19309
19474
  schemaVersion: 1,
19310
19475
  basePromptVersion: "7",
19311
- customPromptVersion: `${generatedAt}-c${cases.length}`,
19476
+ customPromptVersion: `${generatedAt}-c${fallbackCases.length}`,
19312
19477
  generatedAt,
19313
- guidance
19478
+ guidance: fallbackGuidance ?? "",
19479
+ ...Object.keys(byTarget).length > 0 ? { byTarget } : {}
19314
19480
  };
19315
19481
  AnalysisCustomPromptSchema.parse(customPrompt);
19316
19482
  const beforePrompt = buildFailureAnalysisPrompt({
19317
19483
  ...PROMPT_PREVIEW_FIXTURE,
19318
- customPrompt: prevCustomPrompt
19484
+ customPrompt: representativeOverlay(prevCustomPrompt)
19319
19485
  });
19320
19486
  const afterPrompt = buildFailureAnalysisPrompt({
19321
19487
  ...PROMPT_PREVIEW_FIXTURE,
19322
- customPrompt
19488
+ customPrompt: representativeOverlay(customPrompt)
19323
19489
  });
19324
19490
  await storage.prompts.put(job.project, "analysis-custom-prompt", new TextEncoder().encode(JSON.stringify(customPrompt)), {
19325
19491
  customPromptVersion: customPrompt.customPromptVersion,
@@ -19336,6 +19502,19 @@ function createLearningWorker(deps) {
19336
19502
  });
19337
19503
  };
19338
19504
  }
19505
+ /**
19506
+ * A single representative overlay for the before/after prompt preview: the
19507
+ * un-scoped fallback when it has guidance, else the first target overlay by
19508
+ * name, else null. Only the preview uses this — the stored blob keeps every
19509
+ * overlay; run-time injection picks per target (resolveCustomPromptForTarget).
19510
+ */
19511
+ function representativeOverlay(cp) {
19512
+ if (!cp) return null;
19513
+ if (cp.guidance.trim()) return overlayAsPrompt(cp, cp);
19514
+ const firstTarget = cp.byTarget ? Object.keys(cp.byTarget).sort()[0] : void 0;
19515
+ const overlay = firstTarget ? cp.byTarget?.[firstTarget] : void 0;
19516
+ return overlay ? overlayAsPrompt(cp, overlay) : null;
19517
+ }
19339
19518
  /** Read the currently-stored custom prompt, or null when there is none / it's unreadable. */
19340
19519
  async function loadStoredCustomPrompt(storage, project) {
19341
19520
  const entry = await storage.prompts.get(project, "analysis-custom-prompt");
@@ -52,6 +52,7 @@ declare const RunSchema: z.ZodObject<{
52
52
  gitHead: z.ZodNullable<z.ZodString>;
53
53
  promptVersion: z.ZodString;
54
54
  ciRunId: z.ZodNullable<z.ZodString>;
55
+ runUrl: z.ZodOptional<z.ZodNullable<z.ZodString>>;
55
56
  reportCreatedAt: z.ZodString;
56
57
  createdAt: z.ZodString;
57
58
  }, z.core.$strip>;
@@ -217,6 +218,7 @@ declare const ReportSpecResultSchema: z.ZodObject<{
217
218
  reasoning: z.ZodString;
218
219
  }, z.core.$strip>>;
219
220
  analysisSkipped: z.ZodNullable<z.ZodString>;
221
+ customPromptVersion: z.ZodOptional<z.ZodString>;
220
222
  analysisBase: z.ZodOptional<z.ZodNullable<z.ZodObject<{
221
223
  ref: z.ZodString;
222
224
  sha: z.ZodString;
@@ -319,6 +321,7 @@ declare const RunReportDataSchema: z.ZodObject<{
319
321
  }>>;
320
322
  createdAt: z.ZodString;
321
323
  runId: z.ZodNullable<z.ZodString>;
324
+ runUrl: z.ZodOptional<z.ZodNullable<z.ZodString>>;
322
325
  git: z.ZodObject<{
323
326
  head: z.ZodNullable<z.ZodString>;
324
327
  base: z.ZodNullable<z.ZodString>;
@@ -384,6 +387,7 @@ declare const RunReportDataSchema: z.ZodObject<{
384
387
  reasoning: z.ZodString;
385
388
  }, z.core.$strip>>;
386
389
  analysisSkipped: z.ZodNullable<z.ZodString>;
390
+ customPromptVersion: z.ZodOptional<z.ZodString>;
387
391
  analysisBase: z.ZodOptional<z.ZodNullable<z.ZodObject<{
388
392
  ref: z.ZodString;
389
393
  sha: z.ZodString;
@@ -584,6 +588,10 @@ interface HubClient {
584
588
  profile?: string;
585
589
  kind?: "run" | "drift";
586
590
  gitHead?: string;
591
+ /** CI run id (GITHUB_RUN_ID) and its run URL, stamped at open time so an
592
+ * interrupted incremental run still links back to its CI run. */
593
+ ciRunId?: string;
594
+ runUrl?: string;
587
595
  }): Promise<Run>;
588
596
  /** Add finished spec rows (+ evidence) to a running run; `done` closes it. */
589
597
  patchRun(id: string, body: PatchRunRequest): Promise<Run>;
@@ -110,6 +110,8 @@ function createHubClient(opts) {
110
110
  if (meta.profile) params.set("profile", meta.profile);
111
111
  if (meta.kind) params.set("kind", meta.kind);
112
112
  if (meta.gitHead) params.set("gitHead", meta.gitHead);
113
+ if (meta.ciRunId) params.set("ciRunId", meta.ciRunId);
114
+ if (meta.runUrl) params.set("runUrl", meta.runUrl);
113
115
  return json(`/api/v1/runs/open?${params}`, { method: "POST" });
114
116
  },
115
117
  patchRun(id, body) {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.7.0",
3
+ "version": "1.8.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.7.0",
3
+ "version": "1.8.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {