tickmarkr 2.6.7 → 2.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/adapters/catalog-remote.js +5 -2
  2. package/dist/adapters/codex.d.ts +1 -0
  3. package/dist/adapters/codex.js +18 -10
  4. package/dist/adapters/model-lints.js +21 -11
  5. package/dist/adapters/model-windows.js +0 -1
  6. package/dist/adapters/registry.js +7 -3
  7. package/dist/adapters/types.d.ts +2 -0
  8. package/dist/cli/commands/beat.d.ts +3 -3
  9. package/dist/cli/commands/beat.js +39 -19
  10. package/dist/cli/commands/doctor.js +3 -0
  11. package/dist/cli/commands/fleet.js +2 -0
  12. package/dist/cli/commands/plan.js +16 -18
  13. package/dist/cli/commands/verify.js +4 -2
  14. package/dist/cli/help.js +5 -2
  15. package/dist/config/config.js +6 -5
  16. package/dist/drivers/herdr.js +2 -2
  17. package/dist/drivers/orca.d.ts +12 -4
  18. package/dist/drivers/orca.js +200 -39
  19. package/dist/gates/evidence.js +3 -3
  20. package/dist/gates/review.d.ts +1 -1
  21. package/dist/gates/review.js +62 -13
  22. package/dist/gates/run-gates.d.ts +3 -0
  23. package/dist/gates/run-gates.js +115 -20
  24. package/dist/gates/test-manifest.d.ts +36 -2
  25. package/dist/gates/test-manifest.js +55 -10
  26. package/dist/run/beat-lifecycle.d.ts +109 -24
  27. package/dist/run/beat-lifecycle.js +1173 -216
  28. package/dist/run/daemon.d.ts +4 -0
  29. package/dist/run/daemon.js +111 -33
  30. package/dist/run/git-trust.d.ts +78 -3
  31. package/dist/run/git-trust.js +175 -19
  32. package/dist/run/git.d.ts +24 -3
  33. package/dist/run/git.js +140 -21
  34. package/dist/run/merge.js +2 -2
  35. package/dist/run/verification-job.d.ts +2 -0
  36. package/dist/run/verification-job.js +32 -10
  37. package/dist/tui/ink/fleet-app.d.ts +7 -2
  38. package/dist/tui/ink/fleet-app.js +81 -23
  39. package/package.json +4 -1
  40. package/skills/tickmarkr-auto/SKILL.md +58 -14
  41. package/skills/tickmarkr-loop/SKILL.md +65 -14
  42. package/skills/tickmarkr-overseer/SKILL.md +97 -28
  43. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -140,6 +140,10 @@ export declare const watchCommand: (runId: string) => string;
140
140
  * skip stays satisfied; a gate that ran and passed stays satisfied.
141
141
  */
142
142
  export declare const gateSatisfied: (g: GateResult) => boolean;
143
+ /** OBS-1010 / v2.6.8 T1: the review seats a journal's demotions carry into a resumed run as a SOFT
144
+ * preference (ranked last, never excluded) — every review-pool-demotion except the launch-caused ones, which
145
+ * never demote a seat (live or replayed). plan.ts previews a resume with this same fold. */
146
+ export declare function replayedReviewerDemotions(events: readonly JournalEvent[]): Set<string>;
143
147
  /**
144
148
  * T4 (OBS-265): the journal with the review objections a round did NOT hinge on removed. Judge and
145
149
  * review are now launched together, so a round can journal a failed review that the serial walk would
@@ -33,7 +33,7 @@ import { executionSignal, remainingExecutionMs, withExecutionBudget, withoutExec
33
33
  import { repairSelectionDecision, repairSelectionEnabled } from "./repair-selection.js";
34
34
  import { failureDisposition, reserveInfrastructureRetry } from "./recovery.js";
35
35
  import { runEnvironment } from "./environment.js";
36
- import { changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
36
+ import { addDetachedWorktree, changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
37
37
  import { MASK } from "./redact.js";
38
38
  import { runInteractiveSeed } from "./interactive-seed.js";
39
39
  import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
@@ -376,9 +376,26 @@ function classifyInfraResult(g) {
376
376
  // diagnostics (never-started files, a worker RPC timeout) that make such a red infrastructure-SHAPED.
377
377
  // Shape alone never parks anything: it only buys one isolated re-observation before a charge. A
378
378
  // fresh-fingerprint section masks digits to `#`, so both spellings of a number are read.
379
- /** C (E1): the one no-verdict that is the host's slowness, not the seat's — the same predicate run-gates
380
- * applies to its run-scoped tally, read here for the live task strike and the resume reconstruction. */
381
- const proofTimeoutNote = (data) => data.cause === "seat-launch-failed" && data.launchCause === "checkout-proof-timeout";
379
+ /** v2.6.8 T1: a seat that never LAUNCHED (any launchCause — checkout-proof-timeout, none, or other
380
+ * detail) is not a seat that failed to answer — the same predicate run-gates applies to its run-scoped
381
+ * tally, read here for the live task strike and the resume reconstruction. It never strikes. */
382
+ const launchFailureNote = (data) => data.cause === "seat-launch-failed";
383
+ /** v2.6.8 T1: a journaled review-pool-demotion a launch failure caused. The pre-2.6.8 engine struck seats
384
+ * that never launched, so an older journal can carry a demotion whose trigger was a launch failure, or whose
385
+ * two-strike pair counted one beside a single real no-verdict that was not silent on its own. */
386
+ const launchCausedDemotion = (data) => {
387
+ if (launchFailureNote(data))
388
+ return true;
389
+ const silent = data.seatAuthoredBytes === 0 || data.cause === "silent" || data.cause === "launch-never-started";
390
+ return Array.isArray(data.causes) && !silent && data.causes.filter((cause) => cause !== "seat-launch-failed").length < 2;
391
+ };
392
+ /** OBS-1010 / v2.6.8 T1: the review seats a journal's demotions carry into a resumed run as a SOFT
393
+ * preference (ranked last, never excluded) — every review-pool-demotion except the launch-caused ones, which
394
+ * never demote a seat (live or replayed). plan.ts previews a resume with this same fold. */
395
+ export function replayedReviewerDemotions(events) {
396
+ return new Set(events.filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string"
397
+ && !launchCausedDemotion(event.data)).map((event) => event.data.reviewer));
398
+ }
382
399
  /** C: the gate-fresh-forced reason that bounds the out-of-scope re-execution, one per journaled subject. */
