tickmarkr 1.84.0 → 1.86.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.
Files changed (87) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +96 -0
  5. package/dist/adapters/catalog.js +176 -0
  6. package/dist/adapters/claude-code.d.ts +1 -0
  7. package/dist/adapters/claude-code.js +59 -1
  8. package/dist/adapters/fake.js +42 -4
  9. package/dist/adapters/model-lints.d.ts +25 -5
  10. package/dist/adapters/model-lints.js +184 -50
  11. package/dist/adapters/model-windows.d.ts +31 -0
  12. package/dist/adapters/model-windows.js +69 -0
  13. package/dist/adapters/prompt.d.ts +5 -1
  14. package/dist/adapters/prompt.js +13 -4
  15. package/dist/adapters/registry.d.ts +25 -26
  16. package/dist/adapters/registry.js +173 -110
  17. package/dist/adapters/types.d.ts +3 -0
  18. package/dist/adapters/types.js +36 -3
  19. package/dist/brand.d.ts +5 -1
  20. package/dist/brand.js +18 -2
  21. package/dist/cli/commands/doctor.d.ts +3 -0
  22. package/dist/cli/commands/doctor.js +43 -21
  23. package/dist/cli/commands/fleet.d.ts +7 -0
  24. package/dist/cli/commands/fleet.js +94 -74
  25. package/dist/cli/commands/init.js +118 -5
  26. package/dist/cli/commands/status.js +202 -46
  27. package/dist/compile/collateral.d.ts +86 -2
  28. package/dist/compile/collateral.js +294 -3
  29. package/dist/compile/gsd.d.ts +2 -1
  30. package/dist/compile/gsd.js +68 -2
  31. package/dist/compile/native.d.ts +14 -0
  32. package/dist/compile/native.js +161 -12
  33. package/dist/config/config.d.ts +82 -5
  34. package/dist/config/config.js +253 -66
  35. package/dist/config/fleet-overlay.d.ts +25 -20
  36. package/dist/config/fleet-overlay.js +195 -77
  37. package/dist/config/fleet-why.d.ts +23 -0
  38. package/dist/config/fleet-why.js +42 -0
  39. package/dist/drivers/herdr.d.ts +21 -3
  40. package/dist/drivers/herdr.js +344 -110
  41. package/dist/gates/acceptance.js +7 -2
  42. package/dist/gates/baseline.d.ts +1 -0
  43. package/dist/gates/baseline.js +91 -13
  44. package/dist/gates/llm.d.ts +0 -1
  45. package/dist/gates/llm.js +5 -30
  46. package/dist/gates/review.d.ts +9 -1
  47. package/dist/gates/review.js +105 -10
  48. package/dist/gates/run-gates.d.ts +9 -0
  49. package/dist/gates/run-gates.js +285 -41
  50. package/dist/gates/verdict-cause.d.ts +4 -0
  51. package/dist/gates/verdict-cause.js +63 -0
  52. package/dist/graph/schema.d.ts +6 -0
  53. package/dist/graph/schema.js +8 -5
  54. package/dist/route/router.d.ts +0 -5
  55. package/dist/route/router.js +16 -20
  56. package/dist/run/consult.d.ts +6 -0
  57. package/dist/run/consult.js +35 -25
  58. package/dist/run/daemon.d.ts +48 -2
  59. package/dist/run/daemon.js +1488 -330
  60. package/dist/run/journal.d.ts +56 -3
  61. package/dist/run/journal.js +358 -4
  62. package/dist/run/stall.d.ts +35 -1
  63. package/dist/run/stall.js +118 -8
  64. package/dist/tui/cockpit/capture.d.ts +12 -0
  65. package/dist/tui/cockpit/capture.js +37 -1
  66. package/dist/tui/cockpit/components.d.ts +2 -0
  67. package/dist/tui/cockpit/components.js +8 -8
  68. package/dist/tui/cockpit/derive.d.ts +29 -2
  69. package/dist/tui/cockpit/derive.js +219 -23
  70. package/dist/tui/cockpit/run-cockpit.js +128 -27
  71. package/dist/tui/cockpit/theme.d.ts +32 -26
  72. package/dist/tui/cockpit/theme.js +11 -5
  73. package/dist/tui/ink/components.d.ts +0 -15
  74. package/dist/tui/ink/components.js +0 -17
  75. package/dist/tui/ink/fleet-app.d.ts +4 -1
  76. package/dist/tui/ink/fleet-app.js +134 -13
  77. package/fixtures/sample.native.md +1 -1
  78. package/package.json +1 -1
  79. package/skills/tickmarkr-overseer/SKILL.md +354 -34
  80. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
  81. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  82. package/dist/tui/ink/studio-app.d.ts +0 -59
  83. package/dist/tui/ink/studio-app.js +0 -320
  84. package/dist/tui/save.d.ts +0 -38
  85. package/dist/tui/save.js +0 -96
  86. package/dist/tui/staging.d.ts +0 -29
  87. package/dist/tui/staging.js +0 -78
