tickmarkr 2.6.4 → 2.6.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 (73) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/catalog-remote.d.ts +23 -0
  3. package/dist/adapters/catalog-remote.js +38 -0
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +4 -1
  6. package/dist/adapters/codex-commit-check.d.ts +36 -0
  7. package/dist/adapters/codex-commit-check.js +205 -0
  8. package/dist/adapters/codex.d.ts +1 -0
  9. package/dist/adapters/codex.js +12 -6
  10. package/dist/adapters/model-discovery-check.d.ts +33 -0
  11. package/dist/adapters/model-discovery-check.js +123 -0
  12. package/dist/adapters/model-lints.d.ts +1 -1
  13. package/dist/adapters/model-lints.js +4 -0
  14. package/dist/adapters/prompt.js +3 -2
  15. package/dist/adapters/types.d.ts +10 -0
  16. package/dist/cli/commands/approve.js +10 -4
  17. package/dist/cli/commands/doctor.d.ts +2 -0
  18. package/dist/cli/commands/doctor.js +17 -3
  19. package/dist/cli/commands/fleet.d.ts +10 -1
  20. package/dist/cli/commands/fleet.js +363 -106
  21. package/dist/cli/commands/init.js +10 -3
  22. package/dist/cli/commands/plan.js +76 -4
  23. package/dist/cli/commands/verify.d.ts +1 -1
  24. package/dist/cli/commands/verify.js +65 -15
  25. package/dist/config/fleet-overlay.d.ts +16 -1
  26. package/dist/config/fleet-overlay.js +230 -28
  27. package/dist/drivers/orca.d.ts +34 -2
  28. package/dist/drivers/orca.js +206 -32
  29. package/dist/gates/cache.d.ts +4 -0
  30. package/dist/gates/cache.js +10 -2
  31. package/dist/gates/llm.d.ts +4 -1
  32. package/dist/gates/llm.js +6 -1
  33. package/dist/gates/review.js +8 -3
  34. package/dist/gates/run-gates.d.ts +5 -3
  35. package/dist/gates/run-gates.js +112 -56
  36. package/dist/gates/test-manifest.d.ts +3 -0
  37. package/dist/gates/test-manifest.js +1 -1
  38. package/dist/gates/timeout-shaped.d.ts +3 -0
  39. package/dist/gates/timeout-shaped.js +10 -0
  40. package/dist/run/daemon.js +326 -130
  41. package/dist/run/git.js +9 -0
  42. package/dist/run/journal.d.ts +6 -2
  43. package/dist/run/journal.js +22 -7
  44. package/dist/run/launchservices-check.d.ts +56 -0
  45. package/dist/run/launchservices-check.js +117 -0
  46. package/dist/run/operator-state.d.ts +22 -2
  47. package/dist/run/operator-state.js +20 -3
  48. package/dist/run/title-environment.d.ts +45 -0
  49. package/dist/run/title-environment.js +127 -0
  50. package/dist/run/title-preload.cjs +38 -0
  51. package/dist/run/title-preload.d.cts +1 -0
  52. package/dist/tui/cockpit/board.js +12 -6
  53. package/dist/tui/cockpit/keys.d.ts +8 -0
  54. package/dist/tui/cockpit/keys.js +4 -1
  55. package/dist/tui/cockpit/layout.d.ts +2 -2
  56. package/dist/tui/cockpit/layout.js +2 -2
  57. package/dist/tui/cockpit/live-runtime.js +46 -20
  58. package/dist/tui/cockpit/live-store.d.ts +24 -1
  59. package/dist/tui/cockpit/live-store.js +99 -8
  60. package/dist/tui/cockpit/log-view.d.ts +28 -0
  61. package/dist/tui/cockpit/log-view.js +60 -0
  62. package/dist/tui/cockpit/run-view.js +4 -0
  63. package/dist/tui/cockpit/shell.d.ts +2 -1
  64. package/dist/tui/cockpit/shell.js +6 -4
  65. package/dist/tui/ink/fleet-app.d.ts +37 -10
  66. package/dist/tui/ink/fleet-app.js +65 -21
  67. package/package.json +1 -1
  68. package/skills/tickmarkr-auto/SKILL.md +32 -2
  69. package/skills/tickmarkr-loop/SKILL.md +46 -2
  70. package/skills/tickmarkr-overseer/SKILL.md +83 -8
  71. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  72. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  73. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
package/dist/run/git.js CHANGED
@@ -9,6 +9,7 @@ import { StringDecoder } from "node:string_decoder";
9
9
  import { shq } from "../adapters/types.js";
10
10
  import { tickmarkrDir } from "../graph/graph.js";
11
11
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
12
+ import { titleEnvironment } from "./title-environment.js";
12
13
  export { ROUTING_ENV_SEAMS };
13
14
  // OBS-110: gate/baseline/tip-verify children are vitest suites; without a fork cap, concurrent
14
15
  // full-suite gate runs fork a worker-per-core pool per worktree and saturate the operator box.
@@ -324,6 +325,14 @@ function executeShell(cmd, cwd, timeoutMs, login, options, observation) {
324
325
  // OBS-854: descendants can leave the checkout (nested fixture suites do), so cwd alone cannot
325
326
  // attribute them. Every daemon shell exports the daemon pid as their durable parentage marker.
326
327
  env[SUITE_PARENT_ENV] = String(process.pid);
328
+ // A2: on Darwin a Node child's title writes stay in JavaScript (title-environment.ts); runnerInputsHash
329
+ // hashes this same effective NODE_OPTIONS, so a verdict is only reused under the identical mode. Native
330
+ // mode is applied too: it drops a preload a nested shell inherited from its parent (unset when that was all).
331
+ const { nodeOptions } = titleEnvironment(env);
332
+ if (nodeOptions === undefined)
333
+ delete env.NODE_OPTIONS;
334
+ else
335
+ env.NODE_OPTIONS = nodeOptions;
327
336
  // T7: the capacity every result of this shell carries, read HERE — off the environment the child
328
337
  // is about to receive, after the precedence above has settled. An operator export is already in
329
338
  // `env`, so what gets recorded is the operator's number, which is the case a release was re-taken
@@ -650,7 +650,9 @@ export declare const rangePatch: (cwd: string, base: string, head: string) => st
650
650
  * these and is refused as ambiguous; containment never counts.
651
651
  * Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
652
652
  */
653
- export declare function integrationMapped(cwd: string, events: readonly JournalEvent[], o: OwedCheck, mergeBase: unknown, head: unknown): boolean;
653
+ export declare function integrationMapped(cwd: string, events: readonly JournalEvent[], o: OwedCheck, mergeBase: unknown, head: unknown, memo?: OwedProofMemo): boolean;
654
+ /** I2: a caller-scoped set of completed integration re-proofs (see integrationMapped); absent, every fold re-proves. */
655
+ export type OwedProofMemo = Set<string>;
654
656
  /**
655
657
  * Lifetime patch authors of base..head, read from the run: the daemon's subjectAuthors fold
656
658
  * (daemon.ts, not exported and not this task's to edit) restated synchronously so the waive row is
@@ -687,6 +689,8 @@ export type ChannelFact = {
687
689
  * The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
688
690
  * `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
689
691
  * It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
692
+ * `memo` (I2, optional): a store-scoped table reusing the immutable git re-proof of an integration mapping
693
+ * across folds; every artifact, hash, criteria and reviewer condition is still re-read on every fold.
690
694
  */
691
- export declare function foldOwedChecks(events: readonly JournalEvent[], cwd: string): OwedFold;
695
+ export declare function foldOwedChecks(events: readonly JournalEvent[], cwd: string, memo?: OwedProofMemo): OwedFold;
692
696
  export {};
@@ -2425,11 +2425,24 @@ const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
2425
2425
  * these and is refused as ambiguous; containment never counts.
2426
2426
  * Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
2427
2427
  */
2428
- export function integrationMapped(cwd, events, o, mergeBase, head) {
2428
+ export function integrationMapped(cwd, events, o, mergeBase, head, memo) {
2429
2429
  if (typeof mergeBase !== "string" || typeof head !== "string" || !COMMIT_ID.test(mergeBase) || !COMMIT_ID.test(head))
2430
2430
  return false;
2431
2431
  if (!events.some((e) => e.event === "merge" && e.taskId === o.taskId && e.data.commit === head))
2432
2432
  return false;
2433
+ // Only the git component is memoised, and only a completed proof: commit ids name immutable history,
2434
+ // so the key is the obligation's id and recorded range facts plus the claimed mergeBase..head. A refusal
2435
+ // or git failure (a shallow or partial object store can refuse a range it later proves) is never cached
2436
+ // and is re-proved next fold. The merge-row eligibility above is rechecked on every fold.
2437
+ const key = JSON.stringify([o.id, mergeBase, head, o.base, o.head, o.patch, o.patches]);
2438
+ if (memo?.has(key))
2439
+ return true;
2440
+ const proved = integrationProved(cwd, o, mergeBase, head);
2441
+ if (proved)
2442
+ memo?.add(key);
2443
+ return proved;
2444
+ }
2445
+ function integrationProved(cwd, o, mergeBase, head) {
2433
2446
  try {
2434
2447
  const parents = owedGit(cwd, ["show", "-s", "--format=%P", head]).trim().split(" ");
2435
2448
  if (parents.length !== 2 || parents[0] !== mergeBase)
@@ -2609,7 +2622,7 @@ const declaredVendors = (o) => {
2609
2622
  return vendors;
2610
2623
  };
2611
2624
  /** Why a discharge row no longer proves its obligation, re-read from its artifact; undefined when it does. */
2612
- function dischargeProblem(row, o, events, cwd) {
2625
+ function dischargeProblem(row, o, events, cwd, memo) {
2613
2626
  const d = row.data;
2614
2627
  if (row.taskId !== o.taskId)
2615
2628
  return "discharge names another task";
@@ -2617,7 +2630,7 @@ function dischargeProblem(row, o, events, cwd) {
2617
2630
  return "discharge proved other criteria";
2618
2631
  const exact = d.mapping === "exact" && d.mergeBase === o.base && d.head === o.head;
2619
2632
  // The integration mapping is re-proved from git on every fold, never taken from the row's claims.
2620
- const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head);
2633
+ const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head, memo);
2621
2634
  if (!exact && !integration)
2622
2635
  return "discharge range does not bind the obligation";
2623
2636
  let artifact;
@@ -2675,16 +2688,18 @@ function dischargeProblem(row, o, events, cwd) {
2675
2688
  * The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
2676
2689
  * `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
2677
2690
  * It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
2691
+ * `memo` (I2, optional): a store-scoped table reusing the immutable git re-proof of an integration mapping
2692
+ * across folds; every artifact, hash, criteria and reviewer condition is still re-read on every fold.
2678
2693
  */
2679
- export function foldOwedChecks(events, cwd) {
2694
+ export function foldOwedChecks(events, cwd, memo) {
2680
2695
  try {
2681
- return foldOwedChecksOrThrow(events, cwd);
2696
+ return foldOwedChecksOrThrow(events, cwd, memo);
2682
2697
  }
2683
2698
  catch (error) {
2684
2699
  return { known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason: `owed-check fold failed: ${String(error).split("\n")[0]}` }] };
2685
2700
  }
2686
2701
  }
2687
- function foldOwedChecksOrThrow(events, cwd) {
2702
+ function foldOwedChecksOrThrow(events, cwd, memo) {
2688
2703
  const accepted = new Map();
2689
2704
  const discharged = new Set();
2690
2705
  const unknown = [];
@@ -2703,7 +2718,7 @@ function foldOwedChecksOrThrow(events, cwd) {
2703
2718
  const ids = Array.isArray(e.data.ids) ? e.data.ids : [];
2704
2719
  const problems = ids.map((id) => {
2705
2720
  const o = accepted.get(String(id));
2706
- return o ? dischargeProblem(e, o, events, cwd) : "discharge names no recorded obligation";
2721
+ return o ? dischargeProblem(e, o, events, cwd, memo) : "discharge names no recorded obligation";
2707
2722
  });
2708
2723
  const problem = ids.length ? problems.find((p) => p !== undefined) : "discharge names no obligation";
2709
2724
  if (problem)
@@ -0,0 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { AuthHealth, LaunchServicesRecord } from "../adapters/types.js";
3
+ /**
4
+ * v2.6.5 T10 (A1, D-787/D-789): did launchservicesd restart since tickmarkr last looked?
5
+ *
6
+ * On 2026-09-30 a heavy local suite exhausted launchservicesd's Mach port space (0x1000600b); the daemon
7
+ * crashed, respawned with an empty app registry, and every GUI app launched before it read "not open
8
+ * anymore". The tell is the daemon's own identity: a young pid/start beside an old Dock and Finder.
9
+ * doctor and standalone verify read that identity with one bounded /bin/ps call, compare it with the
10
+ * one the FIRST doctor.json record holds (registry order — no new state file or adapter row), and
11
+ * persist one advisory on a change. Unreadable evidence is unknown: the prior identity is kept, never
12
+ * replaced by a guess. Advisory only — verdicts, retries and routing never read it.
13
+ */
14
+ export declare const LAUNCHSERVICES_PROBE_CEILING_MS = 5000;
15
+ export interface LaunchServicesIdentity {
16
+ pid: number;
17
+ start: string;
18
+ }
19
+ export type LaunchServicesObservation = {
20
+ status: "non-darwin";
21
+ } | {
22
+ status: "read";
23
+ identity: LaunchServicesIdentity;
24
+ } | {
25
+ status: "unreadable";
26
+ detail: string;
27
+ };
28
+ /** The closed identity table; "first" is the baseline row when no prior identity is held. */
29
+ export type LaunchServicesStatus = "first" | "unchanged" | "changed-pid" | "changed-start" | "unreadable" | "non-darwin" | "skipped";
30
+ /** Exactly one launchservicesd row, or null (absent, ambiguous or malformed output is unknown). */
31
+ export declare function parsePsIdentity(stdout: string): LaunchServicesIdentity | null;
32
+ export type ProbeTimer = (fire: () => void, ms: number) => () => void;
33
+ interface ProbeSeam {
34
+ platform: NodeJS.Platform;
35
+ spawn: typeof spawn;
36
+ setTimer: ProbeTimer;
37
+ signal?: AbortSignal;
38
+ }
39
+ export declare const setLaunchServicesProbeForTests: (s: Partial<ProbeSeam>) => void;
40
+ export declare const resetLaunchServicesProbeForTests: () => void;
41
+ export declare function observeLaunchServices(s?: ProbeSeam): Promise<LaunchServicesObservation>;
42
+ export declare function launchServicesAdvisory(prior: LaunchServicesIdentity, now: LaunchServicesIdentity): string;
43
+ /** Pure: the table row for one observation against the held record, and the record to hold next. */
44
+ export declare function classifyLaunchServices(prior: Partial<LaunchServicesRecord> | undefined, observed: LaunchServicesObservation): {
45
+ status: LaunchServicesStatus;
46
+ next?: LaunchServicesRecord;
47
+ line?: string;
48
+ };
49
+ /** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
50
+ * Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
51
+ export declare function recordLaunchServices(prior: Record<string, AuthHealth> | null, health: Record<string, AuthHealth>): Promise<{
52
+ status: LaunchServicesStatus;
53
+ health: Record<string, AuthHealth>;
54
+ line?: string;
55
+ }>;
56
+ export {};
@@ -0,0 +1,117 @@
1
+ import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ /**
4
+ * v2.6.5 T10 (A1, D-787/D-789): did launchservicesd restart since tickmarkr last looked?
5
+ *
6
+ * On 2026-09-30 a heavy local suite exhausted launchservicesd's Mach port space (0x1000600b); the daemon
7
+ * crashed, respawned with an empty app registry, and every GUI app launched before it read "not open
8
+ * anymore". The tell is the daemon's own identity: a young pid/start beside an old Dock and Finder.
9
+ * doctor and standalone verify read that identity with one bounded /bin/ps call, compare it with the
10
+ * one the FIRST doctor.json record holds (registry order — no new state file or adapter row), and
11
+ * persist one advisory on a change. Unreadable evidence is unknown: the prior identity is kept, never
12
+ * replaced by a guess. Advisory only — verdicts, retries and routing never read it.
13
+ */
14
+ export const LAUNCHSERVICES_PROBE_CEILING_MS = 5_000;
15
+ // lstart in the C locale: "Wed Sep 30 21:54:33 2026"; comm is the executable path on Darwin.
16
+ const PS_ARGS = ["-A", "-o", "pid=,lstart=,comm="];
17
+ const PS_LINE = /^\s*(\d+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+?)\s*$/;
18
+ const MAX_PS_BYTES = 4 * 1024 * 1024;
19
+ /** Exactly one launchservicesd row, or null (absent, ambiguous or malformed output is unknown). */
20
+ export function parsePsIdentity(stdout) {
21
+ const rows = stdout.split("\n").flatMap((line) => {
22
+ const m = PS_LINE.exec(line);
23
+ return m && basename(m[3]) === "launchservicesd" ? [{ pid: Number(m[1]), start: m[2].replace(/\s+/g, " ") }] : [];
24
+ });
25
+ return rows.length === 1 ? rows[0] : null;
26
+ }
27
+ const production = () => ({
28
+ platform: process.platform,
29
+ spawn,
30
+ setTimer: (fire, ms) => { const t = setTimeout(fire, ms); return () => clearTimeout(t); },
31
+ });
32
+ // ponytail: default-off under the test runner (the catalogRefreshAllowed precedent) so an unowned doctor
33
+ // test never takes a real host reading or changes its doctor.json bytes; tests opt in through the seam.
34
+ let seam;
35
+ export const setLaunchServicesProbeForTests = (s) => { seam = { ...production(), ...s }; };
36
+ export const resetLaunchServicesProbeForTests = () => { seam = undefined; };
37
+ const activeSeam = () => seam ?? (process.env.VITEST === "true" ? undefined : production());
38
+ /** One /bin/ps call that owns only its child, killed at the ceiling or on cancellation and reaped before it resolves. */
39
+ function readIdentity(s) {
40
+ return new Promise((resolve) => {
41
+ const unknown = (detail) => resolve({ status: "unreadable", detail });
42
+ if (s.signal?.aborted) {
43
+ unknown("probe cancelled");
44
+ return;
45
+ }
46
+ let child;
47
+ try {
48
+ child = s.spawn("/bin/ps", PS_ARGS, { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, LC_ALL: "C" } });
49
+ }
50
+ catch (e) {
51
+ unknown(`/bin/ps did not start (${e instanceof Error ? e.message : String(e)})`);
52
+ return;
53
+ }
54
+ let out = "";
55
+ let why;
56
+ const retire = (reason) => { why ??= reason; child.kill("SIGKILL"); };
57
+ const clear = s.setTimer(() => retire(`/bin/ps exceeded the ${LAUNCHSERVICES_PROBE_CEILING_MS} ms kill ceiling`), LAUNCHSERVICES_PROBE_CEILING_MS);
58
+ const cancel = () => retire("probe cancelled");
59
+ s.signal?.addEventListener("abort", cancel, { once: true });
60
+ child.stdout?.on("data", (chunk) => {
61
+ out += chunk.toString("utf8");
62
+ if (out.length > MAX_PS_BYTES)
63
+ retire("/bin/ps output exceeded its bound");
64
+ });
65
+ child.once("error", (e) => { why ??= `/bin/ps failed (${e.message})`; });
66
+ child.once("close", (code) => {
67
+ clear();
68
+ s.signal?.removeEventListener("abort", cancel);
69
+ if (why)
70
+ return unknown(why);
71
+ if (code !== 0)
72
+ return unknown(`/bin/ps exited ${code}`);
73
+ const identity = parsePsIdentity(out);
74
+ return identity ? resolve({ status: "read", identity }) : unknown("no single launchservicesd row in /bin/ps output");
75
+ });
76
+ });
77
+ }
78
+ export function observeLaunchServices(s = production()) {
79
+ return s.platform === "darwin" ? readIdentity(s) : Promise.resolve({ status: "non-darwin" });
80
+ }
81
+ // doctor.json is never schema-validated: a malformed held record is no identity at all.
82
+ const knownPrior = (prior) => typeof prior?.pid === "number" && typeof prior.start === "string";
83
+ export function launchServicesAdvisory(prior, now) {
84
+ return `launchservicesd restarted: pid ${prior.pid} → ${now.pid}, started ${prior.start} → ${now.start}. `
85
+ + `GUI apps launched before ${now.start} may read "not open anymore" (D-787: the daemon crashed and respawned with an empty app registry). `
86
+ + `Remedy: quit and relaunch each affected app, or log out / restart the Mac (a restart ends agent sessions and wipes /private/tmp); `
87
+ + `confirm with /usr/bin/log show (launchservicesd 0x1000600b near ${now.start}) and ~/Library/Logs/DiagnosticReports/ExcUserFault_node-*.ips (advisory — verdicts unchanged)`;
88
+ }
89
+ /** Pure: the table row for one observation against the held record, and the record to hold next. */
90
+ export function classifyLaunchServices(prior, observed) {
91
+ const kept = knownPrior(prior) ? prior : undefined;
92
+ if (observed.status === "non-darwin")
93
+ return { status: "non-darwin", next: kept };
94
+ if (observed.status === "unreadable") {
95
+ const held = kept ? `pid ${kept.pid} started ${kept.start}` : "none";
96
+ return { status: "unreadable", next: kept, line: `launchservicesd identity unknown — ${observed.detail}; prior identity (${held}) retained (advisory — verdicts unchanged)` };
97
+ }
98
+ const now = observed.identity;
99
+ if (!knownPrior(prior))
100
+ return { status: "first", next: now };
101
+ const status = prior.pid !== now.pid ? "changed-pid" : prior.start !== now.start ? "changed-start" : "unchanged";
102
+ if (status === "unchanged")
103
+ return { status, next: prior };
104
+ const advisory = launchServicesAdvisory(prior, now);
105
+ return { status, next: { ...now, advisory }, line: advisory };
106
+ }
107
+ /** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
108
+ * Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
109
+ export async function recordLaunchServices(prior, health) {
110
+ const held = prior ? Object.values(prior)[0]?.launchServices : undefined;
111
+ const s = activeSeam();
112
+ const r = s ? classifyLaunchServices(held, await observeLaunchServices(s)) : { status: "skipped", next: knownPrior(held) ? held : undefined };
113
+ const holder = Object.keys(health)[0];
114
+ if (!holder || !r.next || JSON.stringify(health[holder].launchServices) === JSON.stringify(r.next))
115
+ return { ...r, health };
116
+ return { ...r, health: { ...health, [holder]: { ...health[holder], launchServices: r.next } } };
117
+ }
@@ -1,5 +1,5 @@
1
1
  import { type RunGraph } from "../graph/schema.js";
2
- import { type JournalEvent } from "./journal.js";
2
+ import { type JournalEvent, type OwedFold } from "./journal.js";
3
3
  /** An evidence identity is a physical journal line, never a filtered row ordinal. */
4
4
  export interface EvidenceIdentity {
5
5
  source: string;
@@ -74,6 +74,15 @@ export interface OperatorSnapshot {
74
74
  };
75
75
  latestRunEnd?: EvidenceIdentity;
76
76
  approvedResumeRequired: boolean;
77
+ /** I2: CURRENT owed-check debt from the authority this observation supplied; "unknown" when absent or stale. */
78
+ debt: number | "unknown";
79
+ outstanding: readonly string[];
80
+ }
81
+ /** I2: foldOwedChecks over the complete effective journal, tagged with the journal basis it read. A result
82
+ * whose basis is not the observation's own is stale and counts as unknown debt, never as zero. */
83
+ export interface OwedAuthority {
84
+ basis: string;
85
+ fold: OwedFold;
77
86
  }
78
87
  /** Author channels recorded on a task-done row. A row written before authors were recorded carries none and
79
88
  * projects nothing (never the dispatch); unattributed preserved work arrives as an explicit "unknown" entry. */
@@ -99,12 +108,20 @@ export declare class OperatorStateFold {
99
108
  private total;
100
109
  apply(record: OperatorRecord): void;
101
110
  private task;
102
- snapshot({ graph, sequence, observedAt, readable, graphAvailability }?: {
111
+ /** A run-end is the latest lifecycle boundary: the only state whose headline consults owed-check debt. */
112
+ get ended(): boolean;
113
+ /** `daemonDead`: this run's lock holder was observed provably dead (lock.ts liveness). It is an observation,
114
+ * not a journal row, so it settles the projection only and the fold keeps no trace of it.
115
+ * `owed`/`basis` (I2): COMPLETE needs a known-empty debt computed over this very journal basis. */
116
+ snapshot({ graph, sequence, observedAt, readable, graphAvailability, daemonDead, owed, basis }?: {
103
117
  graph?: RunGraph;
104
118
  sequence?: number;
105
119
  observedAt?: number;
106
120
  readable?: boolean;
107
121
  graphAvailability?: OperatorSnapshot["graphAvailability"];
122
+ daemonDead?: boolean;
123
+ owed?: OwedAuthority;
124
+ basis?: string;
108
125
  }): OperatorSnapshot;
109
126
  private comparableTo;
110
127
  }
@@ -116,4 +133,7 @@ export declare function readOperatorState({ events, source, ...options }: {
116
133
  sequence?: number;
117
134
  observedAt?: number;
118
135
  readable?: boolean;
136
+ daemonDead?: boolean;
137
+ owed?: OwedAuthority;
138
+ basis?: string;
119
139
  }): OperatorSnapshot;
@@ -5,6 +5,9 @@ const bucketNames = ["failed", "human", "blocked", "pending"];
5
5
  const strings = (value) => Array.isArray(value) && value.every(v => typeof v === "string") ? [...value] : undefined;
6
6
  const identity = ({ source, line, id, generation }) => ({ source, line, id, generation });
7
7
  const emptyGates = () => Object.fromEntries(GATE_NAMES.map(g => [g, { state: "not-run" }]));
8
+ /** I1: at a terminal boundary nothing will finish a queued or running cell, so it reads unknown (keeping the
9
+ * row that started it); a measured passed/failed cell is never touched. Returns copies. */
10
+ const settled = (gates, settle = true) => Object.fromEntries(Object.entries(gates).map(([g, cell]) => [g, settle && (cell.state === "queued" || cell.state === "running") ? { ...cell, state: "unknown" } : { ...cell }]));
8
11
  /** Author channels recorded on a task-done row. A row written before authors were recorded carries none and
9
12
  * projects nothing (never the dispatch); unattributed preserved work arrives as an explicit "unknown" entry. */
10
13
  export const doneAuthors = (data) => strings(data.authors);
@@ -60,6 +63,10 @@ export class OperatorStateFold {
60
63
  };
61
64
  this.active = false;
62
65
  this.approved = false;
66
+ for (const t of this.tasks.values()) {
67
+ t.gates = settled(t.gates);
68
+ t.gateActivity = undefined;
69
+ }
63
70
  for (const key of bucketNames)
64
71
  for (const id of this.end.buckets[key] ?? []) {
65
72
  const task = this.task(id);
@@ -132,6 +139,8 @@ export class OperatorStateFold {
132
139
  t.gateActivity = undefined;
133
140
  if (["task-done", "merge", "task-failed", "task-human", "task-blocked"].includes(e.event))
134
141
  t.gateActivity = undefined;
142
+ if (["task-failed", "task-human", "task-blocked"].includes(e.event))
143
+ t.gates = settled(t.gates);
135
144
  const phaseGate = e.event === "phase-start" && typeof e.data.phase === "string"
136
145
  ? e.data.phase.startsWith("gate:") ? e.data.phase.slice(5) : e.data.phase === "judge" ? "acceptance" : e.data.phase === "review" ? "review" : undefined
137
146
  : undefined;
@@ -162,13 +171,18 @@ export class OperatorStateFold {
162
171
  }
163
172
  return t;
164
173
  }
165
- snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true, graphAvailability } = {}) {
174
+ /** A run-end is the latest lifecycle boundary: the only state whose headline consults owed-check debt. */
175
+ get ended() { return !!this.end && !this.active; }
176
+ /** `daemonDead`: this run's lock holder was observed provably dead (lock.ts liveness). It is an observation,
177
+ * not a journal row, so it settles the projection only and the fold keeps no trace of it.
178
+ * `owed`/`basis` (I2): COMPLETE needs a known-empty debt computed over this very journal basis. */
179
+ snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true, graphAvailability, daemonDead = false, owed, basis } = {}) {
166
180
  const hash = graph && graphDefinitionHash(graph);
167
181
  const comparable = this.comparableTo(hash);
168
182
  const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
169
183
  const tasks = ids.map(id => {
170
184
  const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
171
- return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: Object.fromEntries(Object.entries(t.gates).map(([g, cell]) => [g, { ...cell }])) };
185
+ return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: settled(t.gates, daemonDead), ...(daemonDead ? { gateActivity: undefined } : {}) };
172
186
  });
173
187
  const buckets = Object.fromEntries(bucketNames.map(k => [k, this.end?.buckets[k] === undefined ? undefined : [...this.end.buckets[k]]]));
174
188
  // A matching graph contributes planned tasks even when the journal has never named them.
@@ -179,9 +193,11 @@ export class OperatorStateFold {
179
193
  const unresolved = tasks.some(t => ["unknown", "failed", "human", "blocked", "pending", "running"].includes(t.state));
180
194
  const allPlannedMerged = comparable && merged === planned;
181
195
  const currentTip = this.active ? "pending" : this.tipFailed ? "failed" : this.end?.tip ?? "unknown";
196
+ const current = owed !== undefined && basis !== undefined && owed.basis === basis && owed.fold.known && typeof owed.fold.debt === "number";
197
+ const debt = current ? owed.fold.debt : "unknown";
182
198
  const green = readable && comparable && allPlannedMerged && !!this.start && !!this.end && !this.approved && !unresolved
183
199
  && (currentTip === "passed" || currentTip === "not required")
184
- && bucketNames.every(k => buckets[k]?.length === 0);
200
+ && bucketNames.every(k => buckets[k]?.length === 0) && debt === 0;
185
201
  const lifecycle = !readable || !this.start ? "UNKNOWN" : this.active ? "RUNNING" : this.approved ? "APPROVED" : green ? "COMPLETE" : this.end ? "PARTIAL" : "PENDING";
186
202
  return {
187
203
  sequence, observedAt, lastEventAt: this.lastEventAt, firstEventAt: this.firstEventAt, resumes: this.resumes, escalations: this.escalations, lifecycle,
@@ -191,6 +207,7 @@ export class OperatorStateFold {
191
207
  planned, tasks, buckets,
192
208
  gatesRan: { passed: this.passed, total: this.total }, latestRunEnd: this.end?.evidence,
193
209
  approvedResumeRequired: this.approved,
210
+ debt, outstanding: current ? owed.fold.outstanding.map(o => `${o.taskId} ${o.gate}`) : [],
194
211
  };
195
212
  }
196
213
  comparableTo(hash) {
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * A2 (D-787): the process-title mode every shell child receives. On Darwin, children get one
4
+ * `--require` of the shipped title-preload.cjs appended to their NODE_OPTIONS, so a Node child's
5
+ * process.title writes stay in JavaScript instead of each checking in with LaunchServices (npm's own
6
+ * entry keeps its native, secret-hiding title — see title-preload.cts). Everything here fails OPEN to
7
+ * the native mode, which is exactly what children received before A2: another platform, the operator's
8
+ * opt-out, a missing artifact, inherited options the preflight cannot vouch for, or a preflight the
9
+ * runner's own node refused.
10
+ *
11
+ * `shell` (git.ts) applies this to the child it spawns and `runnerInputsHash` (cache.ts) hashes the same
12
+ * value, so a cached verdict or baseline is only ever answered by one produced under the identical mode
13
+ * and preload content.
14
+ */
15
+ export declare const NATIVE_PROCESS_TITLE_ENV = "TICKMARKR_NATIVE_PROCESS_TITLE";
16
+ /** The preflight's kill ceiling, never a predicted duration: one `node -e ""` start. */
17
+ export declare const TITLE_PREFLIGHT_TIMEOUT_MS = 10000;
18
+ export type TitleMode = "native" | "preload";
19
+ export interface TitleEnvironment {
20
+ mode: TitleMode;
21
+ /** Content identity of the preload a child loads; null in native mode. */
22
+ identity: string | null;
23
+ /** The NODE_OPTIONS the child receives: the inherited value less any managed preload in native mode;
24
+ * undefined when nothing is left, so the child's NODE_OPTIONS is unset rather than empty. */
25
+ nodeOptions: string | undefined;
26
+ }
27
+ interface TitleSeams {
28
+ platform: NodeJS.Platform;
29
+ preloadPath: string;
30
+ /** Undefined until a test sets it: the standard library binding is read at CALL time, so a suite that
31
+ * mocks `node:child_process` without `spawnSync` still imports this module. */
32
+ probe?: typeof spawnSync;
33
+ }
34
+ export declare const setTitleEnvironmentForTests: (overrides: Partial<TitleSeams>) => void;
35
+ export declare const resetTitleEnvironmentForTests: () => void;
36
+ /** NODE_OPTIONS splits on spaces outside double quotes; inside them a backslash escapes `"` and `\`. */
37
+ export declare const quoteNodeOption: (value: string) => string;
38
+ /**
39
+ * The title mode a child spawned from `env` receives, and the NODE_OPTIONS that carries it. A nested shell
40
+ * inherits its parent's preload, so every mode first removes it: native gets none (the opt-out, options the
41
+ * probe cannot vouch for, a refusal or a missing artifact must hold in a nested shell too) and preload mode
42
+ * exactly one, this one.
43
+ */
44
+ export declare function titleEnvironment(env: NodeJS.ProcessEnv): TitleEnvironment;
45
+ export {};
@@ -0,0 +1,127 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ /**
7
+ * A2 (D-787): the process-title mode every shell child receives. On Darwin, children get one
8
+ * `--require` of the shipped title-preload.cjs appended to their NODE_OPTIONS, so a Node child's
9
+ * process.title writes stay in JavaScript instead of each checking in with LaunchServices (npm's own
10
+ * entry keeps its native, secret-hiding title — see title-preload.cts). Everything here fails OPEN to
11
+ * the native mode, which is exactly what children received before A2: another platform, the operator's
12
+ * opt-out, a missing artifact, inherited options the preflight cannot vouch for, or a preflight the
13
+ * runner's own node refused.
14
+ *
15
+ * `shell` (git.ts) applies this to the child it spawns and `runnerInputsHash` (cache.ts) hashes the same
16
+ * value, so a cached verdict or baseline is only ever answered by one produced under the identical mode
17
+ * and preload content.
18
+ */
19
+ export const NATIVE_PROCESS_TITLE_ENV = "TICKMARKR_NATIVE_PROCESS_TITLE";
20
+ /** The preflight's kill ceiling, never a predicted duration: one `node -e ""` start. */
21
+ export const TITLE_PREFLIGHT_TIMEOUT_MS = 10_000;
22
+ const shipped = () => ({
23
+ platform: process.platform,
24
+ preloadPath: fileURLToPath(new URL("./title-preload.cjs", import.meta.url)),
25
+ });
26
+ let seams = shipped();
27
+ const preflights = new Map();
28
+ export const setTitleEnvironmentForTests = (overrides) => {
29
+ seams = { ...seams, ...overrides };
30
+ preflights.clear();
31
+ };
32
+ export const resetTitleEnvironmentForTests = () => {
33
+ seams = shipped();
34
+ preflights.clear();
35
+ };
36
+ /** NODE_OPTIONS splits on spaces outside double quotes; inside them a backslash escapes `"` and `\`. */
37
+ export const quoteNodeOption = (value) => `"${value.replace(/[\\"]/g, "\\$&")}"`;
38
+ /** The one option this install adds: its preload, at an absolute path. */
39
+ const preloadOption = () => `--require ${quoteNodeOption(seams.preloadPath)}`;
40
+ /**
41
+ * D-827: does the runner's own node load this preload? Probed at most once per process per (node execPath,
42
+ * preload content identity, mode) — neither the operator's options nor a payload's cwd key it — so a
43
+ * refusal is sticky for that triple. The probe certifies only the context it reproduces: the preload alone,
44
+ * at its absolute path, run in the preload's own directory. `certifiable` sends every payload whose own
45
+ * options could change how that loads — the permission model, other code loaders, a policy or config file,
46
+ * any cwd-relative path — to native before any probe, so what reaches a payload loads the same from the
47
+ * probe's cwd and from the payload's (D-835: an absolute preload with none of those certifies every cwd).
48
+ * A bounded synchronous probe that never goes through `shell` or its spawn seam, so payload start timing,
49
+ * recorded spawn commands and the lease census see payloads only. A refusal — nonzero exit, signal, the ceiling, or a spawn error — selects native payloads without
50
+ * re-probing; a payload is never started twice to find out.
51
+ */
52
+ function preflight(env, identity) {
53
+ const key = [process.execPath, identity, "preload"].join("\0");
54
+ let accepted = preflights.get(key);
55
+ if (accepted === undefined) {
56
+ try {
57
+ const probe = (seams.probe ?? spawnSync)(process.execPath, ["-e", ""], {
58
+ cwd: dirname(seams.preloadPath), env: { ...env, NODE_OPTIONS: preloadOption() },
59
+ stdio: "ignore", timeout: TITLE_PREFLIGHT_TIMEOUT_MS, killSignal: "SIGKILL",
60
+ });
61
+ accepted = probe.status === 0 && !probe.error;
62
+ }
63
+ catch {
64
+ accepted = false;
65
+ }
66
+ preflights.set(key, accepted);
67
+ }
68
+ return accepted;
69
+ }
70
+ /** NODE_OPTIONS as Node splits it: on spaces outside double quotes, each quoted segment unescaped. */
71
+ const optionTokens = (options) => (options.match(/(?:[^\s"]|"(?:[^"\\]|\\.)*")+/g) ?? [])
72
+ .map((token) => token.replace(/"((?:[^"\\]|\\.)*)"/g, (_, quoted) => quoted.replace(/\\(.)/g, "$1")));
73
+ /** Node accepts `_` for `-` in an option's name (`--experimental_permission` is Node 20's permission flag), so
74
+ * a name is classified in its hyphen spelling; the value after `=` is left as written. */
75
+ const hyphenated = (token) => token.replace(/^--[^=]*/, (name) => name.replace(/_/g, "-"));
76
+ /** Options that gate file access, load other code, or read a policy or config file. */
77
+ const LOADING_OPTION = /^(?:-r|--(?:require|import|(?:experimental-)?loader|(?:experimental-)?permission[\w-]*|allow-[\w-]+|(?:experimental-)?policy[\w-]*|experimental-(?:default-)?config-file))(?:=|$)/;
78
+ /** A path a cwd resolves: not absolute, and a directory step or a leading `.` or `~` (Node expands neither). */
79
+ const cwdRelative = (value) => !value.startsWith("/") && /^[.~]|\//.test(value);
80
+ /** Can the one canonical probe vouch for a payload under the operator's own options? No when any token
81
+ * loads, permits or resolves against the cwd — a separate value token or an `=` value alike. */
82
+ const certifiable = (own) => optionTokens(own ?? "").every((token) => !LOADING_OPTION.test(hyphenated(token)) && !cwdRelative(/^-[^=]*=/.test(token) ? token.slice(token.indexOf("=") + 1) : token));
83
+ /** One `--require "<path>"` option in the quoted form quoteNodeOption writes. */
84
+ const QUOTED_REQUIRE = /(?:^|\s+)--require "((?:[^"\\]|\\.)*)"(?=\s|$)/g;
85
+ /** A tickmarkr-managed preload, positively identified: this install's, or another install's shipped
86
+ * `dist/run/title-preload.cjs` whose package.json names it tickmarkr (a nested shell under another install).
87
+ * A user's own file of the same name is never one, so their hook keeps running in every mode. */
88
+ const managed = (path) => {
89
+ if (path === seams.preloadPath)
90
+ return true;
91
+ if (!/[\\/]dist[\\/]run[\\/]title-preload\.cjs$/.test(path))
92
+ return false;
93
+ try {
94
+ return JSON.parse(readFileSync(join(dirname(path), "../../package.json"), "utf8")).name === "tickmarkr";
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ };
100
+ /** The operator's own options: every inherited managed preload removed; every other option byte-identical,
101
+ * and the value untouched without one. */
102
+ const withoutManaged = (options) => {
103
+ const stripped = options?.replace(QUOTED_REQUIRE, (whole, quoted) => managed(quoted.replace(/\\(.)/g, "$1")) ? "" : whole);
104
+ return stripped === options ? options : stripped.trim() || undefined;
105
+ };
106
+ /**
107
+ * The title mode a child spawned from `env` receives, and the NODE_OPTIONS that carries it. A nested shell
108
+ * inherits its parent's preload, so every mode first removes it: native gets none (the opt-out, options the
109
+ * probe cannot vouch for, a refusal or a missing artifact must hold in a nested shell too) and preload mode
110
+ * exactly one, this one.
111
+ */
112
+ export function titleEnvironment(env) {
113
+ const own = withoutManaged(env.NODE_OPTIONS);
114
+ const native = { mode: "native", identity: null, nodeOptions: own };
115
+ if (seams.platform !== "darwin" || env[NATIVE_PROCESS_TITLE_ENV] === "1" || !certifiable(own))
116
+ return native;
117
+ let identity;
118
+ try {
119
+ identity = createHash("sha256").update(readFileSync(seams.preloadPath)).digest("hex").slice(0, 16);
120
+ }
121
+ catch {
122
+ return native; // a missing or unreadable artifact keeps the native setter
123
+ }
124
+ if (!preflight(env, identity))
125
+ return native;
126
+ return { mode: "preload", identity, nodeOptions: [own?.trim(), preloadOption()].filter(Boolean).join(" ") };
127
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // A2 (D-787): loaded through NODE_OPTIONS into Darwin shell children (title-environment.ts decides
4
+ // when). Node's native process.title setter checks in with LaunchServices on every write, and test
5
+ // runners write it per worker; a burst of those check-ins has restarted launchservicesd. Here a title
6
+ // write stays in JavaScript, so `ps` keeps the original command line.
7
+ //
8
+ // npm is the exception: its entry sets the native title first thing so `ps` never shows secrets given
9
+ // on its command line, and that hiding must survive. The entry is recognised by the script it runs —
10
+ // `bin/npm-cli.js` / `bin/npx-cli.js` in every npm major's package layout, or corepack's npm/npx shim —
11
+ // resolved through symlinks, since the `npm` command on PATH is a link to it.
12
+ //
13
+ // CommonJS (.cts → .cjs) so `--require` loads it in every supported Node, ESM package or not. It never
14
+ // throws: a preload that throws kills the payload it was meant to protect.
15
+ const node_fs_1 = require("node:fs");
16
+ const NPM_ENTRY = /[\\/](?:bin[\\/]np[mx]-cli|corepack[\\/]dist[\\/]np[mx])\.js$/;
17
+ // The script node was asked to run; an unresolvable one (`node -e`, a deleted file) is simply not npm.
18
+ const entry = () => {
19
+ try {
20
+ return (0, node_fs_1.realpathSync)(process.argv[1]);
21
+ }
22
+ catch {
23
+ return process.argv[1] ?? "";
24
+ }
25
+ };
26
+ try {
27
+ const native = Object.getOwnPropertyDescriptor(process, "title");
28
+ if (native?.configurable && !NPM_ENTRY.test(entry())) {
29
+ let title = process.title;
30
+ Object.defineProperty(process, "title", {
31
+ configurable: true,
32
+ enumerable: native.enumerable,
33
+ get: () => title,
34
+ set: (value) => { title = String(value); },
35
+ });
36
+ }
37
+ }
38
+ catch { /* fail open: the native setter stays */ }
@@ -0,0 +1 @@
1
+ export {};