tickmarkr 2.6.3 → 2.6.4

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/README.md CHANGED
@@ -183,8 +183,16 @@ can enact the release at its next task boundary; a different live run must end b
183
183
  one resumes. Keep any resume refusal and its remediation visible, including a deny/prefer
184
184
  config conflict; repair the source/config as directed, never edit the compiled graph to
185
185
  force success. After resume, CURRENT TIP is PENDING until fresh evidence arrives. Completion
186
- requires the latest run-end, a nonfailed known tip result, and empty `failed`, `human`,
187
- `blocked` and `pending` buckets; the completed case has 3/3 recorded merges. An unrelated
186
+ requires the latest run-end, a nonfailed known tip result, empty `failed`, `human`,
187
+ `blocked` and `pending` buckets, and owed checks outstanding empty AND known in CURRENT
188
+ `tickmarkr status <runId>` (`outstanding 0`); the completed case has 3/3 recorded merges.
189
+ Empty buckets alone are not completion (D-660): 3/3 merged with every bucket empty and the
190
+ tip passed, after the operator waived T2's review, leaves one accepted-risk review check owed.
191
+ `run`/`resume` exit 0 on execution alone, their final line says
192
+ `execution complete; outstanding 1 (T2 review)` rather than `verified`, and status reads
193
+ `outstanding 1`. That run completes only when `tickmarkr verify --record <runId>` discharges
194
+ the check and CURRENT status reads `outstanding 0` — a discharge after run-end counts, while the
195
+ run-end record keeps its old count; `outstanding unknown` never completes. An unrelated
188
196
  graph says “not comparable” and supplies no borrowed denominator. Historical GATES RAN
189
197
  does not establish current completion.
190
198
 
@@ -1,7 +1,8 @@
1
- import { type TrustDialog, type TrustVerdict, type WorkerAdapter } from "./types.js";
1
+ import { type ModelRetirement, type TrustDialog, type TrustVerdict, type WorkerAdapter } from "./types.js";
2
2
  export declare function readCodexModelsCache(path?: string): {
3
3
  models: string[];
4
4
  fetchedAt?: string;
5
+ retirements?: Record<string, ModelRetirement | null>;
5
6
  };
6
7
  export declare const CODEX_TRUST_DIALOG: TrustDialog;
7
8
  export declare const CODEX_INPUT_BOX: import("./types.js").InputBox;