383
400
  const OUTSIDE_SCOPE_RED = "no-commit-out-of-scope-red";
384
401
  const NAMED_FAILURE_FILE_RE = /^\s*(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\s+>/gm;
@@ -2232,25 +2249,25 @@ export async function runDaemon(repoRoot, opts = {}) {
2232
2249
  process.on("SIGINT", onTermination);
2233
2250
  process.on("SIGTERM", onTermination);
2234
2251
  journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
2235
- const demotedReviewers = new Set(journal.read()
2236
- .filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string")
2237
- .map((event) => event.data.reviewer));
2238
- // OBS-1010: a demotion the JOURNAL carries into this session left the rotation for the run — a
2239
- // resumed daemon excludes that seat from every review pick (reviewer role only; the worker channel
2240
- // is untouched — channel-demotion is the worker fold, replayExcludedChannels). In-session demotions
2241
- // keep their soft ordering above so a still-live seat can be re-tried at the end of the rotation.
2242
- const replayedReviewerExclusions = opts.resume ? [...demotedReviewers] : [];
2252
+ // OBS-1010 / v2.6.8 T1: a demotion the JOURNAL carries into this session is replayed as what it was
2253
+ // live — a SOFT preference (reviewer role only; the worker channel is untouched — channel-demotion is
2254
+ // the worker fold, replayExcludedChannels). The seat ranks last, even over prefer, and may still be
2255
+ // re-tried at the end of the rotation; a resume never hardens one strike into an exclusion. Retirement
2256
+ // is the only hard rule: two real no-verdict strikes (the tally below) put a seat out for the run,
2257
+ // in-session and after resume alike, whatever prefer or history say. A launch-caused demotion an older
2258
+ // engine journaled is not replayed at all — neither soft nor hard.
2259
+ const demotedReviewers = replayedReviewerDemotions(journal.read());
2243
2260
  // OBS-1025 add.2 / OBS-1052 (Leg-2): the run-scoped no-verdict tally run-gates retires a seat from at two
2244
2261
  // strikes. execTask's badReviewers already excludes a seat after ONE strike within a task, so this map
2245
2262
  // is what bounds a seat that flakes once per task (or once per session) across the run. Seeded from the
2246
2263
  // journal's review-no-verdict rows — the same notes noteReviewEvent appends — so a resumed daemon keeps
2247
2264
  // the count; run-gates appends to it in place, exactly as it does demotedReviewers.
2248
- // C (E1): a checkout-proof timeout never struck the live tally (run-gates), so a resume never seeds one.
2265
+ // v2.6.8 T1: a launch failure never struck the live tally (run-gates), so a resume never seeds one.
2249
2266
  const reviewNoVerdicts = new Map();
2250
2267
  for (const event of journal.read()) {
2251
2268
  if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
2252
2269
  continue;
2253
- if (proofTimeoutNote(event.data))
2270
+ if (launchFailureNote(event.data))
2254
2271
  continue;
2255
2272
  reviewNoVerdicts.set(event.data.reviewer, [...(reviewNoVerdicts.get(event.data.reviewer) ?? []), String(event.data.cause)]);
2256
2273
  }
@@ -2777,7 +2794,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2777
2794
  const pristine = mkdtempSync(join(parent, `baseline-recapture-${process.pid}-`));
2778
2795
  const staged = `${baselinePath}.${process.pid}.tmp`;
2779
2796
  try {
2780
- const added = await shGit(`git worktree add --detach ${shq(pristine)} ${shq(baseRef)}`, repoRoot);
2797
+ // the detached worktree capability (see addDetachedWorktree); a commondir moved while it ran is refused
2798
+ const added = await addDetachedWorktree(repoRoot, pristine, baseRef);
2781
2799
  if (added.code !== 0)
2782
2800
  throw new Error(`baseline recapture: journaled base ${baseRef} cannot be checked out: ${added.stderr.trim()}`);
2783
2801
  if (!linkNodeModules(repoRoot, pristine, { force: true }))
@@ -3413,15 +3431,19 @@ export async function runDaemon(repoRoot, opts = {}) {
3413
3431
  await park(t, `${bootstrapExhausted} failed at bootstrap and no eligible channel remains after resume${poolExhaustion(t, cfg)}`, "infra", assignment, rs?.attempts ?? 0, startMs, 0, 0, undefined, 0, "fresh", { cause: "bootstrap", channel: bootstrapExhausted });
3414
3432
  return;
3415
3433
  }
3416
- // Keep one live list: recovery retries must see exclusions added by onGate during the round.
3417
- const badReviewers = [...replayedReviewerExclusions];
3418
- // C (E1): a checkout-proof timeout is the host being slow, not the seat failing — it is excluded for
3419
- // the CURRENT round only (so an always-timeout pool still ends the round), never struck task-wide.
3434
+ // Keep one live list: recovery retries must see exclusions added by onGate during the round. It starts
3435
+ // empty on resume too: a replayed demotion is soft (demotedReviewers), retirement is the run tally.
3436
+ const badReviewers = [];
3437
+ // v2.6.8 T1: a seat that failed to launch (any launchCause) is excluded for the CURRENT round only —
3438
+ // after its one bounded same-seat launch retry (run-gates), so an always-refusing pool still ends the
3439
+ // round — and is never struck task-wide or run-wide: a later round may seat it again.
3420
3440
  let roundTimeouts = [];
3421
- const noteReviewEvent = (e) => {
3441
+ const noteReviewEvent = (note) => {
3442
+ // The same-seat launch retry is bounded per candidate: its row carries the commit it was spent on.
3443
+ const e = note.name === "review-infra-retry" && gateSubject ? { ...note, payload: { ...note.payload, commit: gateSubject.commit } } : note;
3422
3444
  journal.append(e.name, t.id, e.payload);
3423
3445
  if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
3424
- const strikes = proofTimeoutNote(e.payload) ? roundTimeouts : badReviewers;
3446
+ const strikes = launchFailureNote(e.payload) ? roundTimeouts : badReviewers;
3425
3447
  if (!strikes.includes(e.payload.reviewer))
3426
3448
  strikes.push(e.payload.reviewer);
3427
3449
  }
@@ -3434,7 +3456,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3434
3456
  }
3435
3457
  };
3436
3458
  // Keep the measured siblings, including a red judge, while replacing only a missing review.
3437
- // Every dispatched seat is excluded for this task, so the loop is bounded by the eligible pool.
3459
+ // Every seat that returned no verdict is excluded for this task — a launch failure for this round only,
3460
+ // after its one bounded relaunch — and a retired seat for the run, so the loop is bounded by the eligible pool.
3438
3461
  const runReviewRecovery = async (task, ctx, allowSelection = true) => {
3439
3462
  ctx.authorizeInfraRetry = cfg.executionPolicy?.boundedInfrastructure
3440
3463
  ? (subject, cause) => reserveInfrastructureRetry(journal.read(), task.id, subject, journal.append.bind(journal), cause) : undefined;
@@ -3460,28 +3483,83 @@ export async function runDaemon(repoRoot, opts = {}) {
3460
3483
  && row.event === "phase-start" && row.data.phase === "gates").length,
3461
3484
  };
3462
3485
  roundTimeouts = [];
3486
+ // v2.6.8 T1: the same-seat launch retries this candidate already spent, replayed from the journal so
3487
+ // a recovery round, a recheck or a resume never re-grants one (and never turns one into a strike).
3488
+ const launchRetried = () => journal.read().filter((row) => row.taskId === task.id && row.event === "review-infra-retry"
3489
+ && row.data.sameSeat === true && row.data.commit === gateSubject?.commit && typeof row.data.reviewer === "string")
3490
+ .map((row) => row.data.reviewer);
3463
3491
  // C: every gate-round boundary — a returned or a THROWN round — publishes the one row held behind
3464
3492
  // a still-pending sibling and forgets the pending set, before any park or task-failed row lands.
3493
+ // v2.6.8 T1: a review result whose recovery pool is retired-only is classified BEFORE it is published
3494
+ // (retiredOnly), and the round returns that same parked result, so the journal row and the result the
3495
+ // park reads state one outcome — an infrastructure no-verdict, never a failed review.
3465
3496
  const gateRound = async (subject) => {
3497
+ const parked = new Map();
3466
3498
  try {
3467
- return await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts] });
3499
+ const done = await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts], launchRetried: launchRetried(),
3500
+ onGate: async (e) => {
3501
+ const park = e.phase === "end" && e.gate === "review" ? retiredOnly(e.result) : undefined;
3502
+ if (park === undefined || e.phase !== "end")
3503
+ return ctx.onGate?.(e);
3504
+ parked.set(e.result, park);
3505
+ return ctx.onGate?.({ ...e, result: park });
3506
+ } });
3507
+ return { ...done, results: done.results.map((g) => parked.get(g) ?? g) };
3468
3508
  }
