tickmarkr 1.90.1 → 1.90.3

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.
@@ -9,31 +9,35 @@ export type DoctorOpts = {
9
9
  catalog?: CatalogReadResult;
10
10
  catalogNow?: () => Date;
11
11
  };
12
+ export declare function runnerIgnoreFinding(cwd: string): {
13
+ verdict: "pass" | "warn";
14
+ detail: string;
15
+ } | undefined;
12
16
  /**
13
- * Q122s (TRIAL T-OBS-3): tickmarkr provisions run worktrees INSIDE the repo
14
- * (.tickmarkr/worktrees.noindex/). A target repo whose test runner collects with
15
- * repo-wide globs (jest's default `**​/__tests__/**` shape, vitest's default include)
16
- * scans every suite once per live worktree — duplicate-suite interference red the
17
- * baseline AND tip-verify on SentioQ's first external run, a class invisible to
18
- * dogfooding (this repo's own includes are rooted at tests/).
19
- *
20
- * Q130s (TRIAL T-OBS-6): the ignore must be <rootDir>-anchored. Jest
21
- * testPathIgnorePatterns are REGEXES matched against the ABSOLUTE test path; an
22
- * unanchored "/.tickmarkr/" also matches every suite INSIDE a worktree provisioned at
23
- * <repo>/.tickmarkr/worktrees.noindex/… — jest discovers nothing there and the whole
24
- * gate battery fails closed (SentioQ run 2: 10 dispatches, 0 merges). Anchored
25
- * patterns resolve <rootDir> to the worktree itself and stay inert. Vitest
26
- * include/exclude globs match root-relative paths, so they cannot self-match, but the
27
- * prescribed shape is root-anchored anyway.
17
+ * Q134s (doctor --fix): the WRITE half of Q122s/Q130s. Doctor's advisory row cost two
18
+ * repos a hand-edit (SentioQ run 2 paid a whole run first); --fix applies the ignore
19
+ * where a safe textual edit exists and refuses loudly otherwise.
28
20
  *
29
- * Text-level heuristic, advisory only — never enters health/doctor.json or routing.
30
- * ponytail: ceilings — comments are stripped before judgment, but an include built
31
- * from variables is still judged by its literal text, and a pattern string containing
32
- * `//` would be truncated by the line-comment strip. Each costs one advisory row,
33
- * nothing more.
21
+ * Safety contract: only three write shapes, each verified by re-running
22
+ * runnerIgnoreFinding afterward — a write that does not turn the row green is
23
+ * REVERTED and reported as manual. Never touches package.json (jest block → manual).
24
+ * ponytail: text-level edits, same ceilings as the finding; the verify-then-revert
25
+ * loop is what makes them safe to ship.
34
26
  */
