ccqa 1.7.0 → 1.8.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
@@ -2425,22 +2425,22 @@ function toAgentBrowserArgs(action) {
2425
2425
  if (!action.locator) return null;
2426
2426
  return [
2427
2427
  lit("select"),
2428
- lit(locatorToSelector(action.locator)),
2428
+ val(locatorToSelector(action.locator)),
2429
2429
  val(action.value ?? "")
2430
2430
  ];
2431
2431
  case "drag":
2432
2432
  if (!action.locator || !action.target) return null;
2433
2433
  return [
2434
2434
  lit("drag"),
2435
- lit(locatorToSelector(action.locator)),
2436
- lit(locatorToSelector(action.target))
2435
+ val(locatorToSelector(action.locator)),
2436
+ val(locatorToSelector(action.target))
2437
2437
  ];
2438
2438
  case "upload": {
2439
2439
  const files = action.files ?? [];
2440
2440
  if (!action.locator || files.length === 0) return null;
2441
2441
  return [
2442
2442
  lit("upload"),
2443
- lit(locatorToSelector(action.locator)),
2443
+ val(locatorToSelector(action.locator)),
2444
2444
  ...files.map(val)
2445
2445
  ];
2446
2446
  }
@@ -2480,7 +2480,7 @@ function interactionToArgs(action) {
2480
2480
  const abAction = action.action === "type" ? "fill" : action.action;
2481
2481
  const takesInput = action.action === "fill" || action.action === "type";
2482
2482
  if (!(loc.by !== "css" || action.index !== void 0 || action.action === "focus")) {
2483
- const args = [lit(abAction), lit(loc.value)];
2483
+ const args = [lit(abAction), val(loc.value)];
2484
2484
  if (takesInput) args.push(val(action.value ?? ""));
2485
2485
  return args;
2486
2486
  }
@@ -2490,7 +2490,7 @@ function interactionToArgs(action) {
2490
2490
  if (loc.by !== "css") return null;
2491
2491
  if (action.index === "first" || action.index === "last") out.push(lit(action.index));
2492
2492
  else out.push(lit("nth"), lit(String(action.index)));
2493
- out.push(lit(loc.value));
2493
+ out.push(val(loc.value));
2494
2494
  } else {
2495
2495
  if (loc.by === "css") return null;
2496
2496
  out.push(lit(loc.by), val(loc.value));
@@ -3016,6 +3016,7 @@ const ReportSpecResultSchema = z.object({
3016
3016
  assertions: z.array(ReportAssertionSchema).nullable(),
3017
3017
  analysis: FailureAnalysisSchema.nullable(),
3018
3018
  analysisSkipped: z.string().nullable(),
3019
+ customPromptVersion: z.string().optional(),
3019
3020
  analysisBase: z.object({
3020
3021
  ref: z.string(),
3021
3022
  sha: z.string()
@@ -3055,6 +3056,7 @@ const RunReportDataSchema = z.object({
3055
3056
  kind: z.enum(["run", "drift"]).default("run"),
3056
3057
  createdAt: z.string(),
3057
3058
  runId: z.string().nullable(),
3059
+ runUrl: z.string().nullable().optional(),
3058
3060
  git: GitEnvelopeSchema,
3059
3061
  model: z.string().nullable(),
3060
3062
  language: z.string().nullable().default(null),
@@ -3097,14 +3099,54 @@ const LabelsExportSchema = z.object({
3097
3099
  * names and failure signals) lives here and on the hub — never hard-coded
3098
3100
  * into ccqa itself.
3099
3101
  */
3102
+ /**
3103
+ * One per-target overlay: the same learned-note fields as the top-level, minus
3104
+ * `basePromptVersion` (shared across the whole document — the base analysis
3105
+ * prompt is target-agnostic).
3106
+ */
3107
+ const AnalysisCustomPromptOverlaySchema = z.object({
3108
+ customPromptVersion: z.string(),
3109
+ generatedAt: z.string(),
3110
+ guidance: z.string()
3111
+ });
3100
3112
  const AnalysisCustomPromptSchema = z.object({
3101
3113
  schemaVersion: z.literal(1),
3102
3114
  basePromptVersion: z.string(),
3103
3115
  customPromptVersion: z.string(),
3104
3116
  generatedAt: z.string(),
3105
- guidance: z.string()
3117
+ guidance: z.string(),
3118
+ byTarget: z.record(z.string(), AnalysisCustomPromptOverlaySchema).optional()
3106
3119
  });
3107
3120
  /**
3121
+ * Lift one overlay into a standalone single-target `AnalysisCustomPrompt`: the
3122
+ * overlay's own note fields plus the document-wide `schemaVersion` /
3123
+ * `basePromptVersion`, and never a `byTarget` map. Passing the document itself
3124
+ * as the overlay yields the un-scoped top-level note as a clean single prompt.
3125
+ */
3126
+ function overlayAsPrompt(base, overlay) {
3127
+ return {
3128
+ schemaVersion: base.schemaVersion,
3129
+ basePromptVersion: base.basePromptVersion,
3130
+ customPromptVersion: overlay.customPromptVersion,
3131
+ generatedAt: overlay.generatedAt,
3132
+ guidance: overlay.guidance
3133
+ };
3134
+ }
3135
+ /**
3136
+ * The effective single overlay for one target: its `byTarget` entry when it has
3137
+ * usable guidance, else the un-scoped top-level note when THAT has guidance,
3138
+ * else null. The returned value is a plain single-target `AnalysisCustomPrompt`
3139
+ * (no `byTarget`), so every downstream consumer — the prompt block and the
3140
+ * recorded `customPromptVersion` — sees exactly what was injected for the row.
3141
+ */
3142
+ function resolveCustomPromptForTarget(cp, target) {
3143
+ if (!cp) return null;
3144
+ const scoped = cp.byTarget?.[target];
3145
+ if (scoped && scoped.guidance.trim()) return overlayAsPrompt(cp, scoped);
3146
+ if (cp.guidance.trim()) return overlayAsPrompt(cp, cp);
3147
+ return null;
3148
+ }
3149
+ /**
3108
3150
  * Render the custom prompt as a prompt section, or "" when there's nothing to add.
3109
3151
  * Returning "" for the empty/absent case is what keeps the base prompt
3110
3152
  * byte-for-byte identical when no custom prompt is supplied (backward compatibility).
@@ -5694,6 +5736,7 @@ function createFailureAnalysisPass(deps) {
5694
5736
  warnedDiffUnavailable = true;
5695
5737
  info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
5696
5738
  }
5739
+ const customPrompt = resolveCustomPromptForTarget(deps.customPrompt, input.target);
5697
5740
  const fields = {
5698
5741
  analysis: null,
5699
5742
  analysisSkipped: null,
@@ -5733,7 +5776,7 @@ function createFailureAnalysisPass(deps) {
5733
5776
  ...input.artifactsDir ? { artifactsDir: input.artifactsDir } : {},
5734
5777
  ...deps.language ? { outputLanguage: deps.language } : {},
5735
5778
  ...deps.triageUserPrompt ? { triageUserPrompt: deps.triageUserPrompt } : {},
5736
- ...deps.customPrompt ? { customPrompt: deps.customPrompt } : {}
5779
+ ...customPrompt ? { customPrompt } : {}
5737
5780
  }, {
5738
5781
  ...deps.model ? { model: deps.model } : {},
5739
5782
  cwd: deps.cwd,
@@ -5746,7 +5789,8 @@ function createFailureAnalysisPass(deps) {
5746
5789
  printAnalysis(featureName, specName, outcome.analysis);
5747
5790
  return {
5748
5791
  ...fields,
5749
- analysis: outcome.analysis
5792
+ analysis: outcome.analysis,
5793
+ ...customPrompt ? { customPromptVersion: customPrompt.customPromptVersion } : {}
5750
5794
  };
5751
5795
  } };
5752
5796
  }
@@ -5849,6 +5893,7 @@ async function analyzeExternalRows(rows, run) {
5849
5893
  readScript: () => readGeneratedTestSources(ref, deps.cwd),
5850
5894
  failureLog: row.failureLogExcerpt ?? "",
5851
5895
  specYaml: row.specYaml,
5896
+ target: row.target ?? "agent-browser",
5852
5897
  driftIssues,
5853
5898
  artifactsDir: readableArtifactsDir(ref, deps)
5854
5899
  });
@@ -7967,7 +8012,8 @@ function analysisFieldsFor(a, status) {
7967
8012
  analysisSkipped: a.analysisSkipped,
7968
8013
  failureLogExcerpt: a.failureLogExcerpt,
7969
8014
  diffExcerpt: a.diffExcerpt,
7970
- ...a.analysisBase ? { analysisBase: a.analysisBase } : {}
8015
+ ...a.analysisBase ? { analysisBase: a.analysisBase } : {},
8016
+ ...a.customPromptVersion ? { customPromptVersion: a.customPromptVersion } : {}
7971
8017
  };
7972
8018
  if (status === "failed") return { analysisSkipped: ANALYSIS_DISABLED };
7973
8019
  return {};
@@ -8192,6 +8238,7 @@ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts,
8192
8238
  diffExcerpt: null
8193
8239
  };
8194
8240
  if (specDiff.error) info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
8241
+ const customPrompt = resolveCustomPromptForTarget(opts.customPrompt, AGENT_BROWSER_TARGET);
8195
8242
  const outcome = await analyzeFailure({
8196
8243
  liveTranscriptExcerpt: excerpt,
8197
8244
  specYaml: r.specYaml,
@@ -8203,7 +8250,7 @@ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts,
8203
8250
  driftIssues: driftForSpec,
8204
8251
  ...opts.language ? { outputLanguage: opts.language } : {},
8205
8252
  ...opts.triageUserPrompt ? { triageUserPrompt: opts.triageUserPrompt } : {},
8206
- ...opts.customPrompt ? { customPrompt: opts.customPrompt } : {}
8253
+ ...customPrompt ? { customPrompt } : {}
8207
8254
  }, {
8208
8255
  ...opts.model ? { model: opts.model } : {},
8209
8256
  cwd,
@@ -8220,7 +8267,8 @@ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts,
8220
8267
  analysisBase: {
8221
8268
  ref: specDiff.base.ref,
8222
8269
  sha: specDiff.base.sha
8223
- }
8270
+ },
8271
+ ...customPrompt ? { customPromptVersion: customPrompt.customPromptVersion } : {}
8224
8272
  };
8225
8273
  }
8226
8274
  function count(steps, target) {
@@ -8536,27 +8584,27 @@ function actionToLine$1(action) {
8536
8584
  if (val) assertLine = `abAssertNotVisible(${jExpr$1("text=" + val)}, 180_000);`;
8537
8585
  break;
8538
8586
  case "element_visible":
8539
- if (sel) assertLine = `abAssertVisible(${j$1(sel)});`;
8587
+ if (sel) assertLine = `abAssertVisible(${jExpr$1(sel)});`;
8540
8588
  break;
8541
8589
  case "element_not_visible":
8542
- if (sel) assertLine = `abAssertNotVisible(${j$1(sel)});`;
8590
+ if (sel) assertLine = `abAssertNotVisible(${jExpr$1(sel)});`;
8543
8591
  break;
8544
8592
  case "url_contains":
8545
8593
  if (val) assertLine = `abAssertUrl(${jExpr$1(val)});`;
8546
8594
  break;
8547
8595
  case "element_enabled":
8548
8596
  if (isStateSelector(sel)) return tautologicalStateAssertMarker(action, sel);
8549
- if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertEnabled(${j$1(sel)});`;
8597
+ if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertEnabled(${jExpr$1(sel)});`;
8550
8598
  break;
8551
8599
  case "element_disabled":
8552
8600
  if (isStateSelector(sel)) return tautologicalStateAssertMarker(action, sel);
8553
- if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertDisabled(${j$1(sel)});`;
8601
+ if (sel && !sel.startsWith("text=") && !sel.startsWith("[aria-label=")) assertLine = `abAssertDisabled(${jExpr$1(sel)});`;
8554
8602
  break;
8555
8603
  case "element_checked":
8556
- if (sel) assertLine = `abAssertChecked(${j$1(sel)});`;
8604
+ if (sel) assertLine = `abAssertChecked(${jExpr$1(sel)});`;
8557
8605
  break;
8558
8606
  case "element_unchecked":
8559
- if (sel) assertLine = `abAssertUnchecked(${j$1(sel)});`;
8607
+ if (sel) assertLine = `abAssertUnchecked(${jExpr$1(sel)});`;
8560
8608
  break;
8561
8609
  }
8562
8610
  if (comment && assertLine) return `${comment}\n ${assertLine}`;
@@ -8571,8 +8619,9 @@ function actionToLine$1(action) {
8571
8619
  }
8572
8620
  /**
8573
8621
  * 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.
8622
+ * URLs, find texts, CSS/selector-engine strings) become template literals via
8623
+ * `jExpr` when they carry a `${VAR}` / `$VAR` ref; command words and flags are
8624
+ * plain string literals.
8576
8625
  */
8577
8626
  function renderToken(token) {
8578
8627
  return token.expandsEnv ? jExpr$1(token.text) : j$1(token.text);
@@ -8605,7 +8654,7 @@ const j$1 = (s) => JSON.stringify(s);
8605
8654
  * emits them as `${process.env.VAR ?? ""}` template-literal substitutions
8606
8655
  * instead of baking the literal `$VAR` string into the script. Used for
8607
8656
  * values that came from a spec or block param: form fills, opened URLs,
8608
- * assertion texts/URLs.
8657
+ * assertion texts/URLs, and selector strings carrying a `${RUN_ID}`-style ref.
8609
8658
  */
8610
8659
  const jExpr$1 = (s) => envRefsToJsExpression(s);
8611
8660
  //#endregion
@@ -9487,6 +9536,9 @@ function emitPlaywrightDraft(input) {
9487
9536
  * Render a locator (plus positional pick) as a Playwright locator expression.
9488
9537
  * Semantic strategies map 1:1 onto the getBy* family; `by: "css"` keeps its
9489
9538
  * raw selector-engine string (locator() accepts `text=...` forms verbatim).
9539
+ * Every locator value — css included — goes through `jExpr`, so a `${VAR}` /
9540
+ * `$VAR` ref in a recorded selector expands to a `process.env` template
9541
+ * literal instead of baking the literal ref text into the selector.
9490
9542
  */
9491
9543
  function locatorToPlaywright(locator, index) {
9492
9544
  let expr;
@@ -9518,7 +9570,7 @@ function locatorToPlaywright(locator, index) {
9518
9570
  expr = `page.getByTestId(${jExpr(locator.value)})`;
9519
9571
  break;
9520
9572
  case "css":
9521
- expr = `page.locator(${j(locator.value)})`;
9573
+ expr = `page.locator(${jExpr(locator.value)})`;
9522
9574
  break;
9523
9575
  }
9524
9576
  if (index === "first") return `${expr}.first()`;
@@ -10061,6 +10113,25 @@ function createIncrementalReport(reportDir, envelope, sink) {
10061
10113
  };
10062
10114
  }
10063
10115
  //#endregion
10116
+ //#region src/run/github-run.ts
10117
+ /**
10118
+ * The GitHub Actions run URL for the current job, built from the standard
10119
+ * Actions environment variables. Returns null unless all three are present,
10120
+ * so nothing is ever invented for a local run — the same "only when in CI"
10121
+ * contract the report envelope's `runId` (GITHUB_RUN_ID) already follows.
10122
+ */
10123
+ function githubRunUrl(env = process.env) {
10124
+ const server = env["GITHUB_SERVER_URL"];
10125
+ const repo = env["GITHUB_REPOSITORY"];
10126
+ const runId = githubRunId(env);
10127
+ if (!server || !repo || !runId) return null;
10128
+ return `${server}/${repo}/actions/runs/${runId}`;
10129
+ }
10130
+ /** The current GitHub Actions run id (GITHUB_RUN_ID); null outside Actions. */
10131
+ function githubRunId(env = process.env) {
10132
+ return env["GITHUB_RUN_ID"] ?? null;
10133
+ }
10134
+ //#endregion
10064
10135
  //#region src/prompts/agent-update.ts
10065
10136
  /**
10066
10137
  * Build the prompts used by `--update-agent-prompt` to refresh
@@ -10582,11 +10653,15 @@ async function executeRun(targets, opts) {
10582
10653
  let hubSink;
10583
10654
  if (hubCtx != null && opts.pushReport) try {
10584
10655
  const branch = await detectBranch(cwd);
10656
+ const ciRunId = githubRunId();
10657
+ const runUrl = githubRunUrl();
10585
10658
  const opened = await hubCtx.hub.openRun({
10586
10659
  project: hubCtx.project,
10587
10660
  ...branch ? { branch } : {},
10588
10661
  ...opts.profile ? { profile: opts.profile } : {},
10589
10662
  ...git.head ? { gitHead: git.head } : {},
10663
+ ...ciRunId ? { ciRunId } : {},
10664
+ ...runUrl ? { runUrl } : {},
10590
10665
  kind: "run"
10591
10666
  });
10592
10667
  hubRunId = opened.id;
@@ -10932,6 +11007,7 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
10932
11007
  readScript: () => readScriptSafe(s.scriptFile),
10933
11008
  failureLog,
10934
11009
  specYaml,
11010
+ target: AGENT_BROWSER_TARGET,
10935
11011
  driftIssues
10936
11012
  });
10937
11013
  results.push({
@@ -10940,6 +11016,7 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
10940
11016
  analysis: fields.analysis,
10941
11017
  analysisSkipped: fields.analysisSkipped,
10942
11018
  ...fields.analysisBase ? { analysisBase: fields.analysisBase } : {},
11019
+ ...fields.customPromptVersion ? { customPromptVersion: fields.customPromptVersion } : {},
10943
11020
  driftIssues,
10944
11021
  failureLogExcerpt: failureLog.length > 0 ? failureLog : null,
10945
11022
  diffExcerpt: fields.diffExcerpt,
@@ -10958,11 +11035,13 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
10958
11035
  */
10959
11036
  function buildReportEnvelope(args) {
10960
11037
  const { git, customPromptVersion, triageUserPromptHash, opts } = args;
11038
+ const runUrl = githubRunUrl();
10961
11039
  return {
10962
11040
  schemaVersion: 1,
10963
11041
  kind: "run",
10964
11042
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
10965
- runId: process.env["GITHUB_RUN_ID"] ?? null,
11043
+ runId: githubRunId(),
11044
+ ...runUrl !== null ? { runUrl } : {},
10966
11045
  git: {
10967
11046
  head: git.head,
10968
11047
  base: git.base?.ref ?? null,
@@ -14427,6 +14506,7 @@ z.object({
14427
14506
  gitHead: z.string().nullable(),
14428
14507
  promptVersion: z.string(),
14429
14508
  ciRunId: z.string().nullable(),
14509
+ runUrl: z.string().nullable().optional(),
14430
14510
  reportCreatedAt: z.string(),
14431
14511
  createdAt: z.string()
14432
14512
  });
@@ -14592,6 +14672,7 @@ function createPushRunHandler(config) {
14592
14672
  gitHead: report.git.head,
14593
14673
  promptVersion: report.promptVersion,
14594
14674
  ciRunId: report.runId,
14675
+ runUrl: report.runUrl ?? null,
14595
14676
  reportCreatedAt: report.createdAt,
14596
14677
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
14597
14678
  };
@@ -14619,6 +14700,8 @@ function createOpenRunHandler(config) {
14619
14700
  return async (ctx) => {
14620
14701
  const { project, branch, profile, kind } = parseRunScope(ctx);
14621
14702
  const gitHead = ctx.url.searchParams.get("gitHead");
14703
+ const ciRunId = ctx.url.searchParams.get("ciRunId");
14704
+ const runUrl = ctx.url.searchParams.get("runUrl");
14622
14705
  const now = (/* @__PURE__ */ new Date()).toISOString();
14623
14706
  const run = {
14624
14707
  id: randomUUID(),
@@ -14635,7 +14718,8 @@ function createOpenRunHandler(config) {
14635
14718
  },
14636
14719
  gitHead: gitHead || null,
14637
14720
  promptVersion: "",
14638
- ciRunId: null,
14721
+ ciRunId: ciRunId || null,
14722
+ runUrl: runUrl || null,
14639
14723
  reportCreatedAt: now,
14640
14724
  createdAt: now
14641
14725
  };
@@ -14654,6 +14738,7 @@ const PatchRunRequestSchema = z.object({
14654
14738
  language: z.string().nullable().optional(),
14655
14739
  promptVersion: z.string().optional(),
14656
14740
  customPromptVersion: z.string().nullable().optional(),
14741
+ runUrl: z.string().nullable().optional(),
14657
14742
  triageUserPromptHash: z.string().optional()
14658
14743
  }).partial().optional()
14659
14744
  });
@@ -14754,6 +14839,7 @@ function createPatchRunHandler(config) {
14754
14839
  ...reportMeta?.language !== void 0 ? { language: reportMeta.language } : {},
14755
14840
  ...reportMeta?.promptVersion !== void 0 ? { promptVersion: reportMeta.promptVersion } : {},
14756
14841
  ...reportMeta?.customPromptVersion !== void 0 ? { customPromptVersion: reportMeta.customPromptVersion } : {},
14842
+ ...reportMeta?.runUrl !== void 0 ? { runUrl: reportMeta.runUrl } : {},
14757
14843
  ...reportMeta?.triageUserPromptHash !== void 0 ? { triageUserPromptHash: reportMeta.triageUserPromptHash } : {}
14758
14844
  };
14759
14845
  const merged = mergeResults(current?.results ?? [], rows);
@@ -15684,6 +15770,9 @@ ${HTML_BODY}
15684
15770
  </body>
15685
15771
  </html>`;
15686
15772
  }
15773
+ function refreshButton(id) {
15774
+ 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>`;
15775
+ }
15687
15776
  const HTML_BODY = `
15688
15777
  <div id="login" class="login" hidden>
15689
15778
  <div class="login-card">
@@ -15743,9 +15832,7 @@ const HTML_BODY = `
15743
15832
  <div class="page-bar">
15744
15833
  <h1 data-i18n="projects.title">Projects</h1>
15745
15834
  <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>
15835
+ ${refreshButton("projects-refresh")}
15749
15836
  <button class="btn primary sm" id="projects-new">
15750
15837
  <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
15838
  </button>
@@ -15761,9 +15848,7 @@ const HTML_BODY = `
15761
15848
  <div class="page-bar">
15762
15849
  <h1 data-i18n="runs.title">Runs</h1>
15763
15850
  <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>
15851
+ ${refreshButton("runs-refresh")}
15767
15852
  </div>
15768
15853
  <div class="content">
15769
15854
  <div class="card" id="runs-card">
@@ -15784,7 +15869,7 @@ const HTML_BODY = `
15784
15869
  <h1 data-i18n="perspectives.title">Perspectives</h1>
15785
15870
  <span class="updated" id="persp-updated"></span>
15786
15871
  <div class="spacer"></div>
15787
- <button class="btn ghost sm" id="persp-refresh" data-i18n="common.refresh">Refresh</button>
15872
+ ${refreshButton("persp-refresh")}
15788
15873
  </div>
15789
15874
  <div class="content">
15790
15875
  <p id="persp-status" class="empty-note" hidden></p>
@@ -15854,7 +15939,7 @@ const HTML_BODY = `
15854
15939
  <div class="page-bar">
15855
15940
  <h1 data-i18n="learning.title">Learning</h1>
15856
15941
  <div class="spacer"></div>
15857
- <button class="btn ghost sm" id="jobs-refresh" data-i18n="common.refresh">Refresh</button>
15942
+ ${refreshButton("jobs-refresh")}
15858
15943
  </div>
15859
15944
  <div class="content">
15860
15945
  <p id="jobs-status" class="empty-note" hidden></p>
@@ -15877,7 +15962,7 @@ const HTML_BODY = `
15877
15962
  <div class="proj-menu" id="sec-profile-menu" role="menu" hidden></div>
15878
15963
  </div>
15879
15964
  <div class="spacer"></div>
15880
- <button class="btn ghost sm" id="sec-load" data-i18n="common.refresh">Refresh</button>
15965
+ ${refreshButton("sec-load")}
15881
15966
  </div>
15882
15967
  <div class="content">
15883
15968
  <div class="scope-note">
@@ -15902,7 +15987,7 @@ const HTML_BODY = `
15902
15987
  <div class="page-bar">
15903
15988
  <h1 data-i18n="prompts.title">Prompts</h1>
15904
15989
  <div class="spacer"></div>
15905
- <button class="btn ghost sm" id="pr-load" data-i18n="common.refresh">Refresh</button>
15990
+ ${refreshButton("pr-load")}
15906
15991
  </div>
15907
15992
  <div class="content">
15908
15993
  <p id="prompts-status" class="empty-note" hidden></p>
@@ -16143,6 +16228,8 @@ const CSS = `
16143
16228
  .subline { margin-top: 3px; }
16144
16229
  .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
16230
  .ci-badge.local { color: var(--muted-2); }
16231
+ a.ci-badge { text-decoration: none; }
16232
+ a.ci-badge:hover { color: var(--fg); border-color: var(--fg-dim); }
16146
16233
 
16147
16234
  .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
16235
  .badge .d { width: 6px; height: 6px; border-radius: 50%; }
@@ -16606,6 +16693,7 @@ const CLIENT_JS = `
16606
16693
  "prompt.runnAgent.hint": "Notes ccqa keeps for itself while generating runn runbooks, refined automatically. Read-only — ccqa regenerates it.",
16607
16694
  "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
16695
  "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.",
16696
+ "prompt.customPrompt.fallback": "Un-scoped (fallback)",
16609
16697
  "prompt.readonly": "read-only",
16610
16698
  "prompt.notSet": "Not set. Type guidance and Save to store it on the hub.",
16611
16699
  "prompt.notSetRo": "Not set yet — ccqa fills this in as it runs.",
@@ -16623,7 +16711,7 @@ const CLIENT_JS = `
16623
16711
  "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
16712
  },
16625
16713
  ja: {
16626
- "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "Perspectives", "nav.secrets": "シークレット",
16714
+ "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "テスト観点", "nav.secrets": "シークレット",
16627
16715
  "nav.prompts": "プロンプト", "nav.learning": "学習",
16628
16716
  "app.project": "プロジェクト", "app.profile": "プロファイル", "app.disconnect": "切断", "app.noProject": "プロジェクト未選択",
16629
16717
  "app.newProfile": "新規プロファイル",
@@ -16666,15 +16754,15 @@ const CLIENT_JS = `
16666
16754
  "learn.cta.desc": "採点した内容をもとに、ccqaが次回から同じように失敗の原因を分類できるよう学習します。",
16667
16755
  "learn.cta.run": "学習",
16668
16756
  "secrets.title": "シークレット", "prompts.title": "プロンプト", "learning.title": "学習",
16669
- "perspectives.title": "Perspectives",
16757
+ "perspectives.title": "テスト観点",
16670
16758
  "perspectives.search": "ケースを検索…",
16671
16759
  "perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
16672
16760
  "perspectives.filter.live": "ライブ", "perspectives.filter.norec": "未recordのみ",
16673
16761
  "perspectives.col.case": "ケース", "perspectives.col.mode": "モード", "perspectives.col.status": "状態",
16674
16762
  "perspectives.noHit": "該当するケースがありません。",
16675
16763
  "perspectives.updated": "最終更新:",
16676
- "perspectives.empty": "まだperspectivesがありません。ccqa perspectives を実行するか、recordすると自動作成されます。",
16677
- "perspectives.loadFailed": "perspectivesの読み込みに失敗しました",
16764
+ "perspectives.empty": "まだテスト観点がありません。ccqa perspectives を実行するか、recordすると自動作成されます。",
16765
+ "perspectives.loadFailed": "テスト観点の読み込みに失敗しました",
16678
16766
  "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
16679
16767
  "perspectives.status.runnable": "実行可能", "perspectives.status.notRecorded": "未record",
16680
16768
  "perspectives.metric.features": "機能", "perspectives.metric.cases": "テストケース",
@@ -16703,6 +16791,7 @@ const CLIENT_JS = `
16703
16791
  "prompt.runnAgent.hint": "runnランブック生成中にccqaが自分用に書き留め、自動で洗練していくメモです。読み取り専用 — ccqaが再生成します。",
16704
16792
  "prompt.triageUser.hint": "失敗原因を分類するときのルールを自分で書きます(例: どの変更をこのプロジェクトで仕様変更として扱うか)。失敗分析のたびに適用されます。",
16705
16793
  "prompt.customPrompt.hint": "あなたの採点から学習し、ccqaがあなたと同じように失敗の原因を分類できるようにします。読み取り専用 — 学習ジョブが生成します。",
16794
+ "prompt.customPrompt.fallback": "共通(フォールバック)",
16706
16795
  "prompt.readonly": "読み取り専用",
16707
16796
  "prompt.notSet": "未設定。指示を入力して保存するとハブに保存されます。",
16708
16797
  "prompt.notSetRo": "未設定 — ccqaが実行しながら自動で書き込みます。",
@@ -16910,9 +16999,21 @@ const CLIENT_JS = `
16910
16999
  }
16911
17000
 
16912
17001
  function ciBadge(run) {
16913
- return run.ciRunId
16914
- ? el("span", "ci-badge", "Actions #" + run.ciRunId)
16915
- : el("span", "ci-badge local", "local run");
17002
+ if (!run.ciRunId) return el("span", "ci-badge local", "local run");
17003
+ var text = "Actions #" + run.ciRunId;
17004
+ // A link to the GitHub Actions run when the URL was recorded; same chip
17005
+ // style otherwise (plain text).
17006
+ if (run.runUrl) {
17007
+ var a = el("a", "ci-badge", text);
17008
+ a.href = run.runUrl;
17009
+ a.target = "_blank";
17010
+ a.rel = "noopener";
17011
+ // The runs-list row is itself clickable (opens the run); opening the CI
17012
+ // link must not also navigate the row.
17013
+ a.addEventListener("click", function (e) { e.stopPropagation(); });
17014
+ return a;
17015
+ }
17016
+ return el("span", "ci-badge", text);
16916
17017
  }
16917
17018
 
16918
17019
  function labelChip(label) {
@@ -18636,13 +18737,29 @@ const CLIENT_JS = `
18636
18737
  }
18637
18738
 
18638
18739
  // 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.
18740
+ // generatedAt/guidance, plus an optional per-target byTarget map); the textarea
18741
+ // only ever shows the learned guidance text, never the raw JSON. When byTarget
18742
+ // is present, the slot shows the un-scoped fallback (when it has guidance) plus
18743
+ // each per-target overlay under a short header, so it reflects the whole learned
18744
+ // set. A parse failure falls back to the raw text so a malformed custom prompt
18745
+ // still shows something instead of leaving the UI stuck.
18642
18746
  function customPromptDisplayText(text) {
18643
18747
  if (text == null) return "";
18748
+ var NL = "\\n";
18644
18749
  try {
18645
18750
  var parsed = JSON.parse(text);
18751
+ var byTarget = parsed && parsed.byTarget;
18752
+ if (byTarget && typeof byTarget === "object") {
18753
+ var parts = [];
18754
+ var top = typeof parsed.guidance === "string" ? parsed.guidance.trim() : "";
18755
+ if (top) parts.push("[" + t("prompt.customPrompt.fallback") + "]" + NL + top);
18756
+ Object.keys(byTarget).sort().forEach(function (tg) {
18757
+ var entry = byTarget[tg];
18758
+ var g = entry && typeof entry.guidance === "string" ? entry.guidance.trim() : "";
18759
+ if (g) parts.push("[" + tg + "]" + NL + g);
18760
+ });
18761
+ if (parts.length) return parts.join(NL + NL);
18762
+ }
18646
18763
  return typeof parsed.guidance === "string" ? parsed.guidance : text;
18647
18764
  } catch (e) {
18648
18765
  // A malformed custom prompt shouldn't blank the panel; show the raw body but
@@ -19268,6 +19385,22 @@ function evidenceSignalFor(headline, note) {
19268
19385
  }
19269
19386
  function createLearningWorker(deps) {
19270
19387
  const { storage, invoke = invokeClaudeStreaming, authCheck = driftAuthAvailable } = deps;
19388
+ /**
19389
+ * One Claude call turning a batch of graded cases into a calibration note.
19390
+ * Returns null when nothing usable came back so the caller can drop that
19391
+ * group's overlay without failing the whole job.
19392
+ */
19393
+ const learnGuidance = async (cases) => {
19394
+ const { result, isError } = await invoke({
19395
+ prompt: buildLearningUserPrompt(cases.slice(0, LEARNING_MAX_CASES)),
19396
+ systemPrompt: LEARNING_SYSTEM_PROMPT,
19397
+ allowedTools: [],
19398
+ disableBuiltinTools: true,
19399
+ maxTurns: 1
19400
+ }, () => {});
19401
+ const guidance = result?.trim();
19402
+ return isError || !guidance ? null : guidance;
19403
+ };
19271
19404
  return async function runLearningJob(job) {
19272
19405
  const auth = authCheck();
19273
19406
  if (!auth.ok) throw new Error(`triage learning needs Claude auth on the hub: ${auth.reason}`);
@@ -19285,7 +19418,8 @@ function createLearningWorker(deps) {
19285
19418
  predicted: r.predicted.label,
19286
19419
  actualCause: actual,
19287
19420
  evidenceSignal: evidenceSignalFor(r.predicted.headline, r.note),
19288
- matches: r.predicted.label === actual
19421
+ matches: r.predicted.label === actual,
19422
+ ...r.target ? { target: r.target } : {}
19289
19423
  });
19290
19424
  }
19291
19425
  }
@@ -19294,32 +19428,47 @@ function createLearningWorker(deps) {
19294
19428
  runLimit,
19295
19429
  casesConsidered: cases.length
19296
19430
  } });
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);
19431
+ const fallbackCases = [];
19432
+ const targetCases = /* @__PURE__ */ new Map();
19433
+ for (const c of cases) {
19434
+ if (!c.target) {
19435
+ fallbackCases.push(c);
19436
+ continue;
19437
+ }
19438
+ const list = targetCases.get(c.target) ?? [];
19439
+ list.push(c);
19440
+ targetCases.set(c.target, list);
19441
+ }
19307
19442
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
19443
+ const sortedTargets = [...targetCases.keys()].sort((a, b) => a.localeCompare(b));
19444
+ const [fallbackGuidance, ...targetGuidances] = await Promise.all([fallbackCases.length > 0 ? learnGuidance(fallbackCases) : Promise.resolve(null), ...sortedTargets.map((target) => learnGuidance(targetCases.get(target)))]);
19445
+ const byTarget = {};
19446
+ sortedTargets.forEach((target, i) => {
19447
+ const guidance = targetGuidances[i];
19448
+ if (guidance) byTarget[target] = {
19449
+ customPromptVersion: `${generatedAt}-${target}-c${targetCases.get(target).length}`,
19450
+ generatedAt,
19451
+ guidance
19452
+ };
19453
+ });
19454
+ if (!fallbackGuidance && Object.keys(byTarget).length === 0) throw new Error("triage learning: Claude returned no usable calibration note");
19455
+ const prevCustomPrompt = await loadStoredCustomPrompt(storage, job.project);
19308
19456
  const customPrompt = {
19309
19457
  schemaVersion: 1,
19310
19458
  basePromptVersion: "7",
19311
- customPromptVersion: `${generatedAt}-c${cases.length}`,
19459
+ customPromptVersion: `${generatedAt}-c${fallbackCases.length}`,
19312
19460
  generatedAt,
19313
- guidance
19461
+ guidance: fallbackGuidance ?? "",
19462
+ ...Object.keys(byTarget).length > 0 ? { byTarget } : {}
19314
19463
  };
19315
19464
  AnalysisCustomPromptSchema.parse(customPrompt);
19316
19465
  const beforePrompt = buildFailureAnalysisPrompt({
19317
19466
  ...PROMPT_PREVIEW_FIXTURE,
19318
- customPrompt: prevCustomPrompt
19467
+ customPrompt: representativeOverlay(prevCustomPrompt)
19319
19468
  });
19320
19469
  const afterPrompt = buildFailureAnalysisPrompt({
19321
19470
  ...PROMPT_PREVIEW_FIXTURE,
19322
- customPrompt
19471
+ customPrompt: representativeOverlay(customPrompt)
19323
19472
  });
19324
19473
  await storage.prompts.put(job.project, "analysis-custom-prompt", new TextEncoder().encode(JSON.stringify(customPrompt)), {
19325
19474
  customPromptVersion: customPrompt.customPromptVersion,
@@ -19336,6 +19485,19 @@ function createLearningWorker(deps) {
19336
19485
  });
19337
19486
  };
19338
19487
  }
19488
+ /**
19489
+ * A single representative overlay for the before/after prompt preview: the
19490
+ * un-scoped fallback when it has guidance, else the first target overlay by
19491
+ * name, else null. Only the preview uses this — the stored blob keeps every
19492
+ * overlay; run-time injection picks per target (resolveCustomPromptForTarget).
19493
+ */
19494
+ function representativeOverlay(cp) {
19495
+ if (!cp) return null;
19496
+ if (cp.guidance.trim()) return overlayAsPrompt(cp, cp);
19497
+ const firstTarget = cp.byTarget ? Object.keys(cp.byTarget).sort()[0] : void 0;
19498
+ const overlay = firstTarget ? cp.byTarget?.[firstTarget] : void 0;
19499
+ return overlay ? overlayAsPrompt(cp, overlay) : null;
19500
+ }
19339
19501
  /** Read the currently-stored custom prompt, or null when there is none / it's unreadable. */
19340
19502
  async function loadStoredCustomPrompt(storage, project) {
19341
19503
  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.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.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {