tickmarkr 2.8.3 → 2.8.5

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.
@@ -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) {
@@ -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
 
@@ -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,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 {
@@ -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";
@@ -1,11 +1,13 @@
1
- import { readdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { randomBytes } from "node:crypto";
2
+ import { lstatSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
5
  import { z } from "zod";
4
6
  import { channelKey, shq } from "../adapters/types.js";
5
7
  import { compileNative } from "../compile/native.js";
6
8
  import { DEFAULT_DIFF_CAP } from "../config/config.js";
7
9
  import { renderAcceptanceItem } from "../graph/schema.js";
8
- import { sh } from "../run/git.js";
10
+ import { DEFAULT_SHELL_TIMEOUT_MS, sh, shell } from "../run/git.js";
9
11
  import { checkTaskDiffCaps, fetchTaskDiff, isProtectedEvidence, setAsideReceiptPath } from "./review.js";
10
12
  import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, SeatLaunchError, verdictNonceLine } from "./llm.js";
11
13
  import { classifyVerdictCause } from "./verdict-cause.js";
@@ -13,6 +15,8 @@ import { reviewableLogicDiff } from "./artifact-manifest.js";
13
15
  import { diffSidePath } from "./git-paths.js";
14
16
  import { classifyFailureOutput } from "./baseline.js";
15
17
  import { operationFactsAtHead, readOperationFacts } from "./operation-facts.js";
18
+ import { isVitestTestCommand, manifestChildEnv, readTestReport, singleForkRetryCommand, toManifestPath } from "./test-manifest.js";
19
+ import { TEST_REPORTER_SOURCE } from "./test-reporter.js";
16
20
  // Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
17
21
  const JUDGE_TIMEOUT_MS = 900_000;
18
22
  const CitationSchema = z.object({ path: z.string(), line: z.number().int() });
@@ -98,6 +102,26 @@ export function testFiltered(testCmd, name) {
98
102
  const fwd = wrapped ? "-- " : "";
99
103
  return `${testCmd} ${fwd}-t ${shq(pattern)}`;
100
104
  }
105
+ // Queue row 179: the criterion's suite file as the runner's file argument AHEAD of the title filter, appended to a
106
+ // base that already carries its forwarding separator (npmForwarded, under either runner). testFiltered keeps its
107
+ // two-argument form: a criterion with no suite file, and the eval harness.
108
+ const fileFiltered = (base, file, name) => `${base} ${shq(file)} -t ${shq(testFilterPattern(name))}`;
109
+ // The forwarding separator, decided on shell WORDS — the test gate's tokenizer (test-manifest.ts shellTokens/words,
110
+ // unexported) beside its unquoting — never on the raw text: behind any `NAME=value` assignments (whose value, even
111
+ // ` -- `, is not a word of the command), an npm manager word, bare, quoted or escaped, gets ONE `--` unless a later
112
+ // word already is `--` (bare, quoted or escaped). npx, pnpm, yarn and a direct runner forward their arguments as they
113
+ // are. Both runners' branches append through it; the Vitest branch asks the gate's parser only whether the reporter
114
+ // can be forwarded at all (V5), since that parser's own separator reads the raw text.
115
+ const SHELL_TOKEN = /(?:[^\s'"\\&|;<>]+|'[^']*'|"(?:\\.|[^"\\])*"|\\.)+|[&|;<>]+/g;
116
+ const unquoted = (token) => token.replace(/'([^']*)'|"((?:\\.|[^"\\])*)"|\\(.)/g, (_all, single, double, escaped) => single ?? (double !== undefined ? double.replace(/\\(["\\$`])/g, "$1") : escaped));
117
+ function npmForwarded(testCmd) {
118
+ const raw = testCmd.match(SHELL_TOKEN) ?? [];
119
+ let manager = 0;
120
+ while (/^[A-Za-z_]\w*=/.test(raw[manager] ?? ""))
121
+ manager++;
122
+ const argv = raw.slice(manager).map(unquoted);
123
+ return argv[0] === "npm" && !argv.includes("--") ? `${testCmd} --` : testCmd;
124
+ }
101
125
  const VitestListedTestsSchema = z.array(z.object({
102
126
  name: z.string(),
103
127
  file: z.string(),
@@ -238,6 +262,99 @@ function oracleExecutionFailure(label, code, stdout, stderr) {
238
262
  details: `oracle failed: ${label} (exit ${code})${tail(stderr || stdout)}`,
239
263
  };
240
264
  }
265
+ // today's rule for a test oracle's run: exit 0 AND a positive printed run count (OBS-55: a zero-match run is vacuous).
266
+ function countedTestRun(label, filter, r) {
267
+ if (r.code !== 0)
268
+ return oracleExecutionFailure(label, r.code, r.stdout, r.stderr);
269
+ const out = (r.stderr || "") + "\n" + (r.stdout || "");
270
+ const ran = testsRan(out);
271
+ if (ran === null || ran < 1) {
272
+ return { gate: "acceptance", pass: false,
273
+ details: `oracle failed: ${label} — name filter matched zero tests (filter: ${filter})${tail(out)}` };
274
+ }
275
+ return undefined;
276
+ }
277
+ /**
278
+ * Queue row 179 (D-2018, D-2019): a test criterion that declares its suite file L is satisfied by L alone — run
279
+ * 1248's oracles named one file and a same-titled test in another satisfied them. Closed table:
280
+ * L1 L absent or not a regular file (a directory included) in the worktree: a work verdict naming L, under either
281
+ * runner, and the test command never starts.
282
+ * Vitest: L alone, file argument then title filter, under the test gate's nonce-bound per-file reporter, because
283
+ * Vitest's file filter selects EVERY path containing L. PASS only when that report, of this nonce and ending in
284
+ * its run-end certificate with exit 0, shows a passed body in module L and none run in any other module.
285
+ * V2 L without the title (another file holding it): no passed body in L. V3 a deeper path ending with L holding
286
+ * it, whether or not L does: a body run in another module. V4 report missing, another nonce or no certificate,
287
+ * and V5 a script body the reporter cannot be forwarded to: fail closed, unverified.
288
+ * Other runner: L as its file argument before the title filter, today's run-count rule. Declared residual: such a
289
+ * runner gives no per-file evidence, so a test of the title in a path containing L can still satisfy it there.
290
+ */
291
+ async function landedTestOracle(testCmd, title, landing, worktree) {
292
+ const label = `test "${title}" in ${landing}`;
293
+ const passed = { gate: "acceptance", pass: true, details: `✓ test: ${title} (exit 0) in ${landing}` };
294
+ const work = (why) => ({ gate: "acceptance", pass: false, details: `oracle failed: ${label} — ${why}` });
295
+ const unverified = (why) => ({ gate: "acceptance", pass: false,
296
+ details: `oracle failed: ${label} — ${why}; unverified, failing closed`,
297
+ meta: { cause: "oracle-execution", classification: "infra", infra: true, retryable: false } });
298
+ const path = resolve(worktree, landing);
299
+ const within = relative(worktree, path);
300
+ let regular = false;
301
+ try {
302
+ regular = !within.startsWith("..") && !isAbsolute(within) && lstatSync(path).isFile();
303
+ }
304
+ catch { /* absent */ }
305
+ if (!regular)
306
+ return work(`suite file ${landing} is absent from the worktree or is not a regular file; the test command never started`);
307
+ if (!isVitestTestCommand(testCmd, worktree)) {
308
+ return countedTestRun(label, title, await sh(fileFiltered(npmForwarded(testCmd), landing, title), worktree)) ?? passed;
309
+ }
310
+ // V5: the test gate's own parser, through its one pure export, throws on a compound or non-direct script body,
311
+ // where an appended --reporter would reach some other command. Only its refusal is read: its separator reads the
312
+ // raw text, so a quoted `"--"` or an assignment's ` -- ` value would misplace the file, filter and reporter.
313
+ try {
314
+ singleForkRetryCommand(testCmd, worktree, [], []);
315
+ }
316
+ catch (error) {
317
+ return unverified(`the per-file reporter cannot be forwarded to this test command (${error instanceof Error ? error.message : String(error)})`);
318
+ }
319
+ const cmd = fileFiltered(npmForwarded(testCmd), landing, title);
320
+ const dir = mkdtempSync(join(tmpdir(), "tickmarkr-oracle-"));
321
+ try {
322
+ const nonce = randomBytes(16).toString("hex");
323
+ const reporterPath = join(dir, `test-reporter-${nonce}.mjs`);
324
+ const reportPath = join(dir, `test-report-${nonce}.json`);
325
+ writeFileSync(reporterPath, TEST_REPORTER_SOURCE);
326
+ const r = await shell(`${cmd} --reporter=${shq(reporterPath)} --outputFile=${shq(reportPath)}`, worktree, DEFAULT_SHELL_TIMEOUT_MS, false, { env: { ...manifestChildEnv(worktree), TICKMARKR_TEST_REPORT: reportPath, TICKMARKR_TEST_NONCE: nonce } });
327
+ const raw = readTestReport(reportPath);
328
+ const report = raw?.nonce === nonce ? raw : undefined;
329
+ if (r.code !== 0) {
330
+ // the reporter replaces the runner's own output: its failure identities are the evidence of a red.
331
+ const named = report ? [...Object.values(report.completed).flatMap((c) => c.failures ?? []), ...(report.certificate?.diagnostics ?? [])] : [];
332
+ return oracleExecutionFailure(label, r.code, r.stdout, [r.stderr, ...named].filter(Boolean).join("\n"));
333
+ }
334
+ if (!raw)
335
+ return unverified("the runner exited 0 but wrote no per-file report");
336
+ if (!report)
337
+ return unverified(`the per-file report carries nonce "${raw.nonce}", not this invocation's`);
338
+ if (report.certificate?.exitCode !== 0)
339
+ return unverified("the per-file report never reached its run-end certificate with exit 0");
340
+ const key = toManifestPath(within, worktree);
341
+ // a module completed more than once (one path in several projects) keeps only its LAST counters, so its record
342
+ // cannot show that no earlier completion ran a body: it counts as run.
343
+ const repeated = new Set(report.duplicateCompletions ?? []);
344
+ const elsewhere = [...new Set([...Object.keys(report.started), ...Object.keys(report.completed)])].filter((file) => {
345
+ const t = report.completed[file]?.tests;
346
+ return file !== key && (repeated.has(file) || !t || t.passed + t.failed > 0);
347
+ }).sort();
348
+ if (elsewhere.length)
349
+ return work(`the runner's per-file report shows a test body run outside ${landing}, in ${elsewhere.join(", ")} — only ${landing} can satisfy this criterion`);
350
+ if (!((report.completed[key]?.tests?.passed ?? 0) > 0))
351
+ return work(`the runner's per-file report shows no passing test of that title in ${landing}`);
352
+ return passed;
353
+ }
354
+ finally {
355
+ rmSync(dir, { recursive: true, force: true });
356
+ }
357
+ }
241
358
  // v1.70 / OBS-129: the new-file line numbers each changed HUNK spans, per path — parsed from the
242
359
  // unified-diff so a citation is validated against real changed regions, not a substring of the whole
243
360
  // diff text (which also matches the diff's own +++/@@ headers). A hunk's span is every new-file line it
@@ -355,17 +472,18 @@ judge, via, opts = {}) {
355
472
  return { gate: "acceptance", pass: false,
356
473
  details: `oracle failed: test "${a.test}" — no test command configured to run it (failing closed)` };
357
474
  }
358
- const r = await sh(testFiltered(opts.testCmd, a.test), worktree);
359
- const out = (r.stderr || "") + "\n" + (r.stdout || "");
360
- if (r.code !== 0) {
361
- return oracleExecutionFailure(`test "${a.test}"`, r.code, r.stdout, r.stderr);
475
+ // queue row 179: a declared suite file is the only file that can satisfy its criterion.
476
+ if (a.landing !== undefined) {
477
+ const landed = await landedTestOracle(opts.testCmd, a.test, a.landing, worktree);
478
+ if (!landed.pass)
479
+ return landed;
480
+ passedDet.push(landed.details);
481
+ continue;
362
482
  }
363
483
  // OBS-55: exit 0 alone is vacuous when the name filter matched zero tests — fail closed.
364
- const ran = testsRan(out);
365
- if (ran === null || ran < 1) {
366
- return { gate: "acceptance", pass: false,
367
- details: `oracle failed: test "${a.test}" — name filter matched zero tests (filter: ${a.test})${tail(out)}` };
368
- }
484
+ const red = countedTestRun(`test "${a.test}"`, a.test, await sh(testFiltered(opts.testCmd, a.test), worktree));
485
+ if (red)
486
+ return red;
369
487
  passedDet.push(`✓ test: ${a.test} (exit 0)`);
370
488
  }
371
489
  }
@@ -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;
package/dist/gates/llm.js CHANGED
@@ -494,15 +494,20 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
494
494
  // OBS-1177: the beat is armed for an INTERACTIVE (pane) driver only. On the subprocess driver
495
495
  // a headless `claude -p` buffers every byte until it exits, so 0 seat bytes at 30 s is not a
496
496
  // dead launch; that seat keeps its full ceiling, which still bounds it (fail-closed by timeout).
497
- if (reviewing && via.driver.interactive && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
497
+ // Queue row 184: an adapter whose pane review command buffers its whole output until exit
498
+ // (claude-code's `claude -p`) has written nothing of its own until its verdict, working or
499
+ // dead, so the beat cannot tell the two apart and does not arm for it; its ceiling bounds it.
500
+ if (reviewing && via.driver.interactive && adapter.paneReviewBuffersOutput !== true && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
498
501
  firstLivenessObserved = true;
499
502
  // RS-2: the beat reads seat-authored bytes ALONE. CPU evidence never holds a preamble-only
500
503
  // capture open to the ceiling; a seat that has not written one byte of its own is re-routed.
501
- // OBS-944 is why a buffering runner earns no exemption here: the claude-code seat's 901 s
502
- // capture was byte-identical across two legs and ended at the pane-identity line — that
503
- // seat never started, it was not quietly working. RULING-229-06 puts the beat on the PANE
504
- // path only; a headless `-p` runner (runHeadlessDetailed, no pane, no beat) buffers every
505
- // byte until completion and keeps its full ceiling.
504
+ // OBS-944: the claude-code seat's 901 s capture was byte-identical across two legs and ended
505
+ // at the pane-identity line — that seat never started. Until 2.8.3 the orca checkout frame
506
+ // counted as seat bytes and kept every claude pane seat past this beat; once it stopped
507
+ // counting (row 135(a)) the beat killed working claude reviewers too, hence the declared
508
+ // exemption above (row 184), which restores that pre-2.8.3 bound for buffering adapters only.
509
+ // RULING-229-06 puts the beat on the PANE path only; a headless `-p` runner
510
+ // (runHeadlessDetailed, no pane, no beat) buffers every byte until completion and keeps its full ceiling.
506
511
  if (seatAuthoredBytes === 0) {
507
512
  launchNeverStarted = true;
508
513
  forceClose = true;