ccqa 1.24.0 → 1.25.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
@@ -3115,12 +3115,29 @@ const NO_DRIFT_CAUSE = "NO_DRIFT";
3115
3115
  const DRIFT_ACTUAL_CAUSES = [...DRIFT_FAILURE_CAUSES, NO_DRIFT_CAUSE];
3116
3116
  const ACTUAL_CAUSES = [...FAILURE_CAUSES, NO_DRIFT_CAUSE];
3117
3117
  const ActualCauseSchema = z.enum(ACTUAL_CAUSES);
3118
+ /**
3119
+ * Report rows come in kinds, and each answers fewer questions than the last.
3120
+ * A "record" row answers none: it says a recording happened and what it cost,
3121
+ * and nothing was judged, so both vocabularies below are empty for it.
3122
+ *
3123
+ * The one source for the vocabulary: the report envelope, the hub's
3124
+ * `Run.kind` and the `?kind=` query params all derive from this enum, so a
3125
+ * fourth kind cannot be half-added. What a kind that judges nothing may and
3126
+ * may not advance is ADR-0017.
3127
+ */
3128
+ const ReportKindSchema = z.enum([
3129
+ "run",
3130
+ "drift",
3131
+ "record"
3132
+ ]);
3118
3133
  /** What a person may record on a row of this kind. */
3119
3134
  function causesForKind(kind) {
3135
+ if (kind === "record") return [];
3120
3136
  return kind === "drift" ? DRIFT_ACTUAL_CAUSES : FAILURE_CAUSES;
3121
3137
  }
3122
3138
  /** What the model may answer on a row of this kind. */
3123
3139
  function predictedForKind(kind) {
3140
+ if (kind === "record") return [];
3124
3141
  return kind === "drift" ? [...DRIFT_FAILURE_CAUSES, "UNKNOWN"] : PREDICTED_LABELS;
3125
3142
  }
3126
3143
  const SUB_DIAGNOSES = [...FIXABLE_DIAGNOSIS_TYPES, "NONE"];
