@christang/keel 5.66.0 → 5.68.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.
@@ -81,6 +81,7 @@ const CONFIG_DECLARATIONS = [
81
81
  "triage",
82
82
  "delegation",
83
83
  "full_mode_paths",
84
+ "executor_tier",
84
85
  ];
85
86
 
86
87
  const CONFIG_RELATIVE_PATH = path.join("keel", "config.yaml");
@@ -402,6 +403,55 @@ function readDelegationPolicy(repo) {
402
403
  return { declared: true, tier, unknown: [], accepted };
403
404
  }
404
405
 
406
+ // Which guidance an executor loads, declared by the repository rather than
407
+ // judged by the executor. #135's argument is that "do I need this help?" is the
408
+ // judgement a weak executor gets most wrong, so the skip is written down; the
409
+ // absent default therefore reads the guidance, and the worst an unconfigured
410
+ // repository does is pay for a read.
411
+ const EXECUTOR_TIERS = ["standard", "high"];
412
+ const EXECUTOR_TIER_DEFAULT = "standard";
413
+
414
+ // The tier reaches guidance and nothing else. It is a separate key from
415
+ // `delegation:`'s tier on purpose: that one names who runs a *delegated* task,
416
+ // and a repository may hand routine work to a weak delegate while its own
417
+ // session is strong, so one field cannot answer both without lying about one.
418
+ function readExecutorTier(repo) {
419
+ const declared = configScalar(repo, "executor_tier");
420
+ const accepted = [...EXECUTOR_TIERS];
421
+ if (!declared) {
422
+ return {
423
+ declared: false,
424
+ tier: EXECUTOR_TIER_DEFAULT,
425
+ unknown: [],
426
+ accepted,
427
+ };
428
+ }
429
+ // Fail closed, the same rule `delegation:` and `authorize:` follow — except
430
+ // that closed here means *reading* the guidance rather than skipping it. A
431
+ // misspelling must not silently buy the reduction it asked for.
432
+ if (!EXECUTOR_TIERS.includes(declared)) {
433
+ return {
434
+ declared: true,
435
+ tier: EXECUTOR_TIER_DEFAULT,
436
+ unknown: [declared],
437
+ accepted,
438
+ message: executorTierUnreadableMessage(declared, accepted),
439
+ };
440
+ }
441
+ return { declared: true, tier: declared, unknown: [], accepted };
442
+ }
443
+
444
+ // Names the value and the alternatives, never just the key: a reader told their
445
+ // declaration is wrong without being told what is right has to go find the
446
+ // documentation the declaration was supposed to replace.
447
+ function executorTierUnreadableMessage(value, accepted) {
448
+ return (
449
+ `keel/config.yaml declares executor_tier: ${value}, which is not one of `
450
+ + `${accepted.join(", ")}; the ${EXECUTOR_TIER_DEFAULT} tier applies until `
451
+ + "it is corrected, so skill guidance is read rather than skipped"
452
+ );
453
+ }
454
+
405
455
  function configScalar(repo, key) {
406
456
  const configPath = path.join(repo, "keel", "config.yaml");
407
457
  if (!fs.existsSync(configPath)) return null;
@@ -548,12 +598,16 @@ function triageIssue(repo, labels, issue = null) {
548
598
  module.exports = {
549
599
  CONFIG_RELATIVE_PATH,
550
600
  DELEGATION_TIERS,
601
+ EXECUTOR_TIERS,
602
+ EXECUTOR_TIER_DEFAULT,
551
603
  CONFIG_DECLARATIONS,
552
604
  STANDING_AUTHORIZATION_ACTIONS,
553
605
  SCOPED_AUTHORIZATION_ACTIONS,
554
606
  readFullModePaths,
555
607
  fullModePathsUnreadableMessage,
556
608
  readDelegationPolicy,
609
+ readExecutorTier,
610
+ executorTierUnreadableMessage,
557
611
  readPrecedentStore,
558
612
  readStandingAuthorization,
559
613
  readTriagePolicy,
@@ -15,6 +15,7 @@ const {
15
15
  readStandingAuthorization,
16
16
  readFullModePaths,
17
17
  fullModePathsUnreadableMessage,
18
+ readExecutorTier,
18
19
  } = require("./config");
19
20
 
20
21
  const NEXT_ACTIONS = new Set([
@@ -696,6 +697,14 @@ function resolveContext(repo, options) {
696
697
  } else {
697
698
  context.routing = routing.paths;
698
699
  }
700
+ // Reported every session, declared or not, unlike `full_mode_paths` above:
701
+ // the default is the one a reader most needs to see, because a repository
702
+ // that declared nothing is loading guidance it may not want and has no other
703
+ // surface that would tell it so.
704
+ const executor = readExecutorTier(repo);
705
+ context.executorTier = executor.tier;
706
+ if (executor.unknown.length > 0) context.warnings.push(executor.message);
707
+
699
708
  // Set here rather than by the caller, so every consumer of the projection —
700
709
  // text, JSON, and any host reading it — carries the version without having
701
710
  // to know to add it.
@@ -748,6 +757,13 @@ function renderContext(result) {
748
757
  for (const entry of result.routing || []) {
749
758
  lines.push(`Routing: ${entry.path} always routes Full — ${entry.reason}`);
750
759
  }
760
+ if (result.executorTier) {
761
+ lines.push(
762
+ `Executor tier: ${result.executorTier} — affects which skill guidance is `
763
+ + "read and nothing else; no gate, criterion, evidence requirement, or "
764
+ + "Review changes with it"
765
+ );
766
+ }
751
767
  for (const reason of result.reasons) lines.push(`Reason: ${reason}`);
752
768
  for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
753
769
  return `${lines.join("\n")}\n`;
package/src/core/gates.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // Keel 4.1.0 deterministic gate contract.
4
4
 
5
+ const crypto = require("crypto");
5
6
  const fs = require("fs");
6
7
  const path = require("path");
7
8
  const { spawnSync } = require("child_process");
@@ -293,6 +294,112 @@ function taskShapeWarnings(repo, selection, task, compiled) {
293
294
  ];
294
295
  }
295
296
 
297
+ // The requirement names a change declares as new, read from the `## ADDED
298
+ // Requirements` sections of its delta specs. A `## MODIFIED` requirement is
299
+ // deliberately not here: behavior that changed is still behavior an equivalence
300
+ // task can legitimately claim is measurement-stable.
301
+ function addedRequirementNames(repo, change) {
302
+ const specsRoot = path.join(repo, "openspec", "changes", change, "specs");
303
+ const names = new Set();
304
+ let capabilities = [];
305
+ try {
306
+ capabilities = fs.readdirSync(specsRoot);
307
+ } catch {
308
+ return names;
309
+ }
310
+ for (const capability of capabilities) {
311
+ const specPath = path.join(specsRoot, capability, "spec.md");
312
+ let content = "";
313
+ try {
314
+ content = fs.readFileSync(specPath, "utf8");
315
+ } catch {
316
+ continue;
317
+ }
318
+ // Only the ADDED section, bounded by the next `## ` heading, so a
319
+ // requirement listed under MODIFIED or REMOVED is not read as new.
320
+ for (const section of content.split(/^##\s+/m).slice(1)) {
321
+ if (!/^ADDED Requirements\s*$/m.test(section.split(/\r?\n/)[0])) continue;
322
+ for (const match of section.matchAll(/^###\s+Requirement:\s*(.+?)\s*$/gm)) {
323
+ names.add(match[1]);
324
+ }
325
+ }
326
+ }
327
+ return names;
328
+ }
329
+
330
+ // A task's declared strategy, from the text it wrote. Both forms: the compact
331
+ // `Strategy:` entry under `Verify`, and the expanded `Verification Strategy`
332
+ // field beside `Commands`.
333
+ function declaredStrategy(task) {
334
+ const compact = String(field(task, "Verify") || "").match(
335
+ /^\s*-?\s*Strategy:\s*(.+?)\s*$/m
336
+ );
337
+ const value = compact
338
+ ? compact[1]
339
+ : String(field(task, "Verification Strategy") || "");
340
+ return value.trim().toLowerCase();
341
+ }
342
+
343
+ // A `Covers` entry naming a spec scenario: `capability / Requirement / Scenario`.
344
+ // Identifier entries (`D4`, `F1`, `E2`) have no slashes and are not spec claims.
345
+ function specCoverEntries(task) {
346
+ return String(field(task, "Covers") || "")
347
+ .split(/\r?\n/)
348
+ .map((line) => line.replace(/^\s*-\s*/, "").trim())
349
+ .filter((entry) => entry.split("/").length >= 3)
350
+ .map((entry) => entry.split("/").map((part) => part.trim()));
351
+ }
352
+
353
+ // `equivalence` owes no red, which makes it the first strategy reached for by a
354
+ // task that should have one. A task covering a scenario its own change *adds* is
355
+ // claiming that behavior is new and that behavior is unchanged at the same time,
356
+ // and without this guard one of the two claims has no proof anywhere in the
357
+ // change. Satisfied only by a sibling covering the same entry, never by the mere
358
+ // presence of a red-green task somewhere in the change.
359
+ function equivalenceEscapeProblems(repo, selection, task, compiled) {
360
+ const strategy = String(
361
+ ((compiled.capsule || {}).verification || {}).strategy || ""
362
+ ).toLowerCase();
363
+ if (strategy !== "equivalence") return [];
364
+ const added = addedRequirementNames(repo, selection.change);
365
+ if (added.size === 0) return [];
366
+ const problems = [];
367
+ for (const parts of specCoverEntries(task)) {
368
+ const requirement = parts[1];
369
+ if (!added.has(requirement)) continue;
370
+ const entry = parts.join(" / ");
371
+ const scenario = parts.slice(2).join(" / ");
372
+ const covered = selection.tasks.some((sibling) => {
373
+ if (sibling.id === task.id) return false;
374
+ // Read from the sibling's own `Verify` text rather than by compiling it.
375
+ // Compiling made the guard depend on the sibling being otherwise valid: a
376
+ // sibling with any unrelated contract error produced no capsule, so its
377
+ // strategy read as absent and it silently stopped satisfying the guard.
378
+ const siblingStrategy = declaredStrategy(sibling);
379
+ if (!RED_GREEN_VERIFICATION_STRATEGIES.has(siblingStrategy)) return false;
380
+ // The same entry, not any entry. A guard satisfied by the presence of a
381
+ // red-green task would be a check that the change contains one, which
382
+ // every change with more than one task passes.
383
+ return specCoverEntries(sibling).some(
384
+ (other) => other.join(" / ") === entry
385
+ );
386
+ });
387
+ if (!covered) {
388
+ problems.push(
389
+ problem(
390
+ "equivalence-covers-added-behavior",
391
+ `This task declares \`Strategy: equivalence\` and covers `
392
+ + `"${scenario}", a scenario this change adds. Behavior that is new `
393
+ + "is not behavior that is unchanged, and no task of this change "
394
+ + "proves the new half: name a red-green task that covers the same "
395
+ + "entry, or move this Covers entry to the task that implements it."
396
+ )
397
+ );
398
+ }
399
+ }
400
+ return problems;
401
+ }
402
+
296
403
  function taskStart(repo, options) {
297
404
  const selection = loadSelection(repo, options);
298
405
  const task = selection.selected[0];
@@ -300,6 +407,7 @@ function taskStart(repo, options) {
300
407
  const problems = [
301
408
  ...compiled.diagnostics,
302
409
  ...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
410
+ ...equivalenceEscapeProblems(repo, selection, task, compiled),
303
411
  ];
304
412
  // Recording the current fingerprint is idempotent: --record replaces the
305
413
  // selected task's Contract anchor whatever it holds, so reauthorizing a task
@@ -454,6 +562,72 @@ function commandLabels(task) {
454
562
  ].map((match) => match[1]);
455
563
  }
456
564
 
565
+ // `artifact <path> sha256:<digest>`, or null for prose. The path is
566
+ // repo-relative; the digest is what Review is entitled to assume it is reading.
567
+ const ARTIFACT_EVIDENCE =
568
+ /^artifact\s+(\S+)\s+sha256:([0-9a-f]{64})\s*$/i;
569
+
570
+ function artifactReference(value) {
571
+ const match = String(value || "").trim().match(ARTIFACT_EVIDENCE);
572
+ return match ? { path: match[1], digest: match[2].toLowerCase() } : null;
573
+ }
574
+
575
+ // Keel checks identity and reads nothing else: it does not parse the artifact,
576
+ // does not know what a field is, and compares nothing in it. The claim that the
577
+ // numbers agree stays the author's, recorded before Review exactly as
578
+ // `Fails with:` and `Detects:` are. What the digest buys is that the file Review
579
+ // opens is the file the author meant, which a retelling cannot offer.
580
+ function artifactProblems(repo, change, label, reference) {
581
+ if (!reference) return [];
582
+ // The inverse of the `Durable owner:` rule, and for the opposite reason: a
583
+ // follow-up pointer has to outlive the change, while an evidence artifact has
584
+ // to travel with it. `openspec archive` moves the change directory and
585
+ // nothing else, so a path outside it is one the archive is guaranteed to
586
+ // leave behind — and an evidence pointer that breaks on archive is worse than
587
+ // a retelling, which at least survives.
588
+ const changeDir = path.join("openspec", "changes", change);
589
+ const normalized = reference.path.split(path.sep).join("/");
590
+ if (!normalized.startsWith(`${changeDir.split(path.sep).join("/")}/`)) {
591
+ return [
592
+ problem(
593
+ "artifact-outside-change",
594
+ `${label} Evidence references \`${reference.path}\`, which is outside `
595
+ + `\`${changeDir}\`. Archiving moves the change directory and nothing `
596
+ + "else, so this pointer breaks the moment the change is archived. "
597
+ + "Put the artifact inside the change's own directory."
598
+ ),
599
+ ];
600
+ }
601
+ const absolute = path.join(repo, reference.path);
602
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
603
+ return [
604
+ problem(
605
+ "artifact-missing",
606
+ `${label} Evidence references the artifact \`${reference.path}\`, and `
607
+ + "no file is there. An unresolvable reference is worse than a "
608
+ + "retelling: the retelling at least carries the result."
609
+ ),
610
+ ];
611
+ }
612
+ const actual = crypto
613
+ .createHash("sha256")
614
+ .update(fs.readFileSync(absolute))
615
+ .digest("hex");
616
+ if (actual !== reference.digest) {
617
+ return [
618
+ problem(
619
+ "artifact-digest-mismatch",
620
+ `${label} Evidence records \`${reference.path}\` at `
621
+ + `sha256:${reference.digest}, and the file there hashes to `
622
+ + `sha256:${actual}. Re-record the digest if the command was re-run, `
623
+ + "or correct the path — naming both is what lets a reader tell a "
624
+ + "stale record from a pointer at the wrong file."
625
+ ),
626
+ ];
627
+ }
628
+ return [];
629
+ }
630
+
457
631
  function evidenceValue(task, label) {
458
632
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
459
633
  const match = field(task, "Evidence").match(
@@ -1049,11 +1223,21 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
1049
1223
  problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
1050
1224
  }
1051
1225
  for (const label of commands) {
1052
- if (!isConcrete(evidenceValue(task, label))) {
1226
+ const recorded = evidenceValue(task, label);
1227
+ if (!isConcrete(recorded)) {
1053
1228
  problems.push(
1054
1229
  problem("missing-evidence", `Missing concrete Evidence for ${label}.`)
1055
1230
  );
1231
+ continue;
1056
1232
  }
1233
+ // An A/B pairing or a sweep summary *is* the output of one command, and
1234
+ // retelling it into tasks.md is a transcription that can be wrong and that
1235
+ // nobody can re-check (issue #142). A reference is checked rather than
1236
+ // tolerated: as prose it would already have passed as concrete, so without
1237
+ // this the form would buy nothing at all.
1238
+ problems.push(
1239
+ ...artifactProblems(repo, change, label, artifactReference(recorded))
1240
+ );
1057
1241
  }
1058
1242
  // A declared measurement is held against the check's own bare `M<n>` Evidence
1059
1243
  // — the entry where the command and its output are recorded. A literal that
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const crypto = require("crypto");
4
+ const { execFileSync } = require("child_process");
4
5
  const fs = require("fs");
5
6
  const path = require("path");
6
7
 
@@ -143,6 +144,13 @@ const SUPPORTED_VERIFICATION_STRATEGIES = [
143
144
  "snapshot-characterization",
144
145
  "rendered-behavior",
145
146
  "evidence-first",
147
+ // Zero difference, not "nothing could fail first". `evidence-first` is the
148
+ // only other strategy without a red-green obligation, and it is scoped by an
149
+ // absence; an A/B against a base is the opposite — a criterion stronger than
150
+ // red-green, because it also catches the change that incidentally moved a
151
+ // result. Issue #142 measured a task re-recording its contract twice to get
152
+ // past the shape rather than the criterion.
153
+ "equivalence",
146
154
  ];
147
155
 
148
156
  const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
@@ -257,6 +265,77 @@ function isPassingReviewStatus(value) {
257
265
  );
258
266
  }
259
267
 
268
+ // A git ref resolved locally, or null. Reads the repository the gate is already
269
+ // reading and reaches nothing else: a gate that fetched would stop being local
270
+ // and offline, which is the property its verdict rests on.
271
+ function resolveCommit(repo, ref) {
272
+ try {
273
+ return execFileSync(
274
+ "git",
275
+ ["-C", repo, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
276
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
277
+ ).trim() || null;
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ // Four ways an `equivalence` task's shape can be complete and still compare
284
+ // nothing. Each names the declaration it is about: a diagnostic naming the
285
+ // strategy would send the author to the one line that is correct.
286
+ function equivalenceProblems(repo, taskVerification) {
287
+ const problems = [];
288
+ const declaredFields = taskVerification.fieldsDeclared;
289
+ if (!isConcrete(taskVerification.base)) {
290
+ problems.push({
291
+ code: "missing-equivalence-base",
292
+ message:
293
+ "equivalence compares one code path at two commits and declares which "
294
+ + "one it is compared against. Add a `Base:` entry beside `Strategy:` "
295
+ + "naming a git ref — without it the criterion is `the numbers are the "
296
+ + "same as some other numbers`.",
297
+ });
298
+ }
299
+ if (taskVerification.fields.length === 0) {
300
+ problems.push({
301
+ code: "missing-equivalence-fields",
302
+ message: declaredFields
303
+ ? "`Fields:` is declared and resolves to an empty set, so the "
304
+ + "comparison has nothing to compare. It reads as a declaration to "
305
+ + "every reader except the comparison; name the fields, separated by "
306
+ + "commas."
307
+ : "equivalence declares which fields are compared. Add a `Fields:` "
308
+ + "entry beside `Strategy:` listing them, separated by commas — a "
309
+ + "comparison with no field set agrees with everything.",
310
+ });
311
+ }
312
+ if (isConcrete(taskVerification.base)) {
313
+ const base = resolveCommit(repo, taskVerification.base);
314
+ if (!base) {
315
+ problems.push({
316
+ code: "unresolvable-equivalence-base",
317
+ message:
318
+ `\`Base: ${taskVerification.base}\` resolves to no commit in this `
319
+ + "repository. The base is read locally and never fetched, so a ref "
320
+ + "that exists only on a remote is not one this gate can see.",
321
+ });
322
+ } else {
323
+ const head = resolveCommit(repo, "HEAD");
324
+ if (head && head === base) {
325
+ problems.push({
326
+ code: "equivalence-base-is-head",
327
+ message:
328
+ `\`Base: ${taskVerification.base}\` resolves to ${base}, which is `
329
+ + "HEAD. An A/B against itself always agrees, so the check would "
330
+ + "pass having compared nothing — the one shape here that is "
331
+ + "complete, resolvable, and still empty.",
332
+ });
333
+ }
334
+ }
335
+ }
336
+ return problems;
337
+ }
338
+
260
339
  function verification(task) {
261
340
  const compact = fieldValues(task, "Verify");
262
341
  const strategyEntry = compact.find((entry) => /^Strategy:\s*/i.test(entry));
@@ -266,8 +345,19 @@ function verification(task) {
266
345
  // command, and a reason that took an `M<n>` label would be a check the author
267
346
  // never wrote and evidence nobody can record.
268
347
  const reasonEntry = compact.find((entry) => /^Reason:\s*/i.test(entry));
348
+ // `equivalence` compares one code path at two commits. Neither half of that
349
+ // fits in a check: the check is the command, and what it cannot say by itself
350
+ // is which commit it is compared against and which fields are compared. #142
351
+ // proposed a third field for the command too; commands already have exactly
352
+ // one home here, and a second would put half of them outside the labelled
353
+ // evidence `task-complete` enforces.
354
+ const baseEntry = compact.find((entry) => /^Base:\s*/i.test(entry));
355
+ const fieldsEntry = compact.find((entry) => /^Fields:\s*/i.test(entry));
269
356
  const isVerificationField = (entry) =>
270
- /^Strategy:\s*/i.test(entry) || /^Reason:\s*/i.test(entry);
357
+ /^Strategy:\s*/i.test(entry)
358
+ || /^Reason:\s*/i.test(entry)
359
+ || /^Base:\s*/i.test(entry)
360
+ || /^Fields:\s*/i.test(entry);
271
361
  const commandSource = compact.length > 0
272
362
  ? compact.filter((entry) => !isVerificationField(entry))
273
363
  : fieldValues(task, "Commands");
@@ -342,6 +432,24 @@ function verification(task) {
342
432
  ? reasonEntry.replace(/^Reason:\s*/i, "")
343
433
  : field(task, "Verification Reason")
344
434
  ),
435
+ base: normalizeText(
436
+ baseEntry ? baseEntry.replace(/^Base:\s*/i, "") : field(task, "Verification Base")
437
+ ),
438
+ // A set, so "declared but empty" is a state the gate can see. `Fields:` with
439
+ // nothing behind it is the shape that passes while comparing nothing, and it
440
+ // reads as a declaration to everyone except the comparison.
441
+ // Whether the line was written at all, kept beside the parsed set so a
442
+ // refusal can tell an author who wrote nothing from one who wrote an empty
443
+ // set. To the comparison they are the same state; to the author they are
444
+ // opposite mistakes.
445
+ fieldsDeclared: Boolean(fieldsEntry || field(task, "Verification Fields")),
446
+ fields: (fieldsEntry
447
+ ? fieldsEntry.replace(/^Fields:\s*/i, "")
448
+ : field(task, "Verification Fields") || ""
449
+ )
450
+ .split(",")
451
+ .map((entry) => normalizeText(entry))
452
+ .filter(Boolean),
345
453
  commands,
346
454
  };
347
455
  }
@@ -1191,6 +1299,11 @@ function compileTaskContract(repo, change, task) {
1191
1299
  + "needs no reason.",
1192
1300
  });
1193
1301
  }
1302
+ if (taskVerification.strategy.toLowerCase() === "equivalence") {
1303
+ resolved.diagnostics.push(
1304
+ ...equivalenceProblems(repo, taskVerification)
1305
+ );
1306
+ }
1194
1307
  const couplingMode = normalizeText(field(task, "Coupling")).toLowerCase()
1195
1308
  || "none";
1196
1309
  const candidateBoundary = normalizedValues(task, "Candidate Boundary", {
@@ -1311,6 +1424,14 @@ function compileTaskContract(repo, change, task) {
1311
1424
  // other task keeps the capsule shape and fingerprint it had before the
1312
1425
  // field existed.
1313
1426
  ...(taskVerification.reason ? { reason: taskVerification.reason } : {}),
1427
+ // Same rule: emitted only by the strategy that declares them, so every
1428
+ // existing task's capsule shape and fingerprint are untouched. They belong
1429
+ // in the capsule rather than only in the file because a declaration
1430
+ // outside the fingerprint could be edited after the run it describes.
1431
+ ...(taskVerification.base ? { base: taskVerification.base } : {}),
1432
+ ...(taskVerification.fields.length > 0
1433
+ ? { fields: taskVerification.fields }
1434
+ : {}),
1314
1435
  // Emit a tag only when the check opts out of a default, so an untagged
1315
1436
  // check keeps the capsule shape and fingerprint it had before either tag
1316
1437
  // existed. `layer` appears only for `fast`, `regression` only when true.