tickmarkr 1.80.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 (50) 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.d.ts +1 -1
  8. package/dist/cli/commands/ui.js +47 -4
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.js +1 -1
  11. package/dist/compile/collateral.d.ts +19 -0
  12. package/dist/compile/collateral.js +90 -0
  13. package/dist/compile/index.js +18 -4
  14. package/dist/drivers/herdr.d.ts +1 -0
  15. package/dist/drivers/herdr.js +19 -0
  16. package/dist/drivers/types.d.ts +1 -0
  17. package/dist/gates/acceptance.js +50 -8
  18. package/dist/gates/llm.js +34 -19
  19. package/dist/gates/review.d.ts +9 -1
  20. package/dist/gates/review.js +173 -7
  21. package/dist/gates/run-gates.d.ts +1 -0
  22. package/dist/gates/run-gates.js +18 -1
  23. package/dist/report/bundle.d.ts +6 -1
  24. package/dist/report/bundle.js +13 -3
  25. package/dist/run/daemon.d.ts +5 -0
  26. package/dist/run/daemon.js +188 -27
  27. package/dist/run/journal.d.ts +5 -0
  28. package/dist/run/journal.js +71 -1
  29. package/dist/tui/cockpit/capture.d.ts +107 -1
  30. package/dist/tui/cockpit/capture.js +287 -10
  31. package/dist/tui/cockpit/components.d.ts +15 -86
  32. package/dist/tui/cockpit/components.js +49 -55
  33. package/dist/tui/cockpit/derive.d.ts +73 -1
  34. package/dist/tui/cockpit/derive.js +211 -15
  35. package/dist/tui/cockpit/keys.d.ts +244 -0
  36. package/dist/tui/cockpit/keys.js +634 -0
  37. package/dist/tui/cockpit/layout.d.ts +162 -0
  38. package/dist/tui/cockpit/layout.js +229 -0
  39. package/dist/tui/cockpit/live.d.ts +80 -0
  40. package/dist/tui/cockpit/live.js +311 -0
  41. package/dist/tui/cockpit/run-cockpit.d.ts +65 -2
  42. package/dist/tui/cockpit/run-cockpit.js +705 -128
  43. package/dist/tui/cockpit/setup-cockpit.d.ts +149 -1
  44. package/dist/tui/cockpit/setup-cockpit.js +432 -56
  45. package/dist/tui/cockpit/views.d.ts +72 -0
  46. package/dist/tui/cockpit/views.js +56 -0
  47. package/dist/tui/cockpit/width.d.ts +105 -0
  48. package/dist/tui/cockpit/width.js +276 -0
  49. package/fixtures/speckit-sample/tasks.md +2 -2
  50. package/package.json +3 -1
