tickmarkr 1.81.0 → 1.83.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.
Files changed (49) hide show
  1. package/README.md +4 -4
  2. package/dist/brand.d.ts +4 -1
  3. package/dist/brand.js +46 -7
  4. package/dist/cli/commands/approve.js +53 -8
  5. package/dist/cli/commands/plan.js +17 -2
  6. package/dist/cli/commands/report.js +15 -6
  7. package/dist/cli/commands/ui.js +13 -1
  8. package/dist/cli/index.d.ts +1 -1
  9. package/dist/cli/index.js +1 -1
  10. package/dist/compile/collateral.d.ts +19 -0
  11. package/dist/compile/collateral.js +90 -0
  12. package/dist/compile/index.js +18 -4
  13. package/dist/drivers/herdr.d.ts +1 -0
  14. package/dist/drivers/herdr.js +19 -0
  15. package/dist/drivers/types.d.ts +1 -0
  16. package/dist/gates/acceptance.js +50 -8
  17. package/dist/gates/llm.js +34 -19
  18. package/dist/gates/review.d.ts +9 -1
  19. package/dist/gates/review.js +173 -7
  20. package/dist/gates/run-gates.d.ts +1 -0
  21. package/dist/gates/run-gates.js +18 -1
  22. package/dist/report/bundle.d.ts +6 -1
  23. package/dist/report/bundle.js +13 -3
  24. package/dist/run/daemon.d.ts +5 -0
  25. package/dist/run/daemon.js +188 -27
  26. package/dist/run/journal.d.ts +5 -0
  27. package/dist/run/journal.js +71 -1
  28. package/dist/tui/cockpit/capture.d.ts +107 -1
  29. package/dist/tui/cockpit/capture.js +287 -10
  30. package/dist/tui/cockpit/components.d.ts +12 -46
  31. package/dist/tui/cockpit/components.js +48 -48
  32. package/dist/tui/cockpit/derive.d.ts +73 -1
  33. package/dist/tui/cockpit/derive.js +211 -15
  34. package/dist/tui/cockpit/keys.d.ts +114 -19
  35. package/dist/tui/cockpit/keys.js +413 -44
  36. package/dist/tui/cockpit/layout.d.ts +162 -0
  37. package/dist/tui/cockpit/layout.js +229 -0
  38. package/dist/tui/cockpit/live.d.ts +52 -3
  39. package/dist/tui/cockpit/live.js +216 -40
  40. package/dist/tui/cockpit/run-cockpit.d.ts +64 -8
  41. package/dist/tui/cockpit/run-cockpit.js +686 -163
  42. package/dist/tui/cockpit/setup-cockpit.d.ts +146 -1
  43. package/dist/tui/cockpit/setup-cockpit.js +337 -5
  44. package/dist/tui/cockpit/views.d.ts +72 -0
  45. package/dist/tui/cockpit/views.js +56 -0
  46. package/dist/tui/cockpit/width.d.ts +105 -0
  47. package/dist/tui/cockpit/width.js +276 -0
  48. package/fixtures/speckit-sample/tasks.md +2 -2
  49. package/package.json +3 -1
@@ -1,8 +1,9 @@
1
1
  import { type ReactElement } from "react";
2
+ import { Journal } from "../../run/journal.js";
2
3
  import { type TickmarkrConfig } from "../../config/config.js";
3
4
  import { type ComponentState, type JournalRow } from "./components.js";
4
5
  import type { DemoCaptures } from "./demo.js";