3469
3509
  finally {
3470
3510
  settleParallelRound();
3471
3511
  }
3472
3512
  };
3473
- const round = await gateRound(task);
3474
- let review = round.results.find((g) => g.gate === "review");
3475
3513
  // The recovery pick carries every preserved author, exactly as reviewGate's own pick does.
3476
3514
  const authors = ctx.carriedAuthors?.length ? [...ctx.carriedAuthors] : [channelKey(ctx.author)];
3477
- while (review?.meta?.noVerdict === true) {
3478
- // Recovery must honor the gate's floor, including the seats that just failed to
3479
- // return a verdict; a below-floor alternative cannot replace the infra result. This round's
3480
- // proof timeouts count too — runGates folds every excluded seat into its own floor.
3481
- const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]);
3482
- const next = pickReviewer(ctx.author, ctx.channels, [...badReviewers, ...roundTimeouts], cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
3483
- if (!next)
3515
+ // Recovery must honor the gate's floor, including the seats that just failed to
3516
+ // return a verdict; a below-floor alternative cannot replace the infra result. This round's
3517
+ // launch failures count too — runGates folds every excluded seat into its own floor.
3518
+ const pick = (exclude) => pickReviewer(ctx.author, ctx.channels, exclude, cfg.review.prefer ?? [], gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]).floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
3519
+ // v2.6.8 T1: soft preference is not retirement. demotedReviewers — live or replayed on resume — only
3520
+ // ranks a seat last, so a once-demoted seat stays seatable; a seat two REAL no-verdict strikes retired
3521
+ // is out for the run, so every recovery pick excludes it outright — prefer and history can never
3522
+ // resurrect it, and runGates would refuse it anyway. A launch failure is neither: it buys one bounded
3523
+ // same-seat relaunch per candidate (launchRetried) and then leaves the seat out of THIS round only.
3524
+ const retiredSeats = () => [...reviewNoVerdicts].filter(([, causes]) => causes.length >= 2).map(([seat]) => seat);
3525
+ // The retired-only recovery park: when retirement is what empties the pool (the pick without the
3526
+ // retired exclusion would seat a retired seat), record the seat the pool cannot seat and why, buy
3527
+ // no dispatch, and park infrastructure naming it out for the run — never a soft-ranked pick that
3528
+ // dispatches it again, never a gate-fail. The tally is seeded from the journal, so every resume
3529
+ // or recheck replays this same row and park, whether its round dispatched a seat or found none.
3530
+ // It is decided where the review result is published (gateRound): a round that found no eligible
3531
+ // reviewer, or a no-verdict no recovery pick can replace — and once more after the round,
3532
+ // should another task retire a seat in between. The parked result is the same no-verdict shape every
3533
+ // review.ts no-verdict has: in memory `pass: false` (GateResult.pass is a boolean, and an unreviewed
3534
+ // candidate fails closed — it never merges), `noVerdict` + `infra`; the gate-result row written for a
3535
+ // noVerdict review below drops `pass` and adds `skipped`, so outcome readers, round budgets and
3536
+ // failure counters read one infrastructure outcome, and the infra park — not the gate-fail one — is
3537
+ // what the task gets.
3538
+ const retiredOnly = (r) => {
3539
+ if (r.meta?.cannotSeat !== undefined || (r.meta?.noEligibleReviewer !== true && (r.meta?.noVerdict !== true
3540
+ || pick([...badReviewers, ...roundTimeouts, ...retiredSeats()]))))
3541
+ return undefined;
3542
+ const retired = retiredSeats();
3543
+ const blocked = pick([...badReviewers, ...roundTimeouts].filter((seat) => !retired.includes(seat)));
3544
+ if (!blocked || !retired.includes(channelKey(blocked)))
3545
+ return undefined;
3546
+ const seat = channelKey(blocked);
3547
+ const causes = reviewNoVerdicts.get(seat) ?? [];
3548
+ journal.append("review-infra-retry", t.id, { reviewer: seat, cause: "retired", cannotSeat: true, causes });
3549
+ const { noEligibleReviewer: _none, ...meta } = r.meta ?? {};
3550
+ return { ...r, pass: false, meta: { cause: "retired", ...meta, noVerdict: true, cannotSeat: seat, infra: true, classification: "infra" },
3551
+ details: `${r.details}\nreview recovery cannot seat ${seat}: two real no-verdicts (${causes.join(", ")}) retired it — it is out for the run, and no other eligible seat remains` };
3552
+ };
3553
+ const round = await gateRound(task);
3554
+ let review = round.results.find((g) => g.gate === "review");
3555
+ while (review?.meta?.noVerdict === true && review.meta.cannotSeat === undefined) {
3556
+ const next = pick([...badReviewers, ...roundTimeouts, ...retiredSeats()]);
3557
+ if (!next) {
3558
+ const parked = retiredOnly(review);
3559
+ if (parked)
3560
+ round.results = round.results.map((g) => g === review ? parked : g);
3484
3561
  break;
3562
+ }
3485
3563
  journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
3486
3564
  const retried = await gateRound({ ...task, gates: ["review"] });
3487
3565
  const replacement = retried.results.find((g) => g.gate === "review");
@@ -3510,7 +3588,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3510
3588
  gate: "review", flaked: rr.flaked, retried: rr.retried,
3511
3589
  ...(g.meta?.unparseable === true ? { secondUnparseable: true } : {}),
3512
3590
  });
