tickmarkr 1.50.0 → 1.52.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.
@@ -1,12 +1,14 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { shq } from "../adapters/types.js";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
6
+ import { stringify } from "yaml";
5
7
  import { trailerPattern, writePrompt } from "../adapters/prompt.js";
6
8
  import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
7
9
  import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
8
10
  import { bannerShell } from "../brand.js";
9
- import { loadConfig } from "../config/config.js";
11
+ import { globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
10
12
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
11
13
  import { formatOwnedName } from "../drivers/types.js";
12
14
  import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
@@ -17,8 +19,41 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYO
17
19
  import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
18
20
  import { acquireRunLock, releaseRunLock } from "./lock.js";
19
21
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
20
- import { nextChannel, route } from "../route/router.js";
22
+ import { nextChannel, QUALITY_ENV, route } from "../route/router.js";
21
23
  import { desiredPanes } from "./reconcile.js";
24
+ const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
25
+ // An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
26
+ // carrying routing.mode — floors, explore, lints, and provenance all come from config.ts's preset
27
+ // compiler, never duplicated mode math here (the quality-silently-loses defense holds by construction).
28
+ function withOverlayMode(repoRoot, mode, globalDir) {
29
+ const overlay = readOverlayFile(repoOverlayPath(repoRoot));
30
+ const tmp = mkdtempSync(join(tmpdir(), "tickmarkr-mode-"));
31
+ try {
32
+ mkdirSync(join(tmp, ".tickmarkr"), { recursive: true });
33
+ writeFileSync(join(tmp, ".tickmarkr", "config.yaml"), stringify({ ...overlay, routing: { ...overlay.routing, mode } }));
34
+ return loadConfigWithMode(tmp, { globalDir });
35
+ }
36
+ finally {
37
+ rmSync(tmp, { recursive: true, force: true });
38
+ }
39
+ }
40
+ export function resolveRunMode(repoRoot, opts = {}) {
41
+ const overlayMode = (path) => readOverlayFile(path).routing?.mode;
42
+ const source = opts.flag !== undefined ? "run flag"
43
+ : opts.spec !== undefined ? "spec"
44
+ : overlayMode(repoOverlayPath(repoRoot)) !== undefined ? "repo config"
45
+ : overlayMode(join(opts.globalDir ?? globalConfigDir(), "config.yaml")) !== undefined ? "global config"
46
+ : "default";
47
+ const override = opts.flag ?? opts.spec;
48
+ const base = loadConfigWithMode(repoRoot, { globalDir: opts.globalDir });
49
+ const resolved = override === undefined || override === base.mode.mode
50
+ ? base
51
+ : withOverlayMode(repoRoot, override, opts.globalDir);
52
+ const conflict = opts.flag !== undefined && opts.spec !== undefined && MODE_RANK[opts.flag] < MODE_RANK[opts.spec]
53
+ ? `mode conflict: run flag ${opts.flag} selects a mode below the spec-declared ${opts.spec} — the run flag wins this run`
54
+ : undefined;
55
+ return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
56
+ }
22
57
  // VIS-01: one formatter, four readers (run-end journal event, run/resume CLI, run-end notify).
23
58
  // Parity by construction — every caller renders the same complete bucket line.
24
59
  export function formatSummary(s) {
@@ -54,14 +89,11 @@ async function cherryPickCommits(wt, commits) {
54
89
  return carried;
55
90
  }
56
91
  export async function runDaemon(repoRoot, opts = {}) {
57
- const cfg = loadConfig(repoRoot, { globalDir: opts.globalDir });
92
+ // v1.51 T2: retired --quality env seam. Mode resolution owns premium routing now; an exported
93
+ // TICKMARKR_QUALITY must not resurrect the old one-band floor raise in daemon dispatches.
94
+ delete process.env[QUALITY_ENV];
58
95
  const adapters = opts.adapters ?? allAdapters();
59
96
  const health = readDoctor(repoRoot) ?? (await probeAll(adapters));
60
- const channels = discoverChannels(cfg, adapters, health);
61
- // v1.6 ROUTE-06: build the learned profile ONCE at startup (never per task, never in the comparator).
62
- // No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
63
- // for the run, so this run's own telemetry never feeds back into its own routing.
64
- const profile = loadRoutingProfile(repoRoot, cfg);
65
97
  const driver = opts.driver ?? new SubprocessDriver();
66
98
  // HARD-01/02: hold the run lock across the whole read-modify-write of graph.json. Acquire
67
99
  // BEFORE loadGraph; release in the finally below (every exit path, incl. throws).
@@ -72,6 +104,18 @@ export async function runDaemon(repoRoot, opts = {}) {
72
104
  const lock = acquireRunLock(repoRoot, runId);
73
105
  try {
74
106
  let graph = loadGraph(repoRoot);
107
+ // v1.51 T2: the routing mode resolves BEFORE any routing input is built — run flag > spec front-matter
108
+ // > repo > global > default. The resolved cfg carries mode-compiled floors; route() never sees the mode.
109
+ const rm = resolveRunMode(repoRoot, { flag: opts.mode, spec: graph.mode, globalDir: opts.globalDir });
110
+ const cfg = rm.cfg;
111
+ // v1.51 T4: every dispatch provenance line begins with the mode and its source; when a pin won
112
+ // the route (the final "→ " segment is a pin, not a degraded-to-auto tail) it names the mode it bypassed.
113
+ const dispatchProvenance = (p) => `mode ${rm.mode.mode} (${rm.source})${p.split("→ ").pop().startsWith("pin ") ? ` — pin bypasses mode ${rm.mode.mode}` : ""} · ${p}`;
114
+ const channels = discoverChannels(cfg, adapters, health);
115
+ // v1.6 ROUTE-06: build the learned profile ONCE at startup (never per task, never in the comparator).
116
+ // No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
117
+ // for the run, so this run's own telemetry never feeds back into its own routing.
118
+ const profile = loadRoutingProfile(repoRoot, cfg);
75
119
  const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
76
120
  const branchEvent = opts.resume
77
121
  ? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
@@ -131,7 +175,7 @@ export async function runDaemon(repoRoot, opts = {}) {
131
175
  baseRef = await gitHead(repoRoot);
132
176
  baseline = await captureBaseline(repoRoot, commands);
133
177
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
134
- journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness
178
+ journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2
135
179
  }
136
180
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
137
181
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -389,7 +433,7 @@ export async function runDaemon(repoRoot, opts = {}) {
389
433
  lastContextTokens = undefined;
390
434
  graph = setStatus(graph, t.id, "running");
391
435
  saveGraph(repoRoot, graph);
392
- journal.append("task-dispatch", t.id, { assignment, attempt, provenance: r.provenance, retryMode });
436
+ journal.append("task-dispatch", t.id, { assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode });
393
437
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
394
438
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
395
439
  const priorWt = worktreePath(repoRoot, taskBranch);
package/dist/run/git.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { ROUTING_ENV_SEAMS } from "../route/router.js";
2
+ export { ROUTING_ENV_SEAMS };
1
3
  export interface ShResult {
2
4
  code: number;
3
5
  stdout: string;
package/dist/run/git.js CHANGED
@@ -3,14 +3,23 @@ import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:f
3
3
  import { join } from "node:path";
4
4
  import { shq } from "../adapters/types.js";
5
5
  import { tickmarkrDir } from "../graph/graph.js";
6
+ import { ROUTING_ENV_SEAMS } from "../route/router.js";
7
+ export { ROUTING_ENV_SEAMS };
6
8
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
7
9
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
8
10
  function shell(cmd, cwd, timeoutMs, login) {
11
+ // OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
12
+ // TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
13
+ // repo's route() tests red inside the gates. Scrub a copy at this one choke point so
14
+ // children are hermetic by construction; the daemon's own process.env stays unchanged.
15
+ const env = { ...process.env };
16
+ for (const k of ROUTING_ENV_SEAMS)
17
+ delete env[k];
9
18
  return new Promise((resolve) => {
10
19
  // detached: bash gets its own process group so a timeout can kill the whole tree —
11
20
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
12
21
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
13
- const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"], detached: true });
22
+ const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
14
23
  let stdout = "", stderr = "";
15
24
  let timedOut = false, done = false;
16
25
  const finish = (code, err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.50.0",
3
+ "version": "1.52.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",