tickmarkr 2.8.5 → 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.
@@ -10,6 +10,7 @@ export interface CodexCommitProbe {
10
10
  hostileRoot: string;
11
11
  token: string;
12
12
  signal?: AbortSignal;
13
+ repoRoot?: string;
13
14
  }
14
15
  /** Runs the probe script inside the Codex sandbox and resolves its stdout. Injected in tests. */
15
16
  export type CodexSandbox = (probe: CodexCommitProbe) => Promise<string>;
@@ -17,10 +18,10 @@ export interface CodexCommitResult {
17
18
  status: CodexCommitStatus;
18
19
  detail: string;
19
20
  }
20
- /** The `sh` argv production runs at the fixture worktree: `codex sandbox` under the worker grant fragment. */
21
+ /** Queue row 2 (D-2112, D-2114): the fixture uses the same literal grant as a worker. */
21
22
  export declare const codexSandboxArgs: (p: CodexCommitProbe, grant?: string) => string[];
22
- /** A sandbox executing `grant`; production is CODEX_GIT_GRANT. Tests pass a mutant grant to prove discrimination. */
23
- export declare const codexSandboxFor: (grant: string) => CodexSandbox;
23
+ /** Queue row 2 (D-2112): tests may supply a mutant; production renders each fixture. */
24
+ export declare const codexSandboxFor: (grant?: string) => CodexSandbox;
24
25
  export declare const codexSandbox: CodexSandbox;
25
26
  export declare function probeCodexCommit(repoRoot: string, sandbox?: CodexSandbox, signal?: AbortSignal): Promise<CodexCommitResult>;
26
27
  /** A persisted verdict outside the closed table (an older or newer doctor.json) reads unknown, never allowed. */
@@ -4,19 +4,20 @@ import { existsSync, mkdtempSync, readFileSync, rmdirSync, rmSync } from "node:f
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { promisify } from "node:util";
7
- import { assertGitTrust, GitTrustRefusal, protectedGitEnv } from "../run/git-trust.js";
7
+ import { assertGitTrust, GitTrustRefusal } from "../run/git-trust.js";
8
+ import { shGitOk } from "../run/git.js";
8
9
  import { withoutHostSession } from "../drivers/host-env.js";
9
- import { CODEX_GIT_GRANT, codex } from "./codex.js";
10
+ import { renderCodexGitGrant, codex } from "./codex.js";
10
11
  import { shq } from "./types.js";
11
12
  const exec = promisify(execFile);
12
- const git = async (cwd, ...args) => (await exec("git", args, { cwd })).stdout.trim();
13
- // v2.6.7 T4: host git after the sandbox has had the fixture — validation and cleanup — runs the uncached trust
14
- // check and the forced child config on every call, so seat-rewritten metadata can neither run a config program
15
- // nor answer for the probe's commit.
16
- const hostGit = async (cwd, ...args) => {
17
- assertGitTrust(cwd);
18
- return (await exec("git", args, { cwd, env: protectedGitEnv(process.env) })).stdout.trim();
19
- };
13
+ // v2.6.7 T4 → queue row 2 item b (D-2112): every fixture git — setup, validation and cleanup — is tickmarkr's own git
14
+ // (shGitOk): the uncached trust check, the forced inert fsmonitor and hooks and, in a linked checkout, the pin, so
15
+ // seat-rewritten metadata can neither run a config program or hook nor answer for the probe's commit. A trust refusal
16
+ // rejects with GitTrustRefusal naming the path and is reported as an unknown verdict, never folded into another answer.
17
+ const hostGit = async (cwd, args, own) => (await shGitOk(["git", ...args].map(shq).join(" "), cwd, own)).trim();
18
+ /** An ordinary git failure is `fallback`; a trust refusal propagates. */
19
+ const unlessRefused = (fallback) => (error) => { if (error instanceof GitTrustRefusal)
20
+ throw error; return fallback; };
20
21
  /** The fixture checkout's trust after the sandbox returned or threw: a refusal is reported with its path. */