3513
- // C (E1): a seat re-routed after a checkout-proof timeout is excluded for this round only.
3591
+ // v2.6.8 T1: a seat re-routed after a launch failure is excluded for this round only.
3514
3592
  if (!roundTimeouts.includes(rr.flaked))
3515
3593
  badReviewers.push(rr.flaked);
3516
3594
  }
@@ -1,7 +1,8 @@
1
1
  /** A covered git invocation refused before spawn; `path` names the offending metadata. */
2
2
  export declare class GitTrustRefusal extends Error {
3
3
  readonly path: string;
4
- constructor(path: string, detail: string);
4
+ readonly detail: string;
5
+ constructor(path: string, detail: string, options?: ErrorOptions);
5
6
  }
6
7
  /**
7
8
  * The canonical, independently expected common git directory for the checkout enclosing `cwd` — never what
@@ -15,13 +16,87 @@ export declare function trustedCommonDir(cwd: string): string;
15
16
  * extensions.worktreeConfig and the linked gitdir holds a config.worktree.
16
17
  */
17
18
  export declare function assertGitTrust(cwd: string): void;
19
+ /** The child of tickmarkr's own git in a linked checkout: its pinned environment, the post-exit recheck and its release. */
20
+ export interface OwnGitPin {
21
+ /** The whole child environment: the caller's, with the pin set and every unpinned inherited ref backend removed. */
22
+ env: NodeJS.ProcessEnv;
23
+ /** Throws GitTrustRefusal naming commondir when it changed in ANY way since the check that derived `env`. */
24
+ recheck: () => void;
25
+ /** Removes this attempt's private ref view; called once its child has exited, or when it never spawned. */
26
+ release: () => void;
27
+ /** git names ref paths through the view; its output names the trusted common directory instead. */
28
+ unview: (text: string) => string;
29
+ /** The trusted common directory this attempt derived from the layout: the authority a raced worktree add rolls back through. */
30
+ store: string;
31
+ }
32
+ /**
33
+ * v2.6.8 T2: what an own-git CALLER declares at its call site — never read from the command text, its tokens, login or
34
+ * cwd, and never a way out of any pin: every own-git child keeps the path pin, the ref-store pin and the recheck.
35
+ * `createsWorktree: "branch"` is declared by the three branch-creating adds (git writes a branch add's new HEAD as the
36
+ * symbolic branch ref, which the pin leaves intact). The two DETACHED adds declare the capability by calling
37
+ * `addDetachedWorktree` (src/run/git.ts), because under the pin git stubs a detached add's new HEAD as
38
+ * `ref: refs/heads/.invalid`.
39
+ */
40
+ export interface OwnGitCapability {
41
+ createsWorktree?: "branch";
42
+ rollback?: WorktreeRollback;
43
+ }
44
+ /**
45
+ * What a worktree-creating call site declares it creates, so a result refused AFTER its child ran (a commondir raced
46
+ * mid-child) is rolled back: the checkout `dir` and its registration, and — when the add creates or moves one — the
47
+ * branch (`prior` restores a moved branch; without it the new branch is deleted). The rollback runs through the
48
+ * attempt's own trusted common directory (TrustedStoreCapability), never the caller's checkout, so it completes while
49
+ * the caller's commondir stays hostile.
50
+ */
51
+ export interface WorktreeRollback {
52
+ dir: string;
53
+ branch?: {
54
+ name: string;
55
+ prior?: string;
56
+ };
57
+ }
58
+ /**
59
+ * Internal to addDetachedWorktree's add step: its ref store is pinned to the trusted common directory itself, not to a
60
+ * view. git stubs the new checkout's HEAD under ANY ref payload and, through a view, would resolve that stub into a
61
+ * shared `refs/heads/.invalid`; naming the common directory keeps it in the new gitdir, where the next step replaces
62
+ * it. That step writes no ref of the CALLER's checkout, so a commondir removed under it has no HEAD to redirect.
63
+ */
64
+ export interface DetachedAddCapability {
65
+ createsWorktree: "detached";
66
+ rollback: WorktreeRollback;
67
+ }
68
+ /**
69
+ * v2.6.8 T2: cleanup authority. The child reads and writes ONLY `store` — a trusted common directory derived once from
70
+ * the trusted layout (gitfile and `<common>/worktrees/<name>`, never a mutable commondir) — as an ordinary repository:
71
+ * GIT_DIR names it and every inherited redirect (common dir, work tree, ref backend, index, objects) is removed. It never
72
+ * consults the caller's checkout, so removal completes even while that checkout's commondir stays hostile.
73
+ */
74
+ export interface TrustedStoreCapability {
75
+ store: string;
76
+ }
77
+ /**
78
+ * v2.6.8 T2 (P3): the same uncached check, returning the child of tickmarkr's OWN git in a linked checkout — `env`
79
+ * (the caller's) with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set to the canonical gitdir, trusted common directory
80
+ * and worktree root derived above, AND the ref store: GIT_REFERENCE_BACKEND names this attempt's private view of the
81
+ * trusted store (refStoreView), since git's ref store otherwise resolves shared refs through the gitdir's commondir
82
+ * FILE even under GIT_COMMON_DIR. So a commondir rewritten after this check (by a live worker or a surviving
83
+ * descendant) is followed for neither config, hooks, info/, objects nor refs: HEAD, branches, status and diffs read
84
+ * the real repository and every ref write lands in the trusted store, whatever an inherited GIT_CONFIG_* or ref backend
85
+ * says; a commondir REMOVED after it cannot move the main checkout's branch. Before such a child spawns, the git it
86
+ * would run must prove it honours that pin (refStorePinHonoured); one that cannot (git < 2.54) runs without it, below.
87
+ * Nothing here reads the command, and no caller leaves any pin. `recheck` (after the child exits) fails closed on ANY change to commondir since the check: the result
88
+ * of that child is never returned. An ordinary checkout or a non-repository gets no pin.
89
+ * NAMED RESIDUAL: git still reads $GIT_DIR/config.worktree when the trusted common config already enables
90
+ * extensions.worktreeConfig, so a config.worktree planted AFTER this check is not closed.
91
+ */
92
+ export declare function ownGitPin(cwd: string, env: NodeJS.ProcessEnv, capability?: OwnGitCapability | DetachedAddCapability | TrustedStoreCapability): OwnGitPin | undefined;
18
93
  /** The inert hooks directory every protected git child receives (no hook can exist beneath it). */
