tickmarkr 2.8.4 → 2.8.6

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.
Files changed (51) hide show
  1. package/dist/adapters/claude-code.js +3 -2
  2. package/dist/adapters/codex-commit-check.d.ts +4 -3
  3. package/dist/adapters/codex-commit-check.js +44 -29
  4. package/dist/adapters/codex.d.ts +2 -2
  5. package/dist/adapters/codex.js +38 -32
  6. package/dist/adapters/cursor-agent.js +2 -1
  7. package/dist/adapters/grok.js +2 -1
  8. package/dist/adapters/kimi.js +11 -9
  9. package/dist/adapters/model-lints.js +4 -3
  10. package/dist/adapters/opencode.js +2 -1
  11. package/dist/adapters/pi.js +3 -2
  12. package/dist/adapters/qwen.js +2 -1
  13. package/dist/adapters/registry.js +2 -1
  14. package/dist/adapters/types.d.ts +10 -1
  15. package/dist/adapters/types.js +18 -3
  16. package/dist/cli/commands/approve.js +9 -28
  17. package/dist/cli/commands/doctor.d.ts +49 -5
  18. package/dist/cli/commands/doctor.js +956 -94
  19. package/dist/cli/commands/fleet.js +11 -3
  20. package/dist/cli/commands/init.js +27 -19
  21. package/dist/cli/commands/status.js +9 -6
  22. package/dist/cli/commands/verify.js +12 -3
  23. package/dist/compile/gsd.js +4 -3
  24. package/dist/compile/index.js +4 -1
  25. package/dist/compile/native.js +8 -6
  26. package/dist/config/config.d.ts +15 -0
  27. package/dist/config/config.js +67 -5
  28. package/dist/drivers/host-env.d.ts +3 -0
  29. package/dist/drivers/host-env.js +47 -0
  30. package/dist/drivers/orca.js +3 -1
  31. package/dist/drivers/subprocess.d.ts +1 -1
  32. package/dist/drivers/subprocess.js +4 -3
  33. package/dist/gates/baseline.d.ts +1 -1
  34. package/dist/gates/baseline.js +4 -3
  35. package/dist/gates/cache.js +2 -4
  36. package/dist/graph/schema.js +3 -1
  37. package/dist/run/daemon.js +54 -4
  38. package/dist/run/git-trust.d.ts +11 -0
  39. package/dist/run/git-trust.js +1 -1
  40. package/dist/run/git.d.ts +13 -1
  41. package/dist/run/git.js +34 -14
  42. package/dist/run/journal.d.ts +15 -1
  43. package/dist/run/journal.js +83 -17
  44. package/dist/run/lock.d.ts +5 -0
  45. package/dist/run/lock.js +202 -9
  46. package/dist/run/protocol.d.ts +1 -1
  47. package/dist/tui/cockpit/live-store.js +12 -2
  48. package/package.json +1 -1
  49. package/skills/tickmarkr-auto/SKILL.md +65 -21
  50. package/skills/tickmarkr-loop/SKILL.md +65 -21
  51. package/skills/tickmarkr-overseer/SKILL.md +88 -32
@@ -1,7 +1,8 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, readFileSync, readdirSync } from "node:fs";
4
- import { join } from "node:path";
3
+ import { Script } from "node:vm";
4
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
5
+ import { extname, join, normalize, resolve } from "node:path";
5
6
  import { detectPackageManager, turboContinueFindings } from "../../gates/baseline.js";
6
7
  import { version } from "./version.js";
7
8
  import { allAdapters, binaryShadowWarnings, detectCandidateClis, doctorAgeMs, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, readDoctor, resolveShellBinary, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
@@ -12,6 +13,7 @@ import { graphPath, loadGraph, tickmarkrDir, stateDirName } from "../../graph/gr
12
13
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
13
14
  import { loadConfig, overlayPreferShapes } from "../../config/config.js";
14
15
  import { HerdrDriver } from "../../drivers/herdr.js";
16
+ import { classifyHost } from "../../drivers/index.js";
15
17
  import { ORCA_FIXTURE_VERSION, parseEnvelope, resolveOrcaCliBinary } from "../../drivers/orca.js";
16
18
  import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
17
19
  import { recordCodexCommit } from "../../adapters/codex-commit-check.js";
@@ -190,8 +192,19 @@ export async function probeOrcaCapability(cwd, opts = {}) {
190
192
  return { verdict: "fail", detail: "CLI installed but runtime probe failed" };
191
193
  }
192
194
  }
