tickmarkr 2.1.9 → 2.2.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.
Files changed (40) hide show
  1. package/dist/adapters/model-lints.js +9 -0
  2. package/dist/adapters/pi.d.ts +1 -0
  3. package/dist/adapters/pi.js +15 -1
  4. package/dist/adapters/prompt.js +11 -3
  5. package/dist/adapters/registry.js +13 -4
  6. package/dist/adapters/types.d.ts +3 -0
  7. package/dist/adapters/types.js +1 -0
  8. package/dist/cli/commands/approve.d.ts +20 -0
  9. package/dist/cli/commands/approve.js +127 -35
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +14 -4
  12. package/dist/cli/commands/plan.js +4 -1
  13. package/dist/cli/commands/verify.js +2 -0
  14. package/dist/cli/commands/version.d.ts +2 -1
  15. package/dist/cli/commands/version.js +25 -4
  16. package/dist/compile/collateral.js +149 -4
  17. package/dist/compile/native.js +2 -0
  18. package/dist/drivers/index.d.ts +1 -0
  19. package/dist/drivers/index.js +25 -10
  20. package/dist/drivers/orca.d.ts +19 -0
  21. package/dist/drivers/orca.js +45 -9
  22. package/dist/gates/baseline.d.ts +2 -0
  23. package/dist/gates/baseline.js +49 -14
  24. package/dist/gates/llm.d.ts +1 -1
  25. package/dist/gates/llm.js +41 -26
  26. package/dist/gates/review.js +2 -0
  27. package/dist/gates/run-gates.d.ts +6 -0
  28. package/dist/gates/run-gates.js +9 -1
  29. package/dist/run/consult.d.ts +7 -0
  30. package/dist/run/consult.js +16 -2
  31. package/dist/run/daemon.d.ts +12 -6
  32. package/dist/run/daemon.js +306 -24
  33. package/dist/run/journal.d.ts +16 -0
  34. package/dist/run/journal.js +51 -6
  35. package/dist/tui/cockpit/setup-cockpit.d.ts +8 -10
  36. package/dist/tui/cockpit/setup-cockpit.js +92 -53
  37. package/package.json +1 -1
  38. package/skills/tickmarkr-loop/SKILL.md +1 -0
  39. package/skills/tickmarkr-overseer/SKILL.md +17 -0
  40. package/skills/tickmarkr-overseer/scripts/watch-context.sh +2 -2
@@ -148,6 +148,40 @@ export function upheldFeedbackByTask(events) {
148
148
  }
149
149
  return upheld;
150
150
  }
151
+ const CONSULT_ACTIONS = ["retry", "reroute", "decompose", "human"];
152
+ const consultAction = (action) => typeof action === "string" && CONSULT_ACTIONS.includes(action)
153
+ ? action
154
+ : undefined;
155
+ /**
156
+ * The newest structured consult prescription still open for this task. A consult park is only a
157
+ * pause: graph-changed revival can dispatch a fresh worker without a live `applyVerdict()` frame, so
158
+ * prompt-build re-derives the worker-facing bullets from the journal. Notes are deliberately excluded:
159
+ * they are audit prose, and OBS-37a says workers get only structured reason/guidance rendered by
160
+ * `renderRetryGuidance()`.
161
+ */
162
+ export function outstandingConsultGuidance(events, taskId) {
163
+ let carry;
164
+ for (const e of events) {
165
+ if (e.taskId !== taskId)
166
+ continue;
167
+ if (e.event === "consult-verdict") {
168
+ const action = consultAction(e.data.action);
169
+ const reason = typeof e.data.reason === "string" && e.data.reason.trim() ? e.data.reason : undefined;
170
+ const guidance = typeof e.data.guidance === "string" && e.data.guidance.trim() ? e.data.guidance : undefined;
171
+ carry = action && (reason || guidance)
172
+ ? {
173
+ action,
174
+ ...(reason ? { reason } : {}),
175
+ ...(guidance ? { guidance } : {}),
176
+ }
177
+ : undefined;
178
+ }
179
+ else if (e.event === "task-done" || e.event === "merge") {
180
+ carry = undefined;
181
+ }
182
+ }
183
+ return carry;
184
+ }
151
185
  // Reserved for a finding whose OWN evidence names no path. Reporting a blank path a reader would take
152
186
  // for a resolved one is the silent-lie shape the gates exist to refuse, so the field says so outright.
153
187
  export const UNIDENTIFIED = "<unidentified>";
@@ -1360,16 +1394,27 @@ export class Journal {
1360
1394
  }
1361
1395
  return m;
1362
1396
  }