5
- import { type SetupInteractionState, type SetupKeyBinding } from "./keys.js";
6
+ import { type RunKeyEvent, type SetupInteractionState, type SetupKeyBinding } from "./keys.js";
6
7
  export type SetupCockpitData = {
7
8
  readonly binaryVersion: string;
8
9
  readonly harnesses: readonly {
@@ -42,3 +43,147 @@ export declare function SetupCockpitFrame({ data, columns, rows, interaction, bi
42
43
  interaction?: SetupInteractionState;
43
44
  bindings?: readonly SetupKeyBinding[];
44
45
  }): ReactElement;
46
+ /**
47
+ * One parked task awaiting a human decision, derived from the journal alone.
48
+ * The graph is never consulted: a recompile can never make a parked task read
49
+ * as anything but parked on this surface.
50
+ */
51
+ export type ParkedDecision = {
52
+ readonly taskId: string;
53
+ /** The daemon-recorded park kind (task-human data.kind), never inferred prose. */
54
+ readonly kind: string;
55
+ readonly reason: string | undefined;
56
+ /** UTC HH:MM:SS sliced from the newest task-human event's ISO ts, or "unknown". */
57
+ readonly parkedAt: string;
58
+ /** Count of recorded task-dispatch events — never max(attempt)+1. */
59
+ readonly attempts: number;
60
+ /**
61
+ * The newest failed gate-result before the newest task-human, derived with
62
+ * the same rule the production approve command applies. It decides what the
63
+ * confirm inset may promise: uphold exists only when this is "review", and a
64
+ * gate-fail approve records this gate as satisfied.
65
+ */
66
+ readonly failedGate: string | undefined;
67
+ /**
68
+ * A park that is permanent by design: an id the spec retains only so the
69
+ * engagement can resume, gated so it never dispatches. Nothing releases it,
70
+ * so it is drawn read-only and neither verb is offered on it.
71
+ */
72
+ readonly tombstone: boolean;
73
+ };
74
+ /** The parks a verb can release — the rows the surface draws live verbs on. */
75
+ 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
+ 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";
85
+ /** A decision the operator has named but not yet confirmed. */
86
+ export type SetupDecisionCommand = {
87
+ readonly verb: SetupDecisionVerb;
88
+ readonly taskId: string;
89
+ };
90
+ /** What the journal file gained this session — one entry per confirmed write. */
91
+ export type SetupDecisionWrite = {
92
+ readonly taskId: string;
93
+ readonly verb: SetupDecisionVerb;
94
+ /** The release marker read back from the recorded task-approved event. */
95
+ readonly release: string | undefined;
96
+ readonly by: string;
97
+ };
98
+ /**
99
+ * The observable state of the decisions surface. Every field is drawn, so a
100
+ * state the surface cannot show is not expressible: the confirm inset is
101
+ * `confirming`, the verbatim command output is `notice`, and the pending
102
+ * writes region is `writes`.
103
+ */
104
+ export type SetupDecisionsSession = {
105
+ readonly selection: number;
106
+ readonly confirming: SetupDecisionCommand | null;
107
+ readonly writes: readonly SetupDecisionWrite[];
108
+ readonly notice: string | null;
109
+ };
110
+ export declare function initialSetupDecisionsSession(): SetupDecisionsSession;
111
+ export type SetupDecisionsKeyResult = {
112
+ readonly session: SetupDecisionsSession;
113
+ /** Present only when the confirm key landed — the one moment a write may happen. */
114
+ readonly command?: SetupDecisionCommand;
115
+ readonly quit?: boolean;
116
+ };
117
+ /**
118
+ * Uphold exists only where the production command honours it: the newest
119
+ * failed gate before the park is the review gate. Everywhere else the key is
120
+ * inert and the keybar does not advertise it — the surface never promises a
121
+ * decision the command must refuse.
122
+ */
123
+ export declare function upholdAvailable(decision: ParkedDecision): boolean;
124
+ /**
125
+ * The decisions surface's whole key grammar: ↑↓ move, a approve, u uphold,
126
+ * y confirm, n/Esc cancel, q quit. The reducer never writes — it can only
127
+ * *name* a command, and only on the confirm key, so a write the operator did
128
+ * not predict cannot happen. a/u are inert when nothing actionable is parked,
129
+ * and u is inert on a park the command would refuse, matching the keybar, which
130
+ * then does not advertise them. Tombstone parks sit outside the grammar: the
131
+ * pointer never reaches one, so no key can name a decision on it.
132
+ */
133
+ export declare function applySetupDecisionsKey(session: SetupDecisionsSession, event: RunKeyEvent, parked: readonly ParkedDecision[]): SetupDecisionsKeyResult;
134
+ export type SetupDecisionOutcome = {
135
+ readonly ok: true;
136
+ readonly command: SetupDecisionCommand;
137
+ /** The exact string the production command returned. */
138
+ readonly message: string;
139
+ readonly write: SetupDecisionWrite;
140
+ } | {
141
+ readonly ok: false;
142
+ readonly command: SetupDecisionCommand;
143
+ /** The exact string the production command threw. */
144
+ readonly refusal: string;
145
+ };
146
+ /**
147
+ * THE write. It calls the production `approve` command — validation, fail-closed
148
+ * refusals and the journal append are inherited, never re-implemented — and then
149
+ * reads the file back so the session records the release the journal actually
150
+ * gained. A refusal is the command's own message, verbatim, and appends nothing.
151
+ */
152
+ export declare function executeSetupDecision(command: SetupDecisionCommand, { cwd, runId, by }: {
153
+ cwd: string;
154
+ runId: string;
155
+ by: string;
156
+ }): Promise<SetupDecisionOutcome>;
157
+ /** Fold an executed command's outcome into the session the surface draws. */
158
+ export declare function recordSetupDecisionOutcome(session: SetupDecisionsSession, outcome: SetupDecisionOutcome): SetupDecisionsSession;
159
+ /**
160
+ * The confirm inset's lines: the task, the actor, the effect and the file,
161
+ * stated before the single key that can write.
162
+ */
163
+ export declare function setupDecisionConfirmLines(command: SetupDecisionCommand, decision: ParkedDecision | undefined, actor: string, journalFile: string): readonly string[];
164
+ /**
165
+ * The decisions surface. Every row it draws — decisions, the confirm inset,
166
+ * the pending writes region — is measured and clipped through the width
167
+ * module alone (`fitCells`), so a task name carrying a ZWJ grapheme can
168
+ * neither overflow its row nor be cut mid-cluster. The one unclipped element
169
+ * is `notice`: the production command's own words, verbatim.
170
+ */
171
+ export declare function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, }: {
172
+ decisions: readonly ParkedDecision[];
173
+ session: SetupDecisionsSession;
174
+ columns: number;
175
+ actor: string;
176
+ journalFile: string;
177
+ }): ReactElement;
178
+ /**
179
+ * The setup decisions surface on a live engagement. This is the only cockpit
180
+ * surface that writes, and it writes exactly one way: a confirmed approve or
181
+ * uphold through the production command path.
182
+ */
183
+ export declare function runSetupDecisionsCockpit({ input, output, cwd, runId, actor, }: {
184
+ input: NodeJS.ReadStream;
185
+ output: NodeJS.WriteStream;
186
+ cwd: string;
187
+ runId: string;
188
+ actor?: string;
189
+ }): Promise<void>;
@@ -1,14 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box } from "ink";
3
- import { cloneElement, } from "react";
2
+ import { Box, render, useApp, useInput, useStdout } from "ink";
3
+ import { userInfo } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cloneElement, createElement, useState, } from "react";
4
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";
5
9
  import { BANNER, GLYPHS, PLAIN_BANNER, } from "../../brand.js";
