@monte3l/groundwork 0.0.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 (151) hide show
  1. package/README.md +23 -0
  2. package/bin/m3l-groundwork.mjs +10 -0
  3. package/dist/assets.d.ts +20 -0
  4. package/dist/assets.js +79 -0
  5. package/dist/caps.d.ts +25 -0
  6. package/dist/caps.js +69 -0
  7. package/dist/conflicts.d.ts +12 -0
  8. package/dist/conflicts.js +77 -0
  9. package/dist/emit.d.ts +7 -0
  10. package/dist/emit.js +42 -0
  11. package/dist/git.d.ts +3 -0
  12. package/dist/git.js +9 -0
  13. package/dist/harness/conformance.d.ts +20 -0
  14. package/dist/harness/conformance.js +18 -0
  15. package/dist/harness/frontmatter.d.ts +38 -0
  16. package/dist/harness/frontmatter.js +204 -0
  17. package/dist/harness/grade.d.ts +4 -0
  18. package/dist/harness/grade.js +105 -0
  19. package/dist/harness/rules.d.ts +55 -0
  20. package/dist/harness/rules.js +580 -0
  21. package/dist/harness/types.d.ts +32 -0
  22. package/dist/harness/types.js +9 -0
  23. package/dist/inventory.d.ts +63 -0
  24. package/dist/inventory.js +66 -0
  25. package/dist/jsonc.d.ts +14 -0
  26. package/dist/jsonc.js +83 -0
  27. package/dist/main.d.ts +24 -0
  28. package/dist/main.js +297 -0
  29. package/dist/merge-json.d.ts +74 -0
  30. package/dist/merge-json.js +135 -0
  31. package/dist/mode.d.ts +19 -0
  32. package/dist/mode.js +53 -0
  33. package/dist/packs.d.ts +61 -0
  34. package/dist/packs.js +186 -0
  35. package/dist/plugin.d.ts +23 -0
  36. package/dist/plugin.js +79 -0
  37. package/dist/report.d.ts +4 -0
  38. package/dist/report.js +323 -0
  39. package/dist/survey/fs-walk.d.ts +14 -0
  40. package/dist/survey/fs-walk.js +60 -0
  41. package/dist/survey/survey-docs.d.ts +4 -0
  42. package/dist/survey/survey-docs.js +69 -0
  43. package/dist/survey/survey-harness.d.ts +4 -0
  44. package/dist/survey/survey-harness.js +121 -0
  45. package/dist/survey/survey-shape.d.ts +4 -0
  46. package/dist/survey/survey-shape.js +182 -0
  47. package/dist/survey/survey-toolchain.d.ts +4 -0
  48. package/dist/survey/survey-toolchain.js +217 -0
  49. package/dist/survey/survey.d.ts +5 -0
  50. package/dist/survey/survey.js +21 -0
  51. package/dist/survey/types.d.ts +117 -0
  52. package/dist/survey/types.js +8 -0
  53. package/dist/tokens.d.ts +13 -0
  54. package/dist/tokens.js +13 -0
  55. package/dist/toolchain/conformance.d.ts +20 -0
  56. package/dist/toolchain/conformance.js +30 -0
  57. package/dist/toolchain/grade.d.ts +4 -0
  58. package/dist/toolchain/grade.js +244 -0
  59. package/dist/toolchain/rules.d.ts +118 -0
  60. package/dist/toolchain/rules.js +706 -0
  61. package/dist/toolchain/tsconfig-chain.d.ts +36 -0
  62. package/dist/toolchain/tsconfig-chain.js +116 -0
  63. package/dist/toolchain/types.d.ts +27 -0
  64. package/dist/toolchain/types.js +9 -0
  65. package/package.json +59 -0
  66. package/plugin/skills/customize/SKILL.md +305 -0
  67. package/plugin/src/domain-map.ts +134 -0
  68. package/plugin/src/index.ts +4 -0
  69. package/plugin/src/kind-facet-map.ts +174 -0
  70. package/plugin/src/pack-map.ts +65 -0
  71. package/templates/core/.claude/agents/Explore.md +43 -0
  72. package/templates/core/.claude/agents/code-implementer.md +258 -0
  73. package/templates/core/.claude/agents/code-reviewer.md +163 -0
  74. package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
  75. package/templates/core/.claude/agents/test-author.md +211 -0
  76. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
  77. package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
  78. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
  79. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
  80. package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
  81. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
  82. package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
  83. package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
  84. package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
  85. package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
  86. package/templates/core/.claude/rules/agent-dispatch.md +121 -0
  87. package/templates/core/.claude/rules/refactoring.md +52 -0
  88. package/templates/core/.claude/rules/src.md +114 -0
  89. package/templates/core/.claude/rules/tests.md +129 -0
  90. package/templates/core/.claude/settings.json +111 -0
  91. package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
  92. package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
  93. package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
  94. package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
  95. package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
  96. package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
  97. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
  98. package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
  99. package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
  100. package/templates/core/.github/workflows/ci.yml +123 -0
  101. package/templates/core/.github/workflows/dependency-review.yml +26 -0
  102. package/templates/core/.github/workflows/security-audit.yml +54 -0
  103. package/templates/core/.node-version +1 -0
  104. package/templates/core/.prettierignore +5 -0
  105. package/templates/core/.prettierrc.json +4 -0
  106. package/templates/core/CLAUDE.md +127 -0
  107. package/templates/core/README.md +24 -0
  108. package/templates/core/_gitignore +19 -0
  109. package/templates/core/_npmrc +1 -0
  110. package/templates/core/bin/check-exports.mjs +92 -0
  111. package/templates/core/bin/check-harness.mjs +27 -0
  112. package/templates/core/bin/check-node-version.mjs +51 -0
  113. package/templates/core/bin/check-toolchain.mjs +20 -0
  114. package/templates/core/bin/lib/agent-roster.mjs +8 -0
  115. package/templates/core/bin/lib/frontmatter.mjs +210 -0
  116. package/templates/core/bin/lib/harness-rules.mjs +916 -0
  117. package/templates/core/bin/lib/protected-paths.mjs +23 -0
  118. package/templates/core/bin/lib/report.mjs +56 -0
  119. package/templates/core/bin/lib/signed-range.mjs +178 -0
  120. package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
  121. package/templates/core/bin/lib/verify-steps.mjs +131 -0
  122. package/templates/core/bin/lib/verify-steps.packs.json +1 -0
  123. package/templates/core/bin/lint-commit.mjs +50 -0
  124. package/templates/core/bin/strip-claude-trailers.mjs +25 -0
  125. package/templates/core/bin/verify.mjs +64 -0
  126. package/templates/core/commitlint.config.js +11 -0
  127. package/templates/core/docs/research/harness-refresh.md +27 -0
  128. package/templates/core/docs/research/typescript-refresh.md +32 -0
  129. package/templates/core/eslint.config.js +105 -0
  130. package/templates/core/knip.json +6 -0
  131. package/templates/core/lefthook.yml +39 -0
  132. package/templates/core/package.json +58 -0
  133. package/templates/core/pnpm-workspace.yaml +13 -0
  134. package/templates/core/src/index.ts +12 -0
  135. package/templates/core/tests/index.test.ts +8 -0
  136. package/templates/core/tsconfig.base.json +36 -0
  137. package/templates/core/tsconfig.build.json +10 -0
  138. package/templates/core/tsconfig.json +11 -0
  139. package/templates/core/vitest.config.ts +32 -0
  140. package/templates/packs/README.md +81 -0
  141. package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
  142. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
  143. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
  144. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
  145. package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
  146. package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
  147. package/templates/packs/harness-extras/pack.json +65 -0
  148. package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
  149. package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
  150. package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
  151. package/templates/packs/statusline/pack.json +31 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Pure presentation primitives shared by both status-line entry points