@@ -1,6 +1,7 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { extname, join, relative } from "node:path";
3
3
  import picomatch from "picomatch";
4
+ import { criticalPathHits, DEFAULT_CONFIG, DEFAULT_REVIEW_CRITICAL_PATHS, effectiveReviewPolicy, loadConfig, } from "../config/config.js";
4
5
  import { renderAcceptanceItem } from "../graph/schema.js";
5
6
  // Advisory plan-time scan only (OBS-12/13/14/21, OBS-76). NEVER expands files[], fails compile,
6
7
  // or feeds the scope gate — a warning the author acts on. Plain-text (no AST), capped + sorted.
@@ -320,7 +321,297 @@ export function taskBudgetErrors(tasks) {
320
321
  }
321
322
  return errors;
322
323
  }
323
- /** Every Task Unit Contract violation in one pass, ready to throw. */
324
- export function taskUnitContractErrors(tasks) {
325
- return [...separabilityErrors(tasks), ...taskBudgetErrors(tasks)];
324
+ // ── R2 velocity lints (OVERSEER-RULING-20260731-velocity) ───────────────────────────────────
325
+ // v1.84's T1 cost nine review rounds enforcing law the compiler could have refused at authoring
326
+ // time. These three bounds are that refusal: a bounded task surface, goal prose forced down into
327
+ // judge/test criteria, and criteria that may not name symbols owned outside the task's files[].
328
+ /**
329
+ * Max acceptance × files[] surface per task (OVERSEER-RULING-20260731-velocity, R2:
330
+ * `surface = acceptance.length × files.length > 24` fails compile). EVERY criterion counts —
331
+ * a judge item is still a thing one worker must satisfy and one reviewer must verify in the
332
+ * same pass, and counting only test oracles let judge-heavy tasks bypass the bound (the first
333
+ * T6 attempt was rejected for exactly that narrowing). The calibration corpus pins the bound:
334
+ * v1.84's original T1 measures 6×5=30 and fails; v1.79's tasks pass — its widest, T5 at
335
+ * 4×7=28, rides the recorded exception below rather than redefining the metric.
336
+ */
337
+ export const MAX_TASK_SURFACE = 24;
338
+ /**
339
+ * Max goal words per acceptance criterion (v1.84's T1 measured 75). Gameable only by adding
340
+ * criteria — which is the point: every law migrated from goal prose to a criterion moves its
341
+ * enforcement from the 40-minute review round to the 2-minute judge.
342
+ */
343
+ export const MAX_GOAL_WORDS_PER_CRITERION = 60;
344
+ export const SURFACE_CONTRACT_EXCEPTIONS = [
345
+ {
346
+ id: "T5",
347
+ acceptance: 4,
348
+ files: [
349
+ "src/adapters/prompt.ts", "src/gates/acceptance.ts", "src/gates/llm.ts", "src/gates/review.ts",
350
+ "tests/gates/acceptance.test.ts", "tests/gates/judge-retry.test.ts", "tests/gates/review.test.ts",
351
+ ],
352
+ reason: "v1.79 T5 (4×7=28) — the velocity ruling's own fast baseline (7/7 in 89 min), shipped green "
353
+ + "before R2 existed; the bound was calibrated FROM this corpus, not retroactively enforced on it.",
354
+ },
355
+ {
356
+ id: "T4",
357
+ acceptance: 5,
358
+ files: [
359
+ "src/gates/run-gates.ts", "src/run/daemon.ts",
360
+ "tests/gates/pipeline.test.ts", "tests/gates/review-retry.test.ts", "tests/gates/run-gates.test.ts",
361
+ ],
362
+ reason: "v1.85 T4 (5×5=25) — recorded in specs/v1.85-speed-truth.spec.md's Not-in-scope block: the two "
363
+ + "retry-seam test pins are non-negotiable collateral and splitting the pipeline task would "
364
+ + "re-serialize it.",
365
+ },
366
+ ];
367
+ function matchesSurfaceException(t, exceptions) {
368
+ const files = (t.files ?? []).map((f) => f.replace(/^\.\//, "")).sort();
369
+ const items = t.acceptance?.length ?? 0;
370
+ for (const ex of exceptions) {
371
+ if (ex.id !== t.id || ex.acceptance !== items)
372
+ continue;
373
+ const exFiles = [...ex.files].sort();
374
+ if (exFiles.length === files.length && exFiles.every((f, i) => f === files[i]))
375
+ return ex;
376
+ }
377
+ return undefined;
378
+ }
379
+ /**
380
+ * R2 surface bound: acceptance criteria × files[] patterns, one error per offending task.
381
+ * `exceptions` defaults to the recorded contract amendments; tests pass [] to prove an
382
+ * exception is load-bearing.
383
+ */
384
+ export function surfaceErrors(tasks, exceptions = SURFACE_CONTRACT_EXCEPTIONS) {
385
+ const errors = [];
386
+ for (const t of tasks) {
387
+ const items = t.acceptance?.length ?? 0;
388
+ const files = t.files?.length ?? 0;
389
+ const surface = items * files;
390
+ if (surface <= MAX_TASK_SURFACE)
391
+ continue;
392
+ if (matchesSurfaceException(t, exceptions))
393
+ continue; // recorded contract amendment
394
+ errors.push(`${t.id} has an acceptance×files surface of ${surface} (${items} criteria × ${files} files[] `
395
+ + `patterns), above the ${MAX_TASK_SURFACE} bound — split the task so one worker builds and one `
396
+ + `reviewer verifies a smaller unit in a single pass (R2).`);
397
+ }
398
+ return errors;
399
+ }
400
+ /** Words carrying at least one letter or digit in ANY script — bare dashes and bullets are not words. */
401
+ function goalWordCount(goal) {
402
+ return goal.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
403
+ }
404
+ /** R2 goal-density bound: goal words per acceptance criterion, one error per offending task. */
405
+ export function goalDensityErrors(tasks) {
406
+ const errors = [];
407
+ for (const t of tasks) {
408
+ const items = t.acceptance?.length ?? 0;
409
+ if (!items || !t.goal)
410
+ continue;
411
+ const words = goalWordCount(t.goal);
412
+ const ratio = Math.round((words / items) * 10) / 10;
413
+ if (ratio > MAX_GOAL_WORDS_PER_CRITERION) {
414
+ errors.push(`${t.id} carries a goal of ${ratio} words per acceptance criterion (${words} words ÷ ${items} `
415
+ + `criteria), above the ${MAX_GOAL_WORDS_PER_CRITERION} bound — move goal prose into judge/test `
416
+ + `criteria; law that lives only in prose is enforced by a 40-minute review round instead of the `
417
+ + `2-minute judge (R2).`);
418
+ }
419
+ }
420
+ return errors;
421
+ }
422
+ // planFrame class (OBS-248): `export function NAME`, `export const NAME =`, `class NAME` etc.
423
+ // Token heuristic like criteriaSymbols — definition keywords only, no AST, no method/property forms.
424
+ function definitionRe(symbol) {
425
+ return new RegExp(`(?:^|\\n)[ \\t]*(?:export\\s+(?:default\\s+)?)?(?:async\\s+)?`
426
+ + `(?:function\\*?\\s+|const\\s+|let\\s+|var\\s+|class\\s+|interface\\s+|type\\s+|enum\\s+)${symbol}\\b`);
427
+ }
428
+ /**
429
+ * The blocking ownership lint gets its own index, NOT walkCode: walkCode is a capped, fail-open
430
+ * ADVISORY scan (400 files, src/ only, skips giant and unreadable files) — the right shape for
431
+ * plan-time warnings, wrong for a compile error. A unique definition hiding in the 401st file,
432
+ * another code root (tests/, scripts/, a root-level config), or a skipped file would silently
433
+ * pass. This walk is complete: every code file under the repo root, no count cap, no size cap,
434
+ * and anything unreadable is reported so the lint can fail closed instead of trusting a
435
+ * partial scan. Generated mirrors are not definition sites: dist/ is tsc's copy of src/
436
+ * (rebuilt by the test gate's pretest), so indexing it would double every definition and read
437
+ * every unique site as ambiguous — the ownership rule indexes SOURCE only.
438
+ */
439
+ function walkAllCode(repoRoot) {
440
+ const files = [];
441
+ const unreadable = [];
442
+ const rel = (full) => relative(repoRoot, full).split("\\").join("/") || ".";
443
+ const walk = (dir) => {
444
+ let entries;
445
+ try {
446
+ entries = readdirSync(dir, { withFileTypes: true });
447
+ }
448
+ catch {
449
+ unreadable.push(rel(dir));
450
+ return;
451
+ }
452
+ entries.sort((a, b) => a.name.localeCompare(b.name));
453
+ for (const e of entries) {
454
+ if (e.name === "node_modules" || e.name === ".git" || e.name === "dist" || e.name.startsWith("."))
455
+ continue;
456
+ const full = join(dir, e.name);
457
+ if (e.isDirectory()) {
458
+ walk(full);
459
+ continue;
460
+ }
461
+ if (!e.isFile() || !CODE_EXT.test(e.name))
462
+ continue;
463
+ files.push(rel(full));
464
+ }
465
+ };
466
+ try {
467
+ if (!statSync(repoRoot).isDirectory())
468
+ return { files, unreadable }; // no root: empty index
469
+ }
470
+ catch {
471
+ return { files, unreadable };
472
+ }
473
+ walk(repoRoot);
474
+ return { files, unreadable };
475
+ }
476
+ /**
477
+ * R2 symbol ownership: a code identifier named in a judge or test criterion that resolves uniquely
478
+ * to a definition OUTSIDE the task's files[] is unsatisfiable by construction — the v1.84 T1
479
+ * planFrame defect, which burned two review rounds because the enabling symbol lived in another
480
+ * task's file. Zero definition sites (plain-language false positive, external symbol, or a file
481
+ * the task itself creates) and ambiguous tokens (multiple definition sites) stay silent. The index
482
+ * must be COMPLETE: any code path that cannot be read is its own error — the rule fails closed
483
+ * rather than trust a partial scan.
484
+ */
485
+ export function symbolOwnershipErrors(tasks, repoRoot) {
486
+ const perTask = tasks
487
+ .map((t) => ({ t, symbols: criteriaSymbols(t.acceptance ?? []) }))
488
+ .filter((x) => x.symbols.length);
489
+ if (!perTask.length)
490
+ return [];
491
+ const { files: codeFiles, unreadable } = walkAllCode(repoRoot);
492
+ const errors = [];
493
+ const incomplete = (path) => `cannot prove the symbol-ownership rule for ${perTask.map((x) => x.t.id).join(", ")}: ${path} `
494
+ + `is unreadable, so the code index is incomplete — restore read access (or remove the path) and `
495
+ + `recompile; the ownership lint fails closed rather than trust a partial scan (OBS-248).`;
496
+ for (const u of unreadable)
497
+ errors.push(incomplete(u));
498
+ // resolve each symbol to its definition site(s) once, shared across tasks
499
+ const allSymbols = [...new Set(perTask.flatMap((x) => x.symbols))];
500
+ const res = new Map(allSymbols.map((s) => [s, definitionRe(s)]));
501
+ const sites = new Map(allSymbols.map((s) => [s, []]));
502
+ for (const cf of codeFiles) {
503
+ let text;
504
+ try {
505
+ text = readFileSync(join(repoRoot, cf), "utf8"); // no size cap — a skipped file is a blind spot
506
+ }
507
+ catch {
508
+ errors.push(incomplete(cf));
509
+ continue;
510
+ }
511
+ for (const sym of allSymbols) {
512
+ if (text.includes(sym) && res.get(sym).test(text))
513
+ sites.get(sym).push(cf);
514
+ }
515
+ }
516
+ for (const { t, symbols } of perTask) {
517
+ // OBS-22: scopeGate accepts picomatch globs; ownership must agree.
518
+ const scoped = picomatch(t.files.map((f) => f.replace(/^\.\//, "")), { dot: true });
519
+ for (const sym of symbols) {
520
+ const defs = sites.get(sym) ?? [];
521
+ if (defs.length !== 1)
522
+ continue; // unknown or ambiguous — silent by ruling
523
+ const site = defs[0];
524
+ if (scoped(site))
525
+ continue; // defined inside the task's own write surface
526
+ errors.push(`${t.id}: criterion identifier "${sym}" is defined only in ${site}, which is not in files[] — `
527
+ + `add ${site} to files[] or reword the criterion; a worker scoped to files[] cannot satisfy a `
528
+ + `criterion whose enabling symbol lives outside it (OBS-248).`);
529
+ }
530
+ }
531
+ return errors;
532
+ }
533
+ /**
534
+ * R3 review participation (OVERSEER-RULING-20260731-velocity; OBS-186). The compiler assigns
535
+ * `reviewPolicy` from the DECLARED files[] — `judge-only` only when every declared path is provably
536
+ * docs/CHANGELOG/RELEASING/version-mirror leaf work — and the operator's floor may raise it. A task
537
+ * that would SKIP review while touching `review.criticalPaths` is the exact shape OBS-186 caught in
538
+ * the field: the riskiest task in the bundle silently declining the one gate that exists for it. That
539
+ * cannot be a warning, because nothing downstream would stop it — the gate would honour the policy.
540
+ */
541
+ export function reviewParticipationErrors(tasks, review) {
542
+ // The shipped critical paths are a FLOOR, not a default that a config replaces: this lint resolves
543
+ // its config from a repo root the compile seam cannot always name (see taskUnitContractErrors), and
544
+ // the one direction that must never happen is a wrong root lowering enforcement below what tickmarkr
545
+ // ships. Union is monotone — a repo-local list can only add.
546
+ const critical = [...new Set([...DEFAULT_REVIEW_CRITICAL_PATHS, ...(review.criticalPaths ?? [])])];
547
+ const errors = [];
548
+ for (const t of tasks) {
549
+ const files = t.files ?? [];
550
+ const policy = effectiveReviewPolicy(files, review);
551
+ // The non-monotone switch, caught at the only seam that can still refuse the graph. A shape
552
+ // override cannot demote a task the paths assigned `full` — that is precisely the demotion
553
+ // review.policy is structurally incapable of, arriving through a second door.
554
+ const shapeOff = t.shape !== undefined && review.byShape?.[t.shape]?.review === false;
555
+ if (policy === "full" && shapeOff) {
556
+ errors.push(`${t.id} declares full-review work (reviewPolicy full — at least one declared path is not `
557
+ + `leaf-class) while gates.byShape.${t.shape}.review: false would omit the review gate — `
558
+ + `remove that override, or narrow files[] to docs/CHANGELOG/RELEASING/version-mirror leaf `
559
+ + `work; participation is decided by paths, and config may raise a policy, never lower one (R3).`);
560
+ continue;
561
+ }
562
+ if (policy !== "judge-only" && !shapeOff)
563
+ continue; // full review actually runs — nothing skips
564
+ const hits = criticalPathHits(files, critical);
565
+ if (!hits.length)
566
+ continue;
567
+ const why = policy === "judge-only"
568
+ ? "reviewPolicy judge-only — every declared path is leaf-class work"
569
+ : `gates.byShape.${t.shape}.review: false`;
570
+ errors.push(`${t.id} would skip cross-vendor review (${why}) `
571
+ + `while touching review.criticalPaths ${hits.join(", ")} — narrow files[] out `
572
+ + `of the critical path, or set review.policy: full so the task is reviewed; participation is `
573
+ + `decided by paths, and a critical path may never be reviewed by the judge alone (R3).`);
574
+ }
575
+ return errors;
576
+ }
577
+ /**
578
+ * The ACTIVE participation config for a compile rooted at `repoRoot`. Same default-argument contract
579
+ * as `repoRoot` itself: the CLI and daemon compile from inside the target repo, so the repo's own
580
+ * config (plus the global overlay) is the config the run will gate under. A config the loader cannot
581
+ * read degrades to the shipped defaults rather than crashing the compile — every command that reaches
582
+ * this seam loads the same config itself and reports a malformed one loudly.
583
+ */
584
+ function activeReviewParticipation(repoRoot) {
585
+ try {
586
+ const cfg = loadConfig(repoRoot);
587
+ return { ...cfg.review, byShape: cfg.gates.byShape };
588
+ }
589
+ catch {
590
+ return { ...DEFAULT_CONFIG.review, byShape: DEFAULT_CONFIG.gates.byShape };
591
+ }
592
+ }
593
+ /**
594
+ * Every Task Unit Contract violation in one pass, ready to throw. `repoRoot` backs the
595
+ * symbol-ownership lint and the participation config, and defaults to the invocation directory —
596
+ * correct for the CLI/daemon, which compile from inside the target repo.
597
+ *
598
+ * KNOWN GAP (R2 review, T6/T7 scope): the compile seam in src/compile/index.ts
599
+ * (`enforceTaskUnitContract`) does not thread `compileSource`'s `root` argument into this call, so a
600
+ * PROGRAMMATIC compile whose target repo differs from process.cwd() resolves the wrong root and can
601
+ * miss that repo's own `review.criticalPaths`. Threading it is one line in src/compile/index.ts,
602
+ * outside this task's file scope. Two things bound the exposure meanwhile, both live: the critical
603
+ * set here is the UNION with the shipped defaults, so a wrong root can never lower enforcement below
604
+ * the shipped floor; and the review GATE — which is handed the run's real config — refuses to skip a
605
+ * critical path itself (src/gates/review.ts), so a lint this seam misses costs a late verdict rather
606
+ * than an unreviewed one. The compile lint is the early warning; the gate is the fail-closed backstop.
607
+ */
608
+ export function taskUnitContractErrors(tasks, repoRoot = process.cwd(), review = activeReviewParticipation(repoRoot)) {
609
+ return [
610
+ ...separabilityErrors(tasks),
611
+ ...taskBudgetErrors(tasks),
612
+ ...surfaceErrors(tasks),
613
+ ...goalDensityErrors(tasks),
614
+ ...symbolOwnershipErrors(tasks, repoRoot),
615
+ ...reviewParticipationErrors(tasks, review),
616
+ ];
326
617
  }
@@ -1,3 +1,4 @@
1
- import { type RunGraph } from "../graph/schema.js";
1
+ import { type AcceptanceItem, type RunGraph } from "../graph/schema.js";
2
2
  export declare function isGsdPhaseDir(dir: string): boolean;
3
+ export declare function parseTruths(file: string, raw: unknown): AcceptanceItem[] | undefined;
3
4
  export declare function compileGsd(src: string, root?: string): RunGraph;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, relative } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
- import { TIERS, validateGraph } from "../graph/schema.js";
4
+ import { AcceptanceItemSchema, TIERS, validateGraph } from "../graph/schema.js";
5
5
  import { CompileError, assertWriteScope, inferShape, sha256 } from "./common.js";
6
6
  // GSD artifact front-end (spec v1.3): one GSD *plan* is one tickmarkr *task* — a plan is
7
7
  // worktree-sized; its inner <task> steps stay in the worker prompt via context[0] = the plan file.
@@ -16,6 +16,72 @@ export function isGsdPhaseDir(dir) {
16
16
  }
17
17
  }
18
18
  const strings = (v) => (Array.isArray(v) ? v.map(String) : []);
19
+ // Cycle-safe by construction: a recursive YAML alias (`&a [*a]`) is a legal value the parser hands
20
+ // back as a circular object, and a bare JSON.stringify throws a native TypeError on it — which would
21
+ // REPLACE the indexed CompileError this module owes the author with an unrelated crash. Every value
22
+ // the YAML parser can produce renders here. (A repeated non-recursive alias also renders [circular];
23
+ // this is a diagnostic string, not a round-trippable encoding.)
24
+ const show = (v) => {
25
+ const seen = new WeakSet();
26
+ const cycleSafe = (_k, x) => {
27
+ if (typeof x !== "object" || x === null)
28
+ return x;
29
+ if (seen.has(x))
30
+ return "[circular]";
31
+ seen.add(x);
32
+ return x;
33
+ };
34
+ return JSON.stringify(v, cycleSafe) ?? String(v);
35
+ };
36
+ // A truth IS an acceptance item, not a string to coerce. strings()' `.map(String)` turned a typed
37
+ // object truth into the literal "[object Object]" — schema-legal in the judge-compat string form, so
38
+ // it passed validateGraph, reached the worker prompt verbatim and became a judge rubric item; a YAML
39
+ // scalar `- 2.0` silently became "2". Nothing downstream catches either. So fail closed (house
40
+ // precedent: the routing block in compileOne) on anything that is not text and not a typed oracle.
41
+ // The CONTAINER is reported, not collapsed: an absent key returns undefined (nothing was declared) and
42
+ // a present-but-empty list returns [] (declared, contributed nothing) — strings() flattened both, plus
43
+ // a non-list, into the same silent []. Only the non-list fails closed; `truths: []` is real producer
44
+ // output whose plan can still be carried by its <done> lines, and compileOne already rejects a task
45
+ // whose acceptance ends up genuinely empty.
46
+ // Parser liberal, template strict: a string truth stays exactly what strings() made of it — the GSD
47
+ // planner template teaches prose-only and lives in another repo, so this parser must never be
48
+ // stricter than the thing that produced its input. A non-string must satisfy AcceptanceItemSchema
49
+ // EXACTLY — accepted with nothing dropped — which is where the dual-key `text:` beside
50
+ // `command:`/`test:` is DECLARED (schema.ts:25-30); being declared is what makes it survive
51
+ // loadGraph's revalidation, so nothing is ever reattached after.
52
+ export function parseTruths(file, raw) {
53
+ if (raw === undefined)
54
+ return undefined; // key absent: nothing was declared
55
+ if (!Array.isArray(raw)) {
56
+ throw new CompileError(`${file} has a must_haves.truths that is not a list (got ${show(raw)}).\n` +
57
+ ` remedy: write each truth as a "- " list entry, or drop the key.`);
58
+ }
59
+ return raw.map((v, i) => {
60
+ if (typeof v === "string")
61
+ return v;
62
+ const parsed = AcceptanceItemSchema.safeParse(v);
63
+ if (!parsed.success) {
64
+ throw new CompileError(`${file} has a must_haves.truths[${i}] that is neither prose nor a typed acceptance oracle: ${show(v)}\n` +
65
+ ` remedy: write it as plain text, or as {oracle: command, command: <shell>} / {oracle: test, test: <name>} /\n` +
66
+ ` {oracle: judge, text: <rubric>} — command and test may carry a "text:" beside the oracle.`);
67
+ }
68
+ // A SUCCESSFUL parse is lossy too: z.object strips undeclared keys, so {oracle, command, text,
69
+ // severity} validates and compiles with `severity` gone — the same silent coercion in the other
70
+ // direction, and invisible because nothing downstream ever saw the key. Accepting less than the
71
+ // author supplied is not acceptance, so fail closed on whatever validation dropped.
72
+ // Object.hasOwn, never `k in`: the YAML parser hands back `constructor`, `toString`,
73
+ // `hasOwnProperty` and `__proto__` as OWN keys, but `in` finds all four on Object.prototype of the
74
+ // validated output, so each read as "kept" and was stripped in silence — the exact coercion this
75
+ // check exists to stop.
76
+ const dropped = Object.keys(v).filter((k) => !Object.hasOwn(parsed.data, k));
77
+ if (dropped.length) {
78
+ throw new CompileError(`${file} has a must_haves.truths[${i}] carrying key(s) no acceptance oracle declares: ${dropped.join(", ")}\n` +
79
+ ` remedy: drop ${dropped.length === 1 ? "that key" : "those keys"}, or fold the intent into "text:" — a\n` +
80
+ ` compile that kept ${dropped.length === 1 ? "it" : "them"} would discard ${dropped.length === 1 ? "it" : "them"} at the next graph load, unread.`);
81
+ }
82
+ return parsed.data;
83
+ });
84
+ }
19
85
  const WRITE_DIRECTIVE = /\b(?:Create|Write|Add|Emit|Generate)\s+`([^`\s]+)`/gi;
20
86
  const isPathish = (s) => /^[\w./*-]+$/.test(s) && /\.[a-z]{2,4}$/i.test(s);
21
87
  const stripFences = (s) => s.replace(/^```[\s\S]*?^```/gm, "");
@@ -89,7 +155,7 @@ function compileOne(file, storedPath) {
89
155
  .trim();
90
156
  const title = firstSentence || key;
91
157
  const dones = [...body.matchAll(/<done>\s*([\s\S]*?)\s*<\/done>/g)].map((m) => m[1].replace(/\s+/g, " ").trim());
92
- const truths = strings(fm.must_haves?.truths);
158
+ const truths = parseTruths(file, fm.must_haves?.truths) ?? [];
93
159
  const acceptance = [...dones, ...truths].filter(Boolean);
94
160
  if (!acceptance.length) {
95
161
  throw new CompileError(`${file} has no acceptance criteria — every tickmarkr task needs them.\n` +
@@ -1,4 +1,18 @@
1
1
  import { type RunGraph } from "../graph/schema.js";
2
+ /**
3
+ * Classify one `context:` entry against the tree a worker's worktree is built from.
4
+ * ok — reachable
5
+ * untracked — in the author's checkout, invisible to every worker. WARNS, permanently: per-file
6
+ * force-add is the deliberate opt-in, so this is a standing prompt, not a migration.
7
+ * missing — absent from both. FAILS compile; carries a repair when exactly one tracked file
8
+ * shares the basename (ambiguous matches suggest nothing rather than guess).
9
+ * RULING 2026-08-03 (.overseer/OVERSEER-RULING-20260803-context-visibility.md): OBS-170's
10
+ * fail-closed-on-untracked is retired — oracle upheld, policy overruled (it blocked 11 of 13).
11
+ */
12
+ export declare function classifyContextPath(entry: string, tracked: Set<string>, repoDir: string): {
13
+ kind: "ok" | "untracked" | "missing";
14
+ suggestion?: string;
15
+ };
2
16
  export declare const COLLECTABLE_TESTS = "tests/**/*.test.ts";
3
17
  export declare const LEGACY_PREFIX: string;
4
18
  export declare const TICKMARKR_NATIVE_MARKER: RegExp;