@@ -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 ⏎.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Display-cell measurement by grapheme cluster — the cockpit's width authority.
3
+ *
4
+ * Terminals advance on grapheme clusters, not code points: a ZWJ emoji
5
+ * sequence, a regional-indicator flag pair, a skin-tone-modified emoji and a
6
+ * variation-selector sequence each occupy the cells of ONE cluster, while
7
+ * zero-width combining marks add no cells at all. Counting code points
8
+ * over-charges every one of them.
9
+ *
10
+ * Every operation a cockpit surface performs on text — measure it, find which
11
+ * glyph sits under a cell, cut it for a wrap, wrap it into rows, clip it into
12
+ * a fixed column — is exported here, each cluster-safe and each reporting the
13
+ * cell count it produced, so a caller states policy (where to break, what to
14
+ * pad with, which continuation marker to use) and never does cell arithmetic
15
+ * to carry it out. `width.test.ts` pins that coverage against the arithmetic
16
+ * the live call sites perform today.
17
+ *
18
+ * Migration of those call sites is deliberately NOT here. The spec's
19
+ * separability law gives every file exactly one owning task, and the callers
20
+ * that hand-count code points today are owned elsewhere: `layout.ts` by T3,
21
+ * `run-cockpit.tsx`/`live.ts` by T4, `components.tsx` by T5 — each of which
22
+ * carries this file in its context list precisely so it migrates onto these
23
+ * exports. T1 writing into those files would be the concurrent overwrite of
24
+ * another task's surface that the law exists to prevent (OBS-212), so this
25
+ * task owes them a complete module, and owes them nothing else.
26
+ *
27
+ * ponytail: the base-character width table is `string-width`, which
28
+ * `components.tsx` already imports — borrowing it leaves the cockpit ONE East
29
+ * Asian width model instead of two that disagree wherever Unicode moved on
30
+ * (the OBS-198 failure class). This module owns the grapheme layer and decides
31
+ * one width per cluster; marks and joiners are never handed to the table as
32
+ * separately chargeable characters.
33
+ */
34
+ /** Split a string into its grapheme clusters (UAX #29). */
35
+ export declare function graphemeClusters(text: string): string[];
36
+ /**
37
+ * Display cells occupied by `text`, summed cluster by cluster. Summing the
38
+ * parts rather than measuring the whole is what keeps measurement, location
39
+ * and slicing agreeing: every cell this returns belongs to exactly one cluster
40
+ * the other three can name.
41
+ */
42
+ export declare function cellWidth(text: string): number;
43
+ export interface CellLocation {
44
+ readonly clusterIndex: number;
45
+ readonly cluster: string;
46
+ /** First cell of the cluster, inclusive. */
47
+ readonly cellStart: number;
48
+ /** End of the cluster, exclusive. */
49
+ readonly cellEnd: number;
50
+ }
51
+ /**
52
+ * Resolve a cell offset to the grapheme cluster covering it. The result is
53
+ * always a whole cluster — resolution never lands mid-cluster, so both cells
54
+ * of a double-cell cluster resolve to that same cluster. Returns null when the
55
+ * offset is not a non-negative integer inside the string's cell span.
56
+ */
57
+ export declare function locateCell(text: string, cellOffset: number): CellLocation | null;
58
+ export interface CellSlice {
59
+ /** The leading whole clusters that were taken. */
60
+ readonly head: string;
61
+ /** Everything after them — `head + tail` reconstructs the input. */
62
+ readonly tail: string;
63
+ /** Cells `head` occupies, so a caller never re-measures what it just cut. */
64
+ readonly cells: number;
65
+ }
66
+ /**
67
+ * Cut `text` for a wrap: the longest leading run of whole clusters fitting
68
+ * `maxCells`. A leading cluster wider than `maxCells` is taken anyway —
69
+ * mirroring a terminal painting one glyph past the cells left to it, and
70
+ * guaranteeing a wrap loop advances instead of stalling on a glyph too wide
71
+ * for its column. `cells` therefore exceeds `maxCells` in that one case, and
72
+ * only there. `maxCells` below 1 takes nothing.
73
+ */
74
+ export declare function sliceCells(text: string, maxCells: number): CellSlice;
75
+ export interface WrapCellsOptions {
76
+ /** Prefix painted on fitting rows after the first, included in each result. */
77
+ readonly continuationPrefix?: string;
78
+ }
79
+ /**
80
+ * Wrap `text` into rows of at most `maxCells` cells — greedy, breaking between
81
+ * words, and cutting a word that cannot fit a row of its own on a cluster
82
+ * boundary. This is the operation that forces cell arithmetic on a caller that
83
+ * lacks it (`width - measure(current)`, repeated), so it is exported rather
84
+ * than left to be rebuilt per surface; a caller keeps only its own policy —
85
+ * padding and how many rows it will draw. Continuation-marker policy lives in
86
+ * this operation too: when supplied, its measured cells are deducted from
87
+ * subsequent content budgets and the returned continuation rows include it.
88
+ *
89
+ * Rows are never empty of intent. Without a continuation prefix, joining them
90
+ * re-inserts no space that was not in the input and drops none that was. A row
91
+ * exceeds `maxCells` only when its content is a single cluster too wide for
92
+ * the full budget. A continuation prefix is omitted when it leaves no content
93
+ * cell or would make such an over-wide cluster overflow; a marker can therefore
94
+ * never create an overflow or a non-advancing wrap. Always returns at least
95
+ * one row.
96
+ */
97
+ export declare function wrapCells(text: string, maxCells: number, options?: WrapCellsOptions): string[];
98
+ /**
99
+ * Clip `text` into a fixed column: the result occupies exactly `cells` display
100
+ * cells, space-padded when the text is short and cut on a cluster boundary
101
+ * when it is long. A cluster that will not fit whole is dropped rather than
102
+ * half-drawn, so a clipped line can never overflow its region — the frame
103
+ * plan's spans hold by construction, not by the caller's care.
104
+ */
105
+ export declare function fitCells(text: string, cells: number): string;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Display-cell measurement by grapheme cluster — the cockpit's width authority.
3
+ *
4
+ * Terminals advance on grapheme clusters, not code points: a ZWJ emoji
5
+ * sequence, a regional-indicator flag pair, a skin-tone-modified emoji and a
6
+ * variation-selector sequence each occupy the cells of ONE cluster, while
7
+ * zero-width combining marks add no cells at all. Counting code points
8
+ * over-charges every one of them.
9
+ *
10
+ * Every operation a cockpit surface performs on text — measure it, find which
11
+ * glyph sits under a cell, cut it for a wrap, wrap it into rows, clip it into
12
+ * a fixed column — is exported here, each cluster-safe and each reporting the
13
+ * cell count it produced, so a caller states policy (where to break, what to
14
+ * pad with, which continuation marker to use) and never does cell arithmetic
15
+ * to carry it out. `width.test.ts` pins that coverage against the arithmetic
16
+ * the live call sites perform today.
17
+ *
18
+ * Migration of those call sites is deliberately NOT here. The spec's
19
+ * separability law gives every file exactly one owning task, and the callers
20
+ * that hand-count code points today are owned elsewhere: `layout.ts` by T3,
21
+ * `run-cockpit.tsx`/`live.ts` by T4, `components.tsx` by T5 — each of which
22
+ * carries this file in its context list precisely so it migrates onto these
23
+ * exports. T1 writing into those files would be the concurrent overwrite of
24
+ * another task's surface that the law exists to prevent (OBS-212), so this
25
+ * task owes them a complete module, and owes them nothing else.
26
+ *
27
+ * ponytail: the base-character width table is `string-width`, which
28
+ * `components.tsx` already imports — borrowing it leaves the cockpit ONE East
29
+ * Asian width model instead of two that disagree wherever Unicode moved on
30
+ * (the OBS-198 failure class). This module owns the grapheme layer and decides
31
+ * one width per cluster; marks and joiners are never handed to the table as
32
+ * separately chargeable characters.
33
+ */
34
+ import stringWidth from "string-width";
35
+ const graphemeSegmenter = new Intl.Segmenter(undefined, {
36
+ granularity: "grapheme",
37
+ });
38
+ const ZERO_WIDTH_CLUSTER_PART = /[\p{Mn}\p{Me}\p{Cf}]/u;
39
+ const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u;
40
+ const TEXT_VARIATION_SELECTOR = "\uFE0E";
41
+ const EMOJI_VARIATION_SELECTOR = "\uFE0F";
42
+ const COMBINING_ENCLOSING_KEYCAP = "\u20E3";
43
+ /**
44
+ * ECMA-48 control strings and escape sequences used by terminal output. The
45
+ * alternatives are ordered longest first: OSC/DCS strings must be consumed
46
+ * through their BEL or ST terminator before the shorter two-byte ESC form can
47
+ * match their introducer. CSI accepts either the seven-bit ESC [ form or its
48
+ * eight-bit C1 equivalent.
49
+ */
50
+ const ANSI_SEQUENCE = /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\)|\u009D[\s\S]*?(?:\u0007|\u009C)|\u001B[P^_X][\s\S]*?\u001B\\|[\u0090\u0098\u009E\u009F][\s\S]*?\u009C|(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]|\u001B[@-_])/gu;
51
+ function segmentedGraphemes(text) {
52
+ return [...graphemeSegmenter.segment(text)].map(({ segment }) => segment);
53
+ }
54
+ /**
55
+ * Resolve one segmented grapheme to the cells a terminal advances for it.
56
+ * Emoji-presentation clusters are a single double-cell glyph. Every other
57
+ * cluster takes the width of its first printable base; non-spacing marks,
58
+ * enclosing marks, variation selectors and joiners cannot add cells merely
59
+ * because an older width table iterates their code points.
60
+ */
61
+ function clusterCells(cluster) {
62
+ const emojiPresentation = !cluster.includes(TEXT_VARIATION_SELECTOR) &&
63
+ (cluster.includes(EMOJI_VARIATION_SELECTOR) ||
64
+ cluster.includes(COMBINING_ENCLOSING_KEYCAP) ||
65
+ EMOJI_PRESENTATION.test(cluster));
66
+ if (emojiPresentation)
67
+ return 2;
68
+ for (const character of cluster) {
69
+ if (!ZERO_WIDTH_CLUSTER_PART.test(character))
70
+ return stringWidth(character);
71
+ }
72
+ return 0;
73
+ }
74
+ /**
75
+ * Tokenize controls before grapheme segmentation. ANSI payload bytes are not
76
+ * printable graphemes: each complete control is one atomic zero-cell unit,
77
+ * while the plain spans between controls are segmented by UAX #29.
78
+ */
79
+ function displayUnits(text) {
80
+ const units = [];
81
+ let plainStart = 0;
82
+ for (const match of text.matchAll(ANSI_SEQUENCE)) {
83
+ const controlStart = match.index;
84
+ for (const cluster of segmentedGraphemes(text.slice(plainStart, controlStart))) {
85
+ units.push({ text: cluster, cells: clusterCells(cluster), control: false });
86
+ }
87
+ units.push({ text: match[0], cells: 0, control: true });
88
+ plainStart = controlStart + match[0].length;
89
+ }
90
+ for (const cluster of segmentedGraphemes(text.slice(plainStart))) {
91
+ units.push({ text: cluster, cells: clusterCells(cluster), control: false });
92
+ }
93
+ return units;
94
+ }
95
+ /** Split a string into its grapheme clusters (UAX #29). */
96
+ export function graphemeClusters(text) {
97
+ return segmentedGraphemes(text);
98
+ }
99
+ /**
100
+ * Display cells occupied by `text`, summed cluster by cluster. Summing the
101
+ * parts rather than measuring the whole is what keeps measurement, location
102
+ * and slicing agreeing: every cell this returns belongs to exactly one cluster
103
+ * the other three can name.
104
+ */
105
+ export function cellWidth(text) {
106
+ return displayUnits(text).reduce((cells, unit) => cells + unit.cells, 0);
107
+ }
108
+ /**
109
+ * Resolve a cell offset to the grapheme cluster covering it. The result is
110
+ * always a whole cluster — resolution never lands mid-cluster, so both cells
111
+ * of a double-cell cluster resolve to that same cluster. Returns null when the
112
+ * offset is not a non-negative integer inside the string's cell span.
113
+ */
114
+ export function locateCell(text, cellOffset) {
115
+ if (!Number.isInteger(cellOffset) || cellOffset < 0)
116
+ return null;
117
+ let cellStart = 0;
118
+ let clusterIndex = 0;
119
+ for (const unit of displayUnits(text)) {
120
+ if (unit.control)
121
+ continue;
122
+ const cellEnd = cellStart + unit.cells;
123
+ if (cellOffset < cellEnd) {
124
+ return { clusterIndex, cluster: unit.text, cellStart, cellEnd };
125
+ }
126
+ cellStart = cellEnd;
127
+ clusterIndex += 1;
128
+ }
129
+ return null;
130
+ }
131
+ /**
132
+ * Cut `text` for a wrap: the longest leading run of whole clusters fitting
133
+ * `maxCells`. A leading cluster wider than `maxCells` is taken anyway —
134
+ * mirroring a terminal painting one glyph past the cells left to it, and
135
+ * guaranteeing a wrap loop advances instead of stalling on a glyph too wide
136
+ * for its column. `cells` therefore exceeds `maxCells` in that one case, and
137
+ * only there. `maxCells` below 1 takes nothing.
138
+ */
139
+ export function sliceCells(text, maxCells) {
140
+ let head = "";
141
+ let cells = 0;
142
+ let clusters = 0;
143
+ if (maxCells >= 1) {
144
+ for (const unit of displayUnits(text)) {
145
+ if (!unit.control && clusters > 0 && cells + unit.cells > maxCells)
146
+ break;
147
+ head += unit.text;
148
+ cells += unit.cells;
149
+ if (!unit.control)
150
+ clusters += 1;
151
+ }
152
+ }
153
+ return { head, tail: text.slice(head.length), cells };
154
+ }
155
+ /**
156
+ * Divide text into its first word and subsequent space-prefixed runs without
157
+ * ever splitting a cluster or ANSI control. Keeping separators in the runs is
158
+ * what makes wrapping lossless at a line break. A combining mark following a
159
+ * space forms one non-separator cluster, so it stays attached to its word.
160
+ */
161
+ function spacePrefixedRuns(text) {
162
+ const runs = [];
163
+ let run = "";
164
+ let hasWordCluster = false;
165
+ for (const unit of displayUnits(text)) {
166
+ if (!unit.control && unit.text === " " && hasWordCluster) {
167
+ runs.push(run);
168
+ run = " ";
169
+ hasWordCluster = false;
170
+ continue;
171
+ }
172
+ run += unit.text;
173
+ if (!unit.control && unit.text !== " ")
174
+ hasWordCluster = true;
175
+ }
176
+ if (run.length > 0)
177
+ runs.push(run);
178
+ return runs;
179
+ }
180
+ /**
181
+ * Wrap `text` into rows of at most `maxCells` cells — greedy, breaking between
182
+ * words, and cutting a word that cannot fit a row of its own on a cluster
183
+ * boundary. This is the operation that forces cell arithmetic on a caller that
184
+ * lacks it (`width - measure(current)`, repeated), so it is exported rather
185
+ * than left to be rebuilt per surface; a caller keeps only its own policy —
186
+ * padding and how many rows it will draw. Continuation-marker policy lives in
187
+ * this operation too: when supplied, its measured cells are deducted from
188
+ * subsequent content budgets and the returned continuation rows include it.
189
+ *
190
+ * Rows are never empty of intent. Without a continuation prefix, joining them
191
+ * re-inserts no space that was not in the input and drops none that was. A row
192
+ * exceeds `maxCells` only when its content is a single cluster too wide for
193
+ * the full budget. A continuation prefix is omitted when it leaves no content
194
+ * cell or would make such an over-wide cluster overflow; a marker can therefore
195
+ * never create an overflow or a non-advancing wrap. Always returns at least
196
+ * one row.
197
+ */
198
+ export function wrapCells(text, maxCells, options = {}) {
199
+ const budget = Math.max(1, Math.floor(maxCells));
200
+ const requestedPrefix = options.continuationPrefix ?? "";
201
+ const prefixCells = cellWidth(requestedPrefix);
202
+ const continuationPrefix = prefixCells < budget ? requestedPrefix : "";
203
+ const continuationBudget = continuationPrefix.length > 0
204
+ ? budget - prefixCells
205
+ : budget;
206
+ const contentRows = [];
207
+ let row = "";
208
+ let rowCells = 0;
209
+ const contentBudget = () => contentRows.length === 0 ? budget : continuationBudget;
210
+ for (const run of spacePrefixedRuns(text)) {
211
+ let rest = run;
212
+ while (rest.length > 0) {
213
+ const available = contentBudget() - rowCells;
214
+ const restCells = cellWidth(rest);
215
+ if (restCells <= available) {
216
+ row += rest;
217
+ rowCells += restCells;
218
+ break;
219
+ }
220
+ // Prefer a word boundary: a whole space-prefixed run that fits on the
221
+ // next row should not be hard-cut merely to fill the current one.
222
+ if (row.length > 0 && restCells <= continuationBudget) {
223
+ contentRows.push(row);
224
+ row = "";
225
+ rowCells = 0;
226
+ continue;
227
+ }
228
+ const { head, tail, cells } = sliceCells(rest, Math.max(1, available));
229
+ // `sliceCells` deliberately takes an over-wide first cluster to ensure
230
+ // progress. That escape hatch is valid only on an otherwise empty row;
231
+ // flush existing content before retrying it against a fresh budget.
232
+ if (row.length > 0 && cells > available) {
233
+ contentRows.push(row);
234
+ row = "";
235
+ rowCells = 0;
236
+ continue;
237
+ }
238
+ row += head;
239
+ rowCells += cells;
240
+ rest = tail;
241
+ contentRows.push(row);
242
+ row = "";
243
+ rowCells = 0;
244
+ }
245
+ }
246
+ if (row.length > 0 || contentRows.length === 0)
247
+ contentRows.push(row);
248
+ return contentRows.map((content, index) => {
249
+ if (index === 0 ||
250
+ continuationPrefix.length === 0 ||
251
+ prefixCells + cellWidth(content) > budget) {
252
+ return content;
253
+ }
254
+ return `${continuationPrefix}${content}`;
255
+ });
256
+ }
257
+ /**
258
+ * Clip `text` into a fixed column: the result occupies exactly `cells` display
259
+ * cells, space-padded when the text is short and cut on a cluster boundary
260
+ * when it is long. A cluster that will not fit whole is dropped rather than
261
+ * half-drawn, so a clipped line can never overflow its region — the frame
262
+ * plan's spans hold by construction, not by the caller's care.
263
+ */
264
+ export function fitCells(text, cells) {
265
+ if (cells < 1)
266
+ return "";
267
+ let clipped = "";
268
+ let used = 0;
269
+ for (const unit of displayUnits(text)) {
270
+ if (used + unit.cells > cells)
271
+ break;
272
+ clipped += unit.text;
273
+ used += unit.cells;
274
+ }
275
+ return clipped + " ".repeat(cells - used);
276
+ }
@@ -10,11 +10,11 @@
10
10
  - [ ] T002 [P] Implement token refresh in src/auth/refresh.ts
11
11
  - acceptance: expired token triggers exactly one refresh attempt
12
12
  - acceptance: refresh failure surfaces a typed error
13
- - files: src/auth/**
13
+ - files: src/auth/refresh.ts
14
14
  - complexity: 7
15
15
  - [ ] T003 [P] Implement session store in src/auth/store.ts
16
16
  - acceptance: sessions persist across restarts
17
- - files: src/auth/**
17
+ - files: src/auth/store.ts
18
18
  - [ ] T004 Write integration tests for auth flow
19
19
  - acceptance: happy path and refresh-failure path covered
20
20
  - files: tests/**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.80.0",
3
+ "version": "1.83.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,9 +41,11 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@types/react": "^19.2.17",
44
+ "chalk": "^5.6.2",
44
45
  "ink": "^6.8.0",
45
46
  "picomatch": "^4.0.2",
46
47
  "react": "^19.2.8",
48
+ "string-width": "5.1.2",
47
49
  "yaml": "^2.5.0",
48
50
  "zod": "^4.0.0"
49
51
  },