3
+ * (`statusline.mjs`, `subagent-statusline.mjs`): terminal-width fitting, ANSI
4
+ * colors, and the two small formatters both scripts need. No I/O, no
5
+ * shebang, no direct-run block -- this module is only ever imported, and
6
+ * neither entry point imports the other.
7
+ *
8
+ * Anthropic's own statusLine docs (code.claude.com/docs/en/statusline) state
9
+ * that a statusLine script must read the `COLUMNS`/`LINES` env vars to learn
10
+ * the terminal width; `tput cols` does not work inside a statusLine
11
+ * subprocess. `terminalColumns` is the read side of that contract;
12
+ * `displayWidth`/`truncateToWidth`/`fitRow` are what make a real width
13
+ * budget actionable -- segment-level priority dropping plus
14
+ * ANSI/OSC-8-aware truncation, so a narrow terminal degrades by omitting the
15
+ * least important segments first rather than wrapping mid-line.
16
+ */
17
+
18
+ /**
19
+ * SGR (`\x1b[...m`) and OSC-8 (`\x1b]8;;URL\x07...\x1b]8;;\x07`) sequences.
20
+ *
21
+ * Carries the `/g` flag, so `.lastIndex` is stateful across calls to
22
+ * `.test()`/`.exec()` on this exact object — every internal use in this file
23
+ * either goes through `.replace()` (which resets `lastIndex` itself) or
24
+ * explicitly resets `lastIndex` before use (see `tokenize` below). A caller
25
+ * that runs `.test()`/`.exec()` on this constant directly, more than once,
26
+ * must reset `.lastIndex = 0` between calls or clone the pattern first.
27
+ */
28
+ // eslint-disable-next-line no-control-regex -- intentionally matches the ESC (\x1b) and BEL (\x07) control characters that delimit ANSI/OSC-8 sequences
29
+ export const ESCAPE_SEQUENCE_RE = /\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07/g;
30
+
31
+ const COMBINING_RANGES = [
32
+ [0x03_00, 0x03_6f],
33
+ [0x1a_b0, 0x1a_ff],
34
+ [0x1d_c0, 0x1d_ff],
35
+ [0x20_d0, 0x20_ff],
36
+ [0xfe_20, 0xfe_2f],
37
+ ];
38
+
39
+ const PUA_RANGES = [
40
+ [0xe0_00, 0xf8_ff],
41
+ [0xf_00_00, 0xf_ff_fd],
42
+ [0x10_00_00, 0x10_ff_fd],
43
+ ];
44
+
45
+ const WIDE_RANGES = [
46
+ [0x11_00, 0x11_5f],
47
+ [0x2e_80, 0x30_3e],
48
+ [0x30_41, 0x33_ff],
49
+ [0x34_00, 0x4d_bf],
50
+ [0x4e_00, 0x9f_ff],
51
+ [0xa0_00, 0xa4_cf],
52
+ [0xac_00, 0xd7_a3],
53
+ [0xf9_00, 0xfa_ff],
54
+ [0xff_00, 0xff_60],
55
+ [0xff_e0, 0xff_e6],
56
+ [0x2_00_00, 0x3_ff_fd],
57
+ ];
58
+
59
+ /**
60
+ * Codepoints a terminal draws two cells wide because the font renders them as
61
+ * an emoji, not a text glyph. Asked of the engine's own Unicode tables rather
62
+ * than a hand-kept range: the old blanket `U+2600-27BF` / `U+1F300-1FAFF` ranges
63
+ * were wrong in both directions (`⚠ U+26A0`, `✓ U+2713` and `➜ U+279C` are one
64
+ * cell, only the `Emoji_Presentation` subset of those blocks is two), and a
65
+ * table would go stale as Unicode adds emoji. A property escape is built into
66
+ * V8, so it costs no dependency. It does not cover East Asian Wide text, which
67
+ * is why `WIDE_RANGES` stays.
68
+ */
69
+ const EMOJI_PRESENTATION_RE = /^\p{Emoji_Presentation}$/u;
70
+
71
+ /** Codepoints that *can* be drawn as an emoji when followed by U+FE0F. */
72
+ const EMOJI_CAPABLE_RE = /^\p{Emoji}$/u;
73
+
74
+ const ZERO_WIDTH_JOINER = 0x20_0d;
75
+ const VARIATION_SELECTOR_16 = 0xfe_0f;
76
+
77
+ /**
78
+ * @param {number} code a Unicode codepoint.
79
+ * @param {ReadonlyArray<readonly [number, number]>} ranges inclusive
80
+ * `[start, end]` pairs.
81
+ * @returns {boolean} whether `code` falls in any of `ranges`.
82
+ */
83
+ function inRanges(code, ranges) {
84
+ return ranges.some(([start, end]) => code >= start && code <= end);
85
+ }
86
+
87
+ /**
88
+ * The display width of a single codepoint on its own, per this module's East
89
+ * Asian/emoji/Nerd-Font/combining-mark rules. Sequence-dependent codepoints
90
+ * (a ZWJ, or a VS16 that widens the glyph before it) are resolved by
91
+ * {@link clusters}, not here.
92
+ *
93
+ * @param {string} cp a single codepoint (as produced by iterating a string
94
+ * with `[...str]`).
95
+ * @returns {0 | 1 | 2}
96
+ */
97
+ function codepointWidth(cp) {
98
+ const code = cp.codePointAt(0) ?? 0;
99
+ if (
100
+ code === VARIATION_SELECTOR_16 ||
101
+ code === ZERO_WIDTH_JOINER ||
102
+ inRanges(code, COMBINING_RANGES)
103
+ ) {
104
+ return 0;
105
+ }
106
+ if (inRanges(code, PUA_RANGES)) return 1;
107
+ if (EMOJI_PRESENTATION_RE.test(cp) || inRanges(code, WIDE_RANGES)) return 2;
108
+ return 1;
109
+ }
110
+
111
+ /**
112
+ * Splits escape-free text into terminal cells' worth of glyphs: one entry per
113
+ * visible glyph, with anything that only modifies the glyph before it folded
114
+ * into that entry. Folding matters twice over -- the width is right (a
115
+ * `👨‍💻` ZWJ sequence is two cells, not the four its three codepoints sum
116
+ * to; `⚠️` is two because U+FE0F asks for emoji presentation), and
117
+ * {@link truncateToWidth} can never cut a sequence in half.
118
+ *
119
+ * Not handled: a regional-indicator flag pair (`🇮🇹`) is two cells but
120
+ * measures four, and the width of an emoji sequence is taken from its first
121
+ * codepoint. Both only ever over-count, so a row drops a segment early rather
122
+ * than wrapping.
123
+ *
124
+ * @param {string} text text containing no ANSI/OSC-8 sequences.
125
+ * @returns {Array<{ raw: string, width: 0 | 1 | 2 }>}
126
+ */
127
+ function clusters(text) {
128
+ /** @type {Array<{ raw: string, width: 0 | 1 | 2 }>} */
129
+ const out = [];
130
+ let afterJoiner = false;
131
+ for (const cp of text) {
132
+ const code = cp.codePointAt(0) ?? 0;
133
+ const previous = out.at(-1);
134
+
135
+ if (previous !== undefined && (afterJoiner || codepointWidth(cp) === 0)) {
136
+ previous.raw += cp;
137
+ if (
138
+ code === VARIATION_SELECTOR_16 &&
139
+ previous.width === 1 &&
140
+ EMOJI_CAPABLE_RE.test([...previous.raw][0] ?? "")
141
+ ) {
142
+ previous.width = 2;
143
+ }
144
+ afterJoiner = code === ZERO_WIDTH_JOINER;
145
+ continue;
146
+ }
147
+
148
+ out.push({ raw: cp, width: codepointWidth(cp) });
149
+ afterJoiner = code === ZERO_WIDTH_JOINER;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * The terminal-column width of `str` once ANSI/OSC-8 escape sequences are
156
+ * stripped, accounting for zero-width combining marks and joiners, emoji
157
+ * sequences, Nerd Font/PUA single-width glyphs, and East Asian Wide/emoji
158
+ * double-width codepoints.
159
+ *
160
+ * @param {string} str
161
+ * @returns {number}
162
+ */
163
+ export function displayWidth(str) {
164
+ const stripped = str.replace(ESCAPE_SEQUENCE_RE, "");
165
+ let width = 0;
166
+ for (const cluster of clusters(stripped)) width += cluster.width;
167
+ return width;
168
+ }
169
+
170
+ /**
171
+ * @typedef {{ type: "esc", raw: string }} EscToken
172
+ * @typedef {{ type: "char", raw: string, width: 0 | 1 | 2 }} CharToken
173
+ */
174
+
175
+ /**
176
+ * Tokenizes `str` into an ordered list of escape-sequence and visible-glyph
177
+ * tokens, preserving original order.
178
+ *
179
+ * @param {string} str
180
+ * @returns {Array<EscToken | CharToken>}
181
+ */
182
+ function tokenize(str) {
183
+ /** @type {Array<EscToken | CharToken>} */
184
+ const tokens = [];
185
+ let cursor = 0;
186
+ ESCAPE_SEQUENCE_RE.lastIndex = 0;
187
+ let match = ESCAPE_SEQUENCE_RE.exec(str);
188
+ while (match !== null) {
189
+ if (match.index > cursor) {
190
+ for (const { raw, width } of clusters(str.slice(cursor, match.index))) {
191
+ tokens.push({ type: "char", raw, width });
192
+ }
193
+ }
194
+ tokens.push({ type: "esc", raw: match[0] });
195
+ cursor = match.index + match[0].length;
196
+ match = ESCAPE_SEQUENCE_RE.exec(str);
197
+ }
198
+ if (cursor < str.length) {
199
+ for (const { raw, width } of clusters(str.slice(cursor))) {
200
+ tokens.push({ type: "char", raw, width });
201
+ }
202
+ }
203
+ return tokens;
204
+ }
205
+
206
+ /**
207
+ * Whether `raw` is an SGR sequence that opens a color/style (anything other
208
+ * than the exact reset `\x1b[0m`).
209
+ *
210
+ * @param {string} raw
211
+ * @returns {boolean}
212
+ */
213
+ function isColorOpen(raw) {
214
+ return raw.startsWith("\x1b[") && raw !== "\x1b[0m";
215
+ }
216
+
217
+ /**
218
+ * Truncates `str` to fit within `maxWidth` display columns, preserving ANSI
219
+ * color/OSC-8 sequences and never cutting mid-codepoint or mid-escape. If a
220
+ * color was left open by the truncation point, appends a reset so the
221
+ * cut-off segment can't bleed color into whatever follows it.
222
+ *
223
+ * @param {string} str
224
+ * @param {number} maxWidth
225
+ * @param {string} [ellipsis]
226
+ * @returns {string}
227
+ */
228
+ export function truncateToWidth(str, maxWidth, ellipsis = "…") {
229
+ if (maxWidth <= 0) return "";
230
+ if (displayWidth(str) <= maxWidth) return str;
231
+
232
+ const tokens = tokenize(str);
233
+ const ellipsisWidth = displayWidth(ellipsis);
234
+ const limit = maxWidth - ellipsisWidth;
235
+
236
+ let accumulated = 0;
237
+ let colorOpen = false;
238
+ const kept = [];
239
+ for (const token of tokens) {
240
+ if (token.type === "esc") {
241
+ kept.push(token.raw);
242
+ colorOpen =
243
+ token.raw === "\x1b[0m" ? false : colorOpen || isColorOpen(token.raw);
244
+ continue;
245
+ }
246
+ if (accumulated + token.width > limit) break;
247
+ accumulated += token.width;
248
+ kept.push(token.raw);
249
+ }
250
+
251
+ return kept.join("") + ellipsis + (colorOpen ? "\x1b[0m" : "");
252
+ }
253
+
254
+ /**
255
+ * @typedef {{ id: string, priority: number, text: string, minWidth: number }} RowSegment
256
+ */
257
+
258
+ /**
259
+ * @param {ReadonlyArray<RowSegment>} segments original-order segment list.
260
+ * @param {Set<string>} kept ids currently retained.
261
+ * @param {string} separator
262
+ * @returns {number}
263
+ */
264
+ function currentWidth(segments, kept, separator) {
265
+ const texts = segments.filter((s) => kept.has(s.id)).map((s) => s.text);
266
+ return displayWidth(texts.join(separator));
267
+ }
268
+
269
+ /**
270
+ * Fits `segments` into `budget` display columns, dropping the
271
+ * lowest-priority segment (right-side ties dropped first) until the joined
272
+ * width fits, then truncating a sole surviving over-budget segment as a last
273
+ * resort. Segments are always joined and returned in their original array
274
+ * order, never priority order. Pure: never mutates the input `segments`.
275
+ *
276
+ * The sole-survivor truncation floors at `Math.max(segment.minWidth, budget)`
277
+ * — a segment is never cut below its own `minWidth`, even when `budget` is
278
+ * smaller. This is a deliberate floor, not a bug: at a pathologically narrow
279
+ * `COLUMNS` (below a segment's `minWidth`, e.g. under ~14 once the fixed
280
+ * 10-column gutter is subtracted) the returned row can still exceed `budget`
281
+ * by a few columns. An unreadably-truncated segment is worse than a row a
282
+ * few columns over budget at a terminal width no real usage reaches.
283
+ *
284
+ * @param {ReadonlyArray<RowSegment>} segments
285
+ * @param {number} budget
286
+ * @param {string} separator
287
+ * @returns {string}
288
+ */
289
+ export function fitRow(segments, budget, separator) {
290
+ const kept = new Set(segments.map((s) => s.id));
291
+
292
+ while (kept.size > 1 && currentWidth(segments, kept, separator) > budget) {
293
+ let dropId = null;
294
+ let dropPriority = Number.POSITIVE_INFINITY;
295
+ for (const s of segments) {
296
+ if (!kept.has(s.id)) continue;
297
+ if (s.priority <= dropPriority) {
298
+ dropPriority = s.priority;
299
+ dropId = s.id;
300
+ }
301
+ }
302
+ if (dropId === null) break;
303
+ kept.delete(dropId);
304
+ }
305
+
306
+ const survivors = segments.filter((s) => kept.has(s.id));
307
+
308
+ if (survivors.length === 1 && displayWidth(survivors[0].text) > budget) {
309
+ const [sole] = survivors;
310
+ return truncateToWidth(sole.text, Math.max(sole.minWidth, budget));
311
+ }
312
+
313
+ return survivors.map((s) => s.text).join(separator);
314
+ }
315
+
316
+ /**
317
+ * Reads the terminal width a statusLine command was launched with. Per
318
+ * Anthropic's statusLine docs, `COLUMNS`/`LINES` are the only reliable
319
+ * source inside a statusLine subprocess — `tput cols` does not work there.
320
+ *
321
+ * @param {{ COLUMNS?: unknown } | undefined} env
322
+ * @returns {number} the parsed column count, or `80` when absent/invalid.
323
+ */
324
+ export function terminalColumns(env) {
325
+ const n = Number.parseInt(String(env?.COLUMNS ?? ""), 10);
326
+ return Number.isFinite(n) && n > 0 ? n : 80;
327
+ }
328
+
329
+ export const GREEN = "\x1b[32m";
330
+ export const YELLOW = "\x1b[33m";
331
+ export const RED = "\x1b[31m";
332
+ export const CYAN = "\x1b[36m";
333
+ export const BLUE = "\x1b[34m";
334
+ export const MAGENTA = "\x1b[35m";
335
+ export const DIM = "\x1b[2m";
336
+ export const RESET = "\x1b[0m";
337
+ export const SEGMENT_SEPARATOR = `${DIM} · ${RESET}`;
338
+ export const PLACEHOLDER = `${DIM}—${RESET}`;
339
+ export const GUTTER_WIDTH = 10;
340
+
341
+ /**
342
+ * Compact token-count formatter (`45000` -> `"45k"`, `15500` -> `"15.5k"`).
343
+ *
344
+ * @param {number} n always finite when called.
345
+ * @returns {string}
346
+ */
347
+ export function formatTokenCount(n) {
348
+ if (Math.abs(n) < 1000) return String(Math.round(n));
349
+ const kk = n / 1000;
350
+ const rounded = Math.round(kk * 10) / 10;
351
+ return Number.isInteger(rounded)
352
+ ? `${rounded.toFixed(0)}k`
353
+ : `${rounded.toFixed(1)}k`;
354
+ }
355
+
356
+ /**
357
+ * @param {number} deltaSec seconds remaining, may be negative/zero.
358
+ * @returns {string} `"now"`, `"NNm"`, or `"NhMMm"`.
359
+ */
360
+ export function formatDuration(deltaSec) {
361
+ if (deltaSec <= 0) return "now";
362
+ const h = Math.floor(deltaSec / 3600);
363
+ const m = Math.floor((deltaSec % 3600) / 60);
364
+ return h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m`;
365
+ }