tickmarkr 2.8.4 → 2.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/adapters/claude-code.js +3 -2
  2. package/dist/adapters/codex-commit-check.d.ts +4 -3
  3. package/dist/adapters/codex-commit-check.js +44 -29
  4. package/dist/adapters/codex.d.ts +2 -2
  5. package/dist/adapters/codex.js +38 -32
  6. package/dist/adapters/cursor-agent.js +2 -1
  7. package/dist/adapters/grok.js +2 -1
  8. package/dist/adapters/kimi.js +11 -9
  9. package/dist/adapters/model-lints.js +4 -3
  10. package/dist/adapters/opencode.js +2 -1
  11. package/dist/adapters/pi.js +3 -2
  12. package/dist/adapters/qwen.js +2 -1
  13. package/dist/adapters/registry.js +2 -1
  14. package/dist/adapters/types.d.ts +10 -1
  15. package/dist/adapters/types.js +18 -3
  16. package/dist/cli/commands/approve.js +9 -28
  17. package/dist/cli/commands/doctor.d.ts +49 -5
  18. package/dist/cli/commands/doctor.js +956 -94
  19. package/dist/cli/commands/fleet.js +11 -3
  20. package/dist/cli/commands/init.js +27 -19
  21. package/dist/cli/commands/status.js +9 -6
  22. package/dist/cli/commands/verify.js +12 -3
  23. package/dist/compile/gsd.js +4 -3
  24. package/dist/compile/index.js +4 -1
  25. package/dist/compile/native.js +8 -6
  26. package/dist/config/config.d.ts +15 -0
  27. package/dist/config/config.js +67 -5
  28. package/dist/drivers/host-env.d.ts +3 -0
  29. package/dist/drivers/host-env.js +47 -0
  30. package/dist/drivers/orca.js +3 -1
  31. package/dist/drivers/subprocess.d.ts +1 -1
  32. package/dist/drivers/subprocess.js +4 -3
  33. package/dist/gates/baseline.d.ts +1 -1
  34. package/dist/gates/baseline.js +4 -3
  35. package/dist/gates/cache.js +2 -4
  36. package/dist/graph/schema.js +3 -1
  37. package/dist/run/daemon.js +54 -4
  38. package/dist/run/git-trust.d.ts +11 -0
  39. package/dist/run/git-trust.js +1 -1
  40. package/dist/run/git.d.ts +13 -1
  41. package/dist/run/git.js +34 -14
  42. package/dist/run/journal.d.ts +15 -1
  43. package/dist/run/journal.js +83 -17
  44. package/dist/run/lock.d.ts +5 -0
  45. package/dist/run/lock.js +202 -9
  46. package/dist/run/protocol.d.ts +1 -1
  47. package/dist/tui/cockpit/live-store.js +12 -2
  48. package/package.json +1 -1
  49. package/skills/tickmarkr-auto/SKILL.md +65 -21
  50. package/skills/tickmarkr-loop/SKILL.md +65 -21
  51. package/skills/tickmarkr-overseer/SKILL.md +88 -32
@@ -7,7 +7,7 @@ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthE
7
7
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
8
8
  import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