19
94
  export declare const INERT_HOOKS_PATH = "/dev/null";
20
95
  /**
21
96
  * A copy of `env` whose git children run with core.fsmonitor=false and an inert core.hooksPath, despite repo
22
97
  * config and inherited GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS: git applies GIT_CONFIG_PARAMETERS after
23
98
  * GIT_CONFIG_COUNT and the last value wins, so the forced pair is appended last. Every other value survives.
24
- * Named keys only — this does not neutralize arbitrary executable drivers. The pair is inherited by every
25
- * descendant of the protected child (a suite that needs real hooks must clear it for its own git).
99
+ * Named keys only — this does not neutralize arbitrary executable drivers. v2.6.8 T2: only tickmarkr's OWN git
100
+ * children (shGit/shGitOk) receive it; payload shells (gate commands, suites) keep the operator's git config.
26
101
  */
27
102
  export declare function protectedGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
3
4
  import { basename, dirname, join } from "node:path";
4
5
  // v2.6.7 T4 (GHSA-2): a seat-run linked worktree's gitdir is seat-writable (the Codex worker grant), so its
5
6
  // `commondir` and `config.worktree` can redirect every later unsandboxed host git in that checkout to a
@@ -10,10 +11,12 @@ import { basename, dirname, join } from "node:path";
10
11
  /** A covered git invocation refused before spawn; `path` names the offending metadata. */