21
22
  const fixtureRefusal = (worktree) => {
22
23
  try {
@@ -35,14 +36,14 @@ const SCRIPT = `printf %s "$2" > "$1" && echo control=ok || echo control=fail
35
36
  git add -- "$1" && git -c core.hooksPath=/dev/null -c core.logAllRefUpdates=true -c commit.gpgsign=false -c user.name=tickmarkr -c user.email=probe@tickmarkr.invalid commit -q -m "tickmarkr-probe $2" && echo commit=ok || echo commit=fail
36
37
  (set -C; printf %s "$2" > "$3") 2>/dev/null && echo hook=written || echo hook=denied
37
38
  (set -C; printf %s "$2" > "$4") 2>/dev/null && echo root=written || echo root=denied`;
38
- /** The `sh` argv production runs at the fixture worktree: `codex sandbox` under the worker grant fragment. */
39
- export const codexSandboxArgs = (p, grant = CODEX_GIT_GRANT) => [
39
+ /** Queue row 2 (D-2112, D-2114): the fixture uses the same literal grant as a worker. */
40
+ export const codexSandboxArgs = (p, grant = renderCodexGitGrant({ cwd: p.worktree, branch: p.branch, repoRoot: p.repoRoot ?? p.commonDir })) => [
40
41
  "-c",
41
42
  `codex sandbox -c 'sandbox_mode="workspace-write"' ${grant} -- sh -c ${shq(SCRIPT)} sh ${[p.control, p.token, p.hostileHook, p.hostileRoot].map(shq).join(" ")}`,
42
43
  ];
43
- /** A sandbox executing `grant`; production is CODEX_GIT_GRANT. Tests pass a mutant grant to prove discrimination. */
44
+ /** Queue row 2 (D-2112): tests may supply a mutant; production renders each fixture. */
44
45
  export const codexSandboxFor = (grant) => async (p) => (await exec("sh", codexSandboxArgs(p, grant), { cwd: p.worktree, timeout: 20_000, signal: p.signal, env: withoutHostSession() })).stdout;
45
- export const codexSandbox = codexSandboxFor(CODEX_GIT_GRANT);
46
+ export const codexSandbox = codexSandboxFor();
46
47
  const holds = (path, token) => {
47
48
  try {
48
49
  return readFileSync(path, "utf8") === token;
@@ -67,18 +68,28 @@ const contradictory = (out) => {
67
68
  }
68
69
  return false;
69
70
  };
70
- const tipOf = async (repo, ref) => hostGit(repo, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`).catch(() => undefined);
71
+ const tipOf = async (repo, ref) => hostGit(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(unlessRefused(undefined));
71
72
  // This probe's commit and nothing else: one parent (the fixture base), its token in the subject and the control.
72
73
  const isProbeCommit = async (repo, commit, base, token) => {
73
74
  try {
74
- return await hostGit(repo, "log", "-1", "--format=%P %s", commit) === `${base} tickmarkr-probe ${token}`
75
- && await hostGit(repo, "cat-file", "blob", `${commit}:${CONTROL}`) === token;
75
+ return await hostGit(repo, ["log", "-1", "--format=%P %s", commit]) === `${base} tickmarkr-probe ${token}`
76
+ && await hostGit(repo, ["cat-file", "blob", `${commit}:${CONTROL}`]) === token;
76
77
  }
77
- catch {
78
- return false;
78
+ catch (e) {
79
+ return unlessRefused(false)(e);
79
80
  }
80
81
  };
81
82
  export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal) {
83
+ // queue row 2 item b: cleanup stays best effort for ordinary git failures, but a trust refusal there is never
84
+ // folded into the verdict already reached — it is reported as unknown naming its path (an escape still dominates)
85
+ let refusal;
86
+ const result = await probeFixture(repoRoot, sandbox, signal, (e) => { refusal ??= e; });
87
+ return refusal && result.status !== "escape" ? unknown(`fixture git refused (${refusal.message})`) : result;
88
+ }
89
+ async function probeFixture(repoRoot, sandbox, signal, refused) {
90
+ /** A cleanup git's ordinary failure is `fallback`; its trust refusal is reported to the caller, then `fallback`. */
91
+ const cleanup = (fallback) => (e) => { if (e instanceof GitTrustRefusal)
92
+ refused(e); return fallback; };
82
93
  const token = randomUUID();
83
94
  const parent = mkdtempSync(join(tmpdir(), "tickmarkr-codex-commit-"));
84
95
  // git names the fixture's metadata directory after the checkout's basename; a unique one makes that
@@ -91,21 +102,22 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
91
102
  let hostile = [];
92
103
  let sandboxed = false;
93
104
  try {
94
- const commonDir = await git(repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir");
105
+ const commonDir = await hostGit(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
95
106
  lock = join(commonDir, "worktrees", basename(worktree), "index.lock");
96
107
  const hostileHook = join(commonDir, "hooks", branch), hostileRoot = join(commonDir, branch);
97
108
  hostile = [hostileHook, hostileRoot];
98
109
  if (hostile.some((m) => existsSync(m)))
99
110
  return unknown("a foreign file already holds a hostile probe path");
100
- base = await git(repoRoot, "rev-parse", "--verify", "HEAD^{commit}");
101
- await git(repoRoot, "worktree", "add", "-b", branch, worktree, base);
102
- if (join(await git(worktree, "rev-parse", "--absolute-git-dir"), "index.lock") !== lock)
111
+ base = await hostGit(repoRoot, ["rev-parse", "--verify", "HEAD^{commit}"]);
112
+ // the add declares the branch capability: a result refused after its child ran is rolled back (checkout and branch)
113
+ await hostGit(repoRoot, ["worktree", "add", "-b", branch, worktree, base], { createsWorktree: "branch", rollback: { dir: worktree, branch: { name: branch } } });
114
+ if (join(await hostGit(worktree, ["rev-parse", "--absolute-git-dir"]), "index.lock") !== lock)
103
115
  return unknown("the fixture worktree metadata is not where git was expected to put it");
104
116
  const control = join(worktree, CONTROL);
105
117
  if (existsSync(lock))
106
118
  return unknown("a foreign index.lock already holds the fixture worktree");
107
119
  sandboxed = true;
108
- const out = await sandbox({ worktree, commonDir, branch, control, lock, hostileHook, hostileRoot, token, signal });
120
+ const out = await sandbox({ worktree, commonDir, branch, control, lock, hostileHook, hostileRoot, token, signal, repoRoot });
109
121
  // An escape on disk dominates; every other verdict needs consistent receipts AND disk — and trusted metadata.
110
122
  const escape = escaped(hostile, token);
111
123
  if (escape)
@@ -145,6 +157,8 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
145
157
  const refused = sandboxed ? fixtureRefusal(worktree) : undefined;
146
158
  if (refused)
147
159
  return refused;
160
+ if (e instanceof GitTrustRefusal)
161
+ return unknown(`fixture git refused (${e.message})`);
148
162
  return unknown(signal?.aborted ? "probe cancelled" : `probe did not run (${(e instanceof Error ? e.message : String(e)).split("\n")[0]})`);
149
163
  }
150
164
  finally {
@@ -159,7 +173,7 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
159
173
  // prune — that would take an earlier probe's preserved foreign lock (or anyone's prunable worktree).
160
174
  const foreign = lock !== undefined && existsSync(lock);
161
175
  if (lock && !foreign) {
162
- await hostGit(repoRoot, "worktree", "remove", "--force", worktree).catch(() => undefined);
176
+ await hostGit(repoRoot, ["worktree", "remove", "--force", worktree]).catch(cleanup(undefined));
163
177
  // a failed `worktree add` leaves metadata git no longer removes by path: drop this probe's own directory
164
178
  rmSync(dirname(lock), { recursive: true, force: true });
165
179
  try {
@@ -170,9 +184,9 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
170
184
  // The fixture branch and its reflog go only while the tip is still this probe's (the base or its own
171
185
  // commit), and update-ref's old-value check refuses if anyone moves it between this read and the delete.
172
186
  // Metadata preserved for a foreign lock keeps its branch too: that checkout's HEAD still names it.
173
- const tip = base === undefined || foreign ? undefined : await tipOf(repoRoot, ref);
174
- if (tip && base && (tip === base || await isProbeCommit(repoRoot, tip, base, token))) {
175
- await hostGit(repoRoot, "update-ref", "-d", ref, tip).catch(() => undefined);
187
+ const tip = base === undefined || foreign ? undefined : await tipOf(repoRoot, ref).catch(cleanup(undefined));
188
+ if (tip && base && (tip === base || await isProbeCommit(repoRoot, tip, base, token).catch(cleanup(false)))) {
189
+ await hostGit(repoRoot, ["update-ref", "-d", ref, tip]).catch(cleanup(undefined));
176
190
  }
177
191
  rmSync(parent, { recursive: true, force: true });
178
192
  }
@@ -1,11 +1,11 @@
1
- import { type ModelRetirement, type TrustDialog, type TrustVerdict, type WorkerAdapter } from "./types.js";
1
+ import { type ModelRetirement, type TrustDialog, type TrustVerdict, type WorkerAdapter, type WorkerLaunchContext } from "./types.js";
2
2
  export declare function readCodexModelsCache(path?: string): {
3
3
  models: string[];
4
4
  fetchedAt?: string;
5
5
  retirements?: Record<string, ModelRetirement | null>;
6
6
  hidden?: string[];
7
7
  };
8
- export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir && echo /) && tkr_gitdir=$(git rev-parse --absolute-git-dir && echo /) && tkr_common=${tkr_common%?/} && tkr_gitdir=${tkr_gitdir%?/} && { tkr_head=$(git symbolic-ref -q HEAD); [ $? -le 1 ]; } && case $tkr_head in (refs/heads/*) tkr_leaf=${tkr_head%/*};; (*) tkr_leaf=;; esac && [ \"$(printf %s \"$tkr_common$tkr_gitdir$tkr_leaf\" | LC_ALL=C tr -d '\\0-\\37\\177\"\\\\')\" = \"$tkr_common$tkr_gitdir$tkr_leaf\" ] && { [ \"$tkr_gitdir\" = \"$tkr_common\" ] || printf '\"%s\",' \"$tkr_gitdir\"; } && printf '\"%s/objects\"' \"$tkr_common\" && { [ -z \"$tkr_leaf\" ] || printf ',\"%s/%s\",\"%s/logs/%s\"' \"$tkr_common\" \"$tkr_leaf\" \"$tkr_common\" \"$tkr_leaf\"; })]\"";
8
+ export declare function renderCodexGitGrant(ctx?: Partial<WorkerLaunchContext>): string;
9
9
  export declare const CODEX_TRUST_DIALOG: TrustDialog;
10
10
  export declare const CODEX_INPUT_BOX: import("./types.js").InputBox;
11
11
  export declare function seedCodexTrust(repoRoot: string, configPath?: string): TrustVerdict;
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { checkedAuthority } from "../run/git-trust.js";
4
5
  import { probeVersion } from "./claude-code.js";
5
6
  import { parseWorkerResult } from "./prompt.js";
6
7
  import { channelsFromConfig, declareInputBox, MODEL_ID_RE, promptFitsArgv, shq, TokenUsageSchema } from "./types.js";
@@ -100,32 +101,37 @@ function codexRetirement(upgrade) {
100
101
  // the successor id comes from an external file and is rendered in lints — same charset gate as slugs
101
102
  return typeof u.model === "string" && MODEL_ID_RE.test(u.model) ? { retiresAt: u.retirement_at, successor: u.model } : undefined;
102
103
  }
103
- // tickmarkr worktrees keep their gitdir under the MAIN repo's .git/worktrees/<name> — outside the
104
- // workspace-write sandbox root — so git commit dies on index.lock (incident run-20260709-104447
105
- // P87-08: "Unable to create …/.git/worktrees/…/index.lock"). Expanded by the launching shell at the
106
- // worktree cwd, for Codex WORKERS only: v2.6.7 T4 (D-1046) — invoke() (the headless worker form, including
107
- // the interactive size fallback) and interactiveCommand carry it; headlessCommand, which judge, review,
108
- // consult and the model probe launch, carries no writable_roots at all (those seats never commit).
109
- // v2.6.6 T9 (K, D-912) → v2.8.0 T1 (rows 17/18, D-910): the grant is a LEAF of what a linked-worktree branch
110
- // commit writes, never the shared hooks/config (the shipped [common] and D-910's [common, gitdir] let a seat
111
- // write <common>/hooks and <common>/config, which unsandboxed git later runs) and never whole common/refs and
112
- // common/logs (which let a seat move main, any branch or tag, or the stash). The closed root table, in order:
113
- // - the worktree's own gitdir, only when it differs from the common dir (an ordinary checkout gets none);
114
- // - <common>/objects;
115
- // - only when HEAD is a symbolic ref refs/heads/<B>: <common>/refs/heads/<D> and <common>/logs/refs/heads/<D>,
116
- // <D> being <B> without its last slash-separated segment (refs/heads itself when <B> holds no slash);
117
- // a detached HEAD gets no refs or reflog root.
118
- // A directory outside any repository, any failed git read, or any root holding a character of the closed
119
- // forbidden set (U+0000 to U+001F, U+007F DEL, the double quote, the backslash) yields the EMPTY array `[]`:
120
- // no git write root, so a commit fails closed — never a partial array, an escaped string or codex's
121
- // literal-string fallback. Every emitted root is therefore a TOML basic string that parses back to the exact
122
- // path (git's own trailing newline is stripped through a sentinel, never a path's). No common-root fallback;
123
- // packed-refs is not granted (packed-ref ops fail closed); stash, tags, notes and branches outside the leaf
124
- // are denied. Declared (queue row 151): sibling refs in the same leaf (other task branches, the run's integration
125
- // branch under the default prefix) stay writable; since 2.8.1 a write to the integration ref parks the merge and is
126
- // never published.
127
- // doctor's probe (codex-commit-check.ts) executes these exact bytes; it never rebuilds the grant.
128
- export const CODEX_GIT_GRANT = `-c "sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir && echo /) && tkr_gitdir=$(git rev-parse --absolute-git-dir && echo /) && tkr_common=\${tkr_common%?/} && tkr_gitdir=\${tkr_gitdir%?/} && { tkr_head=$(git symbolic-ref -q HEAD); [ $? -le 1 ]; } && case $tkr_head in (refs/heads/*) tkr_leaf=\${tkr_head%/*};; (*) tkr_leaf=;; esac && [ "$(printf %s "$tkr_common$tkr_gitdir$tkr_leaf" | LC_ALL=C tr -d '\\0-\\37\\177"\\\\')" = "$tkr_common$tkr_gitdir$tkr_leaf" ] && { [ "$tkr_gitdir" = "$tkr_common" ] || printf '"%s",' "$tkr_gitdir"; } && printf '"%s/objects"' "$tkr_common" && { [ -z "$tkr_leaf" ] || printf ',"%s/%s","%s/logs/%s"' "$tkr_common" "$tkr_leaf" "$tkr_common" "$tkr_leaf"; })]"`;
104
+ // Queue row 2 item a (D-2112, D-2114): tickmarkr renders literal roots from the checked authority,
105
+ // never a launch-shell git lookup. The worker's common dir must be the run repository's trusted one.
106
+ // Closed table: gitdir only when distinct, common objects, and the assigned branch's ref/reflog leaf
107
+ // only while its HEAD still names that branch. Ordinary checkouts omit gitdir; packed-refs stays denied.
108
+ // Any refusal, absent context or forbidden byte yields []; permitted bytes survive TOML and shq exactly.
109
+ // Queue row 151: sibling refs inside the assigned leaf remain writable.
110
+ export function renderCodexGitGrant(ctx) {
111
+ let roots = [];
112
+ try {
113
+ if (!ctx?.cwd || !ctx.branch || !ctx.repoRoot)
114
+ throw new Error("missing launch context");
115
+ const authority = checkedAuthority(ctx.cwd);
116
+ const run = checkedAuthority(ctx.repoRoot);
117
+ if (!authority || !run || authority.common !== run.common)
118
+ throw new Error("foreign git authority");
119
+ const { gitdir, common } = authority;
120
+ roots = [...(gitdir === common ? [] : [gitdir]), `${common}/objects`];
121
+ const head = readFileSync(join(gitdir, "HEAD"), "utf8");
122
+ if (head === `ref: refs/heads/${ctx.branch}\n` || head === `ref: refs/heads/${ctx.branch}`) {
123
+ const ref = `refs/heads/${ctx.branch}`;
124
+ const leaf = ref.slice(0, ref.lastIndexOf("/"));
125
+ roots.push(`${common}/${leaf}`, `${common}/logs/${leaf}`);
126
+ }
127
+ if (roots.some(root => /[\x00-\x1f\x7f"\\]/.test(root)))
128
+ roots = [];
129
+ }
130
+ catch {
131
+ roots = [];
132
+ }
133
+ return `-c ${shq(`sandbox_workspace_write.writable_roots=[${roots.map(root => `"${root}"`).join(",")}]`)}`;
134
+ }
129
135
  // OBS-125: codex 0.144.x gates enabled hooks behind PER-PROJECT-PATH hook trust — a NEW gate distinct
130
136
  // from the directory trust seedCodexTrust seeds. Every worker runs in a fresh ephemeral worktree, an
131
137
  // untrusted project, so codex re-prompts "Hooks need review" and the interactive worker stalls forever
@@ -275,7 +281,7 @@ export function codexMcpSuppressionFlags(configPath) {
275
281
  }
276
282
  return flags.join(" ");
277
283
  }
278
- // The one headless form: `grant` is "" for every non-worker seat and ` ${CODEX_GIT_GRANT}` for the worker.
284
+ // Queue row 2 (D-2112): non-worker seats omit the grant; workers carry the rendered argument.
279
285
  const codexExec = (promptFile, model, effort, grant) => `codex exec --sandbox workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()}${grant}${codexEffortFlag(effort)} --model ${shq(model)} - < ${shq(promptFile)}`;
280
286
  export const codex = {
281
287
  id: "codex",
@@ -286,7 +292,7 @@ export const codex = {
286
292
  probe: async () => probeVersion("codex"),
287
293
  channels: (cfg) => channelsFromConfig("codex", cfg),
288
294
  // v1.65 T3: every flag the command builder below hardcodes (incl. codexMcpSuppressionFlags' -c/
289
- // --disable and CODEX_GIT_GRANT's -c) — all listed by top-level `codex --help`, verified 2026-07-22.
295
+ // --disable and renderCodexGitGrant's -c) — all listed by top-level `codex --help`, verified 2026-07-22.
290
296
  hardcodedFlags: { binary: "codex", flags: ["--sandbox", "-a", "-s", "--model", "-c", "--disable", "--dangerously-bypass-hook-trust"] },
291
297
  // --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
292
298
  // MCP suppression built per dispatch (config can change between runs) — see codexMcpSuppressionFlags.
@@ -304,11 +310,11 @@ export const codex = {
304
310
  // autonomous approval policy (exec has no approvals to configure).
305
311
  // OBS-930 (Linux): the inlined prompt is ONE argv string and Linux caps one at 131072 bytes, so a
306
312
  // prompt over promptArgvCeiling() returns null → worker-mode-fallback → the headless form (types.ts).
307
- interactiveCommand: (promptFile, model, effort) => promptFitsArgv(promptFile)
308
- ? `codex -a never -s workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()} ${CODEX_GIT_GRANT}${codexEffortFlag(effort)} --model ${shq(model)} "$(cat ${shq(promptFile)})"`
313
+ interactiveCommand: (promptFile, model, effort, ctx) => promptFitsArgv(promptFile)
314
+ ? `codex -a never -s workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()} ${renderCodexGitGrant(ctx)}${codexEffortFlag(effort)} --model ${shq(model)} "$(cat ${shq(promptFile)})"`
309
315
  : null,
310
- invoke(task, _cwd, a, ctx) {
311
- return { command: codexExec(ctx.promptFile, a.model, a.effort, ` ${CODEX_GIT_GRANT}`) };
316
+ invoke(task, cwd, a, ctx) {
317
+ return { command: codexExec(ctx.promptFile, a.model, a.effort, ` ${renderCodexGitGrant({ ...ctx, cwd })}`) };
312
318
  },
313
319
  parse: parseWorkerResult,
314
320
  // v1.22 T5: seed [projects."<repoRoot>"] trust_level="trusted" so fresh worktrees never stall on
@@ -1,8 +1,8 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { existsSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
5
4
  import { filesGlob } from "../graph/files-glob.js";
5
+ import { gitSync } from "../run/git.js";
6
6
  import { piModelVendor } from "./pi.js";
7
7
  import { buildTaskPrompt } from "./prompt.js";
8
8
  import { channelKey, MODEL_ID_RE } from "./types.js";
@@ -249,7 +249,8 @@ const GLOB_CHARS = /[*?{[]/;
249
249
  // and `-z` is load-bearing correctness, not economy: without it git quotes any non-ASCII path, so
250
250
  // every Arabic/UTF-8 tracked file read as absent.
251
251
  function baseTreeAtHead(dir) {
252
- const git = (...args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
252
+ // queue row 2 item b: protected git; a trust refusal throws instead of reading as "no base tree"
253
+ const git = (...args) => gitSync(dir, args);
253
254
  const top = git("rev-parse", "--show-toplevel");
254
255
  const tree = git("ls-tree", "--full-tree", "-r", "-l", "-z", "HEAD");
255
256
  if (top.status !== 0 || tree.status !== 0 || typeof top.stdout !== "string" || typeof tree.stdout !== "string")
@@ -287,7 +288,7 @@ function measureBytes(tree, rel) {
287
288
  return covered ? total : undefined;
288
289
  }
289
290
  function absentContextReason(root, path) {
290
- if (spawnSync("git", ["-C", root, "check-ignore", "-q", "--", path]).status === 0)
291
+ if (gitSync(root, ["check-ignore", "-q", "--", path]).status === 0)
291
292
  return "gitignored";
292
293
  return existsSync(join(root, path)) ? "untracked" : "absent";
293
294
  }
@@ -187,6 +187,12 @@ export declare class SettledTrailerTracker {
187
187
  constructor(markers: readonly BusyFrameMarker[]);
188
188
  sample(paneText: string, trailer: boolean): boolean;
189
189
  }
190
+ /** Queue row 2 item a (D-2112, D-2114): context owned by the task dispatcher. */
191
+ export interface WorkerLaunchContext {
192
+ cwd: string;
193
+ branch: string;
194
+ repoRoot: string;
195
+ }
190
196
  export interface WorkerAdapter {
191
197
  id: string;
192
198
  vendor: string;
@@ -197,13 +203,15 @@ export interface WorkerAdapter {
197
203
  headlessCommand(promptFile: string, model: string, effort?: Effort): string;
198
204
  harnessBannerRows?: readonly string[];
199
205
  paneReviewBuffersOutput?: boolean;
200
- interactiveCommand(promptFile: string, model: string, effort?: Effort): string | null;
206
+ interactiveCommand(promptFile: string, model: string, effort?: Effort, ctx?: WorkerLaunchContext): string | null;
201
207
  interactiveSeed?: InteractiveSeed;
202
208
  resumeCommand?(sessionId: string, promptFile: string, model: string, effort?: Effort): string;
203
209
  sessionIdFrom?(output: string): string | undefined;
204
210
  resumeUnknownContext?: boolean;
205
211
  invoke(task: Task, cwd: string, a: Assignment, ctx: {
206
212
  promptFile: string;
213
+ branch?: string;
214
+ repoRoot?: string;
207
215
  }): Invocation;
208
216
  parse(output: string, nonce: string): WorkerResult;
209
217
  listModels?(): Promise<string[]>;
@@ -1,11 +1,11 @@
1
1
  import { graphDefinitionHash, loadGraph, saveGraph, taskDefinitionFingerprint } from "../../graph/graph.js";
2
2
  import { filesGlob } from "../../graph/files-glob.js";
3
- import { execFileSync } from "node:child_process";
4
3
  import { userInfo } from "node:os";
5
4
  import { allAdapters } from "../../adapters/registry.js";
6
5
  import { channelKey } from "../../adapters/types.js";
7
6
  import { loadConfig } from "../../config/config.js";
8
7
  import { integrationBranch } from "../../run/merge.js";
8
+ import { gitSync } from "../../run/git.js";
9
9
  import { integrationAnchor } from "../../run/daemon.js";
10
10
  import { separabilityErrors, surfaceErrors, taskBudgetErrors } from "../../compile/collateral.js";
11
11
  import { GATE_NAMES } from "../../graph/schema.js";
@@ -444,15 +444,10 @@ function mergedSiblingExempt(cwd, events, status, siblingId, requesterRef) {
444
444
  if (commit === undefined || !FULL_OBJECT_ID.test(commit))
445
445
  return false;
446
446
  const ref = requesterRef();
447
- try {
448
- execFileSync("git", ["rev-parse", "--verify", ref], { cwd, stdio: "ignore" });
449
- execFileSync("git", ["merge-base", "--is-ancestor", commit, ref], { cwd, stdio: "ignore" });
450
- }
451
- catch {
452
- return false;
453
- }
454
- return true;
447
+ return gitOk(cwd, "rev-parse", "--verify", ref) && gitOk(cwd, "merge-base", "--is-ancestor", commit, ref);
455
448
  }
449
+ /** Queue row 2 item b: approve's git runs through the protected path; a trust refusal throws, never reads as false. */
450
+ const gitOk = (cwd, ...args) => gitSync(cwd, args).status === 0;
456
451
  /** The conflicting unordered pairs of separabilityErrors, in its exact emission order. */
457
452
  function separabilityPairs(tasks) {
458
453
  const byId = new Map(tasks.map((t) => [t.id, t.deps ?? []]));
@@ -531,27 +526,13 @@ function failedTaskCommitsAhead(cwd, journal, runId, taskId) {
531
526
  const preservedRef = preserved?.data.ref;
532
527
  let ref = taskRef;
533
528
  if (preservedRef) {
534
- try {
535
- execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
536
- execFileSync("git", ["merge-base", "--is-ancestor", taskRef, preservedRef], { cwd, stdio: "ignore" });
529
+ if (gitOk(cwd, "rev-parse", "--verify", taskRef) && gitOk(cwd, "merge-base", "--is-ancestor", taskRef, preservedRef))
530
+ ref = preservedRef;
531
+ else if (!gitOk(cwd, "rev-parse", "--verify", taskRef))
537
532
  ref = preservedRef;
538
- }
539
- catch {
540
- try {
541
- execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
542
- }
543
- catch {
544
- ref = preservedRef;
545
- }
546
- }
547
- }
548
- try {
549
- const out = execFileSync("git", ["rev-list", "--count", `${integration.base}..${ref}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
550
- return { ref, base, count: Number.parseInt(out.trim(), 10) || 0 };
551
- }
552
- catch {
553
- return { ref, base, count: 0 };
554
533
  }
534
+ const out = gitSync(cwd, ["rev-list", "--count", `${integration.base}..${ref}`]);
535
+ return { ref, base, count: out.status === 0 ? Number.parseInt(out.stdout.trim(), 10) || 0 : 0 };
555
536
  }