1363
- // OBS-130: gate satisfaction is authority, not an inferred daemon state. Only an explicit
1364
- // task-approved event with the typed release marker and a known gate enters this fold. A daemon-made
1365
- // gate-satisfied event, a prior pass/fail result, malformed data, or another task's approval is inert.
1397
+ // OBS-130/OBS-571: gate satisfaction is authority, not an inferred daemon state. Only an explicit
1398
+ // task-approved event with the typed release marker and a known gate enters this fold. Authority is
1399
+ // scoped to that approval: any later approval supersedes it, and the worktree-recreation row written
1400
+ // before the approved gate suffix runs consumes it at enactment. A daemon-made gate-satisfied event,
1401
+ // a prior pass/fail result, malformed data, or another task's approval is inert.
1366
1402
  replaySatisfiedGates() {
1367
1403
  const satisfied = new Map();
1368
1404
  for (const e of this.read()) {
1369
- if (e.event !== "task-approved" || !e.taskId || e.data.release !== GATE_SATISFIED_RELEASE)
1405
+ if (!e.taskId)
1370
1406
  continue;
1371
- if (typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate)) {
1372
- satisfied.set(e.taskId, e.data.gate);
1407
+ if (e.event === "worktree-recreation") {
1408
+ satisfied.delete(e.taskId);
1409
+ continue;
1410
+ }
1411
+ if (e.event === "task-approved") {
1412
+ satisfied.delete(e.taskId);
1413
+ if (e.data.release === GATE_SATISFIED_RELEASE
1414
+ && typeof e.data.gate === "string"
1415
+ && GATE_NAMES.includes(e.data.gate)) {
1416
+ satisfied.set(e.taskId, e.data.gate);
1417
+ }
1373
1418
  }
1374
1419
  }
1375
1420
  return satisfied;
@@ -1,4 +1,5 @@
1
1
  import { type ReactElement } from "react";
2
+ import { type ApprovalDisposition, type ApprovalRunOwner } from "../../cli/commands/approve.js";
2
3
  import { Journal } from "../../run/journal.js";
3
4
  import { type TickmarkrConfig } from "../../config/config.js";
4
5
  import { type ComponentState, type JournalRow } from "./components.js";
@@ -73,15 +74,8 @@ export type ParkedDecision = {
73
74
  };
74
75
  /** The parks a verb can release — the rows the surface draws live verbs on. */
75
76
  export declare function actionableDecisions(decisions: readonly ParkedDecision[]): readonly ParkedDecision[];
76
- /**
77
- * The decisions section's rows: one per task the journal's own status replay
78
- * leaves parked on a human, in the order the journal first named them. The
79
- * replay is the production one (`Journal.replayStatuses`) so this surface and
80
- * `tickmarkr status` can never disagree about what is parked.
81
- */
82
77
  export declare function deriveParkedDecisions(journal: Journal): readonly ParkedDecision[];
83
- /** The two decisions a parked task offers — the production command's two verbs. */
84
- export type SetupDecisionVerb = "approve" | "uphold";
78
+ export type SetupDecisionVerb = "approve" | "waive" | "uphold" | "recheck";
85
79
  /** A decision the operator has named but not yet confirmed. */
86
80
  export type SetupDecisionCommand = {
87
81
  readonly verb: SetupDecisionVerb;
@@ -93,6 +87,7 @@ export type SetupDecisionWrite = {
93
87
  readonly verb: SetupDecisionVerb;
94
88
  /** The release marker read back from the recorded task-approved event. */
95
89
  readonly release: string | undefined;
90
+ readonly disposition: ApprovalDisposition;
96
91
  readonly by: string;
97
92
  };
98
93
  /**
@@ -120,6 +115,7 @@ export type SetupDecisionsKeyResult = {
120
115
  * inert and the keybar does not advertise it — the surface never promises a
121
116
  * decision the command must refuse.
122
117
  */
118
+ export declare function setupDecisionVerbs(decision: ParkedDecision): readonly SetupDecisionVerb[];
123
119
  export declare function upholdAvailable(decision: ParkedDecision): boolean;
124
120
  /**
125
121
  * The decisions surface's whole key grammar: ↑↓ move, a approve, u uphold,
@@ -160,7 +156,7 @@ export declare function recordSetupDecisionOutcome(session: SetupDecisionsSessio
160
156
  * The confirm inset's lines: the task, the actor, the effect and the file,
161
157
  * stated before the single key that can write.
162
158
  */
163
- export declare function setupDecisionConfirmLines(command: SetupDecisionCommand, decision: ParkedDecision | undefined, actor: string, journalFile: string): readonly string[];
159
+ export declare function setupDecisionConfirmLines(command: SetupDecisionCommand, decision: ParkedDecision | undefined, actor: string, journalFile: string, run: ApprovalRunOwner): readonly string[];
164
160
  /**
165
161
  * The decisions surface. Every row it draws — decisions, the confirm inset,
166
162
  * the pending writes region — is measured and clipped through the width
@@ -168,12 +164,14 @@ export declare function setupDecisionConfirmLines(command: SetupDecisionCommand,
168
164
  * neither overflow its row nor be cut mid-cluster. The one unclipped element
169
165
  * is `notice`: the production command's own words, verbatim.
170
166
  */
171
- export declare function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, }: {
167
+ export declare function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, run, }: {
172
168
  decisions: readonly ParkedDecision[];
173
169
  session: SetupDecisionsSession;
174
170
  columns: number;
175
171
  actor: string;
176
172
  journalFile: string;
173
+ /** Who enacts a confirmed write — read from the run lock, never assumed. */
174
+ run: ApprovalRunOwner;
177
175
  }): ReactElement;