@@ -3146,6 +3163,20 @@ const FailureEvidenceSchema = z.object({
3146
3163
  */
3147
3164
  const DriftSurfaceSchema = z.enum(["spec", "generated"]);
3148
3165
  /**
3166
+ * Which repair a `SPEC_CHANGE` needs: `FEATURE_REMOVED` means the behaviour
3167
+ * the spec checks is gone from the code, so the spec goes with it;
3168
+ * `BEHAVIOUR_CHANGED` means it still exists but works differently, so the spec
3169
+ * is rewritten and re-recorded.
3170
+ *
3171
+ * A fourth axis rather than a `subDiagnosis` value, because that vocabulary is
3172
+ * `[...FIXABLE_DIAGNOSIS_TYPES, "NONE"]` — the shapes a machine knows how to
3173
+ * repair — so a spec change always lands on `NONE` there.
3174
+ *
3175
+ * Deliberately without an "unknown" member: absence carries it, and every
3176
+ * reader must treat an absent value as a call to leave to a human.
3177
+ */
3178
+ const SpecChangeKindSchema = z.enum(["FEATURE_REMOVED", "BEHAVIOUR_CHANGED"]);
3179
+ /**
3149
3180
  * LLM output shape. Deliberately NOT .strict(): the model occasionally adds
3150
3181
  * keys, and rejecting the whole analysis over an extra field would collapse
3151
3182
  * a usable prediction into UNKNOWN. Zod's default strips unknown keys.
@@ -3162,7 +3193,8 @@ const FailureAnalysisSchema = z.object({
3162
3193
  recommendation: z.string().default(""),
3163
3194
  evidence: z.array(FailureEvidenceSchema),
3164
3195
  reasoning: z.string(),
3165
- surface: DriftSurfaceSchema.optional()
3196
+ surface: DriftSurfaceSchema.optional(),
3197
+ specChangeKind: SpecChangeKindSchema.optional()
3166
3198
  });
3167
3199
  /**
3168
3200
  * What a drift audit may conclude, in the same vocabulary `ccqa run
@@ -3201,11 +3233,33 @@ const DriftDiagnosisSchema = z.object({
3201
3233
  confidence: z.number().min(0).max(1),
3202
3234
  surface: DriftSurfaceSchema.default("spec"),
3203
3235
  subDiagnosis: DriftSubDiagnosisSchema.default("NONE"),
3236
+ specChangeKind: SpecChangeKindSchema.optional(),
3204
3237
  headline: z.string(),
3205
3238
  recommendation: z.string().default(""),
3206
3239
  evidence: z.array(FailureEvidenceSchema),
3207
3240
  reasoning: z.string().default("")
3208
3241
  });
3242
+ /**
3243
+ * `specChangeKind` names which repair a `SPEC_CHANGE` needs, so under any other
3244
+ * label it is dropped. Dropped rather than rejected — a stray value must not
3245
+ * cost an otherwise usable verdict.
3246
+ *
3247
+ * Two producers apply it. `ccqa audit` normalizes at the parse boundary
3248
+ * (`src/drift/analyze.ts`), so every consumer of a verdict it produced — the
3249
+ * JSON output, the report rows, the hub push — is clean by construction. The
3250
+ * hub normalizes rows on the way into the drift ledger, which is what a
3251
+ * foreign client's push passes through.
3252
+ *
3253
+ * What does NOT hold: a pushed report's stored archive is kept verbatim and
3254
+ * served back unchanged, so a row a foreign client wrote can still carry a
3255
+ * stray field. That is why the UI re-checks the label before rendering the
3256
+ * chip rather than trusting the stored row.
3257
+ */
3258
+ function normalizeDiagnosis(diagnosis) {
3259
+ if (diagnosis.label === "SPEC_CHANGE" || diagnosis.specChangeKind === void 0) return diagnosis;
3260
+ const { specChangeKind: _dropped, ...rest } = diagnosis;
3261
+ return rest;
3262
+ }
3209
3263
  const ReportAssertionSchema = z.object({
3210
3264
  name: z.string(),
3211
3265
  status: z.enum([
@@ -3359,7 +3413,7 @@ const GitEnvelopeSchema = z.object({
3359
3413
  });
3360
3414
  const RunReportDataSchema = z.object({
3361
3415
  schemaVersion: z.literal(1),
3362
- kind: z.enum(["run", "drift"]).default("run"),
3416
+ kind: ReportKindSchema.default("run"),
3363
3417
  createdAt: z.string(),
3364
3418
  runId: z.string().nullable(),
3365
3419
  runUrl: z.string().nullable().optional(),
@@ -6738,7 +6792,7 @@ z.object({
6738
6792
  profile: z.string().nullable(),
6739
6793
  branch: z.string().nullable(),
6740
6794
  status: RunStatusSchema,
6741
- kind: z.enum(["run", "drift"]).default("run"),
6795
+ kind: ReportKindSchema.default("run"),
6742
6796
  drift: z.object({
6743
6797
  specs: z.number(),
6744
6798
  testDrift: z.number(),
@@ -7108,6 +7162,7 @@ z.object({
7108
7162
  const SpecDriftEntrySchema = z.object({
7109
7163
  label: DriftLabelSchema.nullable(),
7110
7164
  surface: DriftSurfaceSchema.optional(),
7165
+ specChangeKind: SpecChangeKindSchema.optional(),
7111
7166
  confidence: z.number().optional(),
7112
7167
  headline: z.string().optional(),
7113
7168
  gitHead: z.string(),
@@ -7158,6 +7213,24 @@ const CreateLearningJobRequestSchema = z.object({
7158
7213
  profile: z.string(),
7159
7214
  runLimit: z.number().int().positive().max(1e3).optional()
7160
7215
  });
7216
+ const MAX_ACK_KEYS = 5e3;
7217
+ const MAX_ACK_KEY_LENGTH = 256;
7218
+ /**
7219
+ * A named set of opaque keys a consumer has already acted on, as stored (see
7220
+ * `AckStore`). `at` is null only for a set that was never written — an ack
7221
+ * nobody has recorded yet reads as empty rather than missing.
7222
+ */
7223
+ const AckSchema = z.object({
7224
+ keys: z.array(z.string()),
7225
+ at: z.string().nullable()
7226
+ });
7227
+ /** Body of `PUT /projects/:project/acks/:name?profile=` — the whole set, not a delta. */
7228
+ const PutAckRequestSchema = z.object({ keys: z.array(z.string().min(1).max(MAX_ACK_KEY_LENGTH)).max(MAX_ACK_KEYS) });
7229
+ AckSchema.extend({
7230
+ project: z.string(),
7231
+ profile: z.string(),
7232
+ name: z.string()
7233
+ });
7161
7234
  //#endregion
7162
7235
  //#region src/run/hub-selection.ts
7163
7236
  /**
@@ -10993,14 +11066,9 @@ async function generateAgentBrowserTest(ctx) {
10993
11066
  blank();
10994
11067
  const agentBrowserSession = fix.useSnapshot ? `ccqa-generate-${Date.now()}` : void 0;
10995
11068
  const runVitestForSession = (path) => runVitest(path, agentBrowserSession);
10996
- let signalHandler = null;
10997
11069
  if (agentBrowserSession) {
10998
11070
  await closeSession(agentBrowserSession);
10999
- signalHandler = () => {
11000
- closeSession(agentBrowserSession).finally(() => process.exit(130));
11001
- };
11002
- process.once("SIGINT", signalHandler);
11003
- process.once("SIGTERM", signalHandler);
11071
+ ctx.teardown?.trackSession(agentBrowserSession);
11004
11072
  }
11005
11073
  try {
11006
11074
  const initialRun = await timedPhase("vitest run #1", () => runVitestForSession(scriptPath), "run");
@@ -11027,11 +11095,10 @@ async function generateAgentBrowserTest(ctx) {
11027
11095
  passed
11028
11096
  };
11029
11097
  } finally {
11030
- if (signalHandler) {
11031
- process.off("SIGINT", signalHandler);
11032
- process.off("SIGTERM", signalHandler);
11098
+ if (agentBrowserSession) {
11099
+ ctx.teardown?.untrackSession(agentBrowserSession);
11100
+ await closeSession(agentBrowserSession);
11033
11101
  }
11034
- if (agentBrowserSession) await closeSession(agentBrowserSession);
11035
11102
  }
11036
11103
  }
11037
11104
  /**
@@ -11836,6 +11903,75 @@ function createIncrementalReport(reportDir, envelope, sink, costNow) {
11836
11903
  };
11837
11904
  }
11838
11905
  //#endregion
11906
+ //#region src/cli/open-hub-run.ts
11907
+ /** The one wording for "this flag needs a hub, and none is configured". */
11908
+ function needsHubConnection(flag) {
11909
+ return `${flag} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`;
11910
+ }
11911
+ const REPORT_TO_HUB_NEEDS_CONNECTION = needsHubConnection("--report-to-hub");
11912
+ /**
11913
+ * The connection a `--report-to-hub` command publishes through. Both CLI
11914
+ * callers check this before the expensive part — the audit's sweep, the
11915
+ * recording's spec lock and browser — so a job that cannot publish spends
11916
+ * nothing finding out.
11917
+ */
11918
+ function requireReportToHubConnection(conn) {
11919
+ if (conn) return conn;
11920
+ error(REPORT_TO_HUB_NEEDS_CONNECTION);
11921
+ process.exit(2);
11922
+ }
11923
+ /**
11924
+ * Open the run a `--report-to-hub` command patches into. Failure is fatal: a
11925
+ * job that asked to publish and cannot reach the hub has not done what it was
11926
+ * told. Thrown rather than exited, so a caller's `finally` still runs (the
11927
+ * audit releases its spec claims there). Not retried: a dropped response after
11928
+ * the hub committed would leave a second orphan running run.
11929
+ */
11930
+ async function openHubRun(kind, conn, cwd, profile) {
11931
+ const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
11932
+ const ciRunId = githubRunId();
11933
+ const runUrl = githubRunUrl();
11934
+ try {
11935
+ const run = await conn.hub.openRun({
11936
+ project: conn.project,
11937
+ kind,
11938
+ ...branch ? { branch } : {},
11939
+ ...profile ? { profile } : {},
11940
+ ...gitHead ? { gitHead } : {},
11941
+ ...ciRunId ? { ciRunId } : {},
11942
+ ...runUrl ? { runUrl } : {}
11943
+ });
11944
+ return {
11945
+ hub: conn.hub,
11946
+ kind,
11947
+ runId: run.id,
11948
+ gitHead
11949
+ };
11950
+ } catch (err) {
11951
+ throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
11952
+ }
11953
+ }
11954
+ /**
11955
+ * Close an open run with its final rows and envelope, answering whether it
11956
+ * closed. A failed seal leaves the run `running` with whatever rows landed — a
11957
+ * wrong record, not merely a missing one — so a CLI caller must not exit clean.
11958
+ * The exit is the caller's to make rather than taken here: `ccqa record` seals
11959
+ * from inside a teardown finalizer, and exiting there would skip the
11960
+ * browser-session reap queued behind it.
11961
+ */
11962
+ async function sealHubRun(push, body) {
11963
+ try {
11964
+ await push.hub.patchRun(push.runId, {
11965
+ ...body,
11966
+ done: true
11967
+ });
11968
+ return true;
11969
+ } catch (err) {
11970
+ error(`hub: could not close the ${push.kind} run ${push.runId}: ${errMessage(err)}`);
11971
+ return false;
11972
+ }
11973
+ }
11974
+ //#endregion
11839
11975
  //#region src/prompts/agent-update.ts
11840
11976
  /**
11841
11977
  * Build the prompts used by the `--learn-*-prompt` flags to refresh
@@ -12373,9 +12509,9 @@ async function executeRun(targets, opts) {
12373
12509
  });
12374
12510
  if (wantsLastGreen && hubCtx == null) throw new RunUsageError("--on-fail-explain needs a hub connection for the per-spec last-green baselines (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN), or an explicit --on-fail-explain-base <ref>");
12375
12511
  const ledgerHub = wantsLastGreen ? hubCtx : null;
12376
- if (rerunProfile !== null && hubCtx == null) throw new RunUsageError("--only-hub-rerun-needed requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12377
- if (opts.reportToHub && hubCtx == null) throw new RunUsageError("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12378
- if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError("--learn-hub-live-prompt requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12512
+ if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
12513
+ if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
12514
+ if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
12379
12515
  const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
12380
12516
  forExecution ? fetchCustomPrompt(hubCtx) : null,
12381
12517
  forExecution ? fetchTriageUserPrompt(hubCtx) : null,
@@ -13137,7 +13273,16 @@ async function streamFiltered(source, sink, capture) {
13137
13273
  function createRunTeardown() {
13138
13274
  const sessions = /* @__PURE__ */ new Set();
13139
13275
  const finalizers = [];
13140
- let torn = false;
13276
+ let running = null;
13277
+ const tearDown = async () => {
13278
+ for (const fn of finalizers) try {
13279
+ await fn();
13280
+ } catch (err) {
13281
+ warn(`teardown finalizer failed (partial report may be incomplete): ${err instanceof Error ? err.message : String(err)}`);
13282
+ }
13283
+ await Promise.all([...sessions].map((name) => closeSession(name)));
13284
+ sessions.clear();
13285
+ };
13141
13286
  return {
13142
13287
  trackSession(name) {
13143
13288
  sessions.add(name);
@@ -13148,24 +13293,18 @@ function createRunTeardown() {
13148
13293
  onFinalize(fn) {
13149
13294
  finalizers.push(fn);
13150
13295
  },
13151
- async run() {
13152
- if (torn) return;
13153
- torn = true;
13154
- for (const fn of finalizers) try {
13155
- await fn();
13156
- } catch (err) {
13157
- warn(`teardown finalizer failed (partial report may be incomplete): ${err instanceof Error ? err.message : String(err)}`);
13158
- }
13159
- await Promise.all([...sessions].map((name) => closeSession(name)));
13160
- sessions.clear();
13296
+ run() {
13297
+ running ??= tearDown();
13298
+ return running;
13161
13299
  }
13162
13300
  };
13163
13301
  }
13164
13302
  /**
13165
13303
  * Install SIGINT/SIGTERM handlers that run {@link RunTeardown.run} then exit
13166
13304
  * with the conventional signal code, and return a disposer that removes them.
13167
- * Mirrors the pattern in `src/targets/agent-browser/generate.ts`. A second
13168
- * signal while tearing down hard-exits immediately rather than waiting.
13305
+ * A command must install at most one: a second handler that also exits would
13306
+ * race this one and could terminate mid-finalizer. A second signal while
13307
+ * tearing down hard-exits immediately rather than waiting.
13169
13308
  */
13170
13309
  function installTeardownSignalHandlers(teardown) {
13171
13310
  let handling = false;
@@ -14691,8 +14830,11 @@ function parseAutoFixFlag(raw) {
14691
14830
  * The `generate` flow shared by `ccqa generate` and the codegen half of
14692
14831
  * `ccqa record`: resolve the spec's target plugin, load its input (the
14693
14832
  * recording, for input:"recording" targets), and dispatch to the plugin.
14694
- * This layer owns the CLI concerns — overwrite confirmation, logging,
14695
- * exit-code policy — while the plugin owns the generation pipeline.
14833
+ * This layer owns the CLI concerns — overwrite confirmation, logging — while
14834
+ * the plugin owns the generation pipeline. A generation whose output still
14835
+ * fails is reported as `{ passed: false }` rather than thrown or exited: both
14836
+ * callers have work left that `process.exit` would skip — `ccqa record
14837
+ * --report-to-hub` still has to seal the run holding what the retries cost.
14696
14838
  */
14697
14839
  async function runGenerate(featureName, specName, opts) {
14698
14840
  header("generate", `${featureName}/${specName}`);
@@ -14700,7 +14842,7 @@ async function runGenerate(featureName, specName, opts) {
14700
14842
  await ensureCcqaDir(cwd);
14701
14843
  const releaseLock = await acquireSpecLock(featureName, specName, "generate", cwd);
14702
14844
  try {
14703
- await runGenerateLocked(featureName, specName, opts, cwd);
14845
+ return await runGenerateLocked(featureName, specName, opts, cwd);
14704
14846
  } finally {
14705
14847
  await releaseLock();
14706
14848
  }
@@ -14731,7 +14873,7 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
14731
14873
  if (existingOutput && !opts.force) {
14732
14874
  if (!await confirmOverwrite(existingOutput)) {
14733
14875
  info("aborted; pass --overwrite to replace it without prompting");
14734
- return;
14876
+ return { passed: true };
14735
14877
  }
14736
14878
  }
14737
14879
  let recording;
@@ -14760,15 +14902,14 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
14760
14902
  maxRetries: opts.maxRetries,
14761
14903
  mode: opts.fixMode,
14762
14904
  useSnapshot: opts.useSnapshot
14763
- }
14905
+ },
14906
+ teardown: opts.teardown
14764
14907
  };
14765
14908
  const result = await target.generate(ctx);
14766
14909
  if (opts.updateAgentPrompt) await runGenerateAgentPromptUpdate(target, featureName, specName, result, opts, cwd);
14767
- if (!result.passed) {
14768
- warn("auto-fix exhausted; test still failing");
14769
- process.exit(1);
14770
- }
14771
- hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
14910
+ if (!result.passed) warn("auto-fix exhausted; test still failing");
14911
+ else hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
14912
+ return { passed: result.passed };
14772
14913
  }
14773
14914
  /**
14774
14915
  * `ccqa generate --learn-hub-codegen-prompt`: refresh the target's learned
@@ -14838,15 +14979,18 @@ async function runGenerateCli(specPath, opts) {
14838
14979
  cwd
14839
14980
  });
14840
14981
  if (opts.learnHubCodegenPrompt && hubClient === null) {
14841
- error("--learn-hub-codegen-prompt requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
14982
+ error(needsHubConnection("--learn-hub-codegen-prompt"));
14842
14983
  process.exit(2);
14843
14984
  }
14844
14985
  const hubContext = hubClient && project ? {
14845
14986
  hub: hubClient,
14846
14987
  project
14847
14988
  } : null;
14989
+ const teardown = createRunTeardown();
14990
+ const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
14991
+ let passed;
14848
14992
  try {
14849
- await runGenerate(featureName, specName, {
14993
+ ({passed} = await runGenerate(featureName, specName, {
14850
14994
  maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
14851
14995
  fixMode: toFixMode(opts.autoFix ?? "interactive"),
14852
14996
  force: opts.overwrite ?? false,
@@ -14856,15 +15000,20 @@ async function runGenerateCli(specPath, opts) {
14856
15000
  targetOverride: opts.target,
14857
15001
  cwd,
14858
15002
  hubContext,
14859
- updateAgentPrompt: opts.learnHubCodegenPrompt ?? false
14860
- });
15003
+ updateAgentPrompt: opts.learnHubCodegenPrompt ?? false,
15004
+ teardown
15005
+ }));
14861
15006
  } catch (e) {
14862
15007
  if (e instanceof SpecLockedError) {
14863
15008
  error(e.message);
14864
15009
  process.exit(2);
14865
15010
  }
14866
15011
  throw e;
15012
+ } finally {
15013
+ await teardown.run();
15014
+ disposeSignalHandlers();
14867
15015
  }
15016
+ if (!passed) process.exit(1);
14868
15017
  }
14869
15018
  //#endregion
14870
15019
  //#region src/cli/record.ts
@@ -14872,7 +15021,7 @@ const VALIDATION_MODES = ["lenient", "strict"];
14872
15021
  const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a test from a spec: run agent-browser to collect actions (trace), then compile them into runnable code via the spec's target (generate) — a vitest test.spec.ts for agent-browser, a @playwright/test spec for the playwright target. Recording-backed targets only; spec-input targets like runn have no trace step (use `ccqa generate`), and agent-browser live specs need no recording.").optionsGroup("How to record:").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--trace-validation <mode>", "What to do with actions that fail post-trace validation: 'lenient' (default) tags them; 'strict' drops them.", (raw) => {
14873
15022
  if (VALIDATION_MODES.includes(raw)) return raw;
14874
15023
  throw new Error(`--trace-validation must be one of ${VALIDATION_MODES.join(" | ")}`);
14875
- }, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--auto-fix-max-retries <n>", "Maximum number of auto-fix retries", "3").option("--trace-only", "Stop after the trace step; do not generate test code").option("--no-session-pin", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").optionsGroup("What to do with the result:").option("--overwrite", "Replace an existing test.spec.ts without warning").optionsGroup("Learning:").option("--learn-hub-trace-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
15024
+ }, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--auto-fix-max-retries <n>", "Maximum number of auto-fix retries", "3").option("--trace-only", "Stop after the trace step; do not generate test code").option("--no-session-pin", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").optionsGroup("What to do with the result:").option("--overwrite", "Replace an existing test.spec.ts without warning").option("--report-to-hub", "Leave a run (kind: record) on the hub saying this spec was recorded and what the recording spent on Claude, so a budget summed over the hub's runs sees it. It advances no ledger: a recording verifies nothing.").optionsGroup("Learning:").option("--learn-hub-trace-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
14876
15025
  await withCostReporting("record", () => runRecord(specPath, opts));
14877
15026
  }));
14878
15027
  async function runRecord(specPath, opts) {
@@ -14911,9 +15060,10 @@ async function runRecord(specPath, opts) {
14911
15060
  project: hubProject
14912
15061
  } : null;
14913
15062
  if (opts.learnHubTracePrompt && hubContext === null) {
14914
- error("--learn-hub-trace-prompt requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
15063
+ error(needsHubConnection("--learn-hub-trace-prompt"));
14915
15064
  process.exit(2);
14916
15065
  }
15066
+ const pushConn = opts.reportToHub ? requireReportToHubConnection(hubContext) : null;
14917
15067
  const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
14918
15068
  if (e instanceof SpecLockedError) {
14919
15069
  error(e.message);
@@ -14921,37 +15071,78 @@ async function runRecord(specPath, opts) {
14921
15071
  }
14922
15072
  throw e;
14923
15073
  });
14924
- let traceResult = null;
15074
+ const push = pushConn ? await openHubRun("record", pushConn, cwdForProfile, opts.hubProfile) : null;
15075
+ if (push) info(`hub: record run opened (${push.runId})`);
15076
+ let recorded = false;
15077
+ let sealed = true;
15078
+ const teardown = createRunTeardown();
15079
+ teardown.onFinalize(async () => {
15080
+ if (push) sealed = await sealRecordPush(push, featureName, specName, recorded);
15081
+ });
15082
+ const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
14925
15083
  try {
14926
- traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
14927
- cwd: cwdForProfile,
14928
- hubContext
14929
- });
14930
- blank();
14931
- if (!opts.traceOnly) await runGenerate(featureName, specName, {
14932
- maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
14933
- fixMode: toFixMode(opts.autoFix ?? "interactive"),
14934
- force: opts.overwrite ?? false,
14935
- useSnapshot: opts.sessionPin !== false,
14936
- language,
14937
- model: opts.model,
14938
- cwd: cwdForProfile,
14939
- hubContext
14940
- });
15084
+ let traceResult = null;
15085
+ let generated = true;
15086
+ try {
15087
+ traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
15088
+ cwd: cwdForProfile,
15089
+ hubContext
15090
+ });
15091
+ blank();
15092
+ if (!opts.traceOnly) generated = (await runGenerate(featureName, specName, {
15093
+ maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
15094
+ fixMode: toFixMode(opts.autoFix ?? "interactive"),
15095
+ force: opts.overwrite ?? false,
15096
+ useSnapshot: opts.sessionPin !== false,
15097
+ language,
15098
+ model: opts.model,
15099
+ cwd: cwdForProfile,
15100
+ hubContext,
15101
+ teardown
15102
+ })).passed;
15103
+ } finally {
15104
+ await releaseLock();
15105
+ }
15106
+ if (opts.learnHubTracePrompt && traceResult !== null) {
15107
+ blank();
15108
+ await updateAgentPrompt({
15109
+ kind: "record",
15110
+ flag: "--learn-hub-trace-prompt",
15111
+ runSummary: buildRecordRunSummary(featureName, specName, traceResult),
15112
+ hubContext,
15113
+ ...opts.model ? { model: opts.model } : {},
15114
+ ...language ? { language } : {}
15115
+ });
15116
+ }
15117
+ recorded = generated;
14941
15118
  } finally {
14942
- await releaseLock();
14943
- }
14944
- if (opts.learnHubTracePrompt && traceResult !== null) {
14945
- blank();
14946
- await updateAgentPrompt({
14947
- kind: "record",
14948
- flag: "--learn-hub-trace-prompt",
14949
- runSummary: buildRecordRunSummary(featureName, specName, traceResult),
14950
- hubContext,
14951
- ...opts.model ? { model: opts.model } : {},
14952
- ...language ? { language } : {}
14953
- });
15119
+ await teardown.run();
15120
+ disposeSignalHandlers();
14954
15121
  }
15122
+ if (!sealed) process.exit(2);
15123
+ if (!recorded) process.exit(1);
15124
+ }
15125
+ /**
15126
+ * Close the record run with the one row this command produced, answering
15127
+ * whether it closed. One spec is recorded per invocation, so one row is the
15128
+ * whole run — enough for the runs list to say what the money bought.
15129
+ */
15130
+ async function sealRecordPush(push, featureName, specName, recorded) {
15131
+ return sealHubRun(push, {
15132
+ rows: [emptySpecRow({
15133
+ feature: featureName,
15134
+ spec: specName,
15135
+ title: null,
15136
+ status: recorded ? "passed" : "failed"
15137
+ })],
15138
+ reportMeta: {
15139
+ git: {
15140
+ head: push.gitHead,
15141
+ base: null
15142
+ },
15143
+ cost: currentReportCost()
15144
+ }
15145
+ });
14955
15146
  }
14956
15147
  /**
14957
15148
  * Compact summary of the trace pass for the record agent-prompt refresh.
@@ -15175,6 +15366,7 @@ Drift found:
15175
15366
  "confidence": 0.0,
15176
15367
  "surface": "spec" | "generated",
15177
15368
  "subDiagnosis": "SELECTOR_DRIFT" | "OVER_ASSERTION" | "NONE",
15369
+ "specChangeKind": "FEATURE_REMOVED" | "BEHAVIOUR_CHANGED",
15178
15370
  "headline": "<one line: what is out of sync>",
15179
15371
  "recommendation": "<what to change to bring them back in sync>",
15180
15372
  "reasoning": "<how you reached this label: what you looked for, what you found, why it is this label and not the other>",
@@ -15186,6 +15378,13 @@ Drift found:
15186
15378
  \`\`\`
15187
15379
 
15188
15380
  \`subDiagnosis\`: \`SELECTOR_DRIFT\` when a selector or string was renamed, \`OVER_ASSERTION\` when the spec asserts something narrower than the product ever promised, \`NONE\` otherwise.
15381
+
15382
+ \`specChangeKind\`: set it only when the label is \`SPEC_CHANGE\`, and omit the field entirely otherwise. It says which repair the spec needs — deleting it, or rewriting and re-recording it:
15383
+
15384
+ - \`FEATURE_REMOVED\` — the code no longer implements the behaviour at all: removed, moved elsewhere, or deliberately disabled. This is the stronger claim, so earn it: your evidence must point at where the implementation would be if it still existed.
15385
+ - \`BEHAVIOUR_CHANGED\` — the behaviour is still there, but its wording, its route, or the conditions it runs under moved.
15386
+
15387
+ When the evidence does not support "gone", answer \`BEHAVIOUR_CHANGED\`. When neither reading is supported, omit the field — there is no value for "I cannot tell", and a human decides what you leave unsaid.
15189
15388
  `;
15190
15389
  }
15191
15390
  function buildDriftUserPrompt(artifacts) {
@@ -15366,10 +15565,11 @@ async function checkSpec(target, opts) {
15366
15565
  continue;
15367
15566
  }
15368
15567
  try {
15568
+ const reply = DriftReplySchema.parse(JSON.parse(json));
15369
15569
  return {
15370
15570
  target,
15371
15571
  ok: true,
15372
- drift: DriftReplySchema.parse(JSON.parse(json)).drift,
15572
+ drift: reply.drift ? normalizeDiagnosis(reply.drift) : null,
15373
15573
  live: artifacts.live,
15374
15574
  title: artifacts.title
15375
15575
  };
@@ -15503,7 +15703,7 @@ function determineExitCode(results, threshold) {
15503
15703
  //#endregion
15504
15704
  //#region src/drift/to-report.ts
15505
15705
  /** Tracks the drift prompt's own version — the two must never drift apart. */
15506
- const DRIFT_REPORT_PROMPT_VERSION = "5";
15706
+ const DRIFT_REPORT_PROMPT_VERSION = "6";
15507
15707
  /**
15508
15708
  * Spec-level status under the given threshold, mirroring determineExitCode's
15509
15709
  * per-spec logic (exit-code.ts) but scoped to a single SpecResult.
@@ -15536,7 +15736,7 @@ function driftResultToRow(result, threshold) {
15536
15736
  status: specStatus(result, threshold)
15537
15737
  }),
15538
15738
  ...result.live === void 0 ? {} : { mode: result.live ? "live" : "deterministic" },
15539
- analysis: result.drift ?? null
15739
+ analysis: result.drift
15540
15740
  };
15541
15741
  }
15542
15742
  /**
@@ -15822,14 +16022,24 @@ async function runAudit(specPath, opts) {
15822
16022
  if (targets.length === 0) exitWithNoSpecs(format, "noDiffIntersection", "no specs intersect the changed file set; nothing to check");
15823
16023
  }
15824
16024
  const blocks = await loadAvailableBlocks(cwd);
15825
- requireReportToHubConnection(opts);
16025
+ let pushConn = null;
16026
+ if (opts.reportToHub) {
16027
+ const pushHub = resolveHubClient(opts);
16028
+ pushConn = requireReportToHubConnection(pushHub && {
16029
+ hub: pushHub,
16030
+ project: resolveProject({
16031
+ project: opts.project,
16032
+ cwd
16033
+ })
16034
+ });
16035
+ }
15826
16036
  let results;
15827
16037
  let promptCtx;
15828
16038
  let push = null;
15829
16039
  try {
15830
16040
  promptCtx = await resolveAuditPromptContext(opts, cwd);
15831
- if (opts.reportToHub) {
15832
- push = await openDriftPush(opts, cwd);
16041
+ if (pushConn) {
16042
+ push = await openHubRun("drift", pushConn, cwd, opts.hubProfile);
15833
16043
  if (format === "text") info(`hub: incremental drift run opened (${push.runId})`);
15834
16044
  }
15835
16045
  results = await analyzeDrift({
@@ -15890,56 +16100,6 @@ async function releaseSpecs(hub, project, profile, holder) {
15890
16100
  }
15891
16101
  }
15892
16102
  /**
15893
- * Fail fast when `--report-to-hub` was requested but no hub connection is
15894
- * available. Called at the top of `runAudit`, before the sweep spends any
15895
- * model calls — `pushDriftResults` (below) keeps its own equivalent, injectable
15896
- * check so it stays safe to call standalone (e.g. in tests) without this guard
15897
- * having already run.
15898
- */
15899
- function requireReportToHubConnection(opts) {
15900
- if (!opts.reportToHub || resolveHubClient(opts)) return;
15901
- error("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
15902
- process.exit(2);
15903
- }
15904
- /**
15905
- * Open the drift run this sweep patches into. A failure here is fatal, as it
15906
- * is for `ccqa run`: a job that asked to publish and cannot reach the hub has
15907
- * not done what it was told, and the audit has no local artifact to fall back
15908
- * on — the hub is its only output. Raised before any spec is checked, so
15909
- * nothing is wasted. Not retried: a dropped response after the hub committed
15910
- * would leave a second orphan running run.
15911
- *
15912
- * `resolveHub` is injectable for tests.
15913
- */
15914
- async function openDriftPush(opts, cwd, resolveHub = resolveHubClient) {
15915
- const hub = resolveHub(opts);
15916
- if (!hub) throw new RunUsageError("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
15917
- const project = resolveProject({
15918
- project: opts.project,
15919
- cwd
15920
- });
15921
- const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
15922
- const ciRunId = githubRunId();
15923
- const runUrl = githubRunUrl();
15924
- try {
15925
- return {
15926
- hub,
15927
- runId: (await hub.openRun({
15928
- project,
15929
- kind: "drift",
15930
- ...branch ? { branch } : {},
15931
- ...opts.hubProfile ? { profile: opts.hubProfile } : {},
15932
- ...gitHead ? { gitHead } : {},
15933
- ...ciRunId ? { ciRunId } : {},
15934
- ...runUrl ? { runUrl } : {}
15935
- })).id,
15936
- gitHead
15937
- };
15938
- } catch (err) {
15939
- throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
15940
- }
15941
- }
15942
- /**
15943
16103
  * Send one finished spec. A failure is warned and swallowed rather than
15944
16104
  * thrown: the seal resends every row, so a dropped patch costs freshness for
15945
16105
  * the rest of the sweep, not the record.
@@ -15971,22 +16131,16 @@ async function sealDriftPush(push, args) {
15971
16131
  customPromptVersion: promptCtx?.customPromptVersion ?? null,
15972
16132
  triageUserPromptHash: promptCtx?.triageUserPromptHash ?? null
15973
16133
  });
15974
- try {
15975
- await push.hub.patchRun(push.runId, {
15976
- rows: report.results,
15977
- done: true,
15978
- reportMeta: {
15979
- git: report.git,
15980
- promptVersion: report.promptVersion,
15981
- customPromptVersion: report.customPromptVersion,
15982
- ...report.triageUserPromptHash ? { triageUserPromptHash: report.triageUserPromptHash } : {},
15983
- cost: report.cost
15984
- }
15985
- });
15986
- } catch (err) {
15987
- error(`hub: could not close the drift run ${push.runId}: ${errMessage(err)}`);
15988
- process.exit(2);
15989
- }
16134
+ if (!await sealHubRun(push, {
16135
+ rows: report.results,
16136
+ reportMeta: {
16137
+ git: report.git,
16138
+ promptVersion: report.promptVersion,
16139
+ customPromptVersion: report.customPromptVersion,
16140
+ ...report.triageUserPromptHash ? { triageUserPromptHash: report.triageUserPromptHash } : {},
16141
+ cost: report.cost
16142
+ }
16143
+ })) process.exit(2);
15990
16144
  if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${push.runId}`);
15991
16145
  }
15992
16146
  /**
@@ -16824,6 +16978,7 @@ function gradedDriftEntry(ledger, key, runId, label) {
16824
16978
  delete graded.headline;
16825
16979
  delete graded.confidence;
16826
16980
  }
16981
+ if (label !== "SPEC_CHANGE") delete graded.specChangeKind;
16827
16982
  return graded;
16828
16983
  }
16829
16984
  //#endregion
@@ -16958,7 +17113,7 @@ function createPushRunHandler(config) {
16958
17113
  runUrl: report.runUrl ?? null,
16959
17114
  reportCreatedAt: report.createdAt,
16960
17115
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
16961
- ...await resolveDeployedSha(config.storage, project, profile, deployedSha),
17116
+ ...await resolveDeployedSha(config.storage, kind, project, profile, deployedSha),
16962
17117
  deployedShaAmbiguous: false
16963
17118
  };
16964
17119
  await config.storage.artifacts.putDir(run.id, dir);
@@ -17009,7 +17164,7 @@ function createOpenRunHandler(config) {
17009
17164
  runUrl: runUrl || null,
17010
17165
  reportCreatedAt: now,
17011
17166
  createdAt: now,
17012
- ...await resolveDeployedSha(config.storage, project, profile, deployedSha),
17167
+ ...await resolveDeployedSha(config.storage, kind, project, profile, deployedSha),
17013
17168
  deployedShaAmbiguous: false
17014
17169
  };
17015
17170
  await config.storage.runs.create(run);
@@ -17131,10 +17286,11 @@ async function updateDriftLedger(storage, run, results) {
17131
17286
  for (const row of results) {
17132
17287
  if (row.status === "skipped") continue;
17133
17288
  const key = `${row.feature}/${row.spec}`;
17134
- const diagnosis = row.analysis;
17289
+ const diagnosis = row.analysis ? normalizeDiagnosis(row.analysis) : null;
17135
17290
  ledger.specs[key] = {
17136
17291
  label: diagnosis ? diagnosis.label : null,
17137
17292
  surface: diagnosis?.surface,
17293
+ specChangeKind: diagnosis?.specChangeKind,
17138
17294
  confidence: diagnosis?.confidence,
17139
17295
  headline: diagnosis?.headline,
17140
17296
  gitHead,
@@ -17157,11 +17313,15 @@ async function updateDriftLedger(storage, run, results) {
17157
17313
  * Best-effort: a deploy log the hub can't read leaves the run unattributed
17158
17314
  * (re-run selection then answers `unknown`) rather than rejecting the run.
17159
17315
  */
17160
- async function resolveDeployedSha(storage, project, profile, explicit) {
17316
+ async function resolveDeployedSha(storage, kind, project, profile, explicit) {
17161
17317
  if (explicit) return {
17162
17318
  deployedSha: explicit,
17163
17319
  deployedShaSource: "client"
17164
17320
  };
17321
+ if (kind === "record") return {
17322
+ deployedSha: null,
17323
+ deployedShaSource: null
17324
+ };
17165
17325
  try {
17166
17326
  const head = await storage.deploys.head(project, profile ?? "default");
17167
17327
  if (head) return {
@@ -17266,17 +17426,19 @@ function createPatchRunHandler(config) {
17266
17426
  sendJson(ctx.res, 200, updated);
17267
17427
  };
17268
17428
  }
17269
- /** GET /api/v1/runs?project&branch&status&limit */
17429
+ /** GET /api/v1/runs?project&branch&status&kind&limit */
17270
17430
  function createListRunsHandler(storage) {
17271
17431
  return async (ctx) => {
17272
17432
  const project = ctx.url.searchParams.get("project");
17273
17433
  const branch = ctx.url.searchParams.get("branch");
17274
17434
  const status = ctx.url.searchParams.get("status");
17275
17435
  const limitRaw = ctx.url.searchParams.get("limit");
17436
+ const kindsRaw = ctx.url.searchParams.get("kind");
17276
17437
  const runs = await storage.runs.list({
17277
17438
  ...project ? { project } : {},
17278
17439
  ...branch ? { branch } : {},
17279
17440
  ...status ? { status } : {},
17441
+ ...kindsRaw ? { kinds: kindsRaw.split(",").map(requireKind) } : {},
17280
17442
  ...limitRaw ? { limit: Number(limitRaw) } : {}
17281
17443
  });
17282
17444
  sendJson(ctx.res, 200, { runs: await Promise.all(runs.map((r) => withGradedDrift(storage, r))) });
@@ -17410,16 +17572,21 @@ function parseRunScope(ctx) {
17410
17572
  const profileRaw = ctx.url.searchParams.get("profile");
17411
17573
  const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
17412
17574
  const kindRaw = ctx.url.searchParams.get("kind");
17413
- if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
17414
17575
  const deployedSha = boundedParam(ctx.url.searchParams.get("deployedSha"), "deployedSha", 64);
17415
17576
  return {
17416
17577
  project,
17417
17578
  branch,
17418
17579
  profile,
17419
- kind: kindRaw ?? "run",
17580
+ kind: kindRaw === null ? "run" : requireKind(kindRaw),
17420
17581
  deployedSha
17421
17582
  };
17422
17583
  }
17584
+ /** One `kind` value, validated against the enum so the message can't drift from it. */
17585
+ function requireKind(raw) {
17586
+ const parsed = ReportKindSchema.safeParse(raw);
17587
+ if (!parsed.success) throw new HttpError(400, "invalid_param", `invalid kind: must be one of ${ReportKindSchema.options.map((k) => `"${k}"`).join(", ")}`);
17588
+ return parsed.data;
17589
+ }
17423
17590
  /**
17424
17591
  * A branch is a free-form label (e.g. `feature/foo`), so `/` is allowed —
17425
17592
  * only length is bounded (a sanity cap; the last-green ledger separately
@@ -17965,7 +18132,7 @@ function createGetAuditNeedHandler(storage) {
17965
18132
  //#endregion
17966
18133
  //#region src/hub/api/handlers/locks.ts
17967
18134
  /** A spec-key list and three short strings; nothing here should approach this. */
17968
- const MAX_BODY_BYTES$1 = 1024 * 1024;
18135
+ const MAX_BODY_BYTES$2 = 1024 * 1024;
17969
18136
  /**
17970
18137
  * POST /api/v1/projects/:project/locks?profile=
17971
18138
  *
@@ -17979,7 +18146,7 @@ function createAcquireLocksHandler(storage) {
17979
18146
  return async (ctx) => {
17980
18147
  const project = requireSafeSegment(ctx.params.project, "project");
17981
18148
  const profile = requireProfileParam(ctx.url);
17982
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$1, AcquireLocksRequestSchema, "lock request");
18149
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, AcquireLocksRequestSchema, "lock request");
17983
18150
  let result = {
17984
18151
  granted: [],
17985
18152
  denied: []
@@ -18008,12 +18175,55 @@ function createReleaseLocksHandler(storage) {
18008
18175
  return async (ctx) => {
18009
18176
  const project = requireSafeSegment(ctx.params.project, "project");
18010
18177
  const profile = requireProfileParam(ctx.url);
18011
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$1, ReleaseLocksRequestSchema, "release request");
18178
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, ReleaseLocksRequestSchema, "release request");
18012
18179
  await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
18013
18180
  ctx.res.writeHead(204).end();
18014
18181
  };
18015
18182
  }
18016
18183
  //#endregion
18184
+ //#region src/hub/api/handlers/acks.ts
18185
+ /**
18186
+ * Pre-parse guard only. `PutAckRequestSchema` holds the real bound (5000 keys
18187
+ * of 256 characters); this sits above the largest body that can satisfy it —
18188
+ * 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
18189
+ * never answered 413 by a limit the documented bounds don't mention.
18190
+ */
18191
+ const MAX_BODY_BYTES$1 = 8 * 1024 * 1024;
18192
+ function requireAckKey(ctx) {
18193
+ return {
18194
+ project: requireSafeSegment(ctx.params.project, "project"),
18195
+ profile: requireProfileParam(ctx.url),
18196
+ name: requireSafeSegment(ctx.params.name, "name")
18197
+ };
18198
+ }
18199
+ /**
18200
+ * GET /api/v1/projects/:project/acks/:name?profile= — an unset ack answers 200
18201
+ * with an empty set, not 404: "nothing acted on yet" is a real answer, and a
18202
+ * 404 there is a special case consumers get wrong.
18203
+ */
18204
+ function createGetAckHandler(storage) {
18205
+ return async (ctx) => {
18206
+ const key = requireAckKey(ctx);
18207
+ const ack = await storage.acks.get(key.project, key.profile, key.name);
18208
+ sendJson(ctx.res, 200, {
18209
+ ...key,
18210
+ ...ack
18211
+ });
18212
+ };
18213
+ }
18214
+ /** PUT /api/v1/projects/:project/acks/:name?profile= */
18215
+ function createPutAckHandler(storage) {
18216
+ return async (ctx) => {
18217
+ const key = requireAckKey(ctx);
18218
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$1, PutAckRequestSchema, "ack body");
18219
+ const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
18220
+ sendJson(ctx.res, 200, {
18221
+ ...key,
18222
+ ...ack
18223
+ });
18224
+ };
18225
+ }
18226
+ //#endregion
18017
18227
  //#region src/hub/core/rerun.ts
18018
18228
  /**
18019
18229
  * When each deployed commit reached the environment. A baseline read at that
@@ -19263,7 +19473,12 @@ const CSS = `
19263
19473
  .empty-note { color: var(--muted); font-size: 13px; padding: 16px 2px; }
19264
19474
 
19265
19475
  .runid { font-family: var(--mono); font-size: 13px; font-weight: 600; }
19266
- .subline { margin-top: 3px; }
19476
+ /* Chips wrap when a run carries several labels. Laid out as inline content
19477
+ they wrapped to a line indented by the chips' own left margin, and the two
19478
+ lines sat at text leading — too tight to read as separate rows. Flex with a
19479
+ gap aligns every line at the same left edge and spaces them the same way
19480
+ horizontally and vertically. */
19481
+ .subline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px; }
19267
19482
  .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; }
19268
19483
  .ci-badge.local { color: var(--muted-2); }
19269
19484
  a.ci-badge { text-decoration: none; }
@@ -19279,6 +19494,15 @@ const CSS = `
19279
19494
  .badge.skipped .d { background: var(--muted); }
19280
19495
  .badge.running { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
19281
19496
  .badge.running .d { background: var(--amber); }
19497
+ /* A drift verdict is a diagnosis, not a broken test: amber, never fail-red.
19498
+ Without these the badge rendered as bare text next to the pill-shaped
19499
+ pass/fail ones, and read as a different kind of thing. */
19500
+ .badge.dr-found { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
19501
+ .badge.dr-found .d { background: var(--amber); }
19502
+ .badge.dr-clean { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
19503
+ .badge.dr-clean .d { background: var(--pass); }
19504
+ .badge.dr-unknown { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
19505
+ .badge.dr-unknown .d { background: var(--muted); }
19282
19506
  .badge-live, .badge-det { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 600; border: 1px solid transparent; }
19283
19507
  .badge-live { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
19284
19508
  .badge-det { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
@@ -19290,8 +19514,11 @@ const CSS = `
19290
19514
  order decides). One amber look for every drift label chip — a label chip
19291
19515
  is a finding, not a severity, so it does not split into fail-red/amber
19292
19516
  the way the old errors/warnings counts did. */
19293
- .chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font); margin-left: 6px; }
19294
- .chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); margin-left: 6px; }
19517
+ .chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font); }
19518
+ .chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); }
19519
+ /* Prose, not an identifier — and neutral, since it qualifies the label chip
19520
+ beside it rather than claiming a severity of its own. */
19521
+ .chip.spec-change-chip { font-family: var(--font); }
19295
19522
  .drift-meta-box { display: flex; flex-direction: column; gap: 4px; }
19296
19523
  /* The chips carry their own margin for the run list, where they sit inline
19297
19524
  after other chips. Here the container owns the spacing, so the margin only
@@ -19765,6 +19992,7 @@ const CLIENT_JS = `
19765
19992
  "meta.drift": "Drift",
19766
19993
  "diag.cause": "Cause", "diag.fix": "Fix",
19767
19994
  "diag.surface": "Surface", "diag.surface.spec": "spec", "diag.surface.generated": "generated code",
19995
+ "diag.specChangeKind.FEATURE_REMOVED": "feature gone", "diag.specChangeKind.BEHAVIOUR_CHANGED": "behaviour changed",
19768
19996
  "acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
19769
19997
  "acc.assertions": "Assertions",
19770
19998
  "acc.artifacts": "Artifacts",
@@ -19773,7 +20001,7 @@ const CLIENT_JS = `
19773
20001
  "spec.kind.live": "Live", "spec.kind.det": "Deterministic",
19774
20002
  "det.steps": "Steps",
19775
20003
  "det.noEvidence": "No step screenshots:",
19776
- "kind.run": "Test run", "kind.drift": "Drift audit",
20004
+ "kind.run": "Test run", "kind.drift": "Drift audit", "kind.record": "Recording",
19777
20005
  "drift.summary.ratio": "{found} of {total} specs",
19778
20006
  "drift.clean": "No drift issues",
19779
20007
  "status.passed": "passed", "status.failed": "failed", "status.skipped": "skipped", "status.running": "running",
@@ -19923,6 +20151,7 @@ const CLIENT_JS = `
19923
20151
  "meta.drift": "ドリフト",
19924
20152
  "diag.cause": "原因", "diag.fix": "対処",
19925
20153
  "diag.surface": "対象", "diag.surface.spec": "spec", "diag.surface.generated": "生成コード",
20154
+ "diag.specChangeKind.FEATURE_REMOVED": "機能が無い", "diag.specChangeKind.BEHAVIOUR_CHANGED": "振る舞いが変わった",
19926
20155
  "acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
19927
20156
  "acc.assertions": "アサーション",
19928
20157
  "acc.artifacts": "成果物",
@@ -19931,7 +20160,7 @@ const CLIENT_JS = `
19931
20160
  "spec.kind.live": "ライブ", "spec.kind.det": "決定的",
19932
20161
  "det.steps": "ステップ",
19933
20162
  "det.noEvidence": "ステップのスクリーンショットなし:",
19934
- "kind.run": "テスト実行", "kind.drift": "ドリフト監査",
20163
+ "kind.run": "テスト実行", "kind.drift": "ドリフト監査", "kind.record": "収録",
19935
20164
  "drift.summary.ratio": "{found} / {total} スペック",
19936
20165
  "drift.clean": "ドリフトの問題なし",
19937
20166
  "status.passed": "合格", "status.failed": "失敗", "status.skipped": "スキップ", "status.running": "実行中",
@@ -20310,6 +20539,21 @@ const CLIENT_JS = `
20310
20539
  return run.kind === "drift" ? driftFoundBadge(driftRunState(run), "drift.run.") : statusBadge(run.status);
20311
20540
  }
20312
20541
 
20542
+ // Which command left the run, and whether its spec counts are a tally of
20543
+ // what was verified. A recording's rows are the specs it wrote, not specs it
20544
+ // checked, so "1 / 1 passed" and a full meter would claim a test result.
20545
+ // A kind from a newer hub keeps the generic label but is read the same
20546
+ // cautious way, since nothing here knows what its counts mean.
20547
+ var KINDS = {
20548
+ run: { label: "kind.run", verifies: true },
20549
+ drift: { label: "kind.drift", verifies: true },
20550
+ record: { label: "kind.record", verifies: false },
20551
+ };
20552
+ function kindOf(kind) { return KINDS[kind] || { label: "kind.run", verifies: false }; }
20553
+ function kindChip(kind) {
20554
+ return el("span", "chip kind-chip", t(kindOf(kind).label));
20555
+ }
20556
+
20313
20557
  // A run's Claude spend, in the same $x.xxxx form as the per-step badge.
20314
20558
  // A run that billed nothing, and one stored before costs were recorded, both
20315
20559
  // arrive as a non-number — printing $0.0000 would claim a measured zero.
@@ -20580,12 +20824,10 @@ const CLIENT_JS = `
20580
20824
  runCell.appendChild(el("div", "runid", r.id.slice(0, 8)));
20581
20825
  var sub = el("div", "subline");
20582
20826
  sub.appendChild(ciBadge(r));
20827
+ sub.appendChild(kindChip(r.kind));
20583
20828
  if (r.kind === "drift") {
20584
- sub.appendChild(el("span", "chip kind-chip", t("kind.drift")));
20585
20829
  var rowDrift = driftSummary(r);
20586
20830
  if (rowDrift) driftChips(rowDrift).forEach(function (chip) { sub.appendChild(chip); });
20587
- } else {
20588
- sub.appendChild(el("span", "chip kind-chip", t("kind.run")));
20589
20831
  }
20590
20832
  runCell.appendChild(sub);
20591
20833
  tr.appendChild(runCell);
@@ -20602,29 +20844,33 @@ const CLIENT_JS = `
20602
20844
  statusCell.appendChild(runStatusBadge(r));
20603
20845
  tr.appendChild(statusCell);
20604
20846
 
20605
- // A drift row counts what the audit found, not what "passed" — the same
20606
- // ratio its detail page shows. Reading passed/total here printed a
20607
- // different number for the same run in the two places you would compare.
20608
- var rowDriftSummary = r.kind === "drift" ? driftSummary(r) : null;
20609
- var found = rowDriftSummary
20610
- ? rowDriftSummary.testDrift + rowDriftSummary.specChange + rowDriftSummary.unknown
20611
- : null;
20612
- var num = rowDriftSummary
20613
- ? found + " / " + rowDriftSummary.specs
20614
- : r.specs.passed + " / " + r.specs.total;
20615
- var fillTotal = rowDriftSummary ? rowDriftSummary.specs : r.specs.total;
20616
- var fillPart = rowDriftSummary ? found : r.specs.passed;
20617
-
20618
20847
  var specsCell = document.createElement("td");
20619
- var specsWrap = el("div", "specs");
20620
- var meter = el("span", "meter" + (rowDriftSummary ? " drift" : ""));
20621
- var pct = fillTotal > 0 ? Math.round((fillPart / fillTotal) * 100) : 0;
20622
- var bar = el("i");
20623
- bar.style.width = pct + "%";
20624
- meter.appendChild(bar);
20625
- specsWrap.appendChild(meter);
20626
- specsWrap.appendChild(el("span", "num muted", num));
20627
- specsCell.appendChild(specsWrap);
20848
+ if (!kindOf(r.kind).verifies) {
20849
+ specsCell.appendChild(el("span", "muted", ""));
20850
+ } else {
20851
+ // A drift row counts what the audit found, not what "passed" — the same
20852
+ // ratio its detail page shows. Reading passed/total here printed a
20853
+ // different number for the same run in the two places you would compare.
20854
+ var rowDriftSummary = r.kind === "drift" ? driftSummary(r) : null;
20855
+ var found = rowDriftSummary
20856
+ ? rowDriftSummary.testDrift + rowDriftSummary.specChange + rowDriftSummary.unknown
20857
+ : null;
20858
+ var num = rowDriftSummary
20859
+ ? found + " / " + rowDriftSummary.specs
20860
+ : r.specs.passed + " / " + r.specs.total;
20861
+ var fillTotal = rowDriftSummary ? rowDriftSummary.specs : r.specs.total;
20862
+ var fillPart = rowDriftSummary ? found : r.specs.passed;
20863
+
20864
+ var specsWrap = el("div", "specs");
20865
+ var meter = el("span", "meter" + (rowDriftSummary ? " drift" : ""));
20866
+ var pct = fillTotal > 0 ? Math.round((fillPart / fillTotal) * 100) : 0;
20867
+ var bar = el("i");
20868
+ bar.style.width = pct + "%";
20869
+ meter.appendChild(bar);
20870
+ specsWrap.appendChild(meter);
20871
+ specsWrap.appendChild(el("span", "num muted", num));
20872
+ specsCell.appendChild(specsWrap);
20873
+ }
20628
20874
  tr.appendChild(specsCell);
20629
20875
 
20630
20876
  tr.appendChild(el("td", "muted num", costText(r.costUsd)));
@@ -20663,7 +20909,7 @@ const CLIENT_JS = `
20663
20909
  // What kind of run this is, said once. The spec cards below used to repeat
20664
20910
  // it per row, which read as "this spec was drift-audited" — a property of
20665
20911
  // the run described as if it varied spec to spec. Same chip as the run list.
20666
- sub.appendChild(el("span", "chip kind-chip", t(run.kind === "drift" ? "kind.drift" : "kind.run")));
20912
+ sub.appendChild(kindChip(run.kind));
20667
20913
  idblock.appendChild(sub);
20668
20914
  head.appendChild(idblock);
20669
20915
 
@@ -20697,7 +20943,7 @@ const CLIENT_JS = `
20697
20943
  driftBox.appendChild(el("div", "muted", ratio));
20698
20944
  metaItem(t("meta.drift"), driftBox);
20699
20945
  }
20700
- } else {
20946
+ } else if (kindOf(run.kind).verifies) {
20701
20947
  metaItem(t("meta.specs"), run.specs.passed + " / " + run.specs.total + " " + t("meta.passed"));
20702
20948
  }
20703
20949
  // Everything this run spent on Claude — live browsing, triage, the audit a
@@ -20806,6 +21052,12 @@ const CLIENT_JS = `
20806
21052
  var a = r.analysis;
20807
21053
  var head = el("div", "analysis-head");
20808
21054
  head.appendChild(labelChip(a.label));
21055
+ // Which repair a SPEC_CHANGE needs — delete the spec, or rewrite it. The
21056
+ // label is re-checked rather than trusted: this chip means nothing beside
21057
+ // any other one, however the row reached the browser.
21058
+ if (a.label === "SPEC_CHANGE" && a.specChangeKind) {
21059
+ head.appendChild(el("span", "chip spec-change-chip", t("diag.specChangeKind." + a.specChangeKind)));
21060
+ }
20809
21061
  head.appendChild(el("span", "conf", Math.round(a.confidence * 100) + "%"));
20810
21062
  wrap.appendChild(head);
20811
21063
  var kv = el("div", "analysis-kv");
@@ -22000,8 +22252,12 @@ const CLIENT_JS = `
22000
22252
  // runs and no deploys to judge). One runs page answers both: runId -> CI URL,
22001
22253
  // and the profiles a run was actually recorded under. A run pushed without a
22002
22254
  // profile lands in "default", exactly as the ledger stores it.
22255
+ //
22256
+ // Only the two kinds a ledger entry can point at, so recordings — which
22257
+ // advance no ledger and are never looked up here — cannot crowd them out of
22258
+ // the window.
22003
22259
  function fetchRunIndex() {
22004
- return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=200")
22260
+ return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run,drift&limit=200")
22005
22261
  .then(function (data) {
22006
22262
  var urls = {};
22007
22263
  var profiles = [];
@@ -23734,7 +23990,8 @@ function createLearningWorker(deps) {
23734
23990
  const runLimit = job.input.runLimit > 0 ? job.input.runLimit : DEFAULT_RUN_LIMIT;
23735
23991
  const runs = await storage.runs.list({
23736
23992
  project: job.project,
23737
- limit: runLimit
23993
+ limit: runLimit,
23994
+ kinds: ["run"]
23738
23995
  });
23739
23996
  const cases = [];
23740
23997
  let excluded = 0;
@@ -23933,6 +24190,8 @@ function registerRoutes(router, config, queue) {
23933
24190
  router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
23934
24191
  router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
23935
24192
  router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
24193
+ router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
24194
+ router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
23936
24195
  const sessionConfig = {
23937
24196
  store: storage.sessions,
23938
24197
  encryptionKey: config.encryptionKey
@@ -23967,6 +24226,16 @@ function registerRoutes(router, config, queue) {
23967
24226
  }
23968
24227
  //#endregion
23969
24228
  //#region src/hub/core/storage/file/fs-helpers.ts
24229
+ /**
24230
+ * Defense-in-depth for a name this layer joins into a file path. The API's
24231
+ * `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
24232
+ * charset and a length — and stays the rule for what a client may name; this
24233
+ * only refuses traversal, so it also covers callers that never came through
24234
+ * HTTP (tests, a library embedding the hub).
24235
+ */
24236
+ function assertSafeName(value, label) {
24237
+ if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
24238
+ }
23970
24239
  /** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
23971
24240
  async function readJson(path) {
23972
24241
  let raw;
@@ -24106,6 +24375,7 @@ function isNotFound(err) {
24106
24375
  * drift-ledger/<project>/<branch>.json (DriftLedger, no profile)
24107
24376
  * deploys/<project>/<profile>/log.json (DeployLog, ring-buffered)
24108
24377
  * deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
24378
+ * acks/<project>/<profile>/<name>.json (Ack: a consumer's acted-on keys)
24109
24379
  *
24110
24380
  * IDs and names are validated by their callers (run ids are server-minted
24111
24381
  * UUIDs; project/profile/name come from validated request params) before
@@ -24197,6 +24467,43 @@ function deployTouchIndexPath(root, project, profile) {
24197
24467
  function specLocksPath(root, project, profile) {
24198
24468
  return join(root, "locks", project, profile, "locks.json");
24199
24469
  }
24470
+ function ackPath(root, project, profile, name) {
24471
+ return join(root, "acks", project, profile, `${name}.json`);
24472
+ }
24473
+ //#endregion
24474
+ //#region src/hub/core/storage/file/ack-store.ts
24475
+ function assertSafeKey(project, profile, name) {
24476
+ assertSafeName(project, "project");
24477
+ assertSafeName(profile, "profile");
24478
+ assertSafeName(name, "name");
24479
+ }
24480
+ /**
24481
+ * Ack storage: one JSON document per (project, profile, name). A write
24482
+ * replaces the document outright, so unlike the ledgers there is no
24483
+ * read-modify-write to serialize — but it still goes through `writeJson`'s
24484
+ * temp-then-rename, so a concurrent reader never sees a half-written set.
24485
+ */
24486
+ function createFileAckStore(root) {
24487
+ return {
24488
+ async get(project, profile, name) {
24489
+ assertSafeKey(project, profile, name);
24490
+ const parsed = AckSchema.safeParse(await readJson(ackPath(root, project, profile, name)));
24491
+ return parsed.success ? parsed.data : {
24492
+ keys: [],
24493
+ at: null
24494
+ };
24495
+ },
24496
+ async put(project, profile, name, keys) {
24497
+ assertSafeKey(project, profile, name);
24498
+ const ack = {
24499
+ keys,
24500
+ at: (/* @__PURE__ */ new Date()).toISOString()
24501
+ };
24502
+ await writeJson(ackPath(root, project, profile, name), ack);
24503
+ return ack;
24504
+ }
24505
+ };
24506
+ }
24200
24507
  //#endregion
24201
24508
  //#region src/hub/core/storage/file/artifact-store.ts
24202
24509
  /**
@@ -24367,14 +24674,6 @@ function createFileSpecLedgerStore(root) {
24367
24674
  //#endregion
24368
24675
  //#region src/hub/core/storage/file/perspectives-store.ts
24369
24676
  /**
24370
- * Defense-in-depth path validation: the HTTP layer already checks the project
24371
- * segment, but this builds a file path from it, so it re-checks rather than
24372
- * trusting callers. Mirrors the sibling prompt/secret stores.
24373
- */
24374
- function assertSafeName$2(value, label) {
24375
- if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
24376
- }
24377
- /**
24378
24677
  * Perspectives storage: one JSON document per project, plain UTF-8 with no
24379
24678
  * encryption (an inventory of what is tested is not a secret). No meta file —
24380
24679
  * the document's own `generatedAt` is its timestamp.
@@ -24382,19 +24681,19 @@ function assertSafeName$2(value, label) {
24382
24681
  function createFilePerspectivesStore(root) {
24383
24682
  return {
24384
24683
  async put(project, blob) {
24385
- assertSafeName$2(project, "project");
24684
+ assertSafeName(project, "project");
24386
24685
  await writeBytes(perspectivesPath(root, project), blob);
24387
24686
  },
24388
24687
  async get(project) {
24389
- assertSafeName$2(project, "project");
24688
+ assertSafeName(project, "project");
24390
24689
  return readBytesOrNull(perspectivesPath(root, project));
24391
24690
  },
24392
24691
  async update(project, mutate) {
24393
- assertSafeName$2(project, "project");
24692
+ assertSafeName(project, "project");
24394
24693
  await updateJson(perspectivesPath(root, project), mutate);
24395
24694
  },
24396
24695
  async delete(project) {
24397
- assertSafeName$2(project, "project");
24696
+ assertSafeName(project, "project");
24398
24697
  await removePath(perspectivesPath(root, project));
24399
24698
  }
24400
24699
  };
@@ -24402,15 +24701,6 @@ function createFilePerspectivesStore(root) {
24402
24701
  //#endregion
24403
24702
  //#region src/hub/core/storage/file/prompt-store.ts
24404
24703
  /**
24405
- * Defense-in-depth path validation: the HTTP layer already checks project/name,
24406
- * but this builds file paths from them, so it re-checks rather than trusting
24407
- * callers. (Which names are allowed at all is the handler's job — this only
24408
- * guards against path traversal.)
24409
- */
24410
- function assertSafeName$1(value, label) {
24411
- if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
24412
- }
24413
- /**
24414
24704
  * Prompt storage, project-scoped (not per-profile — prompts are project-wide).
24415
24705
  * The blob is plain UTF-8 text (Markdown or custom prompt JSON) with no encryption,
24416
24706
  * so this works whether or not `CCQA_HUB_ENCRYPTION_KEY` is configured.
@@ -24418,8 +24708,8 @@ function assertSafeName$1(value, label) {
24418
24708
  function createFilePromptStore(root) {
24419
24709
  return {
24420
24710
  async put(project, name, blob, meta = {}) {
24421
- assertSafeName$1(project, "project");
24422
- assertSafeName$1(name, "name");
24711
+ assertSafeName(project, "project");
24712
+ assertSafeName(name, "name");
24423
24713
  await writeJson(promptMetaPath(root, project, name), {
24424
24714
  meta,
24425
24715
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -24427,8 +24717,8 @@ function createFilePromptStore(root) {
24427
24717
  await writeBytes(promptBlobPath(root, project, name), blob);
24428
24718
  },
24429
24719
  async get(project, name) {
24430
- assertSafeName$1(project, "project");
24431
- assertSafeName$1(name, "name");
24720
+ assertSafeName(project, "project");
24721
+ assertSafeName(name, "name");
24432
24722
  const blob = await readBytesOrNull(promptBlobPath(root, project, name));
24433
24723
  if (!blob) return null;
24434
24724
  return {
@@ -24437,7 +24727,7 @@ function createFilePromptStore(root) {
24437
24727
  };
24438
24728
  },
24439
24729
  async list(project) {
24440
- assertSafeName$1(project, "project");
24730
+ assertSafeName(project, "project");
24441
24731
  const names = (await listDirOrEmpty(promptProjectDir(root, project))).filter((f) => f.endsWith(".txt")).map((f) => f.slice(0, -4));
24442
24732
  const out = [];
24443
24733
  for (const name of names) {
@@ -24451,8 +24741,8 @@ function createFilePromptStore(root) {
24451
24741
  return out;
24452
24742
  },
24453
24743
  async delete(project, name) {
24454
- assertSafeName$1(project, "project");
24455
- assertSafeName$1(name, "name");
24744
+ assertSafeName(project, "project");
24745
+ assertSafeName(name, "name");
24456
24746
  await removePath(promptBlobPath(root, project, name));
24457
24747
  await removePath(promptMetaPath(root, project, name));
24458
24748
  },
@@ -24494,7 +24784,7 @@ function createFileRunStore(root) {
24494
24784
  };
24495
24785
  });
24496
24786
  },
24497
- async list({ project, branch, status, limit }) {
24787
+ async list({ project, branch, status, kinds, limit }) {
24498
24788
  const ids = await listSubdirsOrEmpty(runsDir(root));
24499
24789
  const runs = [];
24500
24790
  for (const id of ids) {
@@ -24503,6 +24793,7 @@ function createFileRunStore(root) {
24503
24793
  if (project !== void 0 && run.project !== project) continue;
24504
24794
  if (branch !== void 0 && run.branch !== branch) continue;
24505
24795
  if (status !== void 0 && run.status !== status) continue;
24796
+ if (kinds !== void 0 && !kinds.includes(run.kind)) continue;
24506
24797
  runs.push(run);
24507
24798
  }
24508
24799
  runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@@ -24521,14 +24812,6 @@ function createFileRunStore(root) {
24521
24812
  }
24522
24813
  //#endregion
24523
24814
  //#region src/hub/core/storage/file/secret-store.ts
24524
- /**
24525
- * Defense-in-depth: project/profile/name are expected to already be validated
24526
- * by the HTTP layer (`requireSafeSegment`), but this store builds file paths
24527
- * directly from them, so it re-checks rather than trusting callers blindly.
24528
- */
24529
- function assertSafeName(value, label) {
24530
- if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
24531
- }
24532
24815
  function assertSafeScope(scope) {
24533
24816
  assertSafeName(scope.project, "project");
24534
24817
  assertSafeName(scope.profile, "profile");
@@ -24622,7 +24905,8 @@ function createFileHubStorage(dataDir) {
24622
24905
  ledger: createFileSpecLedgerStore(dataDir),
24623
24906
  driftLedger: createFileDriftLedgerStore(dataDir),
24624
24907
  deploys: createFileDeployStore(dataDir),
24625
- locks: createFileLockStore(dataDir)
24908
+ locks: createFileLockStore(dataDir),
24909
+ acks: createFileAckStore(dataDir)
24626
24910
  };
24627
24911
  }
24628
24912
  //#endregion