9
9
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
10
- import { ConfigError, DEFAULT_CONFIG, fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, lowerLayerModelOverrides, overlayBytesLoadError, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, repoShadowedUserKeys, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, universeEntryMatches, } from "../../config/config.js";
10
+ import { ConfigError, DEFAULT_CONFIG, fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, lowerLayerModelOverrides, overlayBytesLoadError, ownRepoOverlayPath, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, repoShadowedUserKeys, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, universeEntryMatches, } from "../../config/config.js";
11
11
  import { exclusionReason, projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
12
12
  import { overlayHoldingLine, overlayHoldsMap } from "../../config/fleet-overlay.js";
13
13
  import { SHAPES, TIERS } from "../../graph/schema.js";
@@ -355,7 +355,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
355
355
  // them (destinationEdits below) — the repository overlay when it declares the key or masks it
356
356
  // with null at it or above, else the user overlay.
357
357
  const userPath = join(globalDir, "config.yaml");
358
- const repoPath = repoOverlayPath(cwd);
358
+ // Queue row 195 / D-2102: the save destination is the checkout's OWN file even when the reader
359
+ // fallback (repoOverlayPath) has this linked worktree reading its main checkout's overlay — a
360
+ // first save here creates the worktree's own overlay, which then wins wholesale.
361
+ const repoPath = ownRepoOverlayPath(cwd);
359
362
  const currentUserText = () => (existsSync(userPath) ? readFileSync(userPath, "utf8") : "");
360
363
  const pathOf = (destination) => (destination === "repo" ? repoPath : userPath);
361
364
  // the candidate bytes stand in for the destination layer in the one loader path
@@ -1243,7 +1246,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1243
1246
  // T10: both overlays are guarded whichever one is written, so a holder that moved after the review
1244
1247
  // (the destination itself) refuses instead of retargeting. Null = still current.
1245
1248
  const stalePreview = (pending, reviewed, prior) => {
1246
- const destinationNow = prior ?? (pending.destination === "repo" ? currentRepoOverlayText(cwd) : currentUserText());
1249
+ // Queue row 195 / D-2102: a repo-destination review renders against the EFFECTIVE repository
1250
+ // overlay — in a linked worktree with no overlay of its own that is the main checkout's file,
1251
+ // while the write lands in the worktree's own (ownRepoOverlayPath). The guard therefore
1252
+ // re-reads the effective layer, not the destination's prior bytes; either file moving after
1253
+ // the review moves that read (an own file appearing wins wholesale), so the refusal stands.
1254
+ const destinationNow = pending.destination === "repo" ? currentRepoOverlayText(cwd) : (prior ?? currentUserText());
1247
1255
  const repoNow = pending.destination === "repo" ? destinationNow : currentRepoOverlayText(cwd);
1248
1256
  const userNow = pending.destination === "user" ? destinationNow : currentUserText();
1249
1257
  if (repoNow !== pending.repo) {
@@ -1,4 +1,3 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
@@ -11,7 +10,8 @@ import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
11
10
  import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
12
11
  import { stateDirName, tickmarkrDir } from "../../graph/graph.js";
13
12
  import { orcaHostDetected } from "../../drivers/index.js";
14
- import { WORKTREES_DIR } from "../../run/git.js";
13
+ import { gitSync, WORKTREES_DIR } from "../../run/git.js";
14
+ import { GitTrustRefusal } from "../../run/git-trust.js";
15
15
  import { Journal } from "../../run/journal.js";
16
16
  import { runLockOwner, runLockRunId, runStatusLine } from "../../run/lock.js";
17
17
  import { doctor } from "./doctor.js";
@@ -24,7 +24,7 @@ const ENVIRONMENTS_FOOTER = [
24
24
  "environments:",
25
25
  " herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev · auto picks it first when HERDR_ENV=1",
26
26
  " orca — visible terminals in the Orca app — auto picks it inside an Orca terminal after herdr and before subprocess; set driver: orca to force it · https://onorca.dev",
27
- " claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively",
27
+ " claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively; in Claude Desktop's Code tab (neither herdr nor Orca) one single-tier session runs the loop on the headless subprocess driver, watched with tickmarkr status --watch --plain",
28
28
  " anywhere — no herdr or Orca terminal? same fail-closed gates, headless subprocess driver (legacy: no herdr? same fail-closed gates, headless subprocess driver when Orca markers are absent)",
29
29
  ].join("\n");
30
30
  /** Current run truth, lock first; falls back to an abandoned journal. */
@@ -115,7 +115,7 @@ A run is green only when ALL of these hold: the run-end event exists in the jour
115
115
 
116
116
  ### Verified handoffs
117
117
 
118
- When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the TARGET composer afterward; if the sent text still sits unsubmitted after ~15 s, retry the Enter only when that composer read shows the staged text and no permission prompt or numbered choice holds focus — an Enter sent onto an active prompt approves it or picks a choice instead of submitting the message (OBS-1119). Under that guard, send-keys enter to that pane once and read again (OBS-975); when a prompt or choice holds focus instead, resolve it first and re-read before retrying. Never report "relayed" without read-back.
118
+ When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the TARGET composer afterward; if the sent text still sits unsubmitted after ~15 s, retry the Enter only when that composer read shows the staged text and no permission prompt or numbered choice holds focus — an Enter sent onto an active prompt approves it or picks a choice instead of submitting the message (OBS-1119). Under that guard, send-keys enter to that pane once and read again (OBS-975); when a prompt or choice holds focus instead, resolve it first and re-read before retrying. Never report "relayed" without read-back. On a plain host (neither herdr nor Orca) there is no pane to read back: hand off through the host's cross-session message tool, and count the handoff confirmed only by the peer's reply.
119
119
 
120
120
  ### Orient before you act — this block may be the ONLY guidance your host loaded
121
121
 
@@ -256,13 +256,8 @@ function gitTracked(cwd, path) {
256
256
  const rel = relative(cwd, path);
257
257
  if (rel.startsWith(".."))
258
258
  return false;
259
- try {
260
- execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd, stdio: "ignore" });
261
- return true;
262
- }
263
- catch {
264
- return false;
265
- }
259
+ // queue row 2 item b: protected git; a trust refusal throws, an ordinary failure still reads as untracked
260
+ return gitSync(cwd, ["ls-files", "--error-unmatch", "--", rel]).status === 0;
266
261
  }
267
262
  async function installAgentFiles(cwd, force, docs, notes) {
268
263
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
@@ -375,19 +370,29 @@ function isPathInside(parentDir, candidatePath) {
375
370
  }
376
371
  /**
377
372
  * The ONLY way the --remove path runs git: GIT_DIR, GIT_WORK_TREE and GIT_COMMON_DIR are stripped so the repository acted on
378
- * is always cwd's own, and LC_ALL=C keeps stderr parseable. Throws execFileSync's error (status, stderr) unchanged.
373
+ * is always cwd's own, and LC_ALL=C keeps stderr parseable. Queue row 2 item b: it runs through the protected path (gitSync),
374
+ * whose GitTrustRefusal propagates unchanged; a failed git throws an error carrying its status and stderr.
379
375
  */
380
376
  function removeGit(cwd, args) {
381
377
  const { GIT_DIR: _dir, GIT_WORK_TREE: _workTree, GIT_COMMON_DIR: _commonDir, ...env } = process.env;
382
- return execFileSync("git", args, { cwd, env: { ...env, LC_ALL: "C" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
378
+ const r = gitSync(cwd, args, { env: { ...env, LC_ALL: "C" } });
379
+ if (r.error)
380
+ throw r.error;
381
+ if (r.status !== 0)
382
+ throw Object.assign(new Error(`git ${args.join(" ")} failed (${r.status ?? r.signal})`), { status: r.status, stderr: r.stderr });
383
+ return r.stdout;
383
384
  }
385
+ /** A trust refusal is never one of the --remove path's fail-open answers. */
386
+ const rethrowRefusal = (error) => { if (error instanceof GitTrustRefusal)
387
+ throw error; };
384
388
  function isLinkedWorktree(cwd) {
385
389
  try {
386
390
  const gitDir = removeGit(cwd, ["rev-parse", "--git-dir"]).trim();
387
391
  const gitCommonDir = removeGit(cwd, ["rev-parse", "--git-common-dir"]).trim();
388
392
  return resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir);
389
393
  }
390
- catch {
394
+ catch (error) {
395
+ rethrowRefusal(error);
391
396
  return false;
392
397
  }
393
398
  }
@@ -405,7 +410,8 @@ function checkLiveLock(cwd) {
405
410
  return mainOwner;
406
411
  }
407
412
  }
408
- catch {
413
+ catch (error) {
414
+ rethrowRefusal(error);
409
415
  // not a git repo
410
416
  }
411
417
  return undefined;
@@ -420,6 +426,7 @@ function isGitRepo(cwd) {
420
426
  return true;
421
427
  }
422
428
  catch (err) {
429
+ rethrowRefusal(err);
423
430
  const e = err;
424
431
  const stderr = String(e.stderr ?? "").trim();
425
432
  if (e.status === 128 && stderr.startsWith("fatal: not a git repository (or any of the parent directories)"))
@@ -433,6 +440,7 @@ function git(cwd, args, message) {
433
440
  return removeGit(cwd, args);
434
441
  }
435
442
  catch (err) {
443
+ rethrowRefusal(err);
436
444
  const stderr = String(err.stderr ?? "").trim();
437
445
  throw new Error(stderr ? `${message}: ${stderr}` : message);
438
446
  }
@@ -522,16 +530,16 @@ function handleRemove(cwd) {
522
530
  const out = removeGit(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/tickmarkr/"]);
523
531
  branches = out.split("\n").map((b) => b.trim()).filter(Boolean);
524
532
  }
525
- catch {
526
- // ignore
533
+ catch (error) {
534
+ rethrowRefusal(error);
527
535
  }
528
536
  let preservedRefs = [];
529
537
  try {
530
538
  const out = removeGit(cwd, ["for-each-ref", "--format=%(refname)", "refs/tickmarkr/preserved/"]);
531
539
  preservedRefs = out.split("\n").map((r) => r.trim()).filter(Boolean);
532
540
  }
533
- catch {
534
- // ignore
541
+ catch (error) {
542
+ rethrowRefusal(error);
535
543
  }
536
544
  for (const b of branches) {
537
545
  notes.push(`left branch: ${b}`);
@@ -13,7 +13,7 @@ import { projectOperatorSummary } from "../../run/operator-summary.js";
13
13
  import { trackJournalRows } from "../../run/protocol.js";
14
14
  import { newestPark, permittedDecisionVerbs } from "./approve.js";
15
15
  import { currentOwed, endToEndFirstPass, firstPassText, leadLines, leadRows, needsYouText, wallText } from "./report.js";
16
- import { Journal, bindingToken, effectiveEvents, formatJournalNarration, parseJournalText, physicalLine, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, fatalRunEndCause, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, visibleControls, } from "../../run/journal.js";
16
+ import { Journal, bindingToken, effectiveEvents, formatJournalNarration, parseJournalText, physicalLine, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, fatalRunEndCause, recordedTaskFailureKind, runHasEnded, restoresUpheldFeedback, visibleControls, } from "../../run/journal.js";
17
17
  import { isPidLive, runLockRunId, runStatusLine } from "../../run/lock.js";
18
18
  import { normalizeGateOutcome } from "../../run/outcome.js";
19
19
  import { desiredPanes } from "../../run/reconcile.js";
@@ -1070,15 +1070,18 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1070
1070
  }).map(sanitizeTaskText)
1071
1071
  : [];
1072
1072
  // OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
1073
- // older journal equally readable by asking upheldFeedbackByTask what was active at that exact
1073
+ // older journal equally readable by asking restoresUpheldFeedback what was funded at that exact
1074
1074
  // restore boundary; current resume-restore rows additionally carry the same task for narration.
1075
1075
  // OBS-1178: both folds read decisions through the one decision fold over the whole journal.
1076
+ // Queue row 225 (D-2111): only each task's NEWEST restore speaks — never a historical set.
1076
1077
  const decided = effectiveEvents(events);
1077
1078
  const preservedRefs = preservedRefsByTask(events);
1078
- const restoredUpheldFeedback = new Set(decided.flatMap((event, index) => event.event === "resume-restore" && event.taskId
1079
- && upheldFeedbackByTask(decided.slice(0, index)).has(event.taskId)
1080
- ? [event.taskId]
1081
- : []));
1079
+ const newestRestores = new Map();
1080
+ decided.forEach((event, index) => { if (event.event === "resume-restore" && event.taskId)
1081
+ newestRestores.set(event.taskId, index); });
1082
+ const restoredUpheldFeedback = new Set([...newestRestores]
1083
+ .filter(([taskId, index]) => restoresUpheldFeedback(decided.slice(0, index), taskId))
1084
+ .map(([taskId]) => taskId));
1082
1085
  // OBS-1178: a failed task's newest task-failed row is the token its recheck binds to. Only an
1083
1086
  // effective decision consumes it — a refused or unsound recheck leaves the task failed and the token shown.
1084
1087
  const failureTokens = new Map();
@@ -14,7 +14,7 @@ import { getTask, loadGraph } from "../../graph/graph.js";
14
14
  import { GATE_NAMES } from "../../graph/schema.js";
15
15
  import { executionSignal } from "../../run/execution-budget.js";
16
16
  import { addDetachedWorktree, linkNodeModules, reflogWitness, removeWorktree, shGit, shGitOk } from "../../run/git.js";
17
- import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
17
+ import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT, parseRunId } from "../../run/journal.js";
18
18
  import { describeLeaseWait, withRepositoryLease } from "../../run/lease.js";
19
19
  import { isPidLive } from "../../run/lock.js";
20
20
  import { LAUNCHSERVICES_PORT_STOP, probeLaunchServicesPorts, recordLaunchServices } from "../../run/launchservices-check.js";
@@ -195,8 +195,17 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
195
195
  const stateRoot = await verifyStateRoot(cwd);
196
196
  const fileRoot = (file) => existsSync(join(cwd, ".tickmarkr", file)) ? cwd : stateRoot;
197
197
  if (resolve(stateRoot) !== resolve(cwd)) {
198
- console.error(`verify: state files resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree; per-file origins: `
199
- + STATE_FILES.map(file => `${file}: ${join(fileRoot(file), ".tickmarkr", file)} (${fileRoot(file) === cwd ? "local" : "common root"})`).join("; ") + ")");
198
+ // Queue row 195 / D-2102: the fallback is no longer "read-only" — the one line names every
199
+ // state file with the path it was read from and its origin, AND every path this invocation
200
+ // writes: the shared verdict store always, doctor.json when present, the --record journal when given.
201
+ const reads = STATE_FILES.map(file => `${file}: ${join(fileRoot(file), ".tickmarkr", file)} (${fileRoot(file) === cwd ? "local" : "common root"})`).join("; ");
202
+ const doctorPath = join(fileRoot("doctor.json"), ".tickmarkr", "doctor.json");
203
+ const writes = [
204
+ `verdict store ${getVerdictStore(join(stateRoot, ".tickmarkr")).dir}`,
205
+ ...(existsSync(doctorPath) ? [`doctor.json ${doctorPath}`] : []),
206
+ ...(values.record ? [`record journal ${join(stateRoot, ".tickmarkr", "runs", parseRunId(values.record), "journal.jsonl")}`] : []),
207
+ ].join("; ");
208
+ console.error(`verify: linked worktree — state files resolve from ${join(stateRoot, ".tickmarkr")} (reads: ${reads}; writes: ${writes})`);
200
209
  }
201
210
  const cfg = loadConfig(fileRoot("config.yaml"));
202
211
  const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
@@ -1,9 +1,9 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
2
  import { basename, dirname, isAbsolute, join, relative } from "node:path";
4
3
  import { parse as parseYaml } from "yaml";
5
4
  import { AcceptanceItemSchema, TIERS, validateGraph } from "../graph/schema.js";
6
5
  import { CompileError, assertWriteScope, inferShape, sha256 } from "./common.js";
6
+ import { gitSync } from "../run/git.js";
7
7
  import { classifyContextPath } from "./native.js";
8
8
  // GSD artifact front-end (spec v1.3): one GSD *plan* is one tickmarkr *task* — a plan is
9
9
  // worktree-sized; its inner <task> steps stay in the worker prompt via context[0] = the plan file.
@@ -306,9 +306,10 @@ export function compileGsd(src, root) {
306
306
  // Match native context reachability against the committed tree. This block only obtains the HEAD
307
307
  // snapshot; classifyContextPath remains the single authority for missing, untracked, and glob refs.
308
308
  const gitDir = dirname(files[0]);
309
- const top = spawnSync("git", ["-C", gitDir, "rev-parse", "--show-toplevel"], { encoding: "utf8", maxBuffer: 1 << 28 });
309
+ // queue row 2 item b: protected git; a trust refusal throws instead of reading as "no repository"
310
+ const top = gitSync(gitDir, ["rev-parse", "--show-toplevel"]);
310
311
  // -z (queue row 71): without it a non-ASCII path is C-quoted and a tracked ref reads as untracked
311
- const tree = spawnSync("git", ["-C", gitDir, "ls-tree", "--full-tree", "-r", "--name-only", "-z", "HEAD"], { encoding: "utf8", maxBuffer: 1 << 28 });
312
+ const tree = gitSync(gitDir, ["ls-tree", "--full-tree", "-r", "--name-only", "-z", "HEAD"]);
312
313
  const repoRoot = typeof top.stdout === "string" ? top.stdout.trim() : "";
313
314
  // `root` is the repository whose worktrees will consume these refs. A source nested inside some
314
315
  // unrelated repository (as with vendored fixtures) must not be judged against that outer tree.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse } from "yaml";
4
+ import { repoOverlayPath } from "../config/config.js";
4
5
  import { validateGraph } from "../graph/schema.js";
5
6
  import { taskUnitContractErrors } from "./collateral.js";
6
7
  import { blocksCompile, ownershipFindings, renderOwnershipFinding } from "./ownership.js";
@@ -37,7 +38,9 @@ function detect(src) {
37
38
  function repoOverlayMode(repoRoot) {
38
39
  if (!repoRoot)
39
40
  return undefined;
40
- const path = join(repoRoot, ".tickmarkr", "config.yaml");
41
+ // Queue row 195 / D-2102: the ONE repo-layer path rule — a linked worktree without an overlay of
42
+ // its own proves against the main checkout's file, never against defaults.
43
+ const path = repoOverlayPath(repoRoot);
41
44
  if (!existsSync(path))
42
45
  return undefined;
43
46
  try {
@@ -1,9 +1,9 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { existsSync, readFileSync } from "node:fs";
3
2
  import { basename, dirname, join, resolve } from "node:path";
4
3
  import { filesGlob } from "../graph/files-glob.js";
5
4
  import picomatch from "picomatch";
6
5
  import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, renderAcceptanceItem, SHAPES, TIERS, validateGraph, } from "../graph/schema.js";
6
+ import { gitSync } from "../run/git.js";
7
7
  import { criterionReadsOnly } from "./collateral.js";
8
8
  import { CompileError, inferShape, sha256 } from "./common.js";
9
9
  // OBS-170/OBS-184: `context:` is a promise to the worker, and nothing ever checked it could be kept.
@@ -18,7 +18,8 @@ import { CompileError, inferShape, sha256 } from "./common.js";
18
18
  // `ls-tree -r HEAD` enumerates that tree once; per-path `cat-file -e HEAD:<path>` is the same oracle
19
19
  // one path at a time, and one spawn beats N.
20
20
  function trackedAtHead(dir) {
21
- const git = (...args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
21
+ // queue row 2 item b: every compile git runs through the protected path; a trust refusal throws, never fails open
22
+ const git = (...args) => gitSync(dir, args);
22
23
  const top = git("rev-parse", "--show-toplevel");
23
24
  // --full-tree is load-bearing: without it `ls-tree` scopes to the CWD's subtree and prints paths
24
25
  // relative to it, so a spec in specs/ would see only specs/-relative names and misjudge every
@@ -146,7 +147,7 @@ function symlinkScopeWarnings(file, tasks, base) {
146
147
  if (!scoped.length || !repositoryRoot(file))
147
148
  return [];
148
149
  const tree = base ?? "HEAD";
149
- const git = (...args) => spawnSync("git", ["-C", dirname(file), ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
150
+ const git = (...args) => gitSync(dirname(file), args);
150
151
  const failed = (r) => r.error || r.signal || r.status !== 0 || typeof r.stdout !== "string";
151
152
  const why = (r) => r.signal ? `signal ${r.signal}` : r.status === null ? "git did not run" : `git exit ${r.status}`;
152
153
  const advisory = (what, r) => `tickmarkr: advisory: files[] symlink inspection could not read ${what} in the selected git tree ${JSON.stringify(tree)} (${why(r)}) — symlink scope is unverified for this compile.`;
@@ -270,7 +271,7 @@ function testsAtHead(root) {
270
271
  const cached = headTestCache.get(root);
271
272
  if (cached)
272
273
  return cached;
273
- const grep = spawnSync("git", ["-C", root, "grep", "-I", "-n", "-e", "", "HEAD", "--", "tests/*.test.ts", "tests/**/*.test.ts"], { encoding: "utf8", maxBuffer: 1 << 25 });
274
+ const grep = gitSync(root, ["grep", "-I", "-n", "-e", "", "HEAD", "--", "tests/*.test.ts", "tests/**/*.test.ts"]);
274
275
  if (grep.status !== 0 || typeof grep.stdout !== "string") {
275
276
  headTestCache.set(root, []);
276
277
  return [];
@@ -389,7 +390,7 @@ function criterionScopeFinding(task, text, criterion, id, tests) {
389
390
  // 20-task spec compiled in seconds and the committed-spec corpus test timed out. Fail-open like
390
391
  // testsAtHead: no git answer means no corpus, and the lint stays quiet rather than wrong.
391
392
  function repoFileList(root) {
392
- const ls = spawnSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", maxBuffer: 1 << 25 });
393
+ const ls = gitSync(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
393
394
  if (ls.status !== 0 || typeof ls.stdout !== "string")
394
395
  return [];
395
396
  return ls.stdout.split("\0").filter(Boolean).sort();
@@ -949,7 +950,8 @@ acceptance is required on every task (a nested list of observable outcomes).
949
950
  mode: partner-led | risk-based | staff-led — this engagement's routing mode
950
951
  (loses only to an explicit \`run --mode\` flag)
951
952
  base: commit-or-ref — optional declared source base; the declaration must begin at column zero
952
- (runtime enforcement happens before a run)
953
+ (proven against the run's starting commit at run and resume start; a drifted
954
+ or unresolvable base is refused before anything is written)
953
955
 
954
956
  Fields available per task:
955
957
  goal: outcome the task must achieve (defaults to the title if omitted)
@@ -459,6 +459,21 @@ export type FleetEditable = Partial<Record<DenyScopeKey, string[]>> & {
459
459
  map: Record<string, MapEntry>;
460
460
  floors: Record<string, Tier>;
461
461
  };
462
+ /** The checkout's OWN repo-layer overlay — every writer's destination, and the reader's answer in
463
+ * every case but the one fallback below. */
464
+ export declare function ownRepoOverlayPath(repoRoot: string): string;
465
+ /**
466
+ * Queue row 195 / D-2102: .tickmarkr/ is gitignored, so a fresh session worktree has no overlay of
467
+ * its own and every command there would silently run on defaults plus the global layer. ONE rule,
468
+ * and every repo-layer reader goes through it: a checkout whose own state-dir config.yaml exists
469
+ * reads it; otherwise a LINKED worktree — a gitfile checkout whose trusted common dir
470
+ * (src/run/git-trust.ts trustedCommonDir) lies outside it — reads
471
+ * dirname(common dir)/<state dir>/config.yaml when that file exists; every other case (a main
472
+ * checkout or its subdirectory, no enclosing checkout, a git trust refusal, an absent main file)
473
+ * keeps the checkout's own path. WRITERS never follow this fallback: they use ownRepoOverlayPath.
474
+ * The trust check includes missing, redirected or symlinked commondir metadata and config.worktree
475
+ * under extensions.worktreeConfig; none can authorize a shared overlay.
476
+ */
462
477
  export declare function repoOverlayPath(repoRoot: string): string;
463
478
  export declare function readOverlayFile(path: string): Record<string, unknown>;
464
479
  /** A discovered fleet universe row: the served models of one adapter and, per model, the resolved
@@ -1,11 +1,12 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join, sep } from "node:path";
4
4
  import { filesGlob } from "../graph/files-glob.js";
5
5
  import { Document, isScalar, parse } from "yaml";
6
6
  import { z } from "zod";
7
7
  import { CITED_MODEL_WINDOWS } from "../adapters/model-windows.js";
8
8
  import { stateDirName } from "../graph/graph.js";
9
+ import { assertGitTrust, trustedCommonDir } from "../run/git-trust.js";
9
10
  import { EFFORTS, SHAPES, TIERS } from "../graph/schema.js";
10
11
  const TierEnum = z.enum(TIERS, {
11
12
  error: (iss) => `Invalid option: expected one of "cheap"|"mid"|"frontier" (got ${JSON.stringify(iss.input)})`,
@@ -744,7 +745,7 @@ export function globalConfigDir() {
744
745
  }
745
746
  export function overlayPreferShapes(repoRoot, opts = {}) {
746
747
  const shapes = new Set();
747
- for (const layer of [readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")), readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))]) {
748
+ for (const layer of [readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")), readYaml(repoOverlayPath(repoRoot))]) {
748
749
  const map = layer?.routing?.map;
749
750
  if (!map)
750
751
  continue;
@@ -835,7 +836,7 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
835
836
  // v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
836
837
  // write guard validates EXACTLY what it is about to write through this one loader path.
837
838
  const repoCfg = opts.repoOverlayText === undefined
838
- ? readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))
839
+ ? readYaml(repoOverlayPath(repoRoot))
839
840
  : parse(opts.repoOverlayText);
840
841
  const merged = deepMerge(deepMerge(structuredClone(DEFAULT_CONFIG), globalCfg), repoCfg);
841
842
  const r = TickmarkrConfigSchema.safeParse(merged);
@@ -1017,9 +1018,70 @@ export function configTemplate(overlay) {
1017
1018
  }
1018
1019
  // Fleet overlay mutation, legacy serialization, and diff rendering live together in fleet-overlay.ts.
1019
1020
  export { FLEET_OVERLAY_KEYS, fleetEditableEquals, fleetRepoOverlayFromDelta, renderFleetOverlayWrite, repoOverlayYaml, serializeFleetOverlay, unifiedYamlDiff, } from "./fleet-overlay.js";
1020
- export function repoOverlayPath(repoRoot) {
1021
+ /** The checkout's OWN repo-layer overlay — every writer's destination, and the reader's answer in
1022
+ * every case but the one fallback below. */
1023
+ export function ownRepoOverlayPath(repoRoot) {
1021
1024
  return join(repoRoot, stateDirName(repoRoot), "config.yaml");
1022
1025
  }
1026
+ /**
1027
+ * Queue row 195 / D-2102: .tickmarkr/ is gitignored, so a fresh session worktree has no overlay of
1028
+ * its own and every command there would silently run on defaults plus the global layer. ONE rule,
1029
+ * and every repo-layer reader goes through it: a checkout whose own state-dir config.yaml exists
1030
+ * reads it; otherwise a LINKED worktree — a gitfile checkout whose trusted common dir
1031
+ * (src/run/git-trust.ts trustedCommonDir) lies outside it — reads
1032
+ * dirname(common dir)/<state dir>/config.yaml when that file exists; every other case (a main
1033
+ * checkout or its subdirectory, no enclosing checkout, a git trust refusal, an absent main file)
1034
+ * keeps the checkout's own path. WRITERS never follow this fallback: they use ownRepoOverlayPath.
1035
+ * The trust check includes missing, redirected or symlinked commondir metadata and config.worktree
1036
+ * under extensions.worktreeConfig; none can authorize a shared overlay.
1037
+ */
1038
+ export function repoOverlayPath(repoRoot) {
1039
+ const own = ownRepoOverlayPath(repoRoot);
1040
+ if (existsSync(own))
1041
+ return own;
1042
+ let dotGit;
1043
+ try {
1044
+ for (let dir = realpathSync(repoRoot);;) {
1045
+ const candidate = join(dir, ".git");
1046
+ try {
1047
+ lstatSync(candidate);
1048
+ dotGit = candidate;
1049
+ break;
1050
+ }
1051
+ catch { /* keep walking */ }
1052
+ const up = dirname(dir);
1053
+ if (up === dir)
1054
+ break;
1055
+ dir = up;
1056
+ }
1057
+ }
1058
+ catch {
1059
+ return own;
1060
+ }
1061
+ // A directory .git is a main checkout (or one of its subdirectories); only a gitfile can be linked.
1062
+ if (!dotGit)
1063
+ return own;
1064
+ try {
1065
+ if (!lstatSync(dotGit).isFile())
1066
+ return own;
1067
+ }
1068
+ catch {
1069
+ return own;
1070
+ }
1071
+ let common;
1072
+ try {
1073
+ assertGitTrust(repoRoot);
1074
+ common = trustedCommonDir(repoRoot);
1075
+ }
1076
+ catch {
1077
+ return own;
1078
+ }
1079
+ const checkoutRoot = dirname(dotGit);
1080
+ if (common === checkoutRoot || common.startsWith(`${checkoutRoot}${sep}`))
1081
+ return own; // not linked
1082
+ const shared = ownRepoOverlayPath(dirname(common));
1083
+ return existsSync(shared) ? shared : own;
1084
+ }
1023
1085
  export function readOverlayFile(path) {
1024
1086
  const raw = readYaml(path);
1025
1087
  if (raw === undefined || raw === null)
@@ -0,0 +1,3 @@
1
+ export declare const HOST_SESSION_VARS: readonly ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_HOST_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH", "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_EXECPATH", "CLAUDE_CODE_DESKTOP_APP_VERSION", "CLAUDE_CODE_TERMINAL_MCP_TOOLS", "CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL", "CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES", "CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING", "CLAUDE_CODE_REPORT_FINDINGS", "CLAUDE_CODE_STARTUP_FAILURE_RESULTS", "CLAUDE_CODE_EAGER_FLUSH", "CLAUDE_CODE_DISABLE_CRON", "CLAUDE_CODE_SIMPLE", "CLAUDE_CODE_DISABLE_TERMINAL_TITLE", "CLAUDE_CODE_ACCOUNT_UUID", "CLAUDE_CODE_ORGANIZATION_UUID", "CLAUDE_CODE_USER_EMAIL", "CLAUDE_CODE_OAUTH_SCOPES", "CLAUDE_AGENT_SDK_VERSION", "CLAUDE_AGENT_SDK_MCP_NO_PREFIX", "CLAUDE_PID", "CLAUDE_EFFORT", "CLAUDE_PREVIEW_CLASSIFIER_FLOOR", "CLAUDE_CODE_BRIDGE_SESSION_ID"];
2
+ /** A copy of `env` without the host-session names; the input (the daemon's own process.env) is never mutated. */
3
+ export declare function withoutHostSession(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,47 @@
1
+ // Queue row 193 (D-2102 add.1): a daemon launched from an agent session (the Claude Desktop Code tab, or a
2
+ // claude CLI seat on Orca or herdr) carries that session's host variables, and an agent CLI that inherits
3
+ // them gains the host's tools and effort. This CLOSED denylist is stripped at every choke point an agent
4
+ // CLI or gate command passes: the shell seam, the subprocess driver's seal, the pane seeds and the bare
5
+ // probes. Exact names only — never a prefix: an unlisted CLAUDE_CODE variable and every user auth or
6
+ // provider variable reach children byte-equal. Declared residual: a host variable a future host adds
7
+ // reaches children until it is listed here. Leaf module: imports nothing from src/.
8
+ export const HOST_SESSION_VARS = [
9
+ "CLAUDECODE",
10
+ "CLAUDE_CODE_ENTRYPOINT",
11
+ "CLAUDE_CODE_SESSION_ID",
12
+ "CLAUDE_CODE_HOST_SESSION_ID",
13
+ "CLAUDE_CODE_CHILD_SESSION",
14
+ "CLAUDE_CODE_MESSAGING_SOCKET",
15
+ "CLAUDE_CODE_MESSAGING_TOKEN",
16
+ "CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH",
17
+ "CLAUDE_CODE_SESSION_ATTENDED",
18
+ "CLAUDE_CODE_EXECPATH",
19
+ "CLAUDE_CODE_DESKTOP_APP_VERSION",
20
+ "CLAUDE_CODE_TERMINAL_MCP_TOOLS",
21
+ "CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL",
22
+ "CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES",
23
+ "CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
24
+ "CLAUDE_CODE_REPORT_FINDINGS",
25
+ "CLAUDE_CODE_STARTUP_FAILURE_RESULTS",
26
+ "CLAUDE_CODE_EAGER_FLUSH",
27
+ "CLAUDE_CODE_DISABLE_CRON",
28
+ "CLAUDE_CODE_SIMPLE",
29
+ "CLAUDE_CODE_DISABLE_TERMINAL_TITLE",
30
+ "CLAUDE_CODE_ACCOUNT_UUID",
31
+ "CLAUDE_CODE_ORGANIZATION_UUID",
32
+ "CLAUDE_CODE_USER_EMAIL",
33
+ "CLAUDE_CODE_OAUTH_SCOPES",
34
+ "CLAUDE_AGENT_SDK_VERSION",
35
+ "CLAUDE_AGENT_SDK_MCP_NO_PREFIX",
36
+ "CLAUDE_PID",
37
+ "CLAUDE_EFFORT",
38
+ "CLAUDE_PREVIEW_CLASSIFIER_FLOOR",
39
+ "CLAUDE_CODE_BRIDGE_SESSION_ID",
40
+ ];
41
+ /** A copy of `env` without the host-session names; the input (the daemon's own process.env) is never mutated. */
42
+ export function withoutHostSession(env = process.env) {
43
+ const out = { ...env };
44
+ for (const k of HOST_SESSION_VARS)
45
+ delete out[k];
46
+ return out;
47
+ }
@@ -7,6 +7,7 @@ import { createWorktree, FORK_CAP_ENV, resolvedForkCap, sh } from "../run/git.js
7
7
  import { Journal, parseRunId } from "../run/journal.js";
8
8
  import { stateDirName } from "../graph/graph.js";
9
9
  import { readWatchBoard, requestWatchBoardStop, stopWatchBoard, supervisionPresencePath, WATCH_OWNER_ENV } from "../run/supervision.js";
10
+ import { HOST_SESSION_VARS } from "./host-env.js";
10
11
  import { MAX_BUF } from "./subprocess.js";
11
12
  import { formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
12
13
  // Orca (onorca.dev) as a third execution surface beside herdr and subprocess. tickmarkr keeps
@@ -966,7 +967,8 @@ export class OrcaDriver {
966
967
  // UI's active worktree (`active`/`current`) nor the daemon's cwd — and the command itself moves
967
968
  // into the checkout, because Orca cannot be asked to place a terminal in a path it does not
968
969
  // track (OBS-1004). Shell startup can swallow that prefix; prove it from scrollback below.
969
- const payload = `export ${FORK_CAP_ENV}=${shq(process.env[FORK_CAP_ENV] ?? resolvedForkCap())}; ${cmd}`;
970
+ // Queue row 193: the fork cap stays first; the agent session's host variables never reach the worker.
971
+ const payload = `export ${FORK_CAP_ENV}=${shq(process.env[FORK_CAP_ENV] ?? resolvedForkCap())}; unset ${HOST_SESSION_VARS.join(" ")}; ${cmd}`;
970
972
  launchGuards.getStore()?.(); // v2.6.8 T1: eligibility is re-read here, after every awaited lookup above
971
973
  const env = await this.call("create", [
972
974
  "terminal", "create", "--worktree", `path:${tracked}`, "--title", st.title, "--command", inCheckout(st.cwd, payload),
@@ -2,7 +2,7 @@ import type { ExecutorDriver, NotifyOpts, Slot, TransportState } from "./types.j
2
2
  export declare const MAX_BUF: number;
3
3
  export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH", "ORCA_TERMINAL_HANDLE", "ORCA_PANE_KEY", "ORCA_TAB_ID"];
4
4
  /**
5
- * Copy of worker env with the fork cap applied and host control-plane vars stripped.
5
+ * Copy of worker env with the fork cap applied and host control-plane and host-session vars stripped.
6
6
  * Workers retain the run's concurrency-derived cap (resolvedForkCap), independently of
7
7
  * verification's suite-window budget. The operator's own export still wins.
8
8
  */
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { shq } from "../adapters/types.js";
3
3
  import { createWorktree, FORK_CAP_ENV, resolvedForkCap } from "../run/git.js";
4
+ import { HOST_SESSION_VARS, withoutHostSession } from "./host-env.js";
4
5
  // HARD-03: cap retained worker output so a chatty worker can't grow tickmarkr's heap unbounded.
5
6
  // 2MB is safe against BOTH consumers: the largest read() call site asks for 1000 lines
6
7
  // (daemon.ts:217/247), and every marker (TICKMARKR_RESULT, TICKMARKR_EXIT_<nonce>) is emitted at the
@@ -21,12 +22,12 @@ export const HERDR_CONTROL_VARS = [
21
22
  "ORCA_TAB_ID",
22
23
  ];
23
24
  /**
24
- * Copy of worker env with the fork cap applied and host control-plane vars stripped.
25
+ * Copy of worker env with the fork cap applied and host control-plane and host-session vars stripped.
25
26
  * Workers retain the run's concurrency-derived cap (resolvedForkCap), independently of
26
27
  * verification's suite-window budget. The operator's own export still wins.
27
28
  */
28
29
  export function sealHerdrEnv(env = process.env) {
29
- const out = { ...env };
30
+ const out = withoutHostSession(env); // queue row 193
30
31
  if (!(FORK_CAP_ENV in out))
31
32
  out[FORK_CAP_ENV] = resolvedForkCap();
32
33
  for (const k of HERDR_CONTROL_VARS)
@@ -37,7 +38,7 @@ export function sealHerdrEnv(env = process.env) {
37
38
  export function herdrSealShellPrefix(env = process.env) {
38
39
  const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? resolvedForkCap();
39
40
  return `export ${FORK_CAP_ENV}=${shq(forkCap)}; ` +
40
- HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
41
+ HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + `; unset ${HOST_SESSION_VARS.join(" ")}; `;
41
42
  }
42
43
  export class SubprocessDriver {
43
44
  id = "subprocess";
@@ -60,7 +60,7 @@ export declare function beginGateEvidence(cwd: string, gate: string, command: st
60
60
  subjectCommit: string | null;
61
61
  };
62
62
  termination: {
63
- kind: "unknown" | "timeout" | "signal" | "not-started" | "exit";
63
+ kind: "unknown" | "signal" | "timeout" | "not-started" | "exit";
64
64
  exitCode: number | null;
65
65
  signal: string | null;
66
66
  timedOut: boolean | null;
@@ -1,11 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { execFileSync } from "node:child_process";
3
2
  import { EVICTION_TOMBSTONE_LIMIT, EVICTION_TOMBSTONES_FILE, parseEvictionTombstones } from "../run/receipt-resolver.js";
4
3
  import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
5
4
  import { availableParallelism, loadavg, tmpdir } from "node:os";
6
5
  import { basename, join } from "node:path";
7
6
  import { DEFAULT_EVIDENCE_QUOTA_BYTES } from "../config/config.js";
8
- import { dependencyLinkRefusal, DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
7
+ import { dependencyLinkRefusal, gitSync, DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
9
8
  import { executionSignal } from "../run/execution-budget.js";
10
9
  import { isVitestTestCommand, manifestChildEnv, manifestFileCount } from "./test-manifest.js";
11
10
  export const EVIDENCE_TAIL_BYTES = 16 * 1024;
@@ -121,7 +120,9 @@ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
121
120
  let subjectCommit = opts.subjectCommit ?? null;
122
121
  if (!subjectCommit) {
123
122
  try {
124
- subjectCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
123
+ const result = gitSync(cwd, ["rev-parse", "HEAD"]);
124
+ if (result.status === 0 && result.stdout.trim())
125
+ subjectCommit = result.stdout.trim();
125
126
  }
126
127
  catch { /* unknown subject */ }
127
128
  }