178
176
  /**
179
177
  * The setup decisions surface on a live engagement. This is the only cockpit
@@ -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.1",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@ Outside a multi-agent terminal environment, run the loop directly.
29
29
  - Do not edit the compiled graph to force an outcome; fix the source spec and compile again.
30
30
  - Gates verify commits, diffs, acceptance criteria, and reviews independently. Never trust a worker's claim that work is complete.
31
31
  - Treat missing or unparseable machine results and verdicts as failures. Do not release, resume, or merge around failed gates.
32
+ - A task changing what the daemon DOES must own every surface that TELLS the operator what the daemon does.
32
33
 
33
34
  ## Act by default
34
35
 
@@ -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
@@ -749,6 +755,10 @@ once** — an overseer ran nine hours at 86% unable to read its own number. Arm
749
755
  Herdr's `agent_status`, which is unreliable for vendors whose screen detection is skipped (kimi) — for
750
756
  those, the spawn-time auto-approve flag is the control, not this watcher.
751
757
 
758
+ For `consult`, `surgeon`, and `reviewer` seats, the CONTEXT watcher is wake-only: arm it with
759
+ `TKR_AUTO_CLEAR=0 .claude/skills/tickmarkr-overseer/scripts/watch-context.sh <consult|surgeon|reviewer>
760
+ <agent|pane> 50 50 <handoff-file>`. Only `orchestrator` and `overseer` seats may use auto-clear.
761
+
752
762
  ```bash
753
763
  .claude/skills/tickmarkr-overseer/scripts/watch-pending-input.sh <agent|pane> [poll-s] [cap-s] [confirm-polls]
754
764
  ```
@@ -947,6 +957,13 @@ orchestrator turn boundary.
947
957
  leave clean state after a version is shipped"***. Every line below is a defect that actually happened
948
958
  on the release that produced this rule.
949
959
 
960
+ - **RUN BOTH RELEASE PROOFS BEFORE STANDING DOWN.** The first is the exported-tree suite, which
961
+ maintainers run from the private repo with its `verify:export` script — that tooling is deliberately
962
+ not part of this package, so the command does not exist in a public checkout; it must install,
963
+ build, and run the suite in the exported tree; then
964
+ `TICKMARKR_E2E=1 npx vitest run tests/e2e/orca-smoke.e2e.test.ts` must exercise the installed-Orca
965
+ smoke. A skipped smoke is not proof. Record both results before declaring the release complete.
966
+
950
967
  - **REWRITE THE MEMORY INDEX FIRST, and read it back.** Minutes after `2.1.1` hit npm, the index line
951
968
  a fresh session loads still read *"⛔ 2.1.1 CANNOT ship from run …2011"* — true when written, and by
952
969
  then the exact opposite of the truth. **The index is what everyone loads and the body is what nobody
@@ -33,7 +33,7 @@
33
33
  # usage: watch-context.sh <role-slug> <agent|pane> <warn-pct> <act-pct> [handoff-file] [poll-s] [cap-s]
34
34
  # <role-slug> is ANY seat role — orchestrator, overseer, surgeon, consult — and names the tier
35
35
  # `<role>-context`. It is deliberately NOT a closed set: see OBS-730 at the guard below.
36
- # TKR_AUTO_CLEAR=1 at act-pct WITH a fresh handoff, send /clear and re-brief instead of waking.
36
+ # TKR_AUTO_CLEAR=1 at act-pct WITH a fresh handoff, auto-clear orchestrator/overseer; other roles wake only.
37
37
  # TKR_REBRIEF=<path> the file the re-briefed seat is told to read (defaults to the handoff).
38
38
  # TKR_HANDOFF_MAX_AGE_S how fresh "fresh" is (default 900).
39
39
  # TKR_CLEAR_SETTLE_S seconds to let a cleared seat settle before the re-brief (default 6).
@@ -221,7 +221,7 @@ handoff_fresh() {
221
221
  act_on() {
222
222
  local P="$1"
223
223
  if handoff_fresh; then
224
- if [ "${TKR_AUTO_CLEAR:-0}" = "1" ]; then
224
+ if [ "${TKR_AUTO_CLEAR:-0}" = "1" ] && { [ "$ROLE" = "orchestrator" ] || [ "$ROLE" = "overseer" ]; }; then
225
225
  herdr agent prompt "$TARGET" "/clear" >/dev/null 2>&1
226
226
  sleep "$SETTLE"
227
227
  herdr agent prompt "$TARGET" "Read ${REBRIEF} and continue exactly where it says. Your context was cleared at ${P}% against that handoff; it is current as of $(date '+%H:%M'). Do not reconstruct from memory — everything you need is on disk." >/dev/null 2>&1