6
10
  import { DEFAULT_CONFIG, INTEGRITY_FLOOR_SHAPES, TIER_RANK, TickmarkrConfigSchema, unifiedYamlDiff, } from "../../config/config.js";
7
- import { SHAPES } from "../../graph/schema.js";
11
+ import { GATE_NAMES, SHAPES } from "../../graph/schema.js";
12
+ import { stateDirName } from "../../graph/graph.js";
8
13
  import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel, measureBandRows, Panel, StateGlyph, StatTile, StatusStrip, } from "./components.js";
9
14
  import { FULL_JOURNAL_ROWS, resolveCockpitLayout, } from "./layout.js";
10
15
  import { deriveRunCockpitData, PANEL_CHROME_ROWS, panelRows, wrappedRows, } from "./run-cockpit.js";
11
16
  import { initialSetupInteractionState, keybarEntries, SETUP_PANEL_FOCUS_ORDER, SURFACE_KEY_BINDINGS, } from "./keys.js";
17
+ import { DECISIONS_TAB_HINT, DECISIONS_TAB_TITLE, } from "./views.js";
18
+ // Split across two statements because `setup.test.ts` pins the clipping import
19
+ // by its exact text, proving the rows are drawn through the width module.
20
+ import { fitCells } from "./width.js";
21
+ import { cellWidth } from "./width.js";
12
22
  const APPROVED_SIDE_RAIL_COLUMN_FLOOR = 80;
13
23
  const setupAdditionalBindings = (bindings) => bindings.filter((binding) => !SURFACE_KEY_BINDINGS.run.some((runBinding) => runBinding.key === binding.key));