556
537
  // The status is command OUTPUT, not a typed sibling result: the registered approve function returns
557
538
  // one primitive string and the dispatcher prints those bytes unchanged. A compact sentinel followed
@@ -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";
@@ -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();
@@ -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,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)
@@ -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
  }
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { execSync } from "node:child_process";
3
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
3
  import { dirname, join, relative, resolve } from "node:path";
5
4
  import { tmpdir } from "node:os";
6
5
  import { checkoutIncarnation, describeCapacity, inventoryDependencyLinks, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
7
6
  import { shq } from "../adapters/types.js";
7
+ import { trustedCommonDir } from "../run/git-trust.js";
8
8
  import { titleEnvironment } from "../run/title-environment.js";
9
9
  export const DEFAULT_VERDICT_CACHE_BOUND = 128;
10
10
  let testCacheBound;
@@ -269,9 +269,7 @@ export function resolveStateDir(worktree, artifactDir) {
269
269
  return join(worktree, ".tickmarkr");
270
270
  }
271
271
  try {
272
- const commonDir = execSync("git rev-parse --git-common-dir", { cwd: worktree, encoding: "utf8" }).trim();
273
- const resolved = resolve(worktree, commonDir);
274
- const commonRoot = realpathSync(dirname(resolved));
272
+ const commonRoot = realpathSync(dirname(trustedCommonDir(worktree)));
275
273
  if (existsSync(join(commonRoot, ".tickmarkr"))) {
276
274
  return join(commonRoot, ".tickmarkr");
277
275
  }
@@ -118,7 +118,9 @@ export const RunGraphSchema = z
118
118
  source: z.enum(SPEC_SOURCES),
119
119
  paths: z.array(z.string()),
120
120
  hash: z.string(),
121
- // Q11 compile half: an author-declared ref only. Runtime Git resolution/enforcement is later.
121
+ // Q11 / queue row 220: an author-declared ref, proven at run and resume start against the run's
122
+ // starting commit (HEAD for a run, the recorded run-start base for a resume) by ancestry or patch-id;
123
+ // drifted or unresolvable, the run or resume is refused before it writes anything. Never rebased onto.
122
124
  base: z.string().min(1).optional(),
123
125
  }),
