tickmarkr 2.1.9 → 2.2.0

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.
@@ -4,8 +4,8 @@ import { userInfo } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { cloneElement, createElement, useState, } from "react";
6
6
  import { parse } from "yaml";
7
- import { approve } from "../../cli/commands/approve.js";
8
- import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, REVIEW_UPHELD_RELEASE, } from "../../run/journal.js";
7
+ import { approvalDispositionForRelease, approvalEnactment, approvalRunOwner, approve, } from "../../cli/commands/approve.js";
8
+ import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE, } from "../../run/journal.js";
9
9
  import { BANNER, GLYPHS, PLAIN_BANNER, } from "../../brand.js";
10
10
  import { DEFAULT_CONFIG, INTEGRITY_FLOOR_SHAPES, TIER_RANK, TickmarkrConfigSchema, unifiedYamlDiff, } from "../../config/config.js";
11
11
  import { GATE_NAMES, SHAPES } from "../../graph/schema.js";
@@ -560,6 +560,20 @@ export function actionableDecisions(decisions) {
560
560
  * replay is the production one (`Journal.replayStatuses`) so this surface and
561
561
  * `tickmarkr status` can never disagree about what is parked.
562
562
  */
563
+ function failedGateForNewestPark(events, taskId, parkedIndex) {
564
+ for (let index = parkedIndex - 1; index >= 0; index -= 1) {
565
+ const event = events[index];
566
+ if (event.taskId !== taskId)
567
+ continue;
568
+ if (event.event === "task-approved" || event.event === "task-human" || event.event === "task-dispatch")
569
+ return undefined;
570
+ if (event.event === "gate-result" && event.data.pass === false
571
+ && typeof event.data.gate === "string" && GATE_NAMES.includes(event.data.gate)) {
572
+ return event.data.gate;
573
+ }
574
+ }
575
+ return undefined;
576
+ }
563
577
  export function deriveParkedDecisions(journal) {
564
578
  const statuses = journal.replayStatuses();
565
579
  const events = journal.read();
@@ -579,12 +593,7 @@ export function deriveParkedDecisions(journal) {
579
593
  parkedIndex = index;
580
594
  }
581
595
  const parked = parkedIndex >= 0 ? events[parkedIndex] : undefined;
582
- // The same rule the production approve command applies: the newest failed
583
- // gate-result before the newest task-human controls what approve records
584
- // and whether uphold exists at all. Never inferred from the park's prose.
585
- const failedGate = events.slice(0, Math.max(0, parkedIndex)).reverse().find((event) => event.event === "gate-result" && event.taskId === taskId && event.data.pass === false
586
- && typeof event.data.gate === "string"
587
- && GATE_NAMES.includes(event.data.gate))?.data.gate;
596
+ const failedGate = failedGateForNewestPark(events, taskId, parkedIndex);
588
597
  const kind = typeof parked?.data.kind === "string" ? parked.data.kind : "human-gate";
589
598
  const reason = typeof parked?.data.reason === "string" ? parked.data.reason : undefined;
590
599
  decisions.push({
@@ -610,8 +619,17 @@ export function initialSetupDecisionsSession() {
610
619
  * inert and the keybar does not advertise it — the surface never promises a
611
620
  * decision the command must refuse.
612
621
  */
622
+ export function setupDecisionVerbs(decision) {
623
+ if (decision.tombstone)
624
+ return [];
625
+ if (decision.kind !== "gate-fail")
626
+ return ["approve"];
627
+ if (decision.failedGate === undefined)
628
+ return [];
629
+ return decision.failedGate === "review" ? ["waive", "uphold", "recheck"] : ["waive", "recheck"];
630
+ }
613
631
  export function upholdAvailable(decision) {
614
- return decision.failedGate === "review";
632
+ return setupDecisionVerbs(decision).includes("uphold");
615
633
  }
616
634
  /**
617
635
  * The decisions surface's whole key grammar: ↑↓ move, a approve, u uphold,
@@ -650,25 +668,30 @@ export function applySetupDecisionsKey(session, event, parked) {
650
668
  },
651
669
  };
652
670
  }
653
- if (event.input === "a" || event.input === "u") {
671
+ const verb = { a: "approve", w: "waive", u: "uphold", r: "recheck" }[event.input ?? ""];
672
+ if (verb !== undefined) {
654
673
  const decision = decisions[selection];
655
- if (decision === undefined)
656
- return { session };
657
- if (event.input === "u" && !upholdAvailable(decision))
674
+ if (decision === undefined || !setupDecisionVerbs(decision).includes(verb))
658
675
  return { session };
659
676
  return {
660
677
  session: {
661
678
  ...session,
662
679
  selection,
663
- confirming: {
664
- verb: event.input === "a" ? "approve" : "uphold",
665
- taskId: decision.taskId,
666
- },
680
+ confirming: { verb, taskId: decision.taskId },
667
681
  },
668
682
  };
669
683
  }
670
684
  return { session };
671
685
  }
686
+ const decisionFlag = (verb) => {
687
+ if (verb === "waive")
688
+ return ["--waive"];
689
+ if (verb === "uphold")
690
+ return ["--uphold"];
691
+ if (verb === "recheck")
692
+ return ["--recheck"];
693
+ return [];
694
+ };
672
695
  /**
673
696
  * THE write. It calls the production `approve` command — validation, fail-closed
674
697
  * refusals and the journal append are inherited, never re-implemented — and then
@@ -680,7 +703,7 @@ export async function executeSetupDecision(command, { cwd, runId, by }) {
680
703
  const message = await approve([
681
704
  runId,
682
705
  command.taskId,
683
- ...(command.verb === "uphold" ? ["--uphold"] : []),
706
+ ...decisionFlag(command.verb),
684
707
  "--by",
685
708
  by,
686
709
  ], cwd);
@@ -693,11 +716,12 @@ export async function executeSetupDecision(command, { cwd, runId, by }) {
693
716
  break;
694
717
  }
695
718
  }
719
+ const disposition = approvalDispositionForRelease(release);
696
720
  return {
697
721
  ok: true,
698
722
  command,
699
723
  message,
700
- write: { taskId: command.taskId, verb: command.verb, release, by },
724
+ write: { taskId: command.taskId, verb: command.verb, release, disposition, by },
701
725
  };
702
726
  }
703
727
  catch (error) {
@@ -726,52 +750,58 @@ function decisionRowText(decision) {
726
750
  * append — the release markers are the command's own constants, so this text
727
751
  * cannot drift from the write. An effect the operator cannot read here is an
728
752
  * effect this surface does not have.
753
+ *
754
+ * WHO enacts it is the command's own sentence (`approvalEnactment`), read from
755
+ * the same run-owner fact: predicting "the next resume" over a LIVE run was
756
+ * true only while the daemon read approvals once at startup. It now sweeps them
757
+ * at every task boundary, so that prediction would send the operator to start a
758
+ * second run in this repository — forbidden — for work already scheduled.
729
759
  */
730
- function decisionEffectLines(command, decision) {
731
- if (command.verb === "uphold") {
760
+ function decisionEffectLines(command, decision, run) {
761
+ if (command.verb === "waive") {
762
+ const gate = decision?.failedGate;
763
+ return gate === undefined ? ["effect none — waive refuses without a failed gate"] : [
764
+ `effect disposition waive-gate; appends release ${GATE_SATISFIED_RELEASE} for gate ${gate}`,
765
+ `it marks gate ${gate} satisfied; it does NOT mark the task done`,
766
+ approvalEnactment("waive-gate", run),
767
+ ];
768
+ }
769
+ if (command.verb === "recheck") {
732
770
  return [
733
- `effect appends one task-approved event with release ${REVIEW_UPHELD_RELEASE};`
734
- + " the next resume funds one fixed attempt carrying the findings",
735
- "it does NOT mark the task done or pass any gate",
771
+ `effect disposition re-dispatch; appends release ${RECHECK_RELEASE}`,
772
+ "it marks no gate satisfied",
773
+ approvalEnactment("re-dispatch", run),
736
774
  ];
737
775
  }
738
- if (decision?.kind === "gate-fail") {
739
- if (decision.failedGate === undefined) {
740
- // The command's own refusal, predicted: no write is promised here.
741
- return [
742
- "effect none — the command refuses: parked on gate-fail"
743
- + " but no failed gate result is recorded",
744
- ];
745
- }
746
- const gate = decision.failedGate;
776
+ if (command.verb === "uphold") {
747
777
  return [
748
- `effect appends one task-approved event with release ${GATE_SATISFIED_RELEASE}`
749
- + ` for gate ${gate}; the next resume continues past the approved gate`,
750
- `it marks gate ${gate} satisfied; it does NOT mark the task done`,
778
+ `effect disposition fund-fixed-attempt; appends release ${REVIEW_UPHELD_RELEASE}`,
779
+ approvalEnactment("fund-fixed-attempt", run),
751
780
  ];
752
781
  }
782
+ if (decision?.kind === "gate-fail") {
783
+ return ["effect none — approve is not offered on gate-fail parks; choose waive, recheck or uphold"];
784
+ }
753
785
  if (decision?.kind === ATTEMPT_CAP_RELEASE) {
754
786
  return [
755
- `effect appends one task-approved event with release ${ATTEMPT_CAP_RELEASE};`
756
- + ` the next resume dispatches ${command.taskId} with a fresh attempt budget`,
757
- "it does NOT mark the task done or pass any gate",
787
+ `effect disposition fresh-budget; appends release ${ATTEMPT_CAP_RELEASE}`,
788
+ approvalEnactment("fresh-budget", run),
758
789
  ];
759
790
  }
760
791
  return [
761
- "effect appends one task-approved event with no release;"
762
- + ` the next resume dispatches ${command.taskId}`,
763
- "it does NOT mark the task done or pass any gate",
792
+ `effect disposition dispatch; appends one task-approved event with no release`,
793
+ approvalEnactment("dispatch", run),
764
794
  ];
765
795
  }
766
796
  /**
767
797
  * The confirm inset's lines: the task, the actor, the effect and the file,
768
798
  * stated before the single key that can write.
769
799
  */
770
- export function setupDecisionConfirmLines(command, decision, actor, journalFile) {
800
+ export function setupDecisionConfirmLines(command, decision, actor, journalFile, run) {
771
801
  return [
772
802
  `task ${command.taskId}${decision === undefined ? "" : ` · ${decision.kind} · attempt ${decision.attempts}`}`,
773
803
  `actor ${actor}`,
774
- ...decisionEffectLines(command, decision),
804
+ ...decisionEffectLines(command, decision, run),
775
805
  `file ${journalFile} · append only`,
776
806
  `y ${command.verb} · n cancel`,
777
807
  ];
@@ -783,8 +813,13 @@ function setupDecisionsKeybar(session, decisions) {
783
813
  if (decisions.length === 0)
784
814
  return "q Quit";
785
815
  const selected = Math.min(session.selection, decisions.length - 1);
786
- const uphold = upholdAvailable(decisions[selected]) ? " · u Uphold" : "";
787
- return `↑↓ Move · a Approve${uphold} · q Quit`;
816
+ const verbs = setupDecisionVerbs(decisions[selected]).map((verb) => ({
817
+ approve: "a Approve",
818
+ waive: "w Waive",
819
+ uphold: "u Uphold",
820
+ recheck: "r Recheck",
821
+ })[verb]).join(" · ");
822
+ return `↑↓ Move · ${verbs} · q Quit`;
788
823
  }
789
824
  /**
790
825
  * What the tab is, drawn on the tab it names. The operator's v1.83 UAT read
@@ -793,7 +828,7 @@ function setupDecisionsKeybar(session, decisions) {
793
828
  * keybar hint also draw.
794
829
  */
795
830
  const DECISIONS_TAB_LINE = `tab ${DECISIONS_TAB_HINT} · journal-parked human decisions`
796
- + " · approve and uphold are its only writes";
831
+ + " · approve waive uphold and recheck are its only writes";
797
832
  /**
798
833
  * What the section says when no park needs the operator. It names its own
799
834
  * trigger and both verbs, so an operator who arrives at a quiet surface learns
@@ -801,8 +836,8 @@ const DECISIONS_TAB_LINE = `tab ${DECISIONS_TAB_HINT} · journal-parked human de
801
836
  */
802
837
  const DECISIONS_EMPTY_STATE = [
803
838
  "nothing needs you now — no task is parked on a human gate",
804
- "a task that parks on one appears here with its two decisions —"
805
- + " approve releases it, uphold sides with the reviewer",
839
+ "a task that parks on one appears here with its decisions —"
840
+ + " approve dispatches, waive accepts a gate, uphold funds a fix, recheck re-runs",
806
841
  ];
807
842
  /**
808
843
  * The decisions surface. Every row it draws — decisions, the confirm inset,
@@ -811,15 +846,16 @@ const DECISIONS_EMPTY_STATE = [
811
846
  * neither overflow its row nor be cut mid-cluster. The one unclipped element
812
847
  * is `notice`: the production command's own words, verbatim.
813
848
  */
814
- export function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, }) {
849
+ export function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, run, }) {
815
850
  const inner = Math.max(1, Math.floor(columns) - 4);
816
851
  const actionable = actionableDecisions(decisions);
817
852
  const tombstones = decisions.filter((decision) => decision.tombstone);
818
853
  const selected = Math.min(session.selection, Math.max(0, actionable.length - 1));
819
854
  const confirming = session.confirming;
820
- return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsxs(Panel, { title: `${DECISIONS_TAB_TITLE} · ${decisions.length} parked`, focused: true, children: [_jsx(BodyText, { emphasis: "dim", children: fitCells(DECISIONS_TAB_LINE, inner) }), actionable.map((decision, index) => (_jsx(BodyText, { emphasis: index === selected ? "strong" : "normal", children: fitCells(`${index === selected ? `${GLYPHS.pointer} ` : " "}${decisionRowText(decision)}`, inner) }, decision.taskId))), actionable.length === 0 && DECISIONS_EMPTY_STATE.map((line) => (_jsx(BodyText, { children: fitCells(line, inner) }, line))), tombstones.map((decision) => (_jsx(BodyText, { emphasis: "dim", children: fitCells(` read-only · permanent by design · ${decisionRowText(decision)}`, inner) }, decision.taskId)))] }), confirming !== null && (_jsx(Panel, { title: `CONFIRM ${confirming.verb.toUpperCase()}`, focused: true, children: setupDecisionConfirmLines(confirming, decisions.find((decision) => decision.taskId === confirming.taskId), actor, journalFile).map((line) => (_jsx(BodyText, { children: fitCells(line, inner) }, line))) })), session.notice !== null && _jsx(BodyText, { children: session.notice }), _jsx(Panel, { title: "PENDING WRITES", children: session.writes.length === 0
855
+ return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsxs(Panel, { title: `${DECISIONS_TAB_TITLE} · ${decisions.length} parked`, focused: true, children: [_jsx(BodyText, { emphasis: "dim", children: fitCells(DECISIONS_TAB_LINE, inner) }), actionable.map((decision, index) => (_jsx(BodyText, { emphasis: index === selected ? "strong" : "normal", children: fitCells(`${index === selected ? `${GLYPHS.pointer} ` : " "}${decisionRowText(decision)}`, inner) }, decision.taskId))), actionable.length === 0 && DECISIONS_EMPTY_STATE.map((line) => (_jsx(BodyText, { children: fitCells(line, inner) }, line))), tombstones.map((decision) => (_jsx(BodyText, { emphasis: "dim", children: fitCells(` read-only · permanent by design · ${decisionRowText(decision)}`, inner) }, decision.taskId)))] }), confirming !== null && (_jsx(Panel, { title: `CONFIRM ${confirming.verb.toUpperCase()}`, focused: true, children: setupDecisionConfirmLines(confirming, decisions.find((decision) => decision.taskId === confirming.taskId), actor, journalFile, run).map((line) => (_jsx(BodyText, { children: fitCells(line, inner) }, line))) })), session.notice !== null && _jsx(BodyText, { children: session.notice }), _jsx(Panel, { title: "PENDING WRITES", children: session.writes.length === 0
821
856
  ? _jsx(BodyText, { children: fitCells("nothing written this session", inner) })
822
- : session.writes.map((write) => (_jsx(BodyText, { children: fitCells(`${write.verb} ${write.taskId} · release ${write.release ?? "none"} · by ${write.by}`, inner) }, `${write.verb}:${write.taskId}`))) }), _jsx(BodyText, { emphasis: "dim", children: fitCells(setupDecisionsKeybar(session, actionable), Math.max(1, Math.floor(columns))) })] }));
857
+ : session.writes.map((write) => (_jsx(BodyText, { children: fitCells(`${write.verb} ${write.taskId} · disposition ${write.disposition}`
858
+ + ` · release ${write.release ?? "none"} · by ${write.by}`, inner) }, `${write.verb}:${write.taskId}`))) }), _jsx(BodyText, { emphasis: "dim", children: fitCells(setupDecisionsKeybar(session, actionable), Math.max(1, Math.floor(columns))) })] }));
823
859
  }