14
24
  function deepMerge(base, overlay) {
@@ -191,7 +201,7 @@ function KeysPanel({ bindings, width = 16, focused = false, }) {
191
201
  }
192
202
  function CompactKeysPanel({ bindings, width = 16, focused = false, }) {
193
203
  const contentColumns = Math.max(1, width - 4);
194
- return (_jsx(Panel, { title: "KEYS", width: width, focused: focused, children: keybarEntries(setupAdditionalBindings(bindings)).map((item) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label.slice(0, Math.max(0, contentColumns - [...item.key].length - 1))] })] }, item.key))) }));
204
+ return (_jsx(Panel, { title: "KEYS", width: width, focused: focused, children: keybarEntries(setupAdditionalBindings(bindings)).map((item) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label.slice(0, Math.max(0, contentColumns - cellWidth(item.key) - 1))] })] }, item.key))) }));
195
205
  }
196
206
  function SetupKeybar({ bindings, width, }) {
197
207
  return (_jsx(Box, { flexDirection: "row", flexWrap: "nowrap", width: width, height: 1, overflow: "hidden", "aria-label": "setup cockpit keys", children: keybarEntries(bindings).map((item, index) => (_jsxs(Box, { flexShrink: 0, children: [index > 0 && _jsx(BodyText, { emphasis: "dim", children: " \u00B7 " }), _jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { emphasis: "dim", children: [" ", item.label] })] }, item.key))) }));
@@ -448,7 +458,7 @@ function setupDetailPlan(rows, data, inner, base, journalPreferred) {
448
458
  };
449
459
  }
450
460
  function versionColumnWidth(data) {
451
- return Math.max(...identityVersionLines(data).map((line) => [...line].length));
461
+ return Math.max(...identityVersionLines(data).map((line) => cellWidth(line)));
452
462
  }