@@ -61,20 +61,39 @@ function codexSessionDayDirs(sessions, sinceMs) {
61
61
  // Fails OPEN to { models: [] } on missing/corrupt cache. Version drift observed (installed CLI
62
62
  // 0.143.0 vs cache client_version 0.144.0, 2026-07-10) — hence the defensive try/catch and no shape
63
63
  // assumptions. CODEX_HOME is codex's own relocation env. fetchedAt is the honest codex knowledge age.
64
+ // v2.6.4 T2 (B1a): each listed entry's `upgrade` is the CLI's own retirement notice (0.159.0 cache,
65
+ // 2026-09-29: gpt-5.5 → {model: gpt-5.6-sol, retirement_at: 2026-10-14T19:00:00Z}; every other listed
66
+ // id carries upgrade: null). null records known clean; a malformed or absent `upgrade` records nothing,
67
+ // so that id stays unknown. An unreadable cache returns no retirements at all (unknown, never clean).
64
68
  export function readCodexModelsCache(path) {
65
69
  const p = path ?? join(process.env.CODEX_HOME || join(homedir(), ".codex"), "models_cache.json");
66
70
  try {
67
71
  const d = JSON.parse(readFileSync(p, "utf8"));
68
- const models = (d.models ?? [])
69
- .filter((m) => m?.visibility === "list" && typeof m.slug === "string" && m.slug.length > 0)
70
- .map((m) => m.slug)
71
- .filter((id) => MODEL_ID_RE.test(id));
72
- return { models, fetchedAt: typeof d.fetched_at === "string" ? d.fetched_at : undefined };
72
+ const listed = (d.models ?? [])
73
+ .filter((m) => m?.visibility === "list" && typeof m.slug === "string" && m.slug.length > 0 && MODEL_ID_RE.test(m.slug));
74
+ const retirements = {};
75
+ for (const m of listed) {
76
+ const notice = codexRetirement(m.upgrade);
77
+ if (notice !== undefined)
78
+ retirements[m.slug] = notice;
79
+ }
80
+ return { models: listed.map((m) => m.slug), fetchedAt: typeof d.fetched_at === "string" ? d.fetched_at : undefined, retirements };
73
81
  }
74
82
  catch {
75
83
  return { models: [] };
76
84
  }
77
85
  }
86
+ function codexRetirement(upgrade) {
87
+ if (upgrade === null)
88
+ return null;
89
+ const u = upgrade;
90
+ if (typeof u?.retirement_at !== "string" || !Number.isFinite(Date.parse(u.retirement_at)))
91
+ return undefined;
92
+ if (u.model === undefined)
93
+ return { retiresAt: u.retirement_at };
94
+ // the successor id comes from an external file and is rendered in lints — same charset gate as slugs
95
+ return typeof u.model === "string" && MODEL_ID_RE.test(u.model) ? { retiresAt: u.retirement_at, successor: u.model } : undefined;
96
+ }
78
97
  // tickmarkr worktrees keep their gitdir under the MAIN repo's .git/worktrees/<name> — outside the
79
98
  // workspace-write sandbox root — so git commit dies on index.lock (incident run-20260709-104447
80
99
  // P87-08: "Unable to create …/.git/worktrees/…/index.lock"). Expanded by the pane shell at the
@@ -272,6 +291,8 @@ export const codex = {
272
291
  // v1.5 MODEL-05: codex reads an offline cache, so "now" would re-stamp an ancient cache fresh and the
273
292
  // 30-day staleness lint could never fire. Surface the cache's own fetched_at so doctor stamps the real age.
274
293
  listModelsFetchedAt: () => readCodexModelsCache().fetchedAt,
294
+ // v2.6.4 T2 (B1a): the cache's per-model retirement notices, persisted beside models in doctor.json.
295
+ listModelsRetirements: () => readCodexModelsCache().retirements,
275
296
  collectUsage(cwd, sinceMs) {
276
297
  try {
277
298
  const real = realpathSync(cwd);
@@ -81,6 +81,7 @@ export declare function modelLints(cfg: TickmarkrConfig, health: Record<string,
81
81
  tty?: boolean;
82
82
  stateDir?: string;
83
83
  overlayPreferShapes?: ReadonlySet<string>;
84
+ now?: Date;
84
85
  }): string[];
85
86
  export declare function formatModelAuthLine(excluded: {
86
87
  key: string;
@@ -478,10 +478,55 @@ export function preferEntryLints(cfg, health, mapShapes = new Set()) {
478
478
  sweep("consult.prefer", cfg.consult.prefer);
479
479
  return lints;
480
480
  }
481
+ // v2.6.4 T2 (B1a): doctor.json is read without a schema (compat), so a stored notice is re-checked here —
482
+ // anything but null (known clean) or a dated notice reads as unknown, never as clean.
483
+ function storedRetirement(record, model) {
484
+ if (!record || typeof record !== "object")
485
+ return undefined;
486
+ const r = record[model];
487
+ if (r === null)
488
+ return null;
489
+ const n = r;
490
+ if (typeof n?.retiresAt !== "string" || !Number.isFinite(Date.parse(n.retiresAt)))
491
+ return undefined;
492
+ if (n.successor === undefined)
493
+ return { retiresAt: n.retiresAt };
494
+ return typeof n.successor === "string" && MODEL_ID_RE.test(n.successor) ? { retiresAt: n.retiresAt, successor: n.successor } : undefined;
495
+ }
496
+ // Configured AND listed models only — a configured id the CLI stopped listing already carries the
497
+ // tombstone advisory. The CLI's notice is quoted as-is: its successor is the CLI's upgrade target, never
498
+ // a tier or benchmark claim, and no model name or visibility is read as one. Advisory: routing unchanged.
499
+ function retirementLints(id, listedConfigured, record, nowMs) {
500
+ const lints = [];
501
+ const unknown = [];
502
+ for (const model of listedConfigured) {
503
+ const notice = storedRetirement(record, model);
504
+ if (notice === undefined) {
505
+ unknown.push(model);
506
+ continue;
507
+ }
508
+ if (!notice)
509
+ continue; // listed with no notice: known clean
510
+ const successor = notice.successor
511
+ ? `successor ${notice.successor} (named by the CLI — not a tier claim; classify it per benchmark policy)`
512
+ : "no successor named";
513
+ lints.push(nowMs >= Date.parse(notice.retiresAt)
514
+ ? `${id}: ${model} retired ${notice.retiresAt} per the CLI's notice; ${successor} — tombstone it (${model}: null overlay) or verify the id (advisory — routing unchanged)`
515
+ : `${id}: ${model} retires ${notice.retiresAt} per the CLI's notice; ${successor} — tombstone it (${model}: null overlay) before then (advisory — routing unchanged)`);
516
+ }
517
+ if (unknown.length) {
518
+ const why = record && typeof record === "object"
519
+ ? "the CLI's notice is absent or unreadable"
520
+ : "doctor record carries no CLI retirement notices; rerun tickmarkr doctor";
521
+ lints.push(`${id}: retirement unknown for ${unknown.join(", ")} — ${why}`);
522
+ }
523
+ return lints;
524
+ }
481
525
  // Diffs detected models (doctor.json) against configured tiers, both directions, per installed adapter.
482
526
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
483
527
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
484
528
  export function modelLints(cfg, health, adapters, opts) {
529
+ const nowMs = (opts?.now ?? new Date()).getTime();
485
530
  const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
486
531
  const doctorRef = opts?.tty ? doctorJsonRef(opts.stateDir ?? DEFAULT_STATE_DIR) : "";
487
532
  const lints = [];
@@ -517,6 +562,8 @@ export function modelLints(cfg, health, adapters, opts) {
517
562
  lints.push(`${id}: tiers lists ${model} — CLI no longer reports it; tombstone it (${model}: null overlay) or verify the id`);
518
563
  }
519
564
  }
565
+ if (adapter.listModelsRetirements)
566
+ lints.push(...retirementLints(id, configured.filter((m) => detected.includes(m)), h?.modelRetirements, nowMs));
520
567
  const extra = collapseUnclassified(detected, new Set(configured));
521
568
  if (extra.length) {
522
569
  const shown = extra.slice(0, cap).map((row) => row.model).join(", ");
@@ -525,7 +572,7 @@ export function modelLints(cfg, health, adapters, opts) {
525
572
  }
526
573
  const at = h?.modelsDetectedAt;
527
574
  if (at) {
528
- const days = Math.floor((Date.now() - Date.parse(at)) / DAY_MS); // completed days — never overstate age
575
+ const days = Math.floor((nowMs - Date.parse(at)) / DAY_MS); // completed days — never overstate age
529
576
  if (days >= MODEL_STALE_DAYS)
530
577
  lints.push(`${id}: model knowledge is ${days} days old — rerun tickmarkr doctor`);
531
578
  }
@@ -15,7 +15,8 @@ export type ModelWindowResolution = {
15
15
  };
16
16
  /**
17
17
  * Validate cited model-window claims before production code can consume them.
18
- * These values are vendor-published claims, not measurements made by tickmarkr.
18
+ * These values are vendor-published claims (a vendor page, or a vendor CLI's own model cache), not
19
+ * measurements made by tickmarkr.
19
20
  */
20
21
  export declare function loadModelWindowClaims(input: unknown): readonly ModelWindowClaim[];
21
22
  /** The closed, production-readable catalog of cited context-window claims. */
@@ -9,7 +9,8 @@ const ModelWindowClaimSchema = z
9
9
  .strict();
10
10
  /**
11
11
  * Validate cited model-window claims before production code can consume them.
12
- * These values are vendor-published claims, not measurements made by tickmarkr.
12
+ * These values are vendor-published claims (a vendor page, or a vendor CLI's own model cache), not
13
+ * measurements made by tickmarkr.
13
14
  */
14
15
  export function loadModelWindowClaims(input) {
15
16
  const claims = z.array(ModelWindowClaimSchema).parse(input);
@@ -22,8 +23,15 @@ export function loadModelWindowClaims(input) {
22
23
  return Object.freeze(claims.map((claim) => Object.freeze(claim)));
23
24
  }
24
25
  const ANTHROPIC_SOURCE = "https://platform.claude.com/docs/en/about-claude/models/overview";
25
- const OPENAI_SOURCE = "https://developers.openai.com/api/docs/models";
26
- const OPENAI_GPT_55_SOURCE = "https://developers.openai.com/api/docs/models/gpt-5.5";
26
+ // v2.6.4 T3 (D-717): the Codex rows cite the installed CLI's own models_cache.json snapshot, not the API
27
+ // docs. That file has no public URL, so the source names it in its own scheme, snapshot instant included.
28
+ // Every listed entry there carried context_window=272000 — the budget the codex CLI launches with. The
29
+ // API docs' 1,050,000 (formerly cited here) describes the API surface; it is superseded for these CLI
30
+ // seeds, not called false. These windows are the CLI's self-report, not a tickmarkr measurement. Only the
31
+ // window comes from this snapshot: the codex tiers seeded beside it (config.ts) rest on the provider's
32
+ // descriptions and the retained workhorse tier policy, not on any measured benchmark.
33
+ const CODEX_CLI_CACHE_SOURCE = "codex-cli:models_cache.json#fetched_at=2026-09-29T16:09Z";
34
+ const CODEX_CLI_CACHE_READ_DATE = "2026-09-29";
27
35
  const CURSOR_SOURCE = "https://cursor.com/help/ai-features/max-mode";
28
36
  const ZAI_SOURCE = "https://z.ai/blog/glm-5.2";
29
37
  const XAI_SOURCE = "https://docs.x.ai/developers/models/grok-4.5";
@@ -37,10 +45,13 @@ const VENDORED_MODEL_WINDOW_CLAIMS = [
37
45
  { modelId: "opus", window: 1_000_000, source: ANTHROPIC_SOURCE, readDate: READ_DATE },
38
46
  { modelId: "sonnet", window: 1_000_000, source: ANTHROPIC_SOURCE, readDate: READ_DATE },
39
47
  { modelId: "haiku", window: 200_000, source: ANTHROPIC_SOURCE, readDate: READ_DATE },
40
- { modelId: "gpt-5.6-sol", window: 1_050_000, source: OPENAI_SOURCE, readDate: READ_DATE },
41
- { modelId: "gpt-5.5", window: 1_050_000, source: OPENAI_GPT_55_SOURCE, readDate: READ_DATE },
42
- { modelId: "gpt-5.6-terra", window: 1_050_000, source: OPENAI_SOURCE, readDate: READ_DATE },
43
- { modelId: "gpt-5.6-luna", window: 1_050_000, source: OPENAI_SOURCE, readDate: READ_DATE },
48
+ { modelId: "gpt-6-astra", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
49
+ { modelId: "gpt-6-sol", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
50
+ { modelId: "gpt-6-luna", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
51
+ { modelId: "gpt-5.6-sol", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
52
+ { modelId: "gpt-5.5", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
53
+ { modelId: "gpt-5.6-terra", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
54
+ { modelId: "gpt-5.6-luna", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
44
55
  { modelId: "composer-2.5", window: 200_000, source: CURSOR_SOURCE, readDate: READ_DATE },
45
56
  { modelId: "composer-2.5-fast", window: 200_000, source: CURSOR_SOURCE, readDate: READ_DATE },
46
57
  { modelId: "claude-fable-5-1", window: 1_000_000, source: ANTHROPIC_SOURCE, readDate: OBS_871_READ_DATE },
@@ -36,6 +36,10 @@ export interface ModelAuth {
36
36
  probedAt: string;
37
37
  identity?: string;
38
38
  }
39
+ export interface ModelRetirement {
40
+ retiresAt: string;
41
+ successor?: string;
42
+ }
39
43
  export interface AuthHealth {
40
44
  installed: boolean;
41
45
  authed: boolean;
@@ -46,6 +50,7 @@ export interface AuthHealth {
46
50
  servable?: string[];
47
51
  modelAuth?: Record<string, ModelAuth>;
48
52
  modelIdentities?: Record<string, string>;
53
+ modelRetirements?: Record<string, ModelRetirement | null>;
49
54
  }
50
55
  export declare function modelAuthed(health: AuthHealth | undefined, model: string, allowUnverifiedModels?: boolean): boolean;
51
56
  export interface Invocation {
@@ -190,6 +195,7 @@ export interface WorkerAdapter {
190
195
  parse(output: string, nonce: string): WorkerResult;
191
196
  listModels?(): Promise<string[]>;
192
197
  listModelsFetchedAt?(): string | undefined;
198
+ listModelsRetirements?(): Record<string, ModelRetirement | null> | undefined;
193
199
  collectUsage?(cwd: string, sinceMs: number): TokenUsage | undefined;
194
200
  contextUsage?(session: SessionRef): ContextUsage | null;
195
201
  readSessionTranscript?(session: SessionRef): {
@@ -1,11 +1,13 @@
1
1
  import { graphDefinitionHash, loadGraph, saveGraph, taskDefinitionFingerprint } from "../../graph/graph.js";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { userInfo } from "node:os";
4
+ import { allAdapters } from "../../adapters/registry.js";
5
+ import { channelKey } from "../../adapters/types.js";
4
6
  import { loadConfig } from "../../config/config.js";
5
7
  import { integrationBranch } from "../../run/merge.js";
6
8
  import { separabilityErrors, surfaceErrors, taskBudgetErrors } from "../../compile/collateral.js";
7
9
  import { GATE_NAMES } from "../../graph/schema.js";
8
- import { applyScopeAmendments, bindingToken, engagementComparable, engagementReleased, failedGateBeforePark, parseBindingToken, physicalLine, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
10
+ import { applyScopeAmendments, bindingToken, captureOwedCheck, engagementComparable, engagementReleased, failedGateBeforePark, parseBindingToken, physicalLine, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
9
11
  export const APPROVAL_DISPOSITIONS = ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
10
12
  /**
11
13
  * What each disposition's release actually buys — the clause every operator-facing sentence about
@@ -296,6 +298,28 @@ export async function approve(argv, cwd = process.cwd()) {
296
298
  if (!gateFailPark || !failedGate) {
297
299
  throw new Error(`--waive applies to a gate-fail park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
298
300
  }
301
+ // C1: the waiver's owed check rides the SAME row — one append, captured synchronously so a live
302
+ // daemon's boundary never observes the decision without its obligation (or a second write).
303
+ // The seats a review discharge may resolve to are this repository's declared channels, recorded on
304
+ // the row so every later fold reads the same table whatever process or checkout it runs in.
305
+ let integrationRef = "unknown";
306
+ let declared;
307
+ let owedTask;
308
+ try {
309
+ const cfg = loadConfig(cwd);
310
+ integrationRef = integrationBranch(cfg, runId);
311
+ declared = allAdapters().flatMap((a) => a.channels(cfg)).map((c) => ({ key: channelKey(c), vendor: c.vendor }));
312
+ }
313
+ catch { /* an unreadable config binds no range and declares no seat: unknown */ }
314
+ try {
315
+ owedTask = loadGraph(cwd).tasks.find((t) => t.id === taskId);
316
+ }
317
+ catch { /* recorded as unknown criteria and scope */ }
318
+ const obligation = captureOwedCheck({
319
+ cwd, runId, taskId, gate: failedGate, events, acceptance: owedTask?.acceptance, files: owedTask?.files, declared,
320
+ cause: reason ?? `operator waived failed ${failedGate} gate`, // the row's own park field binds the park
321
+ taskRef: `${integrationRef}--${taskId}`, integrationRef,
322
+ });
299
323
  journal.append("task-approved", taskId, {
300
324
  by,
301
325
  ...(reason ? { reason } : {}),
@@ -304,8 +328,9 @@ export async function approve(argv, cwd = process.cwd()) {
304
328
  gate: failedGate,
305
329
  park: parkBinding,
306
330
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
331
+ obligation,
307
332
  });
308
- return disposition(cwd, runId, "waive-gate", `waived failed gate ${failedGate} for ${taskId} in ${runId} — by ${by}`, serialization.contended);
333
+ return disposition(cwd, runId, "waive-gate", `waived failed gate ${failedGate} for ${taskId} in ${runId} — by ${by}; owes ${failedGate} check ${obligation.id}${obligation.known ? "" : " (debt unknown)"}`, serialization.contended);
309
334
  }
310
335
  if (gateFailPark) {
311
336
  const choices = [`--waive (disposition waive-gate)`, `--recheck (disposition re-dispatch)`];
@@ -34,6 +34,8 @@ export type DoctorOpts = {
34
34
  resolveOrcaBinary?: (cwd: string) => string | undefined;
35
35
  /** Test seam for the runner-owned JSON listing used by the acceptance-oracle report row. */
36
36
  listTests?: (cwd: string) => Promise<VitestListResult>;
37
+ /** Clock for model-knowledge lints (retirement notices, knowledge age); absent = the wall clock. */
38
+ now?: () => Date;
37
39
  };
38
40
  type OrcaCapability = {
39
41
  verdict: "pass" | "fail" | "warn";
@@ -590,6 +590,11 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
590
590
  // no fetch timestamp, so it still stamps now — its staleness lint is best-effort until it surfaces one.
591
591
  if (health[a.id].models.length)
592
592
  health[a.id].modelsDetectedAt = a.listModelsFetchedAt?.() ?? new Date().toISOString();
593
+ // v2.6.4 T2 (B1a): the CLI's retirement notices ride into doctor.json beside the list they describe;
594
+ // an unreadable source leaves the field absent, which every reader treats as unknown.
595
+ const retirements = a.listModelsRetirements?.();
596
+ if (retirements)
597
+ health[a.id].modelRetirements = retirements;
593
598
  }
594
599
  catch { /* fail open: leave models as-is, doctor stays healthy */ }
595
600
  }
@@ -784,7 +789,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
784
789
  const liveBenchStale = liveBenchStalenessFinding(opts.catalogNow?.() ?? new Date());
785
790
  if (liveBenchStale)
786
791
  lintRows.push(attentionRow(liveBenchStale));
787
- lintRows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map(attentionRow));
792
+ lintRows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd), now: opts.now?.() }).map(attentionRow));
788
793
  const excluded = excludedChannels(cfg, adapters, health);
789
794
  if (excluded.length)
790
795
  lintRows.push(attentionRow(exclusionLine(excluded)));