35
- export declare function runnerIgnoreFinding(cwd: string): {
36
- verdict: "pass" | "warn";
27
+ export declare function applyRunnerIgnore(cwd: string): {
28
+ action: "none" | "wrote" | "manual";
29
+ detail: string;
30
+ };
31
+ /**
32
+ * Q140s(b) / operator directive (D-OBS-10): the repo's package manager must resolve in
33
+ * THIS process's environment — the same class of environment the daemon spawns gates
34
+ * into. Dossier run 1: pnpm lived only in the operator's interactive PATH; every gate
35
+ * died on `Unable to find package manager binary` / `spawnSync pnpm ENOENT`, the reds
36
+ * were baseline-forgiven, and no suite ever ran. A which(1)-grade check at doctor time
37
+ * turns that into preflight information.
38
+ */
39
+ export declare function packageManagerFinding(cwd: string, probe?: (pm: string) => boolean): {
40
+ verdict: "pass" | "fail";
37
41
  detail: string;
38
42
  } | undefined;
39
43
  export declare function doctor(_argv: string[], cwd?: string, adapters?: WorkerAdapter[], opts?: DoctorOpts): Promise<string>;
@@ -1,6 +1,8 @@
1
1
  import { writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
5
+ import { detectPackageManager } from "../../gates/baseline.js";
4
6
  import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
5
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, claudeCode, resolveClaudeAliasIdentity } from "../../adapters/claude-code.js";
6
8
  import { BANNER, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
@@ -14,47 +16,49 @@ import { readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog
14
16
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
15
17
  const alignedStatusRow = (verdict, key, value) => ` ${statusRow(verdict, kvRow(key, value).slice(2))}`;
16
18
  const attentionRow = (text) => ` ${statusRow("warn", text)}`;
17
- /**
18
- * Q122s (TRIAL T-OBS-3): tickmarkr provisions run worktrees INSIDE the repo
19
- * (.tickmarkr/worktrees.noindex/). A target repo whose test runner collects with
20
- * repo-wide globs (jest's default `**​/__tests__/**` shape, vitest's default include)
21
- * scans every suite once per live worktree — duplicate-suite interference red the
22
- * baseline AND tip-verify on SentioQ's first external run, a class invisible to
23
- * dogfooding (this repo's own includes are rooted at tests/).
24
- *
25
- * Q130s (TRIAL T-OBS-6): the ignore must be <rootDir>-anchored. Jest
26
- * testPathIgnorePatterns are REGEXES matched against the ABSOLUTE test path; an
27
- * unanchored "/.tickmarkr/" also matches every suite INSIDE a worktree provisioned at
28
- * <repo>/.tickmarkr/worktrees.noindex/… — jest discovers nothing there and the whole
29
- * gate battery fails closed (SentioQ run 2: 10 dispatches, 0 merges). Anchored
30
- * patterns resolve <rootDir> to the worktree itself and stay inert. Vitest
31
- * include/exclude globs match root-relative paths, so they cannot self-match, but the
32
- * prescribed shape is root-anchored anyway.
33
- *
34
- * Text-level heuristic, advisory only — never enters health/doctor.json or routing.
35
- * ponytail: ceilings — comments are stripped before judgment, but an include built
36
- * from variables is still judged by its literal text, and a pattern string containing
37
- * `//` would be truncated by the line-comment strip. Each costs one advisory row,
38
- * nothing more.
39
- */
40
- export function runnerIgnoreFinding(cwd) {
19
+ function detectRunner(cwd) {
41
20
  const readIf = (p) => (existsSync(join(cwd, p)) ? readFileSync(join(cwd, p), "utf8") : "");
42
- const pkgText = readIf("package.json");
43
21
  let pkg = {};
44
22
  try {
45
- pkg = JSON.parse(pkgText || "{}");
23
+ pkg = JSON.parse(readIf("package.json") || "{}");
46
24
  }
47
25
  catch { /* unparseable manifest: judge config files alone */ }
48
26
  const jestConfig = ["jest.config.js", "jest.config.cjs", "jest.config.mjs", "jest.config.ts", "jest.config.json"].find((p) => existsSync(join(cwd, p)));
49
27
  const vitestConfig = ["vitest.config.ts", "vitest.config.js", "vitest.config.mts", "vitest.config.mjs", "vitest.config.cts"].find((p) => existsSync(join(cwd, p)));
50
28
  const testScript = pkg.scripts?.test ?? "";
51
- const isJest = jestConfig !== undefined || pkg.jest !== undefined || /\bjest\b/.test(testScript);
52
- const isVitest = !isJest && (vitestConfig !== undefined || /\bvitest\b/.test(testScript));
53
- if (!isJest && !isVitest)
29
+ if (jestConfig !== undefined || pkg.jest !== undefined || /\bjest\b/.test(testScript)) {
30
+ return { kind: "jest", configPath: jestConfig, pkgJestBlock: pkg.jest !== undefined };
31
+ }
32
+ if (vitestConfig !== undefined || /\bvitest\b/.test(testScript)) {
33
+ return { kind: "vitest", configPath: vitestConfig, pkgJestBlock: false };
34
+ }
35
+ return undefined;
36
+ }
37
+ export function runnerIgnoreFinding(cwd) {
38
+ const readIf = (p) => (existsSync(join(cwd, p)) ? readFileSync(join(cwd, p), "utf8") : "");
39
+ const runner = detectRunner(cwd);
40
+ if (!runner)
54
41
  return undefined;
55
- // Comments are stripped before judgment: a commented-out ignore must not false-pass,
56
- // and a prose comment mentioning .tickmarkr must not false-warn the anchor probe.
57
- const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
42
+ const isJest = runner.kind === "jest";
43
+ const jestConfig = isJest ? runner.configPath : undefined;
44
+ const vitestConfig = isJest ? undefined : runner.configPath;
45
+ let pkg = {};
46
+ try {
47
+ pkg = JSON.parse(readIf("package.json") || "{}");
48
+ }
49
+ catch { /* judged above */ }
50
+ // Comment LINES are stripped before judgment: a commented-out ignore must not
51
+ // false-pass, and a prose comment mentioning .tickmarkr must not false-warn the
52
+ // anchor probe. Stripping is LINE-granular on purpose: in-line regex stripping ate
53
+ // glob strings — `'**/*.d.ts'` contains `/*` and later globs contain `*/`, so a
54
+ // naive block-comment regex deleted the middle of real configs and false-PASSED a
55
+ // repo-wide vitest include (caught on Intl-Dossier's config; shipped briefly in
56
+ // 1.90.1). Trailing same-line comments survive — acceptable: they can only make the
57
+ // probe more conservative on lines that also carry code.
58
+ const stripComments = (s) => s.split("\n").filter((l) => {
59
+ const t = l.trimStart();
60
+ return !(t.startsWith("//") || t.startsWith("/*") || t.startsWith("*"));
61
+ }).join("\n");
58
62
  const configText = stripComments(isJest
59
63
  ? `${jestConfig ? readIf(jestConfig) : ""}${pkg.jest ? JSON.stringify(pkg.jest) : ""}`
60
64
  : vitestConfig ? readIf(vitestConfig) : "");
@@ -85,6 +89,87 @@ export function runnerIgnoreFinding(cwd) {
85
89
  detail: `${isJest ? "jest" : "vitest"} collects repo-wide and will scan .tickmarkr/ run worktrees (duplicate suites, false reds) — ${remedy}`,
86
90
  };
87
91
  }
92
+ /**
93
+ * Q134s (doctor --fix): the WRITE half of Q122s/Q130s. Doctor's advisory row cost two
94
+ * repos a hand-edit (SentioQ run 2 paid a whole run first); --fix applies the ignore
95
+ * where a safe textual edit exists and refuses loudly otherwise.
96
+ *
97
+ * Safety contract: only three write shapes, each verified by re-running
98
+ * runnerIgnoreFinding afterward — a write that does not turn the row green is
99
+ * REVERTED and reported as manual. Never touches package.json (jest block → manual).
100
+ * ponytail: text-level edits, same ceilings as the finding; the verify-then-revert
101
+ * loop is what makes them safe to ship.
102
+ */
103
+ export function applyRunnerIgnore(cwd) {
104
+ const finding = runnerIgnoreFinding(cwd);
105
+ if (!finding)
106
+ return { action: "none", detail: "no recognized test runner" };
107
+ if (finding.verdict === "pass")
108
+ return { action: "none", detail: finding.detail };
109
+ const runner = detectRunner(cwd);
110
+ if (!runner?.configPath) {
111
+ // package.json jest block, or a runner detected only via the test script: hand edit.
112
+ return { action: "manual", detail: finding.detail };
113
+ }
114
+ const file = join(cwd, runner.configPath);
115
+ const original = readFileSync(file, "utf8");
116
+ let text = original;
117
+ if (runner.kind === "jest") {
118
+ // 1) Replace unanchored poison entries in place (the SentioQ run-2 shape).
119
+ text = text.replace(/(["'])\/?\.tickmarkr\/?\1/g, '"<rootDir>/\\\\.tickmarkr/"');
120
+ if (text === original && !/\.tickmarkr/.test(text)) {
121
+ const arr = /testPathIgnorePatterns(["']?)\s*:\s*\[/;
122
+ if (arr.test(text)) {
123
+ // 2) Existing ignore array: prepend the anchored entry.
124
+ text = text.replace(arr, (m) => `${m}"<rootDir>/\\\\.tickmarkr/", `);
125
+ if (runner.configPath.endsWith(".json"))
126
+ text = text.replace(/,(\s*)\]/g, "$1]");
127
+ }
128
+ else {
129
+ // 3) No array: add the property, preserving jest's default /node_modules/ ignore.
130
+ const anchor = /(module\.exports\s*=\s*\{|export\s+default\s+\{|export\s+default\s+defineConfig\(\{)/g;
131
+ if ((text.match(anchor) ?? []).length === 1) {
132
+ text = text.replace(anchor, (m) => `${m}\n testPathIgnorePatterns: ["/node_modules/", "<rootDir>/\\\\.tickmarkr/"],`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ else if (!/\.tickmarkr/.test(text)) {
138
+ // vitest: add the glob to every exclude array — test.exclude is the target;
139
+ // coverage.exclude gaining it is correct behavior, not collateral.
140
+ const arr = /exclude\s*:\s*\[/g;
141
+ if (arr.test(text))
142
+ text = text.replace(arr, (m) => `${m}'.tickmarkr/**', `);
143
+ }
144
+ if (text === original)
145
+ return { action: "manual", detail: finding.detail };
146
+ writeFileSync(file, text);
147
+ const after = runnerIgnoreFinding(cwd);
148
+ if (after?.verdict === "pass") {
149
+ return { action: "wrote", detail: `wrote ${runner.configPath} — ${after.detail}` };
150
+ }
151
+ writeFileSync(file, original); // verify failed: never leave a half-fix behind
152
+ return { action: "manual", detail: finding.detail };
153
+ }
154
+ /**
155
+ * Q140s(b) / operator directive (D-OBS-10): the repo's package manager must resolve in
156
+ * THIS process's environment — the same class of environment the daemon spawns gates
157
+ * into. Dossier run 1: pnpm lived only in the operator's interactive PATH; every gate
158
+ * died on `Unable to find package manager binary` / `spawnSync pnpm ENOENT`, the reds
159
+ * were baseline-forgiven, and no suite ever ran. A which(1)-grade check at doctor time
160
+ * turns that into preflight information.
161
+ */
162
+ export function packageManagerFinding(cwd, probe = (pm) => spawnSync(pm, ["--version"], { stdio: "ignore", shell: false }).error === undefined) {
163
+ if (!existsSync(join(cwd, "package.json")))
164
+ return undefined;
165
+ const pm = detectPackageManager(cwd);
166
+ if (probe(pm))
167
+ return { verdict: "pass", detail: `${pm} (detected) resolves in this environment` };
168
+ return {
169
+ verdict: "fail",
170
+ detail: `repo uses ${pm} but the binary does not resolve in this environment — every auto-detected gate command will die before running any suite (fix: corepack enable, or install ${pm} on the daemon's PATH)`,
171
+ };
172
+ }
88
173
  export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(), opts = {}) {
89
174
  if (_argv.length === 1 && _argv[0] === "--refresh-catalog") {
90
175
  const refreshed = await refreshCatalogCommand({ repoRoot: cwd, now: opts.catalogNow });
@@ -163,9 +248,23 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
163
248
  const herdr = HerdrDriver.available();
164
249
  rows.push(alignedStatusRow(herdr ? "pass" : "fail", "herdr", herdr ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"));
165
250
  // Q122s: the target repo's runner must not scan tickmarkr's own worktrees (advisory row).
251
+ // Q134s: `doctor --fix` writes the ignore when a safe edit exists (verified, else reverted).
166
252
  const runnerIgnore = runnerIgnoreFinding(cwd);
167
- if (runnerIgnore)
168
- rows.push(alignedStatusRow(runnerIgnore.verdict, "test-runner", runnerIgnore.detail));
253
+ if (runnerIgnore) {
254
+ if (_argv.includes("--fix") && runnerIgnore.verdict === "warn") {
255
+ const fixed = applyRunnerIgnore(cwd);
256
+ rows.push(fixed.action === "wrote"
257
+ ? alignedStatusRow("pass", "test-runner", fixed.detail)
258
+ : alignedStatusRow("warn", "test-runner", `--fix could not edit safely — apply by hand: ${fixed.detail}`));
259
+ }
260
+ else {
261
+ rows.push(alignedStatusRow(runnerIgnore.verdict, "test-runner", runnerIgnore.detail));
262
+ }
263
+ }
264
+ // Q140s(b): the repo's package manager must resolve where gates spawn (D-OBS-10).
265
+ const pmRow = packageManagerFinding(cwd);
266
+ if (pmRow)
267
+ rows.push(alignedStatusRow(pmRow.verdict, "package-manager", pmRow.detail));
169
268
  // v1.22 T5: workspace-trust pre-flight — per installed adapter: trusted | seeded | action-required | n/a.
170
269
  // action-required names the exact one-time command (or dialog) the operator must run once.
171
270
  rows.push(legend("workspace trust:"));
@@ -5,7 +5,7 @@ export type CommandResult = string | {
5
5
  };
6
6
  export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
7
7
  export declare const COMMANDS: CommandMap;
8
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
8
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
9
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
10
10
  out: string;
11
11
  code: number;
package/dist/cli/index.js CHANGED
@@ -28,7 +28,7 @@ import { BANNER } from "../brand.js";
28
28
  export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
29
29
  usage: tickmarkr <command>
30
30
  init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs
31
- doctor re-probe adapters, herdr, auth; print capability matrix
31
+ doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)
32
32
  fleet interactive fleet editor (fleet --print for CI drift checks)
33
33
  compile <src> spec → .tickmarkr/graph.json (fails without acceptance criteria)
34
34
  scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)
@@ -37,6 +37,15 @@ export declare function freshFailures(entry: BaselineCommand | undefined, raw: s
37
37
  failing: string[];
38
38
  unreadable: boolean;
39
39
  };
40
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
41
+ /**
42
+ * Operator directive 2026-08-12 (D-OBS-10): tickmarkr is package-manager agnostic.
43
+ * The `packageManager` field is authoritative (corepack's own contract); lockfiles
44
+ * break ties; npm is only the LAST resort. Dossier exhibit: auto-detected
45
+ * `npm run -s test` on a repo whose engines said `"npm": "please-use-pnpm"` — turbo
46
+ * then failed to resolve pnpm and every gate died on environment, not code.
47
+ */
48
+ export declare function detectPackageManager(repoRoot: string): PackageManager;
40
49
  export declare function detectGateCommands(repoRoot: string, cfg: TickmarkrConfig): Record<string, string>;
41
50
  /**
42
51
  * The ceiling a battery runs under: whichever is larger of the shipped constant and the slack applied
@@ -123,17 +123,50 @@ export function freshFailures(entry, raw) {
123
123
  const fresh = current.filter((f) => !known.has(f) && (!FAIL_ANCHOR_RE.test(f) || f.startsWith("FAIL ")));
124
124
  return { failing: fresh.filter((f) => f !== UNRECOGNIZED_FAILURE), unreadable: current.includes(UNRECOGNIZED_FAILURE) };
125
125
  }
126
+ /**
127
+ * Operator directive 2026-08-12 (D-OBS-10): tickmarkr is package-manager agnostic.
128
+ * The `packageManager` field is authoritative (corepack's own contract); lockfiles
129
+ * break ties; npm is only the LAST resort. Dossier exhibit: auto-detected
130
+ * `npm run -s test` on a repo whose engines said `"npm": "please-use-pnpm"` — turbo
131
+ * then failed to resolve pnpm and every gate died on environment, not code.
132
+ */
133
+ export function detectPackageManager(repoRoot) {
134
+ const pkgPath = join(repoRoot, "package.json");
135
+ try {
136
+ const pm = JSON.parse(readFileSync(pkgPath, "utf8")).packageManager;
137
+ const name = pm?.split("@")[0];
138
+ if (name === "pnpm" || name === "yarn" || name === "bun" || name === "npm")
139
+ return name;
140
+ }
141
+ catch { /* no or unparseable manifest: lockfiles decide */ }
142
+ if (existsSync(join(repoRoot, "pnpm-lock.yaml")))
143
+ return "pnpm";
144
+ if (existsSync(join(repoRoot, "yarn.lock")))
145
+ return "yarn";
146
+ if (existsSync(join(repoRoot, "bun.lock")) || existsSync(join(repoRoot, "bun.lockb")))
147
+ return "bun";
148
+ return "npm";
149
+ }
150
+ // Silent where the manager supports it everywhere (npm -s; pnpm's global -s), bare
151
+ // where a flag would break a major version (yarn berry rejects run -s; bun is quiet).
152
+ const RUN_PREFIX = {
153
+ npm: "npm run -s",
154
+ pnpm: "pnpm -s run",
155
+ yarn: "yarn run",
156
+ bun: "bun run",
157
+ };
126
158
  export function detectGateCommands(repoRoot, cfg) {
127
159
  const out = {};
128
160
  const pkgPath = join(repoRoot, "package.json");
129
161
  const scripts = existsSync(pkgPath)
130
162
  ? (JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {})
131
163
  : {};
164
+ const runPrefix = RUN_PREFIX[detectPackageManager(repoRoot)];
132
165
  for (const name of ["build", "test", "lint"]) {
133
166
  if (cfg.gates[name])
134
167
  out[name] = cfg.gates[name];
135
168
  else if (scripts[name])
136
- out[name] = `npm run -s ${name}`;
169
+ out[name] = `${runPrefix} ${name}`;
137
170
  }
138
171
  return out;
139
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.90.1",
3
+ "version": "1.90.3",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",