tickmarkr 2.8.0 → 2.8.1

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.
package/dist/run/git.d.ts CHANGED
@@ -198,16 +198,29 @@ export declare function shGitOk(cmd: string, cwd: string, own?: OwnGitOptions):
198
198
  * A step raced by a commondir change is refused by name before the next one runs, and what the add created is rolled
199
199
  * back through the trusted common directory derived here once (dropWorktree), even while that commondir stays hostile.
200
200
  */
201
- export declare function addDetachedWorktree(cwd: string, dir: string, commitish: string, timeoutMs?: number): Promise<ShResult>;
201
+ export declare function addDetachedWorktree(cwd: string, dir: string, commitish: string, timeoutMs?: number, { logHead }?: {
202
+ logHead?: boolean | undefined;
203
+ }): Promise<ShResult>;
204
+ /**
205
+ * 2.8.1 T1 (ANL-023 §7.1): the reflog witness W(dir, ref) — read-only, the newest entry first. It is valid for a commit
206
+ * x only when the exit is 0, at least one line came back and the first is x: exit 0 with no lines is git's answer for
207
+ * a missing or unreadable log, so an empty witness is a refusal, never an accepted empty.
208
+ */
209
+ export declare function reflogWitness(dir: string, ref: string): Promise<{
210
+ code: number;
211
+ lines: string[];
212
+ }>;
202
213
  export declare function npmDependencyManifestChanged(cwd: string, baselineRef: string): Promise<boolean>;
203
214
  export declare function npmDependencyInstallCommand(cwd: string): string;
204
215
  export declare function gitHead(cwd: string): Promise<string>;
205
216
  export declare const sanitizeBranch: (branch: string) => string;
206
217
  export declare const WORKTREES_DIR = "worktrees.noindex";
207
218
  export declare const worktreePath: (repo: string, branch: string) => string;
208
- /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
219
+ /**
220
+ * OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
221
+ * integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
222
+ */
209
223
  export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
210
- removeIntegration: boolean;
211
224
  removeTaskIds: string[];
212
225
  }): Promise<void>;
213
226
  export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