824
860
  function SetupDecisionsApp({ cwd, runId, actor, }) {
825
861
  const { exit } = useApp();
@@ -846,7 +882,10 @@ function SetupDecisionsApp({ cwd, runId, actor, }) {
846
882
  });
847
883
  }
848
884
  });
849
- return (_jsx(SetupDecisionsSurface, { decisions: decisions, session: session, columns: stdout.columns ?? 80, actor: actor, journalFile: join(stateDirName(cwd), "runs", runId, "journal.jsonl") }));
885
+ return (_jsx(SetupDecisionsSurface, { decisions: decisions, session: session, columns: stdout.columns ?? 80, actor: actor, journalFile: join(stateDirName(cwd), "runs", runId, "journal.jsonl"),
886
+ // read per render, not once at mount: a daemon can start or end while the
887
+ // operator is deciding, and the inset must not promise the wrong enactor.
888
+ run: approvalRunOwner(cwd, runId) }));
850
889
  }
851
890
  /**
852
891
  * The setup decisions surface on a live engagement. This is the only cockpit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.1.9",
3
+ "version": "2.2.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -487,6 +487,12 @@ they are left implicit:
487
487
  were authed on 2026-08-17 while every seat ran claude). Priority when independence is scarce:
488
488
  **verifier > checker > planner > executors** — the independent seat goes cross-vendor
489
489
  (`herdr agent start … --kind codex`), ruled at dispatch, never debated under time pressure.
490
+ **A codex seat inside a git WORKTREE cannot commit and cannot write outside the worktree** (OBS-824, measured
491
+ twice on 2026-09-01): its sandbox pins writes to the worktree's own path, and a worktree's `.git` is a FILE pointing
492
+ at the main repository's object store, so every `git commit` from inside it is refused. Brief such a seat to leave its
493
+ report INSIDE the worktree and to commit nothing — the overseer commits from the main checkout — or give the work to a
494
+ claude seat, or to a throwaway CLONE (a real `.git` directory). A brief that tells a codex-in-worktree seat to commit
495
+ buys a stall, not a commit.
490
496
  4. **Gate every exec lane with the shipped battery, not hand-rolled greps.**
491
497
  `tickmarkr verify --base <ref> --criteria <file>` is the standalone form of the engine's own gates —
492
498
  build/test/lint diffed against a recorded baseline, evidence, scope, plus the semantic judges — one