11
12
  export class GitTrustRefusal extends Error {
12
13
  path;
13
- constructor(path, detail) {
14
- super(`git trust refusal: ${path}: ${detail}`);
14
+ detail;
15
+ constructor(path, detail, options) {
16
+ super(`git trust refusal: ${path}: ${detail}`, options);
15
17
  this.name = "GitTrustRefusal";
16
18
  this.path = path;
19
+ this.detail = detail;
17
20
  }
18
21
  }
19
22
  /** The nearest `.git` entry at or above cwd, or undefined outside any checkout. */
@@ -113,37 +116,55 @@ export function trustedCommonDir(cwd) {
113
116
  throw new GitTrustRefusal(cwd, "no checkout encloses this directory");
114
117
  return authorityOf(dotGit).common;
115
118
  }
116
- /** Read failures and unparseable values never become clean authority. */
117
- function worktreeConfigEnabled(common) {
119
+ /** One key of the trusted common config. Read failures and unparseable values never become clean authority. */
120
+ function trustedConfig(common, key, type) {
118
121
  const config = join(common, "config");
119
122
  if (!present(config))
120
123
  throw new GitTrustRefusal(config, "the trusted common config is missing");
121
124
  try {
122
- return execFileSync("git", ["config", "--file", config, "--type=bool", "--get", "extensions.worktreeConfig"], {
125
+ return execFileSync("git", ["config", "--file", config, ...(type ? [`--type=${type}`] : []), "--get", key], {
123
126
  cwd: "/", encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15_000,
124
- }).trim() === "true";
127
+ }).trim();
125
128
  }
126
129
  catch (error) {
127
130
  const e = error;
128
131
  if (e.status === 1 && !e.stderr?.trim())
129
- return false; // the key is absent
130
- throw new GitTrustRefusal(config, `cannot read extensions.worktreeConfig (${(e.stderr ?? String(error)).trim().split("\n")[0]})`);
132
+ return undefined; // the key is absent
133
+ throw new GitTrustRefusal(config, `cannot read ${key} (${(e.stderr ?? String(error)).trim().split("\n")[0]})`);
131
134
  }
132
135
  }
133
- /**
134
- * The uncached pre-spawn check for a covered git invocation at `cwd`. Outside any checkout it is a no-op
135
- * (unrelated shell commands stay compatible). Inside one it refuses, naming the file, when the commondir git
136
- * would follow resolves anywhere but the expected common directory, or when the trusted common config enables
137
- * extensions.worktreeConfig and the linked gitdir holds a config.worktree.
138
- */
139
- export function assertGitTrust(cwd) {
136
+ const worktreeConfigEnabled = (common) => trustedConfig(common, "extensions.worktreeConfig", "bool") === "true";
137
+ /** commondir's identity: ctime moves on every write, rename, unlink/recreate or restore, and no caller can set it. */
138
+ const stampOf = (path) => {
139
+ try {
140
+ const s = lstatSync(path, { bigint: true });
141
+ return `${s.dev}:${s.ino}:${s.ctimeNs}`;
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ };
147
+ const isSymlink = (path) => { try {
148
+ return lstatSync(path).isSymbolicLink();
149
+ }
150
+ catch {
151
+ return false;
152
+ } };
153
+ /** The checked authority for `cwd` (see assertGitTrust), with the canonical worktree root the gitfile sits in. */
154
+ function checkedAuthority(cwd) {
140
155
  const dotGit = dotGitOf(cwd);
141
156
  if (!dotGit)
142
- return;
157
+ return undefined;
143
158
  const { gitdir, common, linked } = authorityOf(dotGit);
144
159
  const commondir = join(gitdir, "commondir");
160
+ // stamped BEFORE its bytes are read, so any change after this read moves the stamp
161
+ const stamp = linked ? stampOf(commondir) : undefined;
145
162
  if (linked && !present(commondir))
146
163
  throw new GitTrustRefusal(commondir, `is missing, so git would treat the seat-writable gitdir as its own repository; expected ${common}`);
164
+ // git writes commondir as a regular file; through a symlink the bytes git reads could be rewritten and restored
165
+ // without moving the link's own stamp, so that layout is refused rather than trusted
166
+ if (linked && isSymlink(commondir))
167
+ throw new GitTrustRefusal(commondir, "is a symbolic link, so its target could change without moving its stamp");
147
168
  if (present(commondir)) {
148
169
  // read as git reads it (resolveAsGit), never trimmed or lexically normalized: either would let a seat-written
149
170
  // path that names the real common directory to us lead git somewhere else
@@ -161,6 +182,141 @@ export function assertGitTrust(cwd) {
161
182
  if (linked && present(worktreeConfig) && worktreeConfigEnabled(common)) {
162
183
  throw new GitTrustRefusal(worktreeConfig, `extensions.worktreeConfig is enabled in ${join(common, "config")}, so git would read this seat-writable file`);
163
184
  }
185
+ return { gitdir, common, linked, worktree: dirname(dotGit), stamp };
186
+ }
187
+ /**
188
+ * The uncached pre-spawn check for a covered git invocation at `cwd`. Outside any checkout it is a no-op
189
+ * (unrelated shell commands stay compatible). Inside one it refuses, naming the file, when the commondir git
190
+ * would follow resolves anywhere but the expected common directory, or when the trusted common config enables
191
+ * extensions.worktreeConfig and the linked gitdir holds a config.worktree.
192
+ */
193
+ export function assertGitTrust(cwd) {
194
+ checkedAuthority(cwd);
195
+ }
196
+ function storePin(store, env) {
197
+ if (!present(join(store, "HEAD")) || present(join(store, "commondir")))
198
+ throw new GitTrustRefusal(store, "is not an ordinary git directory");
199
+ const child = { ...env, GIT_DIR: store };
200
+ for (const k of ["GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_REFERENCE_BACKEND", "GIT_REF_STORAGE_FORMAT", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"])
201
+ delete child[k];
202
+ return { env: child, store, recheck: () => { }, release: () => { }, unview: (text) => text };
203
+ }
204
+ /**
205
+ * v2.6.8 T2: the ref store an own-git child in a linked checkout is pinned to — a private directory of this attempt
206
+ * whose `refs`, `packed-refs`, `logs`, `worktrees` and `HEAD` are links into the TRUSTED common directory, beside a
207
+ * `HEAD.lock` nothing removes. git decides "linked" from the gitdir's commondir FILE alone: while it is present the
208
+ * child reads this checkout's own HEAD from <view>/worktrees/<id> (its trusted gitdir) and shared refs from the trusted
209
+ * store; when a seat REMOVES it before the child starts, git treats the view as the repository, so no HEAD-relative
210
+ * write (commit, merge, cherry-pick, reset, checkout, update-ref HEAD) can take the view's HEAD lock and nothing lands
211
+ * on the main checkout's branch — the recheck then refuses the result. That lock also refuses a write of the MAIN
212
+ * worktree's HEAD from a linked checkout's own git. Only the files format can be viewed this way: any other
213
+ * `extensions.refStorage` is refused by name, never run unpinned.
214
+ */
215
+ function refStoreView(common) {
216
+ const view = realpathSync(mkdtempSync(join(tmpdir(), "tickmarkr-refs-")));
217
+ const release = () => rmSync(view, { recursive: true, force: true }); // unlinks the links, never their targets
218
+ try {
219
+ for (const name of ["refs", "packed-refs", "logs", "worktrees", "HEAD"])
220
+ symlinkSync(join(common, name), join(view, name));
221
+ writeFileSync(join(view, "HEAD.lock"), "");
222
+ }
223
+ catch (error) {
224
+ release();
225
+ throw error;
226
+ }
227
+ return { uri: `files://${view}`, release, unview: (text) => text.split(view).join(common) };
228
+ }
229
+ function assertFilesRefStore(common) {
230
+ const format = trustedConfig(common, "extensions.refStorage") ?? "files";
231
+ if (format !== "files") {
232
+ throw new GitTrustRefusal(join(common, "config"), `extensions.refStorage is ${format}; tickmarkr's own git can pin only the files ref store, so it is refused instead of running unpinned`);
233
+ }
234
+ }
235
+ /** A detached auto gc or maintenance would outlive its child and read refs through a view already released. */
236
+ const NO_BACKGROUND_GIT = "'gc.auto=0' 'maintenance.auto=false'";
237
+ const PROBE_REF = "refs/tickmarkr/ref-pin-probe", PROBE_TARGET = "refs/tickmarkr/ref-pin-honoured";
238
+ /**
239
+ * Whether, before the child spawns, the git its PATH resolves honours GIT_REFERENCE_BACKEND (git >= 2.54): a fresh
240
+ * probe repository whose own refs lack PROBE_REF, pinned to a fresh store that holds it as a symbolic ref. A git that
241
+ * ignores the pin reads the probe repository's own refs. Nothing is cached: each attempt probes again.
242
+ */
243
+ function refStorePinHonoured(env) {
244
+ const dir = mkdtempSync(join(tmpdir(), "tickmarkr-ref-pin-"));
245
+ try {
246
+ const repo = join(dir, "repo"), store = join(dir, "store");
247
+ for (const d of [join(repo, "objects"), join(repo, "refs"), join(store, "refs", "tickmarkr")])
248
+ mkdirSync(d, { recursive: true });
249
+ for (const head of [join(repo, "HEAD"), join(store, "HEAD")])
250
+ writeFileSync(head, "ref: refs/heads/main\n");
251
+ writeFileSync(join(store, PROBE_REF), `ref: ${PROBE_TARGET}\n`);
252
+ const probe = { ...env, GIT_DIR: repo, GIT_REFERENCE_BACKEND: `files://${store}` };
253
+ for (const k of ["GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"])
254
+ delete probe[k];
255
+ let read = "";
256
+ try {
257
+ read = execFileSync("git", ["symbolic-ref", PROBE_REF], { cwd: dir, env: probe, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15_000 }).trim();
258
+ }
259
+ catch { /* refused below */ }
260
+ return read === PROBE_TARGET;
261
+ }
262
+ finally {
263
+ rmSync(dir, { recursive: true, force: true });
264
+ }
265
+ }
266
+ let warnedRefStorePin = false;
267
+ /** Said once per process: the fallback below is a weaker guard, and the operator should know why and what lifts it. */
268
+ function warnRefStorePinUnavailable() {
269
+ if (warnedRefStorePin)
270
+ return;
271
+ warnedRefStorePin = true;
272
+ console.error("tickmarkr: the git on this PATH does not honour GIT_REFERENCE_BACKEND (needs git 2.54 or newer), so tickmarkr's own git in linked checkouts runs without its ref-store pin — path pins and the post-exit commondir recheck only, the 2.6.7 guard; upgrade git for the full pin");
273
+ }
274
+ /**
275
+ * v2.6.8 T2 (P3): the same uncached check, returning the child of tickmarkr's OWN git in a linked checkout — `env`
276
+ * (the caller's) with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set to the canonical gitdir, trusted common directory
277
+ * and worktree root derived above, AND the ref store: GIT_REFERENCE_BACKEND names this attempt's private view of the
278
+ * trusted store (refStoreView), since git's ref store otherwise resolves shared refs through the gitdir's commondir
279
+ * FILE even under GIT_COMMON_DIR. So a commondir rewritten after this check (by a live worker or a surviving
280
+ * descendant) is followed for neither config, hooks, info/, objects nor refs: HEAD, branches, status and diffs read
281
+ * the real repository and every ref write lands in the trusted store, whatever an inherited GIT_CONFIG_* or ref backend
282
+ * says; a commondir REMOVED after it cannot move the main checkout's branch. Before such a child spawns, the git it
283
+ * would run must prove it honours that pin (refStorePinHonoured); one that cannot (git < 2.54) runs without it, below.
284
+ * Nothing here reads the command, and no caller leaves any pin. `recheck` (after the child exits) fails closed on ANY change to commondir since the check: the result
285
+ * of that child is never returned. An ordinary checkout or a non-repository gets no pin.
286
+ * NAMED RESIDUAL: git still reads $GIT_DIR/config.worktree when the trusted common config already enables
287
+ * extensions.worktreeConfig, so a config.worktree planted AFTER this check is not closed.
288
+ */
289
+ export function ownGitPin(cwd, env, capability = {}) {
290
+ if ("store" in capability)
291
+ return storePin(capability.store, env);
292
+ const { createsWorktree } = capability;
293
+ const authority = checkedAuthority(cwd);
294
+ if (!authority?.linked)
295
+ return undefined;
296
+ const { gitdir, common, worktree, stamp } = authority;
297
+ const child = { ...env, GIT_DIR: gitdir, GIT_COMMON_DIR: common, GIT_WORK_TREE: worktree,
298
+ GIT_CONFIG_PARAMETERS: `${env.GIT_CONFIG_PARAMETERS?.trim() ?? ""} ${NO_BACKGROUND_GIT}`.trim() };
299
+ // GIT_REF_STORAGE_FORMAT only chooses a NEW repository's format; an inherited one is never a store this child follows
300
+ delete child.GIT_REF_STORAGE_FORMAT;
301
+ assertFilesRefStore(common);
302
+ const commondir = join(gitdir, "commondir");
303
+ const recheck = () => {
304
+ if (stampOf(commondir) !== stamp) {
305
+ throw new GitTrustRefusal(commondir, "changed while tickmarkr's own git ran in this checkout, so that result is refused");
306
+ }
307
+ };
308
+ // D-1484: a git older than 2.54 cannot take the ref-store pin. Refusing it would refuse tickmarkr's own git in every
309
+ // linked checkout on most installed gits, and a hostile git binary ignores every pin anyway; so it runs with the
310
+ // path pins and the post-exit recheck (the 2.6.7 guard). NAMED RESIDUAL there: refs still resolve through the
311
+ // commondir file, so a rewrite between check and exit can reach the hostile store before the recheck refuses.
312
+ if (!refStorePinHonoured(child)) {
313
+ delete child.GIT_REFERENCE_BACKEND;
314
+ warnRefStorePinUnavailable();
315
+ return { env: child, store: common, release: () => { }, unview: (text) => text, recheck };
316
+ }
317
+ const view = createsWorktree === "detached" ? { uri: `files://${common}`, release: () => { }, unview: (text) => text } : refStoreView(common);
318
+ child.GIT_REFERENCE_BACKEND = view.uri;
319
+ return { env: child, store: common, release: view.release, unview: view.unview, recheck };
164
320
  }
165
321
  /** The inert hooks directory every protected git child receives (no hook can exist beneath it). */
166
322
  export const INERT_HOOKS_PATH = "/dev/null";
@@ -169,8 +325,8 @@ const FORCED_GIT_CONFIG = `'core.fsmonitor=false' 'core.hooksPath=${INERT_HOOKS_
169
325
  * A copy of `env` whose git children run with core.fsmonitor=false and an inert core.hooksPath, despite repo
170
326
  * config and inherited GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS: git applies GIT_CONFIG_PARAMETERS after
171
327
  * GIT_CONFIG_COUNT and the last value wins, so the forced pair is appended last. Every other value survives.
172
- * Named keys only — this does not neutralize arbitrary executable drivers. The pair is inherited by every
173
- * descendant of the protected child (a suite that needs real hooks must clear it for its own git).
328
+ * Named keys only — this does not neutralize arbitrary executable drivers. v2.6.8 T2: only tickmarkr's OWN git
329
+ * children (shGit/shGitOk) receive it; payload shells (gate commands, suites) keep the operator's git config.
174
330
  */
175
331
  export function protectedGitEnv(env) {
176
332
  const prior = env.GIT_CONFIG_PARAMETERS?.trim();