453
463
  /**
454
464
  * The banner sits beside the version column, so a narrow frame leaves it fewer
@@ -530,3 +540,325 @@ export function SetupCockpitFrame({ data, columns, rows, interaction, bindings =
530
540
  ? (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(KeysPanel, { bindings: bindings, width: resolvedSidePanelWidth, focused: focusedPanel === "KEYS" }) }))
531
541
  : (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(CompactKeysPanel, { bindings: bindings, width: resolvedSidePanelWidth, focused: focusedPanel === "KEYS" }) })))] }), _jsx(StatusStrip, { width: columns, items: setupStatusItems(data, currentInteraction) }), _jsx(SetupKeybar, { bindings: bindings, width: columns })] }));
532
542
  }
543
+ /**
544
+ * There is no closed park kind for a declaration-shaped retirement, so the
545
+ * evidence read here is the one the daemon recorded: a pre-dispatch human-gate
546
+ * park whose reason carries the task's own title, and that title is where the
547
+ * spec declares the tombstone. Read narrowly on purpose — every other park kind
548
+ * is actionable no matter what its prose says.
549
+ */
550
+ function isTombstonePark(kind, reason) {
551
+ return kind === "human-gate" && /\btombstone\b/iu.test(reason ?? "");
552
+ }
553
+ /** The parks a verb can release — the rows the surface draws live verbs on. */
554
+ export function actionableDecisions(decisions) {
555
+ return decisions.filter((decision) => !decision.tombstone);
556
+ }
557
+ /**
558
+ * The decisions section's rows: one per task the journal's own status replay
559
+ * leaves parked on a human, in the order the journal first named them. The
560
+ * replay is the production one (`Journal.replayStatuses`) so this surface and
561
+ * `tickmarkr status` can never disagree about what is parked.
562
+ */
563
+ export function deriveParkedDecisions(journal) {
564
+ const statuses = journal.replayStatuses();
565
+ const events = journal.read();
566
+ const decisions = [];
567
+ for (const [taskId, status] of statuses) {
568
+ if (status !== "human")
569
+ continue;
570
+ let parkedIndex = -1;
571
+ let attempts = 0;
572
+ for (let index = 0; index < events.length; index += 1) {
573
+ const event = events[index];
574
+ if (event.taskId !== taskId)
575
+ continue;
576
+ if (event.event === "task-dispatch")
577
+ attempts += 1;
578
+ else if (event.event === "task-human")
579
+ parkedIndex = index;
580
+ }
581
+ 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;
588
+ const kind = typeof parked?.data.kind === "string" ? parked.data.kind : "human-gate";
589
+ const reason = typeof parked?.data.reason === "string" ? parked.data.reason : undefined;
590
+ decisions.push({
591
+ taskId,
592
+ kind,
593
+ reason,
594
+ parkedAt: typeof parked?.ts === "string" && parked.ts.length >= 19
595
+ ? parked.ts.slice(11, 19)
596
+ : "unknown",
597
+ attempts,
598
+ failedGate,
599
+ tombstone: isTombstonePark(kind, reason),
600
+ });
601
+ }
602
+ return decisions;
603
+ }
604
+ export function initialSetupDecisionsSession() {
605
+ return { selection: 0, confirming: null, writes: [], notice: null };
606
+ }
607
+ /**
608
+ * Uphold exists only where the production command honours it: the newest
609
+ * failed gate before the park is the review gate. Everywhere else the key is
610
+ * inert and the keybar does not advertise it — the surface never promises a
611
+ * decision the command must refuse.
612
+ */
613
+ export function upholdAvailable(decision) {
614
+ return decision.failedGate === "review";
615
+ }
616
+ /**
617
+ * The decisions surface's whole key grammar: ↑↓ move, a approve, u uphold,
618
+ * y confirm, n/Esc cancel, q quit. The reducer never writes — it can only
619
+ * *name* a command, and only on the confirm key, so a write the operator did
620
+ * not predict cannot happen. a/u are inert when nothing actionable is parked,
621
+ * and u is inert on a park the command would refuse, matching the keybar, which
622
+ * then does not advertise them. Tombstone parks sit outside the grammar: the
623
+ * pointer never reaches one, so no key can name a decision on it.
624
+ */
625
+ export function applySetupDecisionsKey(session, event, parked) {
626
+ const decisions = actionableDecisions(parked);
627
+ if (session.confirming !== null) {
628
+ if (event.input === "y") {
629
+ return {
630
+ session: { ...session, confirming: null },
631
+ command: session.confirming,
632
+ };
633
+ }
634
+ if (event.input === "n" || event.key.escape === true) {
635
+ return { session: { ...session, confirming: null } };
636
+ }
637
+ return { session };
638
+ }
639
+ if (event.input === "q")
640
+ return { session, quit: true };
641
+ const selection = Math.min(session.selection, Math.max(0, decisions.length - 1));
642
+ if (event.key.upArrow === true || event.key.downArrow === true) {
643
+ if (decisions.length === 0)
644
+ return { session };
645
+ const delta = event.key.upArrow === true ? -1 : 1;
646
+ return {
647
+ session: {
648
+ ...session,
649
+ selection: Math.max(0, Math.min(decisions.length - 1, selection + delta)),
650
+ },
651
+ };
652
+ }
653
+ if (event.input === "a" || event.input === "u") {
654
+ const decision = decisions[selection];
655
+ if (decision === undefined)
656
+ return { session };
657
+ if (event.input === "u" && !upholdAvailable(decision))
658
+ return { session };
659
+ return {
660
+ session: {
661
+ ...session,
662
+ selection,
663
+ confirming: {
664
+ verb: event.input === "a" ? "approve" : "uphold",
665
+ taskId: decision.taskId,
666
+ },
667
+ },
668
+ };
669
+ }
670
+ return { session };
671
+ }
672
+ /**
673
+ * THE write. It calls the production `approve` command — validation, fail-closed
674
+ * refusals and the journal append are inherited, never re-implemented — and then
675
+ * reads the file back so the session records the release the journal actually
676
+ * gained. A refusal is the command's own message, verbatim, and appends nothing.
677
+ */
678
+ export async function executeSetupDecision(command, { cwd, runId, by }) {
679
+ try {
680
+ const message = await approve([
681
+ runId,
682
+ command.taskId,
683
+ ...(command.verb === "uphold" ? ["--uphold"] : []),
684
+ "--by",
685
+ by,
686
+ ], cwd);
687
+ const events = Journal.open(cwd, runId).read();
688
+ let release;
689
+ for (let index = events.length - 1; index >= 0; index -= 1) {
690
+ const event = events[index];
691
+ if (event.event === "task-approved" && event.taskId === command.taskId) {
692
+ release = typeof event.data.release === "string" ? event.data.release : undefined;
693
+ break;
694
+ }
695
+ }
696
+ return {
697
+ ok: true,
698
+ command,
699
+ message,
700
+ write: { taskId: command.taskId, verb: command.verb, release, by },
701
+ };
702
+ }
703
+ catch (error) {
704
+ return {
705
+ ok: false,
706
+ command,
707
+ refusal: error instanceof Error ? error.message : String(error),
708
+ };
709
+ }
710
+ }
711
+ /** Fold an executed command's outcome into the session the surface draws. */
712
+ export function recordSetupDecisionOutcome(session, outcome) {
713
+ return outcome.ok
714
+ ? { ...session, notice: outcome.message, writes: [...session.writes, outcome.write] }
715
+ : { ...session, notice: outcome.refusal };
716
+ }
717
+ /** The text of one decisions row — measured and clipped only by the width module. */
718
+ function decisionRowText(decision) {
719
+ return `${decision.taskId} · ${decision.kind} · parked ${decision.parkedAt}`
720
+ + ` · attempt ${decision.attempts}`
721
+ + (decision.reason === undefined ? "" : ` · ${decision.reason}`);
722
+ }
723
+ /**
724
+ * The effect the confirmed write will actually have, derived from the park
725
+ * kind and the failed gate the same way the production command derives its
726
+ * append — the release markers are the command's own constants, so this text
727
+ * cannot drift from the write. An effect the operator cannot read here is an
728
+ * effect this surface does not have.
729
+ */
730
+ function decisionEffectLines(command, decision) {
731
+ if (command.verb === "uphold") {
732
+ 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",
736
+ ];
737
+ }
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;
747
+ 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`,
751
+ ];
752
+ }
753
+ if (decision?.kind === ATTEMPT_CAP_RELEASE) {
754
+ 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",
758
+ ];
759
+ }
760
+ 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",
764
+ ];
765
+ }
766
+ /**
767
+ * The confirm inset's lines: the task, the actor, the effect and the file,
768
+ * stated before the single key that can write.
769
+ */
770
+ export function setupDecisionConfirmLines(command, decision, actor, journalFile) {
771
+ return [
772
+ `task ${command.taskId}${decision === undefined ? "" : ` · ${decision.kind} · attempt ${decision.attempts}`}`,
773
+ `actor ${actor}`,
774
+ ...decisionEffectLines(command, decision),
775
+ `file ${journalFile} · append only`,
776
+ `y ${command.verb} · n cancel`,
777
+ ];
778
+ }
779
+ /** The keybar the surface's state can actually honour — nothing else is drawn. */
780
+ function setupDecisionsKeybar(session, decisions) {
781
+ if (session.confirming !== null)
782
+ return "y Confirm · n Cancel";
783
+ if (decisions.length === 0)
784
+ return "q Quit";
785
+ 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`;
788
+ }
789
+ /**
790
+ * What the tab is, drawn on the tab it names. The operator's v1.83 UAT read
791
+ * "Setup" as configuration; this surface configures nothing, so it says what it
792
+ * does and what it may write, from the one constant the tab strip and the
793
+ * keybar hint also draw.
794
+ */
795
+ const DECISIONS_TAB_LINE = `tab ${DECISIONS_TAB_HINT} · journal-parked human decisions`
796
+ + " · approve and uphold are its only writes";
797
+ /**
798
+ * What the section says when no park needs the operator. It names its own
799
+ * trigger and both verbs, so an operator who arrives at a quiet surface learns
800
+ * what would put a row here rather than reading the quiet as unfinished work.
801
+ */
802
+ const DECISIONS_EMPTY_STATE = [
803
+ "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",
806
+ ];
807
+ /**
808
+ * The decisions surface. Every row it draws — decisions, the confirm inset,
809
+ * the pending writes region — is measured and clipped through the width
810
+ * module alone (`fitCells`), so a task name carrying a ZWJ grapheme can
811
+ * neither overflow its row nor be cut mid-cluster. The one unclipped element
812
+ * is `notice`: the production command's own words, verbatim.
813
+ */
814
+ export function SetupDecisionsSurface({ decisions, session, columns, actor, journalFile, }) {
815
+ const inner = Math.max(1, Math.floor(columns) - 4);
816
+ const actionable = actionableDecisions(decisions);
817
+ const tombstones = decisions.filter((decision) => decision.tombstone);
818
+ const selected = Math.min(session.selection, Math.max(0, actionable.length - 1));
819
+ 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
821
+ ? _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))) })] }));
823
+ }
824
+ function SetupDecisionsApp({ cwd, runId, actor, }) {
825
+ const { exit } = useApp();
826
+ const { stdout } = useStdout();
827
+ const readDecisions = () => deriveParkedDecisions(Journal.open(cwd, runId));
828
+ const [decisions, setDecisions] = useState(readDecisions);
829
+ const [session, setSession] = useState(initialSetupDecisionsSession);
830
+ useInput((input, key) => {
831
+ if (key.ctrl && input === "c") {
832
+ exit();
833
+ return;
834
+ }
835
+ const result = applySetupDecisionsKey(session, { input, key }, decisions);
836
+ setSession(result.session);
837
+ if (result.quit === true)
838
+ exit();
839
+ if (result.command !== undefined) {
840
+ const command = result.command;
841
+ void executeSetupDecision(command, { cwd, runId, by: actor }).then((outcome) => {
842
+ setSession((current) => recordSetupDecisionOutcome(current, outcome));
843
+ // Re-derive from the file the write landed in: the released task's row
844
+ // leaves the section because the journal says so, not because we say so.
845
+ setDecisions(readDecisions());
846
+ });
847
+ }
848
+ });
849
+ return (_jsx(SetupDecisionsSurface, { decisions: decisions, session: session, columns: stdout.columns ?? 80, actor: actor, journalFile: join(stateDirName(cwd), "runs", runId, "journal.jsonl") }));
850
+ }
851
+ /**
852
+ * The setup decisions surface on a live engagement. This is the only cockpit
853
+ * surface that writes, and it writes exactly one way: a confirmed approve or
854
+ * uphold through the production command path.
855
+ */
856
+ export async function runSetupDecisionsCockpit({ input, output, cwd, runId, actor = userInfo().username, }) {
857
+ const app = render(createElement(SetupDecisionsApp, { cwd, runId, actor }), { stdin: input, stdout: output, exitOnCtrlC: false, patchConsole: false });
858
+ try {
859
+ await app.waitUntilExit();
860
+ }
861
+ finally {
862
+ app.unmount();
863
+ }
864
+ }
@@ -0,0 +1,72 @@
1
+ /** The run cockpit's rail order. Number keys are one-based positions here. */
2
+ export declare const RUN_VIEWS: readonly [{
3
+ readonly id: "run";
4
+ readonly label: "Run";
5
+ readonly title: "RUN";
6
+ readonly detailTitle: "RUN DETAIL";
7
+ }, {
8
+ readonly id: "tasks";
9
+ readonly label: "Tasks";
10
+ readonly title: "TASKS";
11
+ readonly detailTitle: "TASK DETAIL";
12
+ }, {
13
+ readonly id: "gates";
14
+ readonly label: "Gates";
15
+ readonly title: "GATES";
16
+ readonly detailTitle: "GATE DETAIL";
17
+ }, {
18
+ readonly id: "journal";
19
+ readonly label: "Journal";
20
+ readonly title: "JOURNAL";
21
+ readonly detailTitle: "EVENT DETAIL";
22
+ }, {
23
+ readonly id: "fleet";
24
+ readonly label: "Fleet";
25
+ readonly title: "FLEET";
26
+ readonly detailTitle: "CHANNEL DETAIL";
27
+ }];
28
+ export type RunView = (typeof RUN_VIEWS)[number];
29
+ export type RunViewId = RunView["id"];
30
+ export declare function runViewAt(index: number): RunView | undefined;
31
+ export declare function runViewById(id: RunViewId): RunView;
32
+ /** Rail movement stops at either end; it never points outside the five views. */
33
+ export declare function moveRunViewSelection(selection: number, delta: number): number;
34
+ /** A one-based number key's view, or undefined for a digit the rail does not own. */
35
+ export declare function runViewForNumber(input: string): RunView | undefined;
36
+ /**
37
+ * The second tab's drawn name. It was SETUP through v1.83; the operator's UAT
38
+ * read that as configuration, and what the tab draws is journal-parked human
39
+ * decisions whose only writes are approve and uphold. Stated once here so the
40
+ * tab strip, the keybar hint and the surface's own header cannot drift apart.
41
+ */
42
+ export declare const DECISIONS_TAB_TITLE = "DECISIONS";
43
+ /** The same name in the keybar's sentence case — the word drawn beside Tab. */
44
+ export declare const DECISIONS_TAB_HINT = "Decisions";
45
+ /**
46
+ * The decisions tab's rail. Every label fits the rail's own column unwrapped —
47
+ * a wrapped rail row is a row nothing charges to the height budget, and that is
48
+ * what pushed the surface past a 14-row terminal. Read-only sections say so in
49
+ * their body, not in a label long enough to wrap.
50
+ */
51
+ export declare const SETUP_SECTIONS: readonly [{
52
+ readonly id: "decisions";
53
+ readonly label: "Decisions";
54
+ readonly title: "DECISIONS";
55
+ readonly readOnly: false;
56
+ }, {
57
+ readonly id: "fleet";
58
+ readonly label: "Fleet";
59
+ readonly title: "FLEET";
60
+ readonly readOnly: true;
61
+ }, {
62
+ readonly id: "config";
63
+ readonly label: "Config";
64
+ readonly title: "CONFIG";
65
+ readonly readOnly: true;
66
+ }];
67
+ export type SetupSection = (typeof SETUP_SECTIONS)[number];
68
+ export type SetupSectionId = SetupSection["id"];
69
+ /** The section a rail position names; out-of-range positions clamp to the rail. */
70
+ export declare function setupSectionAt(index: number): SetupSection;
71
+ /** Rail movement stops at either end; it never points outside the sections. */
72
+ export declare function moveSetupSectionSelection(selection: number, delta: number): number;
@@ -0,0 +1,56 @@
1
+ /** The run cockpit's rail order. Number keys are one-based positions here. */
2
+ export const RUN_VIEWS = [
3
+ { id: "run", label: "Run", title: "RUN", detailTitle: "RUN DETAIL" },
4
+ { id: "tasks", label: "Tasks", title: "TASKS", detailTitle: "TASK DETAIL" },
5
+ { id: "gates", label: "Gates", title: "GATES", detailTitle: "GATE DETAIL" },
6
+ { id: "journal", label: "Journal", title: "JOURNAL", detailTitle: "EVENT DETAIL" },
7
+ { id: "fleet", label: "Fleet", title: "FLEET", detailTitle: "CHANNEL DETAIL" },
8
+ ];
9
+ export function runViewAt(index) {
10
+ return Number.isInteger(index) ? RUN_VIEWS[index] : undefined;
11
+ }
12
+ export function runViewById(id) {
13
+ return RUN_VIEWS.find((view) => view.id === id) ?? RUN_VIEWS[0];
14
+ }
15
+ /** Rail movement stops at either end; it never points outside the five views. */
16
+ export function moveRunViewSelection(selection, delta) {
17
+ return Math.max(0, Math.min(RUN_VIEWS.length - 1, selection + delta));
18
+ }
19
+ /** A one-based number key's view, or undefined for a digit the rail does not own. */
20
+ export function runViewForNumber(input) {
21
+ if (!/^\d$/u.test(input))
22
+ return undefined;
23
+ return runViewAt(Number(input) - 1);
24
+ }
25
+ /**
26
+ * The second tab's drawn name. It was SETUP through v1.83; the operator's UAT
27
+ * read that as configuration, and what the tab draws is journal-parked human
28
+ * decisions whose only writes are approve and uphold. Stated once here so the
29
+ * tab strip, the keybar hint and the surface's own header cannot drift apart.
30
+ */
31
+ export const DECISIONS_TAB_TITLE = "DECISIONS";
32
+ /** The same name in the keybar's sentence case — the word drawn beside Tab. */
33
+ export const DECISIONS_TAB_HINT = "Decisions";
34
+ /**
35
+ * The decisions tab's rail. Every label fits the rail's own column unwrapped —
36
+ * a wrapped rail row is a row nothing charges to the height budget, and that is
37
+ * what pushed the surface past a 14-row terminal. Read-only sections say so in
38
+ * their body, not in a label long enough to wrap.
39
+ */
40
+ export const SETUP_SECTIONS = [
41
+ { id: "decisions", label: "Decisions", title: "DECISIONS", readOnly: false },
42
+ { id: "fleet", label: "Fleet", title: "FLEET", readOnly: true },
43
+ { id: "config", label: "Config", title: "CONFIG", readOnly: true },
44
+ ];
45
+ /** The section a rail position names; out-of-range positions clamp to the rail. */
46
+ export function setupSectionAt(index) {
47
+ const clamped = Math.max(0, Math.min(SETUP_SECTIONS.length - 1, Math.floor(index)));
48
+ return SETUP_SECTIONS[clamped];
49
+ }
50
+ /** Rail movement stops at either end; it never points outside the sections. */
51
+ export function moveSetupSectionSelection(selection, delta) {
52
+ return Math.max(0, Math.min(SETUP_SECTIONS.length - 1, selection + delta));
53
+ }
54
+ // Sections carry no number key: 1–5 names a view on the whole surface, and a
55
+ // digit that meant a section here would be the same key with two meanings.
56
+ // The sections are reached the way the contract gives them — ↑↓ and ⏎.