124
126
  tasks: z.array(TaskSchema).min(1),
@@ -34,7 +34,7 @@ import { executionSignal, remainingExecutionMs, withExecutionBudget, withoutExec
34
34
  import { repairSelectionDecision, repairSelectionEnabled } from "./repair-selection.js";
35
35
  import { failureDisposition, reserveInfrastructureRetry } from "./recovery.js";
36
36
  import { runEnvironment } from "./environment.js";
37
- import { addDetachedWorktree, changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, reflogWitness, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shell, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
37
+ import { addDetachedWorktree, changeRepresented, cleanupRunWorktrees, declaredBaseContainment, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, reflogWitness, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shell, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
38
38
  import { MASK } from "./redact.js";
39
39
  import { runInteractiveSeed } from "./interactive-seed.js";
40
40
  import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
@@ -1145,6 +1145,48 @@ export function integrationAnchor(events) {
1145
1145
  return { unreadable: `the newest merge row's from is ${show(merge.data.from)}, not an object id` };
1146
1146
  return { anchor: merge.data.commit };
1147
1147
  }
1148
+ /**
1149
+ * Queue row 220 (D-2111): prove the spec's declared base against the commit this run starts from — the
1150
+ * checkout HEAD for a fresh run (recorded as its run-start baseRef), the recorded run-start baseRef for a
1151
+ * resume, never the checkout's current HEAD: a resume never re-bases, and no run rebases onto the declared
1152
+ * ref. Drift and an unresolvable answer throw; runDaemon calls this before it writes anything.
1153
+ */
1154
+ async function proveDeclaredBase(repoRoot, runId, ref, resume) {
1155
+ const action = resume ? `resume ${runId}` : "run";
1156
+ const remedy = `Remedy: check out a branch that contains ${ref}, or correct base: in the spec and recompile.`;
1157
+ const unresolvable = (against, why) => new Error(`refusing to ${action}: declared base ${ref} is unresolvable against ${against} — ${why}. ${remedy}`);
1158
+ let start;
1159
+ let against;
1160
+ if (resume) {
1161
+ const recorded = Journal.open(repoRoot, runId).read().find((e) => e.event === "run-start")?.data.baseRef;
1162
+ if (typeof recorded !== "string" || !recorded)
1163
+ throw unresolvable("the recorded run-start base", "the journal's run-start records no baseRef");
1164
+ start = recorded;
1165
+ against = `the recorded run-start base ${short(start)}`;
1166
+ }
1167
+ else {
1168
+ const head = await shGit("git rev-parse --verify HEAD^{commit}", repoRoot);
1169
+ if (head.code !== 0)
1170
+ throw unresolvable("HEAD", `git said for HEAD: ${(head.stderr || head.stdout).trim()}`);
1171
+ start = head.stdout.trim();
1172
+ against = `HEAD ${short(start)}`;
1173
+ }
1174
+ const probe = await declaredBaseContainment(repoRoot, ref, start);
1175
+ if (probe.result === "contained")
1176
+ return { ref, via: probe.via, start };
1177
+ if (probe.result === "unresolvable")
1178
+ throw unresolvable(against, `git said for ${probe.ref}: ${probe.evidence}`);
1179
+ const lines = await Promise.all(probe.missing.map(async (m) => {
1180
+ const subject = await shGit(`git log -1 --format=%s ${shq(m.commit)}`, repoRoot);
1181
+ // a drift refusal must name the subject: an unreadable one refuses as unresolvable, quoting git
1182
+ if (subject.code !== 0)
1183
+ throw unresolvable(against, `git said for missing commit ${short(m.commit)}: ${(subject.stderr || subject.stdout).trim()}`);
1184
+ return ` ${short(m.commit)} ${subject.stdout.trim()}${m.merge ? " (merge)" : ""}`
1185
+ + ` — ${m.paths.length > 0 ? m.paths.join(", ") : "no paths"}`;
1186
+ }));
1187
+ throw new Error(`refusing to ${action}: declared base ${ref} has drifted from ${against} — `
1188
+ + `${probe.missing.length} declared commit(s) missing:\n${lines.join("\n")}\n${remedy}`);
1189
+ }
1148
1190
  /** The ref legs' details: the leg, both commits, the symbolic target and the repair text. */
1149
1191
  export function identityDetails(leg, state, branch, runId, expected) {
1150
1192
  const found = state.kind === "regular" || state.kind === "symbolic" ? state.commit : undefined;
@@ -2105,6 +2147,12 @@ export async function runDaemon(repoRoot, opts = {}) {
2105
2147
  let baselineFailed = false;
2106
2148
  try {
2107
2149
  graph = loadGraph(repoRoot);
2150
+ // Queue row 220 (D-2111): a declared base is proven HERE — before any journal row (lock-reclaimed and
2151
+ // exit-cause included), the resume anchor checks, a worktree, the integration ref or baseline capture —
2152
+ // so a refusal leaves nothing; the finally releases the lock and repository claim. No base: unchanged.
2153
+ const declaredBase = graph.spec.base === undefined ? undefined
2154
+ : await proveDeclaredBase(repoRoot, runId, graph.spec.base, opts.resume === true);
2155
+ const declaredBaseRow = declaredBase ? { declaredBase: { ref: declaredBase.ref, via: declaredBase.via } } : {};
2108
2156
  // One bounded snapshot supplies every dispatch. On resume the current journal still participates
2109
2157
  // in the fold so its task-done/ordinary-approval events can retire older evidence, but findings it
2110
2158
  // produced are suppressed: same-run feedback already replays those bytes and must not deliver them
@@ -2943,6 +2991,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2943
2991
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
2944
2992
  ...(opts.retryFailed ? { retryFailed: true } : {}),
2945
2993
  ...(opts.graphChanged ? { graphChanged: true } : {}), // OBS-1073: the release is the engagement's
2994
+ ...declaredBaseRow,
2946
2995
  });
2947
2996
  runStarted = true;
2948
2997
  await placeBoard();
@@ -2993,7 +3042,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2993
3042
  }
2994
3043
  }
2995
3044
  else {
2996
- baseRef = await gitHead(repoRoot);
3045
+ baseRef = declaredBase?.start ?? await gitHead(repoRoot); // row 220: the proven commit is the run's base
2997
3046
  journal.append("baseline-start", undefined, { baseRef, commands, capacity: resolvedCapacity() });
2998
3047
  await placeBoard();
2999
3048
  baselinePending = true;
@@ -3034,6 +3083,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3034
3083
  branch, graphDefinitionHash: graphDefinitionHash(graph),
3035
3084
  integrationAnchor: "journal", // 2.8.1 T2: this run creates its branch at baseRef and journals every publication with `from`
3036
3085
  ...(prior ? { supersedes: prior.runId } : {}),
3086
+ ...declaredBaseRow,
3037
3087
  }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2; supersedes: v1.53 T5
3038
3088
  runStarted = true;
3039
3089
  // v1.53 T5: mark the prior run AFTER this run's run-start exists, so the prior journal never
@@ -5186,7 +5236,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5186
5236
  const icmd = retryMode === "resume"
5187
5237
  ? adapter.resumeCommand(sessionId, promptFile, assignment.model, assignment.effort)
5188
5238
  : cfg.visibility.worker === "interactive" && driver.interactive
5189
- ? adapter.interactiveCommand(promptFile, assignment.model, assignment.effort)
5239
+ ? adapter.interactiveCommand(promptFile, assignment.model, assignment.effort, { cwd: wt, branch: taskBranch, repoRoot })
5190
5240
  : null;
5191
5241
  // v1.69 T6: adapters that declare interactiveSeed launch the real TUI and inject the prompt as a
5192
5242
  // user turn; they do NOT need the argv-seeding surface that interactiveCommand represents.
@@ -5203,7 +5253,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5203
5253
  // timing can never interleave a `$(…)` with what follows it (the codex corruption class).
5204
5254
  const workerCmd = interactive
5205
5255
  ? (hasSeed ? ":" : icmd)
5206
- : adapter.invoke(t, wt, assignment, { promptFile }).command;
5256
+ : adapter.invoke(t, wt, assignment, { promptFile, branch: taskBranch, repoRoot }).command;
5207
5257
  const dispatchScript = promptFile.replace(/\.md$/, ".sh");
5208
5258
  // Approval can reset the attempt counter while the old pane remains retained.
5209
5259
  // Its ownership claim must survive a new engagement reusing the script path.
@@ -4,11 +4,21 @@ export declare class GitTrustRefusal extends Error {
4
4
  readonly detail: string;
5
5
  constructor(path: string, detail: string, options?: ErrorOptions);
6
6
  }
7
+ interface Authority {
8
+ gitdir: string;
9
+ common: string;
10
+ linked: boolean;
11
+ }
7
12
  /**
8
13
  * The canonical, independently expected common git directory for the checkout enclosing `cwd` — never what
9
14
  * a mutable commondir says. Throws GitTrustRefusal naming the authority path when it cannot be established.
10
15
  */
11
16
  export declare function trustedCommonDir(cwd: string): string;
17
+ /** The checked authority for `cwd` (see assertGitTrust), with the canonical worktree root the gitfile sits in. */
18
+ export declare function checkedAuthority(cwd: string): (Authority & {
19
+ worktree: string;
20
+ stamp?: string;
21
+ }) | undefined;
12
22
  /**
13
23
  * The uncached pre-spawn check for a covered git invocation at `cwd`. Outside any checkout it is a no-op
14
24
  * (unrelated shell commands stay compatible). Inside one it refuses, naming the file, when the commondir git
@@ -119,3 +129,4 @@ export declare const INERT_HOOKS_PATH = "/dev/null";
119
129
  * children (shGit/shGitOk) receive it; payload shells (gate commands, suites) keep the operator's git config.
120
130
  */
121
131
  export declare function protectedGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
132
+ export {};
@@ -151,7 +151,7 @@ catch {
151
151
  return false;
152
152
  } };
153
153
  /** The checked authority for `cwd` (see assertGitTrust), with the canonical worktree root the gitfile sits in. */
154
- function checkedAuthority(cwd) {
154
+ export function checkedAuthority(cwd) {
155
155
  const dotGit = dotGitOf(cwd);
156
156
  if (!dotGit)
157
157
  return undefined;
package/dist/run/git.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CommandReceiptAttribution, ShellReceipt } from "./protocol.js";
2
- import { spawn } from "node:child_process";
2
+ import { spawn, type SpawnSyncReturns } from "node:child_process";
3
3
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
4
4
  import { type OwnGitCapability } from "./git-trust.js";
5
5
  export { ROUTING_ENV_SEAMS };
@@ -187,6 +187,18 @@ export declare function sh(cmd: string, cwd: string, timeoutMs?: number, options
187
187
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number, own?: OwnGitOptions, options?: Pick<ShellOptions, "bytes">): Promise<ShResult>;
188
188
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
189
189
  export declare function shGitOk(cmd: string, cwd: string, own?: OwnGitOptions): Promise<string>;
190
+ /**
191
+ * Queue row 2 item b (D-1042, D-2112, D-2114): the SYNCHRONOUS own-git entry for operator commands — argv, no shell.
192
+ * It applies what shGit's own-git entry applies, from the same sources: the uncached trust check and, in a linked
193
+ * checkout, ownGitPin's pin, post-exit recheck and ref-view release, each derived from `dir` — the directory git
194
+ * targets (a site's -C directory), never the caller's cwd — and protectedGitEnv's forced core.fsmonitor=false and
195
+ * inert core.hooksPath appended last. A trust refusal throws GitTrustRefusal naming the path (before the spawn, or
196
+ * after it for a commondir changed while the child ran); every other outcome is spawnSync's own result, so a caller's
197
+ * fail-open answer for an ordinary git failure is unchanged.
198
+ */
199
+ export declare function gitSync(dir: string, args: readonly string[], { env }?: {
200
+ env?: NodeJS.ProcessEnv;
201
+ }): SpawnSyncReturns<string>;
190
202
  /**
191
203
  * v2.6.8 T2: the DETACHED worktree capability, declared by calling it (the daemon's baseline recapture and standalone
192
204
  * verify's base worktree). Under the ref-store pin git stubs a detached add's new HEAD as `ref: refs/heads/.invalid`
package/dist/run/git.js CHANGED
@@ -2,7 +2,7 @@ import { observeVerificationReceipt, observeVerificationSpawn, verificationJobEn
2
2
  import { executionSignal, withoutExecutionBudget } from "./execution-budget.js";
3
3
  import { commandLeaseEnvironment, repositoryLeaseEnvironment, registerRepositoryChild, withCommandLease } from "./lease.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
- import { execFile, execFileSync, spawn } from "node:child_process";
5
+ import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
6
6
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
7
7
  import { availableParallelism, homedir, tmpdir } from "node:os";
8
8
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -600,6 +600,27 @@ export async function shGitOk(cmd, cwd, own = {}) {
600
600
  throw new Error(`command failed (${r.code}): ${cmd}\n${r.stderr || r.stdout}`);
601
601
  return r.stdout;
602
602
  }
603
+ /**
604
+ * Queue row 2 item b (D-1042, D-2112, D-2114): the SYNCHRONOUS own-git entry for operator commands — argv, no shell.
605
+ * It applies what shGit's own-git entry applies, from the same sources: the uncached trust check and, in a linked
606
+ * checkout, ownGitPin's pin, post-exit recheck and ref-view release, each derived from `dir` — the directory git
607
+ * targets (a site's -C directory), never the caller's cwd — and protectedGitEnv's forced core.fsmonitor=false and
608
+ * inert core.hooksPath appended last. A trust refusal throws GitTrustRefusal naming the path (before the spawn, or
609
+ * after it for a commondir changed while the child ran); every other outcome is spawnSync's own result, so a caller's
610
+ * fail-open answer for an ordinary git failure is unchanged.
611
+ */
612
+ export function gitSync(dir, args, { env = process.env } = {}) {
613
+ const forced = protectedGitEnv(env);
614
+ const pin = ownGitPin(dir, forced);
615
+ try {
616
+ const r = spawnSync("git", args, { cwd: dir, env: pin?.env ?? forced, encoding: "utf8", maxBuffer: 1 << 28, stdio: ["ignore", "pipe", "pipe"] });
617
+ pin?.recheck();
618
+ return pin && typeof r.stdout === "string" ? { ...r, stdout: pin.unview(r.stdout), stderr: pin.unview(r.stderr) } : r;
619
+ }
620
+ finally {
621
+ pin?.release();
622
+ }
623
+ }
603
624
  /**
604
625
  * v2.6.8 T2: the DETACHED worktree capability, declared by calling it (the daemon's baseline recapture and standalone
605
626
  * verify's base worktree). Under the ref-store pin git stubs a detached add's new HEAD as `ref: refs/heads/.invalid`
@@ -42,7 +42,14 @@ export declare const bindingToken: (binding: DecisionBinding) => string;
42
42
  export declare function parseBindingToken(token: string): DecisionBinding | undefined;
43
43
  /** The binding a task-approved row recorded under `park` or `failure`, when well formed. */
44
44
  export declare function recordedBinding(value: unknown): DecisionBinding | undefined;
45
- /** The newest failed gate before a park row — the gate a waive of that park would satisfy. */
45
+ /**
46
+ * The newest failed gate before a park row — the gate a waive of that park would satisfy.
47
+ * Queue row 225 (D-2111): a gate-fail park with no task-dispatch, gate-result (passing or failing) or
48
+ * unvoided task-approved row of its task since its previous task-human — a resume's re-park at the same
49
+ * review round cap — inherits that park's failed gate, the walk continuing past it. A task-approved row
50
+ * an approval-refused row of the task voids before the newer park (its lines name it, or it names none)
51
+ * does not end the walk; refusals are read here directly, never through foldDecisions (which calls this).
52
+ */
46
53
  export declare function failedGateBeforePark(events: readonly JournalEvent[], taskId: string, parkIndex: number): GateName | undefined;
47
54
  /** The physical journal line of `events[i]` (see SOURCE_LINE). */
48
55
  export declare const physicalLine: (events: readonly JournalEvent[], i: number) => number;
@@ -96,6 +103,13 @@ export interface PreservedRef {
96
103
  export declare function preservedRefsByTask(events: JournalEvent[]): Map<string, PreservedRef[]>;
97
104
  export declare function reviewRoundsSinceApproval(events: JournalEvent[], taskId: string, rounds?: (decided: JournalEvent[]) => JournalEvent[]): number;
98
105
  export declare function upheldFeedbackByTask(events: JournalEvent[]): Map<string, string>;
106
+ /**
107
+ * Queue row 225 (D-2111): a restore names upheld feedback only when it is FUNDED — the task carries an
108
+ * active uphold brief AND an effective task-approved row newer than its newest task-human. A restore
109
+ * that only re-parks at the same cap announces nothing, so a spent uphold is never re-announced.
110
+ * `decided` is already read through the decision fold (effectiveEvents).
111
+ */
112
+ export declare function restoresUpheldFeedback(decided: JournalEvent[], taskId: string): boolean;
99
113
  export interface StructuredFinding {
100
114
  class: string;
101
115
  path: string;
@@ -97,17 +97,44 @@ export function recordedBinding(value) {
97
97
  const { line, ts } = value;
98
98
  return typeof line === "number" && Number.isSafeInteger(line) && line > 0 && typeof ts === "string" ? { line, ts } : undefined;
99
99
  }
100
- /** The newest failed gate before a park row — the gate a waive of that park would satisfy. */
100
+ /**
101
+ * The newest failed gate before a park row — the gate a waive of that park would satisfy.
102
+ * Queue row 225 (D-2111): a gate-fail park with no task-dispatch, gate-result (passing or failing) or
103
+ * unvoided task-approved row of its task since its previous task-human — a resume's re-park at the same
104
+ * review round cap — inherits that park's failed gate, the walk continuing past it. A task-approved row
105
+ * an approval-refused row of the task voids before the newer park (its lines name it, or it names none)
106
+ * does not end the walk; refusals are read here directly, never through foldDecisions (which calls this).
107
+ */
101
108
  export function failedGateBeforePark(events, taskId, parkIndex) {
109
+ let inherits = events[parkIndex]?.data.kind === "gate-fail";
110
+ let refused = new Set();
102
111
  for (let i = parkIndex - 1; i >= 0; i -= 1) {
103
112
  const event = events[i];
104
113
  if (event.taskId !== taskId)
105
114
  continue;
106
- if (event.event === "task-approved" || event.event === "task-human" || event.event === "task-dispatch")
115
+ if (event.event === "task-human") {
116
+ // Only a gate-fail predecessor has a failed gate to hand down; any other kind ends the walk as today.
117
+ if (!inherits || event.data.kind !== "gate-fail")
118
+ return undefined;
119
+ // The walk now reads the previous park's own span, under that park's own refusals.
120
+ refused = new Set();
121
+ }
122
+ else if (event.event === APPROVAL_REFUSED) {
123
+ if (refused !== "all")
124
+ refused = Array.isArray(event.data.lines) ? new Set([...refused, ...event.data.lines]) : "all";
125
+ }
126
+ else if (event.event === "task-approved") {
127
+ if (!inherits || (refused !== "all" && !refused.has(physicalLine(events, i))))
128
+ return undefined;
129
+ }
130
+ else if (event.event === "task-dispatch") {
107
131
  return undefined;
108
- if (event.event === "gate-result" && event.data.pass === false
109
- && typeof event.data.gate === "string" && GATE_NAMES.includes(event.data.gate)) {
110
- return event.data.gate;
132
+ }
133
+ else if (event.event === "gate-result") {
134
+ if (event.data.pass === false && typeof event.data.gate === "string" && GATE_NAMES.includes(event.data.gate)) {
135
+ return event.data.gate;
136
+ }
137
+ inherits = false; // a passing gate-result keeps the park's own span
111
138
  }
112
139
  }
113
140
  return undefined;
@@ -321,6 +348,16 @@ export function upheldFeedbackByTask(events) {
321
348
  }
322
349
  return upheld;
323
350
  }
351
+ /**
352
+ * Queue row 225 (D-2111): a restore names upheld feedback only when it is FUNDED — the task carries an
353
+ * active uphold brief AND an effective task-approved row newer than its newest task-human. A restore
354
+ * that only re-parks at the same cap announces nothing, so a spent uphold is never re-announced.
355
+ * `decided` is already read through the decision fold (effectiveEvents).
356
+ */
357
+ export function restoresUpheldFeedback(decided, taskId) {
358
+ const newest = (event) => decided.reduce((at, e, i) => e.taskId === taskId && e.event === event ? i : at, -1);
359
+ return newest("task-approved") > newest("task-human") && upheldFeedbackByTask(decided).has(taskId);
360
+ }
324
361
  const CONSULT_ACTIONS = ["retry", "reroute", "decompose", "human"];
325
362
  const consultAction = (action) => typeof action === "string" && CONSULT_ACTIONS.includes(action)
326
363
  ? action
@@ -2008,7 +2045,7 @@ export class Journal {
2008
2045
  const folded = !decisionRow && data.fatal === true ? [...priorEvents, { ts: new Date().toISOString(), event, data }] : priorEvents;
2009
2046
  return decisionRow ? reduced : { ...reduced, owedChecks: foldOwedChecks(folded, join(this.dir, "..", "..", "..")) };
2010
2047
  })()
2011
- : event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
2048
+ : event === "resume-restore" && rowTaskId && restoresUpheldFeedback(effectiveEvents(priorEvents), rowTaskId)
2012
2049
  ? {
2013
2050
  ...inputData,
2014
2051
  upheldFeedbackRestoredFor: rowTaskId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.8.5",
3
+ "version": "2.8.6",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",