195
+ /** A file's text, "" when absent, or the error code when it cannot be read (D-2102 add.31: never a throw). */
196
+ function readText(cwd, p) {
197
+ if (!existsSync(join(cwd, p)))
198
+ return { text: "" };
199
+ try {
200
+ return { text: readFileSync(join(cwd, p), "utf8") };
201
+ }
202
+ catch (e) {
203
+ return { text: "", error: e.code ?? "unreadable" };
204
+ }
205
+ }
193
206
  function detectRunner(cwd) {
194
- const readIf = (p) => (existsSync(join(cwd, p)) ? readFileSync(join(cwd, p), "utf8") : "");
207
+ const readIf = (p) => readText(cwd, p).text;
195
208
  let pkg = {};
196
209
  try {
197
210
  pkg = JSON.parse(readIf("package.json") || "{}");
@@ -208,71 +221,840 @@ function detectRunner(cwd) {
208
221
  }
209
222
  return undefined;
210
223
  }
211
- export function runnerIgnoreFinding(cwd) {
212
- const readIf = (p) => (existsSync(join(cwd, p)) ? readFileSync(join(cwd, p), "utf8") : "");
213
- const runner = detectRunner(cwd);
214
- if (!runner)
224
+ // Vitest 3.2.7's configDefaults.exclude, written out so an exclude doctor adds keeps the runner's defaults.
225
+ const VITEST_DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/cypress/**", "**/.{idea,git,cache,output,temp}/**",
226
+ "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"];
227
+ // Queue row 197 (D-2102 add.15, add.30): the canonical entries, as string values.
228
+ const CANONICAL = {
229
+ jest: { state: "<rootDir>/\\.tickmarkr/", session: "<rootDir>/\\.claude/worktrees/", ignore: "testPathIgnorePatterns" },
230
+ vitest: { state: ".tickmarkr/**", session: ".claude/worktrees/**", ignore: "test.exclude" },
231
+ };
232
+ const DIRS = ["state", "session"];
233
+ const SCANS = { state: ".tickmarkr/ run worktrees", session: ".claude/worktrees/ session worktrees" };
234
+ const INSIDE = {
235
+ state: "inside run worktrees (provisioned under .tickmarkr/worktrees.noindex/)",
236
+ session: "inside a session worktree (.claude/worktrees/<name>/)",
237
+ };
238
+ const STRING_LITERAL = /^(["'])(?:\\.|(?!\1)[^\\\n])*\1$/;
239
+ // The one spread with a known value: Vitest 3.2.7's own default exclude list (it holds neither entry).
240
+ // The ECMAScript line terminators: a line comment ends at any of them (queue row 197 review, D-2102 add.31).
241
+ const LINE_END = /[\n\r\u2028\u2029]/;
242
+ /** The offset of the first line terminator at or after `from`, or the end of `text`. */
243
+ const lineEnd = (text, from) => {
244
+ const at = text.slice(from).search(LINE_END);
245
+ return at < 0 ? text.length : from + at;
246
+ };
247
+ // Whitespace and commas between members or elements (comments are already blanked).
248
+ const BETWEEN = /^[\s,]*/;
249
+ // Comments are blanked (offsets kept, so the writer can splice the original) before judgment: a
250
+ // commented-out ignore must not false-pass, and a prose comment mentioning .tickmarkr must not
251
+ // false-warn the anchor probe. The scan skips string literals on purpose: regex stripping ate glob
252
+ // strings — `'**/*.d.ts'` contains `/*` and later globs contain `*/`, so a naive block-comment regex
253
+ // deleted the middle of real configs and false-PASSED a repo-wide vitest include (caught on
254
+ // Intl-Dossier's config; shipped briefly in 1.90.1). Queue row 197: a block comment's inner lines
255
+ // are blanked too, so entries commented out between its delimiters never read as live.
256
+ function blankComments(s) {
257
+ let out = "";
258
+ for (let i = 0; i < s.length; i++) {
259
+ const c = s[i];
260
+ let end = i + 1;
261
+ if (c === '"' || c === "'" || c === "`") {
262
+ for (; end < s.length && s[end] !== c && (c === "`" || !LINE_END.test(s[end])); end++)
263
+ if (s[end] === "\\")
264
+ end++;
265
+ end = Math.min(end + 1, s.length);
266
+ }
267
+ else if (c === "\\")
268
+ end = i + 2;
269
+ else if (s.startsWith("//", i))
270
+ end = lineEnd(s, i);
271
+ else if (s.startsWith("/*", i))
272
+ end = s.indexOf("*/", i + 2) < 0 ? s.length : s.indexOf("*/", i + 2) + 2;
273
+ const chunk = s.slice(i, end);
274
+ out += c === "/" && (s[i + 1] === "/" || s[i + 1] === "*") ? chunk.replace(/[^\n\r\u2028\u2029]/g, " ") : chunk;
275
+ i = end - 1;
276
+ }
277
+ return out;
278
+ }
279
+ /** Offset of the first top-level , ) ] or } at or after `start` (strings skipped, brackets nested). */
280
+ function scanValue(text, start) {
281
+ let depth = 0;
282
+ for (let i = start; i < text.length; i++) {
283
+ const c = text[i];
284
+ if (c === '"' || c === "'" || c === "`") {
285
+ for (i++; i < text.length && text[i] !== c; i++)
286
+ if (text[i] === "\\")
287
+ i++;
288
+ }
289
+ else if ("([{".includes(c))
290
+ depth++;
291
+ else if (")]}".includes(c) && depth-- === 0)
292
+ return i;
293
+ else if (c === "," && depth === 0)
294
+ return i;
295
+ }
296
+ return text.length;
297
+ }
298
+ /** The offset just past the bracket opened at `open` (strings skipped, brackets nested). */
299
+ function closing(text, open) {
300
+ let depth = 0;
301
+ for (let i = open; i < text.length; i++) {
302
+ const c = text[i];
303
+ if (c === '"' || c === "'" || c === "`") {
304
+ for (i++; i < text.length && text[i] !== c; i++)
305
+ if (text[i] === "\\")
306
+ i++;
307
+ }
308
+ else if ("([{".includes(c))
309
+ depth++;
310
+ else if (")]}".includes(c) && --depth === 0)
311
+ return i + 1;
312
+ }
313
+ return text.length;
314
+ }
315
+ /** The top-level elements of an array literal's source (offsets into it); undefined for any other expression. */
316
+ function arrayElements(value) {
317
+ if (!value.startsWith("[") || closing(value, 0) !== value.length)
215
318
  return undefined;
319
+ const out = [];
320
+ for (let pos = 1; pos < value.length - 1;) {
321
+ pos += BETWEEN.exec(value.slice(pos, value.length - 1))?.[0].length ?? 0;
322
+ if (pos >= value.length - 1)
323
+ break;
324
+ const end = Math.min(scanValue(value, pos), value.length - 1);
325
+ out.push({ text: value.slice(pos, end).trimEnd(), start: pos });
326
+ pos = end > pos ? end : pos + 1;
327
+ }
328
+ return out;
329
+ }
330
+ /** The members directly inside the object literal opened at `open`; a spread, shorthand, method or computed key has key "". */
331
+ function members(text, open) {
332
+ const close = closing(text, open) - 1;
333
+ const out = [];
334
+ for (let i = open + 1; i < close;) {
335
+ i += BETWEEN.exec(text.slice(i, close))?.[0].length ?? 0;
336
+ if (i >= close)
337
+ break;
338
+ const key = /^(?:(["'])((?:\\.|(?!\1)[^\\\n])*)\1|((?:[A-Za-z_$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})(?:[\w$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})*))\s*:\s*/.exec(text.slice(i, close));
339
+ const start = i + (key?.[0].length ?? 0);
340
+ const stop = Math.min(scanValue(text, start), close);
341
+ out.push({ key: key ? key[3] ?? key[2] : "", start, value: text.slice(start, stop).trimEnd() });
342
+ i = stop > i ? stop : i + 1;
343
+ }
344
+ return out;
345
+ }
346
+ /**
347
+ * D-2102 add.25 (c′) and add.28: a settings key's or a string value's escapes decoded strictly — \xHH,
348
+ * \uHHHH, \u{H…} and \n \t \r \b \f \v \0 \' \" \\ — or undefined for any other escape (an octal, a line
349
+ * continuation, any other character).
350
+ */
351
+ function decodeString(raw) {
352
+ let bad = false;
353
+ const SINGLE = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", v: "\v", "0": "\0" };
354
+ const out = raw.replace(/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|u\{([0-9a-fA-F]{1,6})\}|([ntrbfv'"\\]|0(?![0-9]))|[\s\S]?)/g, (_, x, u, cp, single) => {
355
+ const code = x ?? u ?? cp;
356
+ if (code !== undefined && parseInt(code, 16) <= 0x10ffff)
357
+ return String.fromCodePoint(parseInt(code, 16));
358
+ if (single !== undefined)
359
+ return SINGLE[single] ?? single;
360
+ bad = true;
361
+ return "";
362
+ });
363
+ return bad ? undefined : out;
364
+ }
365
+ /**
366
+ * The named members of a PATH object (the exported config, its `test` object), and why they cannot be read
367
+ * literally (D-2102 add.25, add.35): every member must be `<plain identifier | plain string>: <value>` and every
368
+ * key must appear once — a spread, a computed, escaped or shorthand key, a method or getter, `__proto__` or a
369
+ * repeated key reads unverifiable. Values off the path are only compiled; the keys of nested value maps
370
+ * (transform, moduleNameMapper, alias…) are data and are never read here.
371
+ */
372
+ function settingsOf(text, open, keys, what) {
373
+ const all = members(text, open);
374
+ const found = new Map();
375
+ const seen = new Set();
376
+ let computed;
377
+ for (const m of all) {
378
+ if (m.key === "" || m.key.includes("\\"))
379
+ computed ??= `${what} has a member that is not a plain key and value`;
380
+ else if (m.key === "__proto__")
381
+ computed ??= `${what} sets __proto__`;
382
+ else if (seen.has(m.key))
383
+ computed ??= `${m.key} is set twice`;
384
+ seen.add(m.key);
385
+ if (keys.includes(m.key))
386
+ found.set(m.key, m);
387
+ }
388
+ return { get: (key) => found.get(key), computed };
389
+ }
390
+ /**
391
+ * D-2102 add.28: what a decoded value may still escape. A jest regex may use \. \/ \- and \\ outside a
392
+ * character class only; a glob or a path may hold no backslash at all.
393
+ */
394
+ function plainEscapes(value, form) {
395
+ if (form === "path")
396
+ return !value.includes("\\");
397
+ let inClass = false;
398
+ for (let i = 0; i < value.length; i++) {
399
+ const c = value[i];
400
+ if (c === "\\") {
401
+ if (inClass || i + 1 >= value.length || !"./-\\".includes(value[i + 1]))
402
+ return false;
403
+ i++;
404
+ }
405
+ else if (inClass)
406
+ inClass = c !== "]";
407
+ else if (c === "[")
408
+ inClass = true;
409
+ }
410
+ return true;
411
+ }
412
+ /**
413
+ * A setting's literal strings, each decoded strictly (D-2102 add.28) so every check reads the value the
414
+ * runner reads: undefined when absent, "computed" for any other expression, "escape" for an escape the
415
+ * decoder or the value's form does not allow.
416
+ */
417
+ function literalList(value, form = "path", defaults = new Set()) {
418
+ if (value === undefined)
419
+ return undefined;
420
+ const elements = STRING_LITERAL.test(value) ? [{ text: value, start: 0 }] : arrayElements(value);
421
+ if (!elements?.every((e) => STRING_LITERAL.test(e.text) || defaultsSpread(e.text, defaults)))
422
+ return "computed";
423
+ const out = [];
424
+ for (const e of elements.filter((e) => STRING_LITERAL.test(e.text))) {
425
+ const decoded = decodeString(e.text.slice(1, -1));
426
+ if (decoded === undefined || !plainEscapes(decoded, form))
427
+ return "escape";
428
+ out.push(decoded);
429
+ }
430
+ return out;
431
+ }
432
+ /** The spread of Vitest's own default excludes, `...<configDefaults>.exclude`, through a bound configDefaults. */
433
+ const defaultsSpread = (element, defaults) => defaults.has(/^\.\.\.\s*([A-Za-z_$][\w$]*)\.exclude$/.exec(element)?.[1] ?? "");
434
+ /** Why a literalList result is unreadable, for a setting shown as `name`. */
435
+ const unreadableList = (list, name) => list === "computed" ? `${name} is not a literal` : list === "escape" ? `${name} holds an escape doctor does not read` : undefined;
436
+ const NOT_ONE_OBJECT = "is not one object literal";
437
+ // D-2102 add.34: the modules whose defineConfig / defineProject return their one object argument unchanged.
438
+ const WRAPPER_MODULES = { vitest: ["vitest/config", "vitest", "vite"], jest: ["jest"] };
439
+ /**
440
+ * The offset of the object literal a value is COMPLETELY — itself, or the one argument of an identity wrapper
441
+ * call — at `start` in its text, or why it is not: `a && b`, a conditional, a trailing operator, and any call
442
+ * whose callee is not one of `wrappers` (a custom, local, re-exported or unresolved defineConfig included).
443
+ */
444
+ function objectIn(value, start, wrappers) {
445
+ const call = /^([A-Za-z_$][\w$]*)\(\s*/.exec(value);
446
+ if (call && !wrappers.has(call[1]))
447
+ return `the call ${call[1]}(…) is not an identity wrapper imported from the runner`;
448
+ const lead = call?.[0].length ?? 0;
449
+ if (value[lead] !== "{")
450
+ return NOT_ONE_OBJECT;
451
+ const rest = value.slice(closing(value, lead)).trim();
452
+ return rest === (lead ? ")" : "") ? start + lead : NOT_ONE_OBJECT;
453
+ }
454
+ const ID = "[A-Za-z_$][\\w$]*";
455
+ // D-2102 add.37: the only value imports doctor reads — named bindings from the runner's own module (an import of
456
+ // any other module runs that module's code). `import type` binds no value and is checked by the real parse.
457
+ const NAMED_IMPORT = /^import\s+\{([^}]*)\}\s*from\s*(["'])([^"'\n]+)\2$/;
458
+ const REQUIRE = new RegExp(`^const\\s+\\{([^}]*)\\}\\s*=\\s*require\\(\\s*(["'])([^"'\\n]+)\\2\\s*\\)$`);
459
+ // The modules whose configDefaults is Vitest's own default data (D-2102 add.37 follow-up).
460
+ const DEFAULTS_MODULES = ["vitest/config", "vitest"];
461
+ // What any other code a config would run reads as (D-2102 add.37).
462
+ const RUNS = "it runs code doctor does not read";
463
+ function bindingsOf(statement, kind) {
464
+ const named = (list, rename, dataModule) => {
465
+ const out = { names: [], wrappers: [], defaults: [] };
466
+ const specs = list.split(",").map((x) => x.trim());
467
+ if (specs.length > 1 && specs[specs.length - 1] === "")
468
+ specs.pop(); // one trailing comma
469
+ for (const spec of specs) {
470
+ const m = rename.exec(spec);
471
+ if (!m)
472
+ return "invalid";
473
+ if (m[1])
474
+ continue; // a `type` specifier binds no value
475
+ const local = m[3] ?? m[2];
476
+ out.names.push(local);
477
+ if (m[2] === "defineConfig" || m[2] === "defineProject")
478
+ out.wrappers.push(local);
479
+ if (m[2] === "configDefaults" && dataModule)
480
+ out.defaults.push(local);
481
+ }
482
+ return out;
483
+ };
484
+ if (/^import(?=[\s{*'"])/.test(statement)) {
485
+ const m = NAMED_IMPORT.exec(statement);
486
+ if (!m || !WRAPPER_MODULES[kind].includes(m[3]))
487
+ return "runs";
488
+ return named(m[1], new RegExp(`^(type\\s+)?(${ID})(?:\\s+as\\s+(${ID}))?$`), DEFAULTS_MODULES.includes(m[3]));
489
+ }
490
+ const r = REQUIRE.exec(statement);
491
+ if (!r || !WRAPPER_MODULES[kind].includes(r[3]))
492
+ return undefined;
493
+ return named(r[1], new RegExp(`^()(${ID})(?:\\s*:\\s*(${ID}))?$`), DEFAULTS_MODULES.includes(r[3]));
494
+ }
495
+ /**
496
+ * D-2102 add.37: whether `value` is PURE DATA, read with a small strict grammar — string, number, boolean and
497
+ * null literals, a template with no `${`, `process.env.<NAME>`, an identifier naming an earlier pure-data
498
+ * const, arrays of pure data, and objects whose every member is `<plain identifier | string>: <pure data>`
499
+ * with each key once, at every level. Nothing else reads: no call, `new`, function or arrow, operator but a
500
+ * unary minus on a number, `this`, `arguments`, spread, computed key, getter, method, shorthand or `__proto__`.
501
+ * ponytail: the declared cost — a config that calls anything beyond the runner's own defineConfig or
502
+ * defineProject (a plugin, a helper, a ternary on the environment) warns, queue row 208.
503
+ */
504
+ function pureData(value, consts, defaults = new Set()) {
505
+ let i = 0;
506
+ const ws = () => { while (i < value.length && /\s/.test(value[i]))
507
+ i++; };
508
+ const take = (re) => {
509
+ const m = re.exec(value.slice(i));
510
+ if (m)
511
+ i += m[0].length;
512
+ return m;
513
+ };
514
+ let path = [];
515
+ const list = (close, item) => {
516
+ i++;
517
+ ws();
518
+ if (value[i] === close) {
519
+ i++;
520
+ return true;
521
+ }
522
+ for (;;) {
523
+ if (!item())
524
+ return false;
525
+ ws();
526
+ if (value[i] === ",") {
527
+ i++;
528
+ ws();
529
+ if (value[i] === close) {
530
+ i++;
531
+ return true;
532
+ }
533
+ continue;
534
+ }
535
+ if (value[i] === close) {
536
+ i++;
537
+ return true;
538
+ }
539
+ return false;
540
+ }
541
+ };
542
+ const datum = () => {
543
+ ws();
544
+ const c = value[i];
545
+ if (c === '"' || c === "'")
546
+ return take(/^(["'])(?:\\[\s\S]|(?!\1)[^\\\n\r])*\1/) !== null;
547
+ if (c === "`")
548
+ return take(/^`(?:\\[\s\S]|[^\\`$]|\$(?!\{))*`/) !== null;
549
+ if (c === "[") {
550
+ // The one spread pure data admits: Vitest's own default excludes, as an element of test.exclude, from a
551
+ // configDefaults bound by a named import of "vitest/config" or "vitest" (the docs' own spelling).
552
+ const inExclude = path.join(".") === "test.exclude";
553
+ return list("]", () => {
554
+ ws();
555
+ const spread = inExclude ? /^\.\.\.\s*([A-Za-z_$][\w$]*)\.exclude(?![\w$.([])/.exec(value.slice(i)) : null;
556
+ if (spread && defaults.has(spread[1])) {
557
+ i += spread[0].length;
558
+ return true;
559
+ }
560
+ return datum();
561
+ });
562
+ }
563
+ if (c === "{") {
564
+ const seen = new Set();
565
+ return list("}", () => {
566
+ ws();
567
+ const k = take(/^(?:([A-Za-z_$][\w$]*)|(["'])((?:\\[\s\S]|(?!\2)[^\\\n\r])*)\2)\s*:/);
568
+ if (!k)
569
+ return false;
570
+ const key = k[1] ?? decodeString(k[3]);
571
+ if (key === undefined || key === "__proto__" || seen.has(key))
572
+ return false;
573
+ seen.add(key);
574
+ const outer = path;
575
+ path = [...outer, key];
576
+ const ok = datum();
577
+ path = outer;
578
+ return ok;
579
+ });
580
+ }
581
+ if (take(/^-?(?:0[xX][\da-fA-F](?:_?[\da-fA-F])*|0[oO][0-7](?:_?[0-7])*|0[bB][01](?:_?[01])*|(?:\d(?:_?\d)*)?\.?\d(?:_?\d)*(?:[eE][+-]?\d+)?)n?(?![\w$.])/))
582
+ return true;
583
+ const word = /^(?:process\.env\.[A-Za-z_$][\w$]*|[A-Za-z_$][\w$]*)(?![\w$.([`])/.exec(value.slice(i))?.[0];
584
+ if (word && (["true", "false", "null"].includes(word) || word.startsWith("process.env.") || consts.has(word))) {
585
+ i += word.length;
586
+ return true;
587
+ }
588
+ return false;
589
+ };
590
+ if (!datum())
591
+ return false;
592
+ ws();
593
+ return i === value.length;
594
+ }
595
+ /**
596
+ * The top-level statements of a (comment-blanked) module: each ends at a `;` or a line break outside
597
+ * strings and brackets, unless the line ends in an operator or the next one continues the expression.
598
+ */
599
+ function topLevel(text) {
600
+ const out = [];
601
+ let [depth, start] = [0, -1];
602
+ const end = (at) => {
603
+ const raw = text.slice(start, at);
604
+ if (raw.trim())
605
+ out.push({ start: start + raw.length - raw.trimStart().length, text: raw.trim() });
606
+ start = -1;
607
+ };
608
+ for (let i = 0; i < text.length; i++) {
609
+ const c = text[i];
610
+ if (start < 0) {
611
+ if (/[\s;]/.test(c))
612
+ continue;
613
+ start = i;
614
+ }
615
+ if (c === '"' || c === "'" || c === "`") {
616
+ for (i++; i < text.length && text[i] !== c; i++)
617
+ if (text[i] === "\\")
618
+ i++;
619
+ }
620
+ else if ("([{".includes(c))
621
+ depth++;
622
+ else if (")]}".includes(c))
623
+ depth--;
624
+ else if (depth === 0 && c === ";")
625
+ end(i);
626
+ else if (depth === 0 && LINE_END.test(c) && !/[-=+*/%&|^!?:,.<>([{]$/.test(text.slice(start, i).trimEnd())
627
+ && !/^(?:[-.?:&|+*/%=,<>()[\]{}`]|as\b|satisfies\b|in\b|instanceof\b)/.test(text.slice(i + 1).trimStart()))
628
+ end(i);
629
+ }
630
+ if (start >= 0)
631
+ end(text.length);
632
+ return out;
633
+ }
634
+ function staticExport(text, json, kind) {
635
+ const wrappers = new Set();
636
+ const defaults = new Set();
637
+ const literal = (value, start) => {
638
+ const open = objectIn(value, start, wrappers);
639
+ return typeof open === "number" ? { open, wrappers, defaults } : open === NOT_ONE_OBJECT ? `the export ${NOT_ONE_OBJECT}` : open;
640
+ };
641
+ if (json) {
642
+ const open = objectIn(text.trim(), text.search(/\S/), wrappers);
643
+ return typeof open === "number" ? { open, wrappers, defaults } : `the config ${NOT_ONE_OBJECT}`;
644
+ }
645
+ // D-2102 add.35, a whitelist: the top level holds only imports in the strict grammar (type-only included),
646
+ // at most one `const { … } = require(<the runner's module>)`, `const <ident>[: T] = <expr>` lines, and EXACTLY
647
+ // ONE export of an object or an identity wrapper around one, directly or through one such const. Any other
648
+ // statement — let or var, a function or class, an expression, a second export, exports.default,
649
+ // `export { x as default }`, a reassignment — reads unverifiable.
650
+ const other = RUNS;
651
+ let exported;
652
+ const consts = new Map();
653
+ const bound = new Map(); // every value name a top-level statement binds, and how often
654
+ const values = []; // every expression a top-level statement evaluates
655
+ let requires = 0;
656
+ for (const { start, text: t } of topLevel(text)) {
657
+ const b = bindingsOf(t, kind);
658
+ if (b === "invalid")
659
+ return "invalid syntax";
660
+ if (b === "runs")
661
+ return RUNS;
662
+ if (b) {
663
+ for (const name of b.names)
664
+ bound.set(name, (bound.get(name) ?? 0) + 1);
665
+ for (const name of b.wrappers)
666
+ wrappers.add(name);
667
+ if (kind === "vitest")
668
+ for (const name of b.defaults)
669
+ defaults.add(name);
670
+ if (!/^import/.test(t)) {
671
+ if (++requires > 1)
672
+ return "the config requires the runner's module more than once";
673
+ values.push(t.slice(t.indexOf("=") + 1));
674
+ }
675
+ continue;
676
+ }
677
+ const exp = /^(?:export\s+default|(module)\.exports\s*=)\s*/.exec(t);
678
+ const decl = /^const\s+([A-Za-z_$][\w$]*)\s*=\s*/.exec(t);
679
+ if (exp) {
680
+ if (exported)
681
+ return "the config exports more than once";
682
+ exported = { start: start + exp[0].length, value: t.slice(exp[0].length), cjs: exp[1] !== undefined };
683
+ values.push(exported.value);
684
+ }
685
+ else if (decl) {
686
+ consts.set(decl[1], { start: start + decl[0].length, value: t.slice(decl[0].length) });
687
+ bound.set(decl[1], (bound.get(decl[1]) ?? 0) + 1);
688
+ values.push(t.slice(decl[0].length));
689
+ }
690
+ else
691
+ return other;
692
+ }
693
+ if (!exported)
694
+ return "the config has no export";
695
+ // Wrapper trust through require holds only for the unbound CommonJS global, so a file that binds require,
696
+ // module or exports itself — or binds any name twice — is unread.
697
+ const shadow = ["require", "module", "exports"].find((name) => bound.has(name));
698
+ if (shadow)
699
+ return `the config binds its own ${shadow}`;
700
+ const twice = [...bound].find(([, n]) => n > 1)?.[0];
701
+ if (twice)
702
+ return `the config binds ${twice} more than once`;
703
+ const id = /^[A-Za-z_$][\w$]*$/.exec(exported.value.trim())?.[0];
704
+ const value = id ? consts.get(id) : exported;
705
+ if (!value)
706
+ return `the export of ${id}`;
707
+ // Balanced brackets are not syntax: every evaluated expression must compile as JavaScript (compiled, never
708
+ // run). A TypeScript config arrives here with its types already erased to whitespace (D-2102 add.39, see
709
+ // realParse), so no TypeScript form is skipped or stripped by hand.
710
+ for (const v of values) {
711
+ try {
712
+ new Script(`(${v}\n)`);
713
+ }
714
+ catch {
715
+ return "invalid syntax";
716
+ }
717
+ }
718
+ // D-2102 add.37: the exported config is pure data — every const, and the export itself (or the one argument
719
+ // of an identity wrapper around it), read with the pure-data grammar; a const may name only earlier consts.
720
+ const earlier = new Set();
721
+ for (const [name, c] of consts) {
722
+ if (!pureData(c.value, earlier, name === id ? defaults : undefined))
723
+ return RUNS;
724
+ earlier.add(name);
725
+ }
726
+ const shown = exported.value;
727
+ const call = /^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/.exec(shown);
728
+ if (call && !wrappers.has(call[1]))
729
+ return `the call ${call[1]}(…) is not an identity wrapper imported from the runner`;
730
+ if (!pureData(call ? call[2] : shown, earlier, defaults))
731
+ return RUNS;
732
+ return literal(value.value, value.start);
733
+ }
734
+ // D-2102 add.33: the jest ignore entries doctor judges, by a whitelist — ANCHORED: one leading <rootDir>, then
735
+ // only [A-Za-z0-9_./-] or "\." and no ".." segment (so "<rootDir>/.next/" reads); LITERAL: no <rootDir>, only
736
+ // [A-Za-z0-9_/-] or "\." (a bare "." is a wildcard). Every other entry is opaque and leaves the config unread.
737
+ const ANCHORED_ENTRY = /^<rootDir>(?:[A-Za-z0-9_./-]|\\\.)*$/;
738
+ const LITERAL_ENTRY = /^(?:[A-Za-z0-9_/-]|\\\.)*$/;
739
+ const judged = (entry) => (ANCHORED_ENTRY.test(entry) && !/(?:^|\/)\.\.(?:\/|$)/.test(entry.slice("<rootDir>".length))) || LITERAL_ENTRY.test(entry);
740
+ // The exact unanchored spellings the SentioQ run-2 shape used (Q130s), which --fix replaces with the canonical twin.
741
+ const POISON = { state: /^\/?\\?\.tickmarkr\/?$/, session: /^\/?\\?\.claude\/worktrees\/?$/ };
742
+ const WORKTREE_DIRS = { state: ".tickmarkr/worktrees.noindex", session: ".claude/worktrees" };
743
+ const PROBE = "tkr-doctor-probe";
744
+ /** Jest's reading of an ignore entry under `rootDir`: a leading <rootDir> resolved as jest-config does, then a RegExp. */
745
+ const jestIgnore = (entry, rootDir) => new RegExp(entry.startsWith("<rootDir>") ? resolve(rootDir, normalize(`./${entry.slice("<rootDir>".length)}`)) : entry);
746
+ /**
747
+ * D-2102 add.33: the first ignore entry that SELF-MATCHES (Q130s), found by executing it as jest reads it — it
748
+ * matches a probe suite inside a run or session worktree, rooted there, but not the same suite in the main
749
+ * checkout at `root` (probed at src/ and, for a literal, along its own tail: add.34, see literalTails). It
750
+ * classifies the ignore list only; it never claims what the runner collects.
751
+ * ponytail: residual — a literal keyed on a worktree NAME that does not exist yet is not probed (queue row
752
+ * 208); the names probed are a fixed one plus every directory present under each worktree root.
753
+ */
754
+ function selfMatching(entries, root) {
755
+ const file = `${PROBE}.test.ts`;
756
+ for (const pattern of entries) {
757
+ for (const dir of DIRS) {
758
+ if (POISON[dir].test(pattern))
759
+ return { pattern, dir };
760
+ const base = join(root, WORKTREE_DIRS[dir]);
761
+ let present = [];
762
+ try {
763
+ present = readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
764
+ }
765
+ catch { /* none yet */ }
766
+ for (const name of [PROBE, ...present]) {
767
+ const tree = join(base, name);
768
+ try {
769
+ for (const rel of [`src/${file}`, ...literalTails(pattern, `${tree}/`, root, file)]) {
770
+ if (jestIgnore(pattern, tree).test(`${tree}/${rel}`) && !jestIgnore(pattern, root).test(`${root}/${rel}`))
771
+ return { pattern, dir };
772
+ }
773
+ }
774
+ catch {
775
+ return { pattern, dir };
776
+ } // a root path that does not compile as a regex: judged self-matching
777
+ }
778
+ }
779
+ }
780
+ return undefined;
781
+ }
782
+ /**
783
+ * D-2102 add.34: the probe paths a LITERAL entry's own tail builds — wherever the end of `tree` (with its "/")
784
+ * begins the literal and that occurrence reaches past `root`, the rest of the literal becomes the path below
785
+ * the tree, so an entry like "worktrees/feature-x/test/" is probed at feature-x/test/ and not only at src/.
786
+ */
787
+ function literalTails(pattern, tree, root, file) {
788
+ if (pattern.startsWith("<rootDir>"))
789
+ return [];
790
+ const literal = pattern.replace(/\\\./g, ".");
791
+ const out = [];
792
+ for (let i = 0; i < tree.length; i++) {
793
+ const tail = tree.slice(i);
794
+ if (literal.length > tail.length && literal.startsWith(tail) && i + literal.length > root.length)
795
+ out.push(`${literal.slice(tail.length)}${file}`);
796
+ }
797
+ return out;
798
+ }
799
+ /**
800
+ * D-2102 add.22 and add.29: why the source is not one clean parse, else undefined — a "/" outside strings
801
+ * and comments (a regex literal or a division: doctor parses neither), an unclosed string or comment, a
802
+ * template with a substitution, or an unmatched or unclosed bracket. The end of the file never closes anything.
803
+ */
804
+ function unclean(text) {
805
+ const stack = [];
806
+ for (let i = 0; i < text.length; i++) {
807
+ const c = text[i];
808
+ if (text.startsWith("//", i))
809
+ i = lineEnd(text, i);
810
+ else if (text.startsWith("/*", i)) {
811
+ const end = text.indexOf("*/", i + 2);
812
+ if (end < 0)
813
+ return "an unclosed comment";
814
+ i = end + 1;
815
+ }
816
+ else if (c === "/")
817
+ return "a / outside strings and comments (a regex literal or a division)";
818
+ else if (c === "\\")
819
+ return RUNS; // D-2102 add.37: an escaped identifier spells a name doctor would not see
820
+ else if (c === '"' || c === "'" || c === "`") {
821
+ let j = i + 1;
822
+ for (; j < text.length && text[j] !== c; j++) {
823
+ if (text[j] === "\\")
824
+ j++;
825
+ else if (c !== "`" && LINE_END.test(text[j]))
826
+ return "an unclosed string";
827
+ else if (c === "`" && text.startsWith("${", j))
828
+ return "a template literal with a substitution";
829
+ }
830
+ if (j >= text.length)
831
+ return "an unclosed string";
832
+ i = j;
833
+ }
834
+ else if ("([{".includes(c))
835
+ stack.push(c);
836
+ else if (")]}".includes(c) && stack.pop() !== "([{"[")]}".indexOf(c)])
837
+ return "an unmatched closing bracket";
838
+ }
839
+ return stack.length ? "an unclosed bracket" : undefined;
840
+ }
841
+ // D-2102 add.37 and add.39: the one child that reads the source on stdin, erases TypeScript types (when this Node
842
+ // has stripTypeScriptTypes; its default strip mode replaces each type with whitespace, so offsets stay put) and
843
+ // parses the result as a module (vm.SourceTextModule, never linked or evaluated), then as a script (vm.Script,
844
+ // never run), reporting {"ok":true,"text":<stripped>} or {"ok":false,"why":"no-strip"|"syntax"} on stdout. It
845
+ // runs with --no-warnings, so 22.x's ExperimentalWarning never reaches doctor's output; no temp file is written.
846
+ const PARSE_CHILD = `(() => {
847
+ const fs = require("node:fs"), vm = require("node:vm"), mod = require("node:module");
848
+ const [ext, strip] = process.argv.slice(1);
849
+ const out = (r) => process.stdout.write(JSON.stringify(r));
850
+ let code = fs.readFileSync(0, "utf8");
851
+ if (/^\\.(?:m|c)?ts$/.test(ext)) {
852
+ if (strip === "absent" || typeof mod.stripTypeScriptTypes !== "function") return out({ ok: false, why: "no-strip" });
853
+ try { code = mod.stripTypeScriptTypes(code); } catch { return out({ ok: false, why: "syntax" }); }
854
+ }
855
+ let parsed = false;
856
+ if (typeof vm.SourceTextModule === "function") {
857
+ try { new vm.SourceTextModule(code); parsed = true; } catch {}
858
+ }
859
+ if (!parsed) {
860
+ try { new vm.Script(code); parsed = true; } catch {}
861
+ }
862
+ out(parsed ? { ok: true, text: code } : { ok: false, why: "syntax" });
863
+ })()`;
864
+ // ponytail: verdicts are memoized by exact bytes (and extension and seam), so one doctor run's reads before and
865
+ // after a repair, and its re-reads of unchanged bytes, parse once; the memo is dropped past 64 entries.
866
+ const parsed = new Map();
867
+ /**
868
+ * D-2102 add.37 and add.39: the config's text with its TypeScript types erased (offsets unchanged), once a real
869
+ * parser accepts it — or why not. The source reaches PARSE_CHILD on stdin through process.execPath with no shell
870
+ * and no temp file. A spawn error, a timeout, a non-zero exit, a missing or malformed answer, or erased text of
871
+ * another length reads "doctor could not check <file>'s syntax"; a TypeScript config on a Node without
872
+ * stripTypeScriptTypes (below 22.13) reads unverifiable; nothing here throws.
873
+ */
874
+ export function realParse(source, configPath, seam = {}) {
875
+ const ext = extname(configPath) || ".js";
876
+ const key = `${seam.strip ?? ""}\0${seam.timeoutMs ?? ""}\0${ext}\0${source}`;
877
+ const hit = parsed.get(key);
878
+ if (hit)
879
+ return hit;
880
+ const unchecked = { why: `doctor could not check ${configPath}'s syntax` };
881
+ let result = unchecked;
882
+ try {
883
+ const r = spawnSync(process.execPath, ["--no-warnings", "--experimental-vm-modules", "-e", PARSE_CHILD, ext, seam.strip ?? "auto"], {
884
+ input: source, encoding: "utf8", env: {}, timeout: seam.timeoutMs ?? 10_000, maxBuffer: 4 * (source.length + 1024 * 1024), shell: false,
885
+ });
886
+ let verdict;
887
+ try {
888
+ verdict = JSON.parse(r.stdout ?? "");
889
+ }
890
+ catch {
891
+ verdict = undefined;
892
+ }
893
+ if (r.error || r.status !== 0 || !verdict)
894
+ result = unchecked;
895
+ else if (verdict.ok === true && typeof verdict.text === "string")
896
+ result = verdict.text.length === source.length ? { text: verdict.text } : unchecked;
897
+ else if (verdict.why === "no-strip")
898
+ result = { why: "this Node has no stripTypeScriptTypes to read a TypeScript config" };
899
+ else if (verdict.why === "syntax")
900
+ result = { why: "invalid syntax" };
901
+ }
902
+ catch {
903
+ result = unchecked;
904
+ }
905
+ if (parsed.size >= 64)
906
+ parsed.clear();
907
+ parsed.set(key, result);
908
+ return result;
909
+ }
910
+ // Out of claim (D-2102 add.30, add.35, queue row 207): a Vite plugin's config hook and a jest preset change what
911
+ // the runner resolves, not the config's own literal list, so this reader neither follows nor warns on them.
912
+ function ignoreList(cwd, runner) {
216
913
  const isJest = runner.kind === "jest";
217
- const jestConfig = isJest ? runner.configPath : undefined;
218
- const vitestConfig = isJest ? undefined : runner.configPath;
219
914
  let pkg = {};
220
915
  try {
221
- pkg = JSON.parse(readIf("package.json") || "{}");
916
+ pkg = JSON.parse(readText(cwd, "package.json").text || "{}");
222
917
  }
223
- catch { /* judged above */ }
224
- // Comment LINES are stripped before judgment: a commented-out ignore must not
225
- // false-pass, and a prose comment mentioning .tickmarkr must not false-warn the
226
- // anchor probe. Stripping is LINE-granular on purpose: in-line regex stripping ate
227
- // glob strings — `'**/*.d.ts'` contains `/*` and later globs contain `*/`, so a
228
- // naive block-comment regex deleted the middle of real configs and false-PASSED a
229
- // repo-wide vitest include (caught on Intl-Dossier's config; shipped briefly in
230
- // 1.90.1). Trailing same-line comments survive — acceptable: they can only make the
231
- // probe more conservative on lines that also carry code.
232
- const stripComments = (s) => s.split("\n").filter((l) => {
233
- const t = l.trimStart();
234
- return !(t.startsWith("//") || t.startsWith("/*") || t.startsWith("*"));
235
- }).join("\n");
236
- const configText = stripComments(isJest
237
- ? `${jestConfig ? readIf(jestConfig) : ""}${pkg.jest ? JSON.stringify(pkg.jest) : ""}`
238
- : vitestConfig ? readIf(vitestConfig) : "");
239
- if (/\.tickmarkr|worktrees\.noindex/.test(configText)) {
240
- // Q130s self-match probe: erase every <rootDir>-anchored token (through the end of
241
- // its quoted string); any surviving mention is a pattern that would also match
242
- // inside a provisioned worktree (jest regex-vs-absolute-path semantics only).
243
- const unanchored = configText.replace(/<rootDir>[^"'`\s]*/g, "");
244
- if (isJest && /\.tickmarkr|worktrees\.noindex/.test(unanchored)) {
245
- return {
246
- verdict: "warn",
247
- detail: `jest ignore for .tickmarkr/ is not <rootDir>-anchored — testPathIgnorePatterns are regexes against ABSOLUTE paths, so inside run worktrees (provisioned under .tickmarkr/worktrees.noindex/) it matches EVERY suite and the test gate fails closed; use "<rootDir>/\\\\.tickmarkr/" in ${jestConfig ?? "the package.json jest block"}`,
248
- };
918
+ catch { /* judged by detectRunner */ }
919
+ const config = runner.configPath ? readText(cwd, runner.configPath) : { text: isJest && pkg.jest ? JSON.stringify(pkg.jest) : "" };
920
+ const source = config.text;
921
+ const json = !runner.configPath || runner.configPath.endsWith(".json");
922
+ const base = { file: runner.configPath ?? (isJest ? "the package.json jest block" : "a vitest config"), key: CANONICAL[runner.kind].ignore, json };
923
+ const unreadable = (why) => ({ ...base, unreadable: why });
924
+ if (config.error)
925
+ return unreadable(config.error);
926
+ // D-2102 add.22: another file the runner may read instead of, or beside, this one.
927
+ const present = (stem) => ["ts", "mts", "cts", "js", "mjs", "cjs", "json"].map((e) => `${stem}.${e}`).filter((f) => existsSync(join(cwd, f)));
928
+ const external = isJest ? [] : [...present("vitest.workspace"), ...present("vitest.projects")];
929
+ if (external.length)
930
+ return unreadable(`${external[0]} declares projects beside it`);
931
+ const configs = isJest ? [...present("jest.config"), ...(pkg.jest !== undefined ? ["the package.json jest block"] : [])] : present("vitest.config");
932
+ if (configs.length > 1)
933
+ return unreadable(`${configs.join(" and ")} both configure ${runner.kind}`);
934
+ if (!source.trim())
935
+ return base; // no config: the runner's default list
936
+ // D-2102 add.32: a JSON config is read only once JSON.parse accepts it — jest reads it with JSON.parse.
937
+ if (json) {
938
+ try {
939
+ JSON.parse(source);
940
+ }
941
+ catch {
942
+ return unreadable("invalid JSON");
249
943
  }
250
- return { verdict: "pass", detail: `${isJest ? "jest" : "vitest"} config ignores .tickmarkr/ — run worktrees stay out of the suite` };
251
944
  }
252
- // Rooted collection globs never reach .tickmarkr/…; only repo-wide `**/`-style patterns (or
253
- // runner defaults, which are repo-wide) can collect worktree copies.
254
- const repoWide = configText === "" || /["'`]\*\*\//.test(configText);
255
- if (!repoWide) {
256
- return { verdict: "pass", detail: `${isJest ? "jest" : "vitest"} collection globs are rooted — run worktrees are not collectable` };
945
+ // D-2102 add.39: a TypeScript config is read only through its type-erased text (same offsets, so the writer
946
+ // edits the original bytes in place); a JavaScript one is read as written and parsed for real last.
947
+ const path = runner.configPath ?? "config.js";
948
+ const ts = /\.(?:m|c)?ts$/.test(path);
949
+ const erased = ts ? realParse(source, path) : undefined;
950
+ if (erased && "why" in erased)
951
+ return unreadable(erased.why);
952
+ const plain = erased?.text ?? source;
953
+ const dirty = unclean(plain);
954
+ if (dirty)
955
+ return unreadable(dirty);
956
+ const text = blankComments(plain);
957
+ const exported = staticExport(text, json, runner.kind);
958
+ if (typeof exported === "string")
959
+ return unreadable(exported);
960
+ if (!json && !ts) {
961
+ const parse = realParse(source, path);
962
+ if ("why" in parse)
963
+ return unreadable(parse.why);
257
964
  }
258
- const remedy = isJest
259
- ? `add "<rootDir>/\\\\.tickmarkr/" to testPathIgnorePatterns in ${jestConfig ?? "the package.json jest block"}`
260
- : `add ".tickmarkr/**" to test.exclude in ${vitestConfig ?? "a vitest config"}`;
261
- return {
262
- verdict: "warn",
263
- detail: `${isJest ? "jest" : "vitest"} collects repo-wide and will scan .tickmarkr/ run worktrees (duplicate suites, false reds) — ${remedy}`,
965
+ const top = exported.open;
966
+ // Projects carry their own ignore lists, which the config's own list does not speak for.
967
+ const read = (open, keys, what, key, form) => {
968
+ const s = settingsOf(text, open, keys, what);
969
+ if (s.computed)
970
+ return unreadable(s.computed);
971
+ if (s.get("projects") ?? s.get("workspace"))
972
+ return unreadable("it declares projects, each with its own ignore list");
973
+ const target = s.get(key);
974
+ const entries = literalList(target?.value, form, isJest ? undefined : exported.defaults);
975
+ if (typeof entries === "string")
976
+ return unreadable(unreadableList(entries, base.key) ?? entries);
977
+ if (!isJest)
978
+ return { ...base, entries, target, top, open, defaults: exported.defaults };
979
+ // D-2102 add.32 and add.33: an entry outside the whitelist can spell a worktree directory without its
980
+ // letters in a row (a class, a group, a bare-dot wildcard), so a config holding one is unread while it
981
+ // stays; the canonical entries are anchored. The exact SentioQ spellings stay judged, so --fix replaces them.
982
+ const opaque = (entries ?? []).find((e) => !judged(e) && !POISON.state.test(e) && !POISON.session.test(e));
983
+ if (opaque !== undefined)
984
+ return unreadable(`testPathIgnorePatterns entry ${JSON.stringify(opaque)} holds regex syntax doctor does not read`);
985
+ // D-2102 add.33: a rootDir other than the config's own directory re-roots <rootDir>, canonical entries included.
986
+ const rootDir = literalList(s.get("rootDir")?.value);
987
+ if (typeof rootDir === "string" || (rootDir && (rootDir.length !== 1 || !/^(?:\.\/?|<rootDir>\/?)$/.test(rootDir[0])))) {
988
+ return unreadable(typeof rootDir === "string" ? "rootDir is not a literal, so doctor cannot tell whether it re-roots <rootDir>"
989
+ : `rootDir ${JSON.stringify(rootDir.join(", "))} re-roots <rootDir> away from ${base.file}'s own directory`);
990
+ }
991
+ let root = cwd;
992
+ try {
993
+ root = realpathSync(cwd);
994
+ }
995
+ catch { /* judged from cwd as given */ }
996
+ return { ...base, entries, target, top, open, floating: selfMatching(entries ?? [], root) };
264
997
  };
998
+ if (isJest)
999
+ return read(top, ["testPathIgnorePatterns", "projects", "rootDir"], "the config", "testPathIgnorePatterns", "regex");
1000
+ const outer = settingsOf(text, top, ["test"], "the config");
1001
+ if (outer.computed)
1002
+ return unreadable(outer.computed);
1003
+ const test = outer.get("test");
1004
+ if (!test)
1005
+ return { ...base, entries: undefined, top };
1006
+ const open = objectIn(test.value, test.start, exported.wrappers);
1007
+ if (typeof open === "string")
1008
+ return unreadable(open === NOT_ONE_OBJECT ? `test ${NOT_ONE_OBJECT}` : open);
1009
+ return { ...read(open, ["exclude", "projects", "workspace"], "test", "exclude", "path"), top };
1010
+ }
1011
+ export function runnerIgnoreFinding(cwd) {
1012
+ const runner = detectRunner(cwd);
1013
+ if (!runner)
1014
+ return undefined;
1015
+ const name = runner.kind;
1016
+ const quoted = (d) => JSON.stringify(CANONICAL[name][d]);
1017
+ const list = ignoreList(cwd, runner);
1018
+ const where = `${list.key} in ${list.file}`;
1019
+ if (list.unreadable) {
1020
+ return { verdict: "warn", detail: `doctor could not read ${list.file} (${list.unreadable}); add ${quoted("session")} to ${list.key}, with ${quoted("state")} beside it` };
1021
+ }
1022
+ // Q130s, and queue row 197 for the session worktree: a run started inside a worktree carries the
1023
+ // directories above the checkout in every absolute test path.
1024
+ const float = list.floating;
1025
+ if (float) {
1026
+ const what = float.dir === "state" ? ".tickmarkr/" : ".claude/worktrees/";
1027
+ return {
1028
+ verdict: "warn",
1029
+ detail: `jest ignore for ${what} is not <rootDir>-anchored: ${JSON.stringify(float.pattern)} — testPathIgnorePatterns are regexes against ABSOLUTE paths, so ${INSIDE[float.dir]} it matches the checkout's own suites and the test gate fails closed; use ${quoted("state")} and ${quoted("session")} in ${where}`,
1030
+ };
1031
+ }
1032
+ const missing = DIRS.filter((d) => !(list.entries ?? []).includes(CANONICAL[name][d]));
1033
+ if (missing.length) {
1034
+ const [first, second] = missing.map(quoted);
1035
+ return {
1036
+ verdict: "warn",
1037
+ detail: `${where} does not carry ${missing.map(quoted).join(" or ")}, so ${name} may scan ${missing.map((d) => SCANS[d]).join(" and ")} (duplicate suites, false reds) — add ${first} to ${where}${second ? `, with ${second} beside it` : ""}`,
1038
+ };
1039
+ }
1040
+ return { verdict: "pass", detail: `${where} carries ${quoted("session")} beside ${quoted("state")} — doctor reads this literal list only and does not prove what ${name} collects` };
265
1041
  }
266
1042
  /**
267
1043
  * Q134s (doctor --fix): the WRITE half of Q122s/Q130s. Doctor's advisory row cost two
268
1044
  * repos a hand-edit (SentioQ run 2 paid a whole run first); --fix applies the ignore
269
1045
  * where a safe textual edit exists and refuses loudly otherwise.
270
1046
  *
271
- * Safety contract: only three write shapes, each verified by re-running
272
- * runnerIgnoreFinding afterward — a write that does not turn the row green is
273
- * REVERTED and reported as manual. Never touches package.json (jest block → manual).
274
- * ponytail: text-level edits, same ceilings as the finding; the verify-then-revert
275
- * loop is what makes them safe to ship.
1047
+ * Safety contract (queue row 197; D-2102 add.8 and add.30): the canonical entries the config's own literal
1048
+ * ignore list lacks go into that array as complete string-literal elements (never a coverage array or a
1049
+ * scalar), beside the state dir's canonical entry, else first; a jest element that is exactly an unanchored
1050
+ * entry for either directory (the SentioQ run-2 shape) is replaced by its canonical twin; a jest config with
1051
+ * no testPathIgnorePatterns gets the property, preserving jest's default /node_modules/ ignore, as before
1052
+ * this change (a JSON config with its key quoted), and a vitest config without test.exclude gets one that
1053
+ * keeps Vitest 3.2.7's default excludes ahead of the two entries — inside a new test object where it has
1054
+ * none (D-2102 add.31). A config doctor could not read or any other element is manual with the file
1055
+ * byte-unchanged. The write is verified by
1056
+ * re-running the same rule on the written file (and JSON.parse for a .json config); a write that does not
1057
+ * turn the row green is REVERTED byte-for-byte and reported as manual. Never touches package.json.
276
1058
  */
277
1059
  export function applyRunnerIgnore(cwd) {
278
1060
  const finding = runnerIgnoreFinding(cwd);
@@ -281,49 +1063,123 @@ export function applyRunnerIgnore(cwd) {
281
1063
  if (finding.verdict === "pass")
282
1064
  return { action: "none", detail: finding.detail };
283
1065
  const runner = detectRunner(cwd);
284
- if (!runner?.configPath) {
285
- // package.json jest block, or a runner detected only via the test script: hand edit.
286
- return { action: "manual", detail: finding.detail };
287
- }
1066
+ const manual = { action: "manual", detail: finding.detail };
1067
+ // package.json jest block, or a runner detected only via the test script: hand edit.
1068
+ if (!runner?.configPath)
1069
+ return manual;
1070
+ const list = ignoreList(cwd, runner);
1071
+ if (list.unreadable)
1072
+ return manual;
1073
+ const kind = runner.kind;
1074
+ const canon = CANONICAL[kind];
288
1075
  const file = join(cwd, runner.configPath);
289
- const original = readFileSync(file, "utf8");
290
- let text = original;
291
- if (runner.kind === "jest") {
292
- // 1) Replace unanchored poison entries in place (the SentioQ run-2 shape).
293
- text = text.replace(/(["'])\/?\.tickmarkr\/?\1/g, '"<rootDir>/\\\\.tickmarkr/"');
294
- if (text === original && !/\.tickmarkr/.test(text)) {
295
- const arr = /testPathIgnorePatterns(["']?)\s*:\s*\[/;
296
- if (arr.test(text)) {
297
- // 2) Existing ignore array: prepend the anchored entry.
298
- text = text.replace(arr, (m) => `${m}"<rootDir>/\\\\.tickmarkr/", `);
299
- if (runner.configPath.endsWith(".json"))
300
- text = text.replace(/,(\s*)\]/g, "$1]");
301
- }
302
- else {
303
- // 3) No array: add the property, preserving jest's default /node_modules/ ignore.
304
- const anchor = /(module\.exports\s*=\s*\{|export\s+default\s+\{|export\s+default\s+defineConfig\(\{)/g;
305
- if ((text.match(anchor) ?? []).length === 1) {
306
- text = text.replace(anchor, (m) => `${m}\n testPathIgnorePatterns: ["/node_modules/", "<rootDir>/\\\\.tickmarkr/"],`);
307
- }
308
- }
1076
+ const read = readText(cwd, runner.configPath);
1077
+ if (read.error)
1078
+ return manual;
1079
+ const original = read.text;
1080
+ const edits = [];
1081
+ const entries = (...values) => `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
1082
+ /** Insert `member` first in the object literal opened at `open`, in the object's own layout. */
1083
+ const addMember = (open, member) => {
1084
+ const lead = /^\s*/.exec(original.slice(open + 1))?.[0] ?? "";
1085
+ const empty = original[open + 1 + lead.length] === "}";
1086
+ const gap = /[\n\r]/.test(lead) ? lead.replace(/^[^\n\r]*/, "") : " ";
1087
+ edits.push({ at: open + 1, remove: 0, insert: `${gap}${member}${empty ? "" : ","}` });
1088
+ };
1089
+ const target = list.target;
1090
+ if (target) {
1091
+ const elements = arrayElements(target.value);
1092
+ if (!elements?.every((e) => STRING_LITERAL.test(e.text) || defaultsSpread(e.text, list.defaults ?? new Set())))
1093
+ return manual;
1094
+ const at = (i) => target.start + elements[i].start;
1095
+ const values = elements.map((e) => {
1096
+ const value = STRING_LITERAL.test(e.text) ? decodeString(e.text.slice(1, -1)) : undefined;
1097
+ if (kind !== "jest" || value === undefined)
1098
+ return value;
1099
+ return POISON.state.test(value) ? canon.state : POISON.session.test(value) ? canon.session : value;
1100
+ });
1101
+ values.forEach((v, i) => {
1102
+ if (v !== undefined && v !== decodeString(elements[i].text.slice(1, -1)))
1103
+ edits.push({ at: at(i), remove: elements[i].text.length, insert: JSON.stringify(v) });
1104
+ });
1105
+ const add = DIRS.filter((d) => !values.includes(canon[d])).map((d) => JSON.stringify(canon[d])).join(", ");
1106
+ const state = values.indexOf(canon.state);
1107
+ if (add) {
1108
+ edits.push(state >= 0 ? { at: at(state) + elements[state].text.length, remove: 0, insert: `, ${add}` }
1109
+ : elements.length ? { at: at(0), remove: 0, insert: `${add}, ` }
1110
+ : { at: target.start + 1, remove: 0, insert: add });
309
1111
  }
310
1112
  }
311
- else if (!/\.tickmarkr/.test(text)) {
312
- // vitest: add the glob to every exclude array — test.exclude is the target;
313
- // coverage.exclude gaining it is correct behavior, not collateral.
314
- const arr = /exclude\s*:\s*\[/g;
315
- if (arr.test(text))
316
- text = text.replace(arr, (m) => `${m}'.tickmarkr/**', `);
1113
+ else if (kind === "jest" && list.top !== undefined) {
1114
+ // No ignore property: add it whole, preserving jest's default /node_modules/ ignore (the 2.8.4 repair;
1115
+ // a JSON config gets the quoted key, D-2102 add.31).
1116
+ addMember(list.top, `${list.json ? '"testPathIgnorePatterns"' : "testPathIgnorePatterns"}: ${entries("/node_modules/", canon.state, canon.session)}`);
1117
+ }
1118
+ else if (kind === "vitest" && list.top !== undefined) {
1119
+ // No test.exclude (D-2102 add.31): add it, keeping Vitest's own defaults ahead of the two entries; with
1120
+ // no test object at all, add that.
1121
+ const exclude = `exclude: ${entries(...VITEST_DEFAULT_EXCLUDE, canon.state, canon.session)}`;
1122
+ if (list.open !== undefined)
1123
+ addMember(list.open, exclude);
1124
+ else
1125
+ addMember(list.top, `test: { ${exclude} }`);
1126
+ }
1127
+ if (!edits.length)
1128
+ return manual; // no safe target, or a self-match no canonical entry can fix
1129
+ // Last offset first; at one offset the replacement precedes the insertion before it.
1130
+ const text = [...edits].sort((a, b) => b.at - a.at || b.remove - a.remove)
1131
+ .reduce((t, e) => t.slice(0, e.at) + e.insert + t.slice(e.at + e.remove), original);
1132
+ let after;
1133
+ try {
1134
+ writeFileSync(file, text);
1135
+ if (runner.configPath.endsWith(".json"))
1136
+ JSON.parse(text);
1137
+ after = runnerIgnoreFinding(cwd);
1138
+ }
1139
+ catch {
1140
+ after = undefined;
317
1141
  }
318
- if (text === original)
319
- return { action: "manual", detail: finding.detail };
320
- writeFileSync(file, text);
321
- const after = runnerIgnoreFinding(cwd);
322
- if (after?.verdict === "pass") {
1142
+ if (after?.verdict === "pass")
323
1143
  return { action: "wrote", detail: `wrote ${runner.configPath} — ${after.detail}` };
1144
+ try {
1145
+ writeFileSync(file, original);
1146
+ }
1147
+ catch { /* the write itself failed, so the file is unchanged */ } // verify failed: never leave a half-fix behind
1148
+ return manual;
1149
+ }
1150
+ /**
1151
+ * Queue row 196 (D-2102 add.1): the host decides where seats run. herdr or Orca markers (classifyHost)
1152
+ * name that driver; with neither, seats run headless on the subprocess driver with no daemon board.
1153
+ * Only the exact claude-desktop entry point names Claude Desktop — a cli entry point is a terminal seat
1154
+ * and no Claude variable alone is a host. Panes configured where no pane host exists cannot be honored.
1155
+ */
1156
+ export function hostRows(env, llm) {
1157
+ const host = classifyHost(env);
1158
+ if (host !== "none") {
1159
+ return [{ verdict: "pass", key: "host", detail: `${host === "herdr" ? "herdr" : "Orca"} — auto selects the ${host} driver` }];
324
1160
  }
325
- writeFileSync(file, original); // verify failed: never leave a half-fix behind
326
- return { action: "manual", detail: finding.detail };
1161
+ const name = env.CLAUDE_CODE_ENTRYPOINT === "claude-desktop" ? "Claude Desktop" : "no herdr or Orca host";
1162
+ return [
1163
+ { verdict: "pass", key: "host", detail: `${name} — seats run headless on the subprocess driver with no daemon board` },
1164
+ ...(llm === "pane"
1165
+ ? [{ verdict: "warn", key: "visibility", detail: "visibility.llm is pane, but panes cannot be honored on this host — run under herdr or Orca, or set visibility.llm: headless" }]
1166
+ : []),
1167
+ ];
1168
+ }
1169
+ /**
1170
+ * Queue row 196 (D-2102 add.1): gates inherit the launching locale. The effective value is LC_ALL, else
1171
+ * LC_CTYPE, else LANG (an empty value counts as unset). Unset, C or POSIX is the C-locale fallback;
1172
+ * C.UTF-8 is a UTF-8 locale and passes. Advisory only — doctor never sets or writes a locale.
1173
+ */
1174
+ export function localeFinding(env) {
1175
+ const name = ["LC_ALL", "LC_CTYPE", "LANG"].find((key) => env[key]);
1176
+ const value = name ? env[name] : undefined;
1177
+ if (name && value !== "C" && value !== "POSIX")
1178
+ return { verdict: "pass", detail: `${name}=${value}` };
1179
+ return {
1180
+ verdict: "warn",
1181
+ detail: `${name ? `${name}=${value}` : "no locale set (LC_ALL, LC_CTYPE and LANG unset)"} — gates inherit the C-locale fallback (Node formats as en-US); fix: set LANG in your shell profile, e.g. export LANG=en_US.UTF-8`,
1182
+ };
327
1183
  }
328
1184
  /**
329
1185
  * Q140s(b) / operator directive (D-OBS-10): the repo's package manager must resolve in
@@ -469,9 +1325,10 @@ const RUNNER_CONFIG_PATHS = [
469
1325
  "vitest.config.cts",
470
1326
  ];
471
1327
  function runnerConfigSnapshot(cwd) {
1328
+ // An unreadable config is not snapshotted: the writer never edits a file it could not read (D-2102 add.31).
472
1329
  return new Map(RUNNER_CONFIG_PATHS.flatMap((path) => {
473
- const absolute = join(cwd, path);
474
- return existsSync(absolute) ? [[path, readFileSync(absolute, "utf8")]] : [];
1330
+ const read = readText(cwd, path);
1331
+ return existsSync(join(cwd, path)) && !read.error ? [[path, read.text]] : [];
475
1332
  }));
476
1333
  }
477
1334
  function actualTextDiff(path, before, after) {
@@ -502,8 +1359,8 @@ export function doctorFixOnly(cwd = process.cwd()) {
502
1359
  : { action: "none", detail: finding?.detail ?? "no recognized test runner" };
503
1360
  const verified = runnerIgnoreFinding(cwd);
504
1361
  const diffs = [...before].flatMap(([path, original]) => {
505
- const current = readFileSync(join(cwd, path), "utf8");
506
- return current === original ? [] : [actualTextDiff(path, original, current)];
1362
+ const current = readText(cwd, path);
1363
+ return current.error || current.text === original ? [] : [actualTextDiff(path, original, current.text)];
507
1364
  });
508
1365
  const result = repair.action === "wrote" && verified?.verdict !== "pass"
509
1366
  ? "invalid repair result (verification did not pass)"
@@ -737,6 +1594,11 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
737
1594
  rows.push(...detectCandidateClis({ cwd }).map(({ binary, path }) => alignedStatusRow("warn", binary, `detected at ${path} (no drive contract — not routable)`)));
738
1595
  const herdr = HerdrDriver.available();
739
1596
  rows.push(alignedStatusRow(herdr ? "pass" : "fail", "herdr", herdr ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"));
1597
+ // Queue rows 196 and 197 (D-2102 add.1): where seats run, and the locale every gate inherits.
1598
+ for (const row of hostRows(process.env, cfg.visibility.llm))
1599
+ rows.push(alignedStatusRow(row.verdict, row.key, row.detail));
1600
+ const locale = localeFinding(process.env);
1601
+ rows.push(alignedStatusRow(locale.verdict, "locale", locale.detail));
740
1602
  // Q122s: the target repo's runner must not scan tickmarkr's own worktrees (advisory row).
741
1603
  // Q134s: `doctor --fix` writes the ignore when a safe edit exists (verified, else reverted).
742
1604
  const runnerIgnore = runnerIgnoreFinding(cwd);