tickmarkr 2.7.9 → 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/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;
@@ -216,7 +304,9 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
216
304
  // Persistent reuse requires the run's durable journal. Without it there is no
217
305
  // recorded cycle provenance to reconcile with the caller's capacity checks.
218
306
  const hit = journalFound ? store.get(id) : undefined;
219
- if (hit && id && hit.pass && !isInfraResult(hit)) {
307
+ // S19m4: a forgiven test green written before the tip read kill text never proves the suite completed.
308
+ const killTextUnread = gate === "test" && hit?.meta?.forgiven === true && hit.meta.killTextRead !== true;
309
+ if (hit && id && hit.pass && !isInfraResult(hit) && !killTextUnread) {
220
310
  results.push({
221
311
  gate,
222
312
  cmd,
@@ -339,7 +429,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
339
429
  });
340
430
  continue;
341
431
  }
342
- const verdict = freshFailures(entry, stripped);
432
+ const verdict = freshFailures(entry, stripped, gate === "test");
343
433
  const { failing, unreadable } = verdict;
344
434
  // S22: a missing or unreadable entry forgives nothing; the red names it rather than a bare exit code.
345
435
  const refusal = baselineEntryRefusal(gate, verdict);
@@ -366,8 +456,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
366
456
  // often the previous session's — so a capture taken under a different resolved capacity forgives
367
457
  // nothing at the tip either. An absent capacity is a pre-T7 baseline and keeps today's verdict.
368
458
  const comparable = sameCapacity(entry?.capacity, r.capacity);
369
- const forgiven = r.code !== 0 && baselineRed && failing.length === 0 && !unreadable && cause !== "infra"
459
+ const forgivable = r.code !== 0 && baselineRed && failing.length === 0 && !unreadable && cause !== "infra"
370
460
  && comparable;
461
+ // S19m4 (battery parity): kill text beside forgivable failures means the suite never completed — red, infra.
462
+ const killLine = forgivable && !greenTeardown && !deficit ? verdict.killLine : undefined;
463
+ const forgiven = forgivable && killLine === undefined;
464
+ const rowCause = killLine !== undefined ? "infra" : cause;
371
465
  const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
372
466
  if (!pass)
373
467
  writeTipLog(artifact, raw);
@@ -378,18 +472,20 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
378
472
  evidenceReceipt, evidenceReceipts,
379
473
  exitCode: r.code,
380
474
  fingerprints: r.code !== 0 && !greenTeardown ? fingerprint(stripped) : [],
381
- details: `${cause === "infra" ? "infra; " : ""}${rerun ? `${rerun}: ` : ""}` + (deficit?.replace(/^infra; /, "") ?? (r.code === 0 ? "exit 0"
475
+ details: `${rowCause === "infra" ? "infra; " : ""}${rerun ? `${rerun}: ` : ""}` + (deficit?.replace(/^infra; /, "") ?? (r.code === 0 ? "exit 0"
382
476
  : greenTeardown ? `exit ${r.code} after a green suite summary; only the runner's teardown fingerprint followed it`
383
477
  : forgiven ? `exit ${r.code} but only baseline-recorded failures (forgiven vs baseline)`
384
- : !comparable && baselineRed && failing.length === 0
385
- ? `exit ${r.code}; every failure is baseline-recorded, but that capture ran under `
386
- + `${describeCapacity(entry?.capacity)} and this verification ran under ${describeCapacity(r.capacity)} `
387
- + `— forgiveness across a changed capacity is not evidence`
388
- : `exit ${r.code}`))
478
+ : killLine !== undefined ? `exit ${r.code}; every failure is baseline-recorded, but the runner printed kill text — `
479
+ + `it never completed a suite, so nothing is forgiven: ${killLine}`
480
+ : !comparable && baselineRed && failing.length === 0
481
+ ? `exit ${r.code}; every failure is baseline-recorded, but that capture ran under `
482
+ + `${describeCapacity(entry?.capacity)} and this verification ran under ${describeCapacity(r.capacity)} `
483
+ + `— forgiveness across a changed capacity is not evidence`
484
+ : `exit ${r.code}`))
389
485
  // S22: every red here names a missing or unreadable entry beside its own cause, deficit included
390
486
  + (!pass && refusal ? `; ${refusal}` : ""),
391
487
  ...(forgiven && !deficit ? { forgiven: true, ...(baseline?.provenance ? { baselineProvenance: baseline.provenance } : {}) } : {}),
392
- ...(cause ? { cause } : {}),
488
+ ...(rowCause ? { cause: rowCause } : {}),
393
489
  ...(pass ? {} : { artifact }),
394
490
  };
395
491
  results.push(tipResult);
@@ -406,14 +502,20 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
406
502
  }
407
503
  const id = pending.get(result.gate);
408
504
  if (id && id.tree === completedTree && !isInfraResult(result)) {
409
- store.set(id, { ...result, meta: {
505
+ const verdict = { ...result, meta: {
410
506
  fingerprints: result.fingerprints, forgiven: result.forgiven, baselineProvenance: result.baselineProvenance,
507
+ ...(result.forgiven && result.gate === "test" ? { killTextRead: true } : {}),
411
508
  reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
412
509
  artifact: result.artifact,
413
510
  evidenceReceipt: result.evidenceReceipt, evidenceReceipts: result.evidenceReceipts,
414
511
  evidenceAbsence: result.evidenceAbsence, originRunRoot: result.originRunRoot,
415
512
  nonce: result.nonce, stdoutPath: result.stdoutPath, stderrPath: result.stderrPath,
416
- } });
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);
417
519
  }
418
520
  }
419
521
  return results;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.7.9",
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