package/dist/run/git.js CHANGED
@@ -607,7 +607,10 @@ export async function shGitOk(cmd, cwd, own = {}) {
607
607
  * A step raced by a commondir change is refused by name before the next one runs, and what the add created is rolled
608
608
  * back through the trusted common directory derived here once (dropWorktree), even while that commondir stays hostile.
609
609
  */
610
- export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
610
+ export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS, { logHead = false } = {}) {
611
+ // 2.8.1 T1 (ANL-023 §7.1): `logHead` writes the new checkout's HEAD reflog whatever the repository's
612
+ // core.logAllRefUpdates says, so a later HEAD move by any command is appended to an established witness.
613
+ const git = logHead ? "git -c core.logAllRefUpdates=always" : "git";
611
614
  // the authority this operation keeps, rollback included: derived once from the trusted layout, never a commondir
612
615
  const store = trustedCommonDir(cwd);
613
616
  const resolved = await shGit(`git rev-parse --verify --end-of-options ${shq(`${commitish}^{commit}`)}`, cwd, timeoutMs);
@@ -616,12 +619,12 @@ export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAU
616
619
  const oid = resolved.stdout.trim();
617
620
  // under a ref payload git also resolves its new-HEAD stub into a shared refs/heads/.invalid; it is deleted again
618
621
  const rollback = { dir, branch: { name: ".invalid" } };
619
- const added = await shellEntry(`git worktree add --no-checkout --detach ${shq(dir)} ${shq(oid)}`, cwd, timeoutMs, false, {}, { createsWorktree: "detached", rollback });
622
+ const added = await shellEntry(`${git} worktree add --no-checkout --detach ${shq(dir)} ${shq(oid)}`, cwd, timeoutMs, false, {}, { createsWorktree: "detached", rollback });
620
623
  if (added.code !== 0)
621
624
  return added;
622
625
  let repaired;
623
626
  try {
624
- repaired = await shGit(`git update-ref --no-deref HEAD ${shq(oid)} && git update-ref -d refs/heads/.invalid && git reset -q --hard --no-recurse-submodules`, dir, timeoutMs);
627
+ repaired = await shGit(`${git} update-ref --no-deref HEAD ${shq(oid)} && ${git} update-ref -d refs/heads/.invalid && ${git} reset -q --hard --no-recurse-submodules`, dir, timeoutMs);
625
628
  }
626
629
  catch (error) {
627
630
  await dropWorktree(store, rollback, timeoutMs).catch((failure) => { throw rollbackFailed(error, failure); });
@@ -631,6 +634,15 @@ export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAU
631
634
  await dropWorktree(store, rollback, timeoutMs);
632
635
  return repaired;
633
636
  }
637
+ /**
638
+ * 2.8.1 T1 (ANL-023 §7.1): the reflog witness W(dir, ref) — read-only, the newest entry first. It is valid for a commit
639
+ * x only when the exit is 0, at least one line came back and the first is x: exit 0 with no lines is git's answer for
640
+ * a missing or unreadable log, so an empty witness is a refusal, never an accepted empty.
641
+ */
642
+ export async function reflogWitness(dir, ref) {
643
+ const r = await shGit(`git reflog show --no-abbrev --format=%H ${shq(ref)}`, dir);
644
+ return { code: r.code, lines: r.stdout.split("\n").map((line) => line.trim()).filter(Boolean) };
645
+ }
634
646
  /** A failed rollback is never swallowed: it rides on the error the operation already throws (a refusal stays one). */
635
647
  function rollbackFailed(error, failure) {
636
648
  const why = `; its rollback failed, so what it created may remain: ${failure instanceof Error ? failure.message : String(failure)}`;
@@ -738,10 +750,11 @@ const sanitize = sanitizeBranch;
738
750
  // once after this rename — the same one-time per-install cost as the v1.38 state-dir rename.
739
751
  export const WORKTREES_DIR = "worktrees.noindex";
740
752
  export const worktreePath = (repo, branch) => join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
741
- /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
753
+ /**
754
+ * OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
755
+ * integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
756
+ */
742
757
  export async function cleanupRunWorktrees(repo, branch, opts) {
743
- if (opts.removeIntegration)
744
- await removeWorktree(repo, worktreePath(repo, branch));
745
758
  for (const id of opts.removeTaskIds)
746
759
  await removeWorktree(repo, worktreePath(repo, `${branch}--${id}`));
747
760
  }
@@ -1,5 +1,6 @@
1
1
  import type { TickmarkrConfig } from "../config/config.js";
2
2
  import { type Baseline, type BaselineProvenance, type GateEvidenceOptions, type FailureClassification } from "../gates/baseline.js";
3
+ import { type VerificationIdentity } from "../gates/cache.js";
3
4
  import type { GateEvidenceReceipt } from "./protocol.js";
4
5
  export interface TipVerifyResult {
5
6
  evidenceReceipt?: GateEvidenceReceipt;
@@ -30,18 +31,124 @@ export interface TipVerifyResult {
30
31
  * for a kill, the shared runner classifier for everything else. `infra` means nothing was verified.
31
32
  */
32
33
  cause?: FailureClassification;
34
+ /** 2.8.1 T1: with `deferVerdicts`, the verdict-store write this result would have made, left to the caller. */
35
+ deferredVerdict?: {
36
+ id: VerificationIdentity;
37
+ verdict: TipVerifyResult & {
38
+ meta: Record<string, unknown>;
39
+ };
40
+ };
33
41
  }
34
42
  /** Carry execution evidence without minting an invocation or rebasing its artifact paths. */
35
43
  export declare function reusedTipEvidence(source: Partial<TipVerifyResult>): Partial<TipVerifyResult>;
36
44
  export declare function integrationBranch(cfg: TickmarkrConfig, runId: string): string;
45
+ /**
46
+ * 2.8.1 T2 (ANL-023 §7.2): creates the integration ref create-only at `baseRef` (the empty old value means "must not
47
+ * exist") and returns the published tip E. The run keeps no checkout of the branch: every later publication is a
48
+ * compare-and-swap of the ref from `repo` (mergeTask). An existing or uncreatable ref throws — the fatal path's text.
49
+ */
37
50
  export declare function ensureIntegration(repo: string, branch: string, baseRef: string): Promise<string>;
38
- export declare function integrationHead(intWt: string): Promise<string>;
39
- export declare function mergeTask(intWt: string, taskBranch: string, message: string, gatedCommit: string): Promise<{
40
- ok: boolean;
41
- conflict?: string;
42
- tipMoved?: {
51
+ /** 2.8.1 T1 (ANL-023 §7.1): the closed states of the integration ref, as R(ref) reads them. */
52
+ export type IntegrationRefState = {
53
+ kind: "regular";
54
+ commit: string;
55
+ } | {
56
+ kind: "missing";
57
+ } | {
58
+ kind: "symbolic";
59
+ target: string;
60
+ commit?: string;
61
+ } | {
62
+ kind: "unreadable";
63
+ read: string;
64
+ };
65
+ export type IntegrationLeg = "symbolic" | "missing" | "value" | "unreadable";
66
+ /**
67
+ * R(ref): the integration ref read from `repo` through the own-git runner, never its reflog. The exact ref only —
68
+ * a descendant ref (`refs/heads/<branch>/x`) is not this ref, and a ref that exists but names no commit is unreadable.
69
+ */
70
+ export declare function readIntegrationRef(repo: string, branch: string): Promise<IntegrationRefState>;
71
+ /** I(x): holds iff the ref is regular at x; otherwise the leg, with the state the repair text is chosen by. */
72
+ export declare function checkIntegrationIdentity(repo: string, branch: string, expected: string): Promise<{
73
+ ok: true;
74
+ state: IntegrationRefState;
75
+ } | {
76
+ ok: false;
77
+ leg: IntegrationLeg;
78
+ state: IntegrationRefState;
79
+ }>;
80
+ /**
81
+ * The repair text (shared by run end's failure row, the merge park and the resume refusal): backticked commands run
82
+ * from the repository root in order, stopping at the first that fails. A found commit is preserved create-only under
83
+ * refs/tickmarkr/found/<runId>/<F> before a no-deref compare-and-swap restores the ref; a symbolic found ref or one
84
+ * already holding another commit stops the sequence before the restore. Dangling and unreadable print no command.
85
+ */
86
+ export declare function integrationRepairText(state: IntegrationRefState, branch: string, runId: string, expected: string): string;
87
+ type GitResult = {
88
+ code: number;
89
+ stdout: string;
90
+ stderr: string;
91
+ };
92
+ /** The merge's git steps after the identity check, in order; `publish` is the compare-and-swap. */
93
+ export type MergeStep = "ancestry" | "merge-tree" | "commit-tree" | "publish";
94
+ /** Test-only: runs before each step; a returned result replaces that step's git result (never `publish`'s). */
95
+ export type MergeSeam = (step: MergeStep) => Promise<GitResult | void> | GitResult | void;
96
+ export interface IntegrationMove {
97
+ branch: string;
98
+ expected: string;
99
+ found: string | null;
100
+ leg: IntegrationLeg;
101
+ target?: string;
102
+ stage: "check" | "publish";
103
+ }
104
+ export type MergeOutcome = {
105
+ ok: true;
106
+ commit: string;
107
+ from: string;
108
+ } | {
109
+ ok: true;
110
+ alreadyMerged: string;
111
+ } | {
112
+ ok: false;
113
+ tipMoved: {
43
114
  gatedCommit: string;
44
115
  branchTip: string;
45
116
  };
46
- }>;
47
- export declare function verifyIntegrationTip(intWt: string, commands: Record<string, string>, runDir: string, baseline?: Baseline, evidenceOptions?: GateEvidenceOptions): Promise<TipVerifyResult[]>;
117
+ } | {
118
+ ok: false;
119
+ conflict: string;
120
+ } | {
121
+ ok: false;
122
+ integrationMoved: IntegrationMove;
123
+ state: IntegrationRefState;
124
+ } | {
125
+ ok: false;
126
+ alreadyIntegrated: {
127
+ gated: string;
128
+ tip: string;
129
+ };
130
+ } | {
131
+ ok: false;
132
+ infra: {
133
+ step: MergeStep;
134
+ message: string;
135
+ };
136
+ };
137
+ /**
138
+ * 2.8.1 T2 (ANL-023 §7.2): publishes `gated` onto the integration ref by compare-and-swap from the stored tip
139
+ * `expected`, all git from `repo` — no checkout. (1) the task-tip check; (2) I(expected); (3) ancestry; (4) merge-tree;
140
+ * (5) commit-tree; (6) `update-ref --no-deref` from `expected`; (7) `onPublished(M)` in the same synchronous
141
+ * continuation as (6)'s result, so the caller's rows land in publication order. `merged` is the task's recorded merge
142
+ * commits, newest first: a gated commit already in the history is done only through its own recorded merge.
143
+ * `ancestorOnly` (a replay that ran no gates): a gated commit not in E is refused `infra`, never published.
144
+ */
145
+ export declare function mergeTask(repo: string, { branch, expected, merged, ancestorOnly }: {
146
+ branch: string;
147
+ expected: string;
148
+ merged?: readonly string[];
149
+ ancestorOnly?: boolean;
150
+ }, taskBranch: string, message: string, gated: string, onPublished: (commit: string) => void, seam?: MergeSeam): Promise<MergeOutcome>;
151
+ export declare function verifyIntegrationTip(intWt: string, commands: Record<string, string>, runDir: string, baseline?: Baseline, evidenceOptions?: GateEvidenceOptions, { deferVerdicts }?: {
152
+ deferVerdicts?: boolean;
153
+ }): Promise<TipVerifyResult[]>;
154
+ export {};
package/dist/run/merge.js CHANGED
@@ -4,8 +4,7 @@ import { shq } from "../adapters/types.js";
4
4
  import { baselineEntryRefusal, beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, classifiedFileDurations, fingerprint, freshFailures, } from "../gates/baseline.js";
5
5
  import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
6
6
  import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
7
- import { tickmarkrDir } from "../graph/graph.js";
8
- import { dependencyLinkRefusal, describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
7
+ import { dependencyLinkRefusal, describeCapacity, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit } from "./git.js";
9
8
  import { executionSignal } from "./execution-budget.js";
10
9
  // Diagnostic convenience logs are observational, just like receipt persistence.
11
10
  function writeTipLog(path, text) {
@@ -32,42 +31,131 @@ export function reusedTipEvidence(source) {
32
31
  export function integrationBranch(cfg, runId) {
33
32
  return `${cfg.integrationBranchPrefix}${runId}`;
34
33
  }
35
- const sanitize = (branch) => branch.replace(/[^\w.-]+/g, "-");
34
+ /**
35
+ * 2.8.1 T2 (ANL-023 §7.2): creates the integration ref create-only at `baseRef` (the empty old value means "must not
36
+ * exist") and returns the published tip E. The run keeps no checkout of the branch: every later publication is a
37
+ * compare-and-swap of the ref from `repo` (mergeTask). An existing or uncreatable ref throws — the fatal path's text.
38
+ */
36
39
  export async function ensureIntegration(repo, branch, baseRef) {
37
40
  branch = await resolveIntegrationBranch(repo, branch);
38
- const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
39
- if (!existsSync(join(dir, ".git"))) {
40
- const exists = (await shGit(`git rev-parse --verify refs/heads/${shq(branch)}`, repo)).code === 0;
41
- if (exists) {
42
- await shGitOk(`git worktree add ${shq(dir)} ${shq(branch)}`, repo, { createsWorktree: "branch", rollback: { dir } });
43
- }
44
- else {
45
- await shGitOk(`git worktree add -b ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo, { createsWorktree: "branch", rollback: { dir, branch: { name: branch } } });
46
- }
47
- }
48
- linkNodeModules(repo, dir);
49
- return dir;
41
+ // ponytail: no --create-reflog (ANL-023 §7.2 names it): the repository's own core.logAllRefUpdates decides, because
42
+ // nothing reads this reflog (X10) and run-end-integration's E-P10 builds its no-reflog legacy branch through a fresh
43
+ // run with logging disabled. Add the flag back once that fixture builds its legacy branch by hand.
44
+ const r = await shGit(`git update-ref --no-deref -m ${shq("tickmarkr: run start")} ${shq(`refs/heads/${branch}`)} ${shq(baseRef)} ""`, repo);
45
+ if (r.code !== 0)
46
+ throw new Error(`integration branch ${branch} already exists or cannot be created: ${(r.stderr || r.stdout).trim()}`);
47
+ return baseRef;
48
+ }
49
+ /**
50
+ * R(ref): the integration ref read from `repo` through the own-git runner, never its reflog. The exact ref only —
51
+ * a descendant ref (`refs/heads/<branch>/x`) is not this ref, and a ref that exists but names no commit is unreadable.
52
+ */
53
+ export async function readIntegrationRef(repo, branch) {
54
+ const ref = `refs/heads/${branch}`;
55
+ const unreadable = (cmd, r) => ({ kind: "unreadable", read: `${cmd} exited ${r.code}${(r.stderr || r.stdout).trim() ? `: ${(r.stderr || r.stdout).trim()}` : ""}` });
56
+ const symbolic = await shGit(`git symbolic-ref -q ${shq(ref)}`, repo);
57
+ if (symbolic.code !== 0 && symbolic.code !== 1)
58
+ return unreadable("git symbolic-ref", symbolic);
59
+ const commit = await shGit(`git rev-parse --verify -q ${shq(`${ref}^{commit}`)}`, repo);
60
+ if (commit.code !== 0 && commit.code !== 1)
61
+ return unreadable("git rev-parse", commit);
62
+ const found = commit.code === 0 ? commit.stdout.trim() : undefined;
63
+ if (symbolic.code === 0)
64
+ return { kind: "symbolic", target: symbolic.stdout.trim(), ...(found ? { commit: found } : {}) };
65
+ if (found)
66
+ return { kind: "regular", commit: found };
67
+ const exists = await shGit(`git show-ref --verify -q ${shq(ref)}`, repo);
68
+ return exists.code === 1 ? { kind: "missing" } : unreadable("git show-ref", exists);
50
69
  }
51
- export function integrationHead(intWt) {
52
- return gitHead(intWt);
70
+ /** I(x): holds iff the ref is regular at x; otherwise the leg, with the state the repair text is chosen by. */
71
+ export async function checkIntegrationIdentity(repo, branch, expected) {
72
+ const state = await readIntegrationRef(repo, branch);
73
+ if (state.kind === "regular" && state.commit === expected)
74
+ return { ok: true, state };
75
+ return { ok: false, leg: state.kind === "regular" ? "value" : state.kind, state };
53
76
  }
54
- export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
55
- const tip = await shGit(`git rev-parse --verify ${shq(`refs/heads/${taskBranch}`)}`, intWt);
77
+ /**
78
+ * The repair text (shared by run end's failure row, the merge park and the resume refusal): backticked commands run
79
+ * from the repository root in order, stopping at the first that fails. A found commit is preserved create-only under
80
+ * refs/tickmarkr/found/<runId>/<F> before a no-deref compare-and-swap restores the ref; a symbolic found ref or one
81
+ * already holding another commit stops the sequence before the restore. Dangling and unreadable print no command.
82
+ */
83
+ export function integrationRepairText(state, branch, runId, expected) {
84
+ const ref = `refs/heads/${branch}`;
85
+ const byHand = `inspect ${ref} and restore it to ${expected} by hand`;
86
+ const found = state.kind === "regular" ? state.commit : state.kind === "symbolic" ? state.commit : undefined;
87
+ if (found) {
88
+ const keep = `refs/tickmarkr/found/${runId}/${found}`;
89
+ return `\`test -z "$(git symbolic-ref -q ${keep})"\` then \`test "$(git rev-parse -q --verify ${keep})" = ${found} || git update-ref --no-deref ${keep} ${found} ""\` then \`git update-ref --no-deref ${ref} ${expected} ${found}\``;
90
+ }
91
+ if (state.kind === "missing")
92
+ return `\`git update-ref --no-deref ${ref} ${expected} ""\``;
93
+ if (state.kind === "symbolic")
94
+ return `symbolic to ${state.target}, which names no commit; ${byHand}`;
95
+ return state.kind === "unreadable" ? `${ref} is unreadable (${state.read}); ${byHand}` : byHand;
96
+ }
97
+ /**
98
+ * 2.8.1 T2 (ANL-023 §7.2): publishes `gated` onto the integration ref by compare-and-swap from the stored tip
99
+ * `expected`, all git from `repo` — no checkout. (1) the task-tip check; (2) I(expected); (3) ancestry; (4) merge-tree;
100
+ * (5) commit-tree; (6) `update-ref --no-deref` from `expected`; (7) `onPublished(M)` in the same synchronous
101
+ * continuation as (6)'s result, so the caller's rows land in publication order. `merged` is the task's recorded merge
102
+ * commits, newest first: a gated commit already in the history is done only through its own recorded merge.
103
+ * `ancestorOnly` (a replay that ran no gates): a gated commit not in E is refused `infra`, never published.
104
+ */
105
+ export async function mergeTask(repo, { branch, expected, merged = [], ancestorOnly = false }, taskBranch, message, gated, onPublished, seam) {
106
+ const tip = await shGit(`git rev-parse --verify ${shq(`refs/heads/${taskBranch}`)}`, repo);
56
107
  if (tip.code !== 0)
57
108
  return { ok: false, conflict: tip.stderr || tip.stdout };
58
109
  const branchTip = tip.stdout.trim();
59
- if (branchTip !== gatedCommit)
60
- return { ok: false, tipMoved: { gatedCommit, branchTip } };
61
- // Merge the verified hash, not the mutable branch name: a move after the comparison cannot land ungated content.
62
- const r = await shGit(`git merge --no-ff ${shq(gatedCommit)} -m ${shq(message)}`, intWt);
63
- if (r.code === 0)
64
- return { ok: true };
65
- const conflict = (await shGit("git status --porcelain", intWt)).stdout
66
- .split("\n")
67
- .filter((l) => l.startsWith("UU") || l.startsWith("AA"))
68
- .join("\n") || r.stderr || r.stdout;
69
- await shGit("git merge --abort", intWt);
70
- return { ok: false, conflict };
110
+ if (branchTip !== gated)
111
+ return { ok: false, tipMoved: { gatedCommit: gated, branchTip } };
112
+ const moved = (leg, state, stage) => ({
113
+ ok: false, state, integrationMoved: { branch, expected, leg, stage,
114
+ found: (state.kind === "regular" || state.kind === "symbolic" ? state.commit : undefined) ?? null,
115
+ ...(state.kind === "symbolic" ? { target: state.target } : {}) },
116
+ });
117
+ const infra = (step, r) => ({ ok: false, infra: { step, message: (r.stderr || r.stdout).trim() || `exit ${r.code}` } });
118
+ const run = async (step, cmd) => (await seam?.(step)) || shGit(cmd, repo);
119
+ const held = await checkIntegrationIdentity(repo, branch, expected);
120
+ if (!held.ok)
121
+ return moved(held.leg, held.state, "check");
122
+ const ancestry = await run("ancestry", `git merge-base --is-ancestor ${shq(gated)} ${shq(expected)}`);
123
+ if (ancestry.code === 0) {
124
+ // Ancestor or equal tip: only the task's own recorded merge (second parent = gated) makes it done. A resume replay
125
+ // hands the task's own gated commit here, never a tip recreated on E (runDaemon's resume gate replay).
126
+ for (const commit of merged) {
127
+ const second = await shGit(`git rev-parse --verify -q ${shq(`${commit}^2`)}`, repo);
128
+ if (second.code !== 0)
129
+ break; // a failed read (no second parent, no such object) is never passed over: fail closed
130
+ if (second.stdout.trim() === gated)
131
+ return { ok: true, alreadyMerged: commit };
132
+ }
133
+ return { ok: false, alreadyIntegrated: { gated, tip: expected } };
134
+ }
135
+ if (ancestry.code !== 1)
136
+ return infra("ancestry", ancestry);
137
+ // A caller that skipped the gates on its own ancestry proof (a resume replay) never reaches construction.
138
+ if (ancestorOnly)
139
+ return infra("ancestry", { code: 1, stdout: "", stderr: `${gated} is not in ${expected}; an ungated replay publishes nothing` });
140
+ const tree = await run("merge-tree", `git merge-tree --write-tree ${shq(expected)} ${shq(gated)}`);
141
+ if (tree.code === 1)
142
+ return { ok: false, conflict: (tree.stdout || tree.stderr).trim() };
143
+ if (tree.code !== 0)
144
+ return infra("merge-tree", tree);
145
+ const made = await run("commit-tree", `git commit-tree ${shq(tree.stdout.split("\n")[0].trim())} -p ${shq(expected)} -p ${shq(gated)} -m ${shq(message)}`);
146
+ if (made.code !== 0)
147
+ return infra("commit-tree", made);
148
+ const commit = made.stdout.trim();
149
+ await seam?.("publish");
150
+ const published = await shGit(`git update-ref --no-deref -m ${shq(message)} ${shq(`refs/heads/${branch}`)} ${shq(commit)} ${shq(expected)}`, repo);
151
+ if (published.code === 0) {
152
+ onPublished(commit); // no await between the compare-and-swap's result and the caller's rows
153
+ return { ok: true, commit, from: expected };
154
+ }
155
+ const state = await readIntegrationRef(repo, branch);
156
+ if (state.kind === "regular" && state.commit === expected)
157
+ return infra("publish", published);
158
+ return moved(state.kind === "regular" ? "value" : state.kind, state, "publish");
71
159
  }
72
160
  // OBS-34 ruled strict exit-code verify; Q121s (TRIAL T-OBS-1) narrows it: a red whose failure
73
161
  // fingerprints are ALL recorded in the run's baseline is forgiven with the battery's own math
@@ -75,7 +163,7 @@ export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
75
163
  // terminus unreachable by construction and misattributed the red to the last merged task.
76
164
  // Fail-closed edges kept: no baseline → strict; baseline green for that gate → strict; any fresh
77
165
  // fingerprint, or output with no recognizable failure shape, → failed.
78
- export async function verifyIntegrationTip(intWt, commands, runDir, baseline, evidenceOptions = {}) {
166
+ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, evidenceOptions = {}, { deferVerdicts = false } = {}) {
79
167
  const results = [];
80
168
  let runStartCommands;
81
169
  let journalFound = false;
@@ -414,7 +502,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
414
502
  }
415
503
  const id = pending.get(result.gate);
416
504
  if (id && id.tree === completedTree && !isInfraResult(result)) {
417
- store.set(id, { ...result, meta: {
505
+ const verdict = { ...result, meta: {
418
506
  fingerprints: result.fingerprints, forgiven: result.forgiven, baselineProvenance: result.baselineProvenance,
419
507
  ...(result.forgiven && result.gate === "test" ? { killTextRead: true } : {}),
420
508
  reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
@@ -422,7 +510,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
422
510
  evidenceReceipt: result.evidenceReceipt, evidenceReceipts: result.evidenceReceipts,
423
511
  evidenceAbsence: result.evidenceAbsence, originRunRoot: result.originRunRoot,
424
512
  nonce: result.nonce, stdoutPath: result.stdoutPath, stderrPath: result.stderrPath,
425
- } });
513
+ } };
514
+ // 2.8.1 T1: a deferring caller (run end) writes the verdict itself, only after its closing identity check.
515
+ if (deferVerdicts)
516
+ result.deferredVerdict = { id, verdict };
517
+ else
518
+ store.set(id, verdict);
426
519
  }
427
520
  }
428
521
  return results;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -503,6 +503,14 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
503
503
  - **the INTEGRATION subject** — the integration-branch commit that worktree was created from, named by
504
504
  sha from the journal's `task-dispatch` row or `tickmarkr status`; never "the run", and never the
505
505
  branch name alone, which moves while you are reading it;
506
+ - **the operator view of that branch** — a live run has NO integration checkout: the daemon publishes
507
+ each merge onto `tickmarkr/<runId>` by compare-and-swap from the repository root. View it through a
508
+ detached snapshot (`git worktree add --detach <dir> tickmarkr/<runId>`), refreshed by removing it
509
+ (`git worktree remove <dir>`) and re-adding it; check the branch out attached only with the daemon
510
+ stopped (no live `graph.lock`) — an attached checkout taken while the run is live goes stale at the next
511
+ publication, and any write to the ref parks the next merge infra. Run `tickmarkr verify` and `report`
512
+ from the main checkout or a snapshot;
513
+ a legacy attached view from a 2.8.0 run is stale from the first publication;
506
514
  - **and, where the fix is a FILE the tree must hold** — a new test, a fixture, a schema — its presence
507
515
  at that commit: `git -C <task-worktree> cat-file -e <task-head>:<path>`. A fix that lives only in the
508
516
  daemon's `dist` cannot make a worker's missing oracle appear, and a worker red on that missing file is