@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,996 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * statusLine: renders a fixed five-row layout -- session, model, context,
4
+ * quota, work -- built entirely from the JSON Claude Code pipes to stdin
5
+ * (code.claude.com/docs/en/statusline). The five-row guarantee is
6
+ * `renderStatusLine`'s own contract on the success path only: the CLI entry's
7
+ * `catch` (bottom of this file) falls back to a single minimal `ctx --%` line
8
+ * on a JSON-parse failure, by design -- the fallback must never itself risk
9
+ * throwing, so it does not attempt to build five gutter+placeholder rows.
10
+ *
11
+ * Each row is width-fit against the real terminal width via
12
+ * `statusline-layout.mjs`'s `fitRow`/`terminalColumns`/`displayWidth`: Anthropic's
13
+ * docs state that `COLUMNS` must be read from the environment (`tput cols`
14
+ * does not work inside a statusLine subprocess), and that reading is what lets
15
+ * a narrow terminal drop its lowest-priority segments instead of wrapping
16
+ * mid-line.
17
+ *
18
+ * `statusLine` is the one documented surface exposing live
19
+ * `context_window.used_percentage` -- no hook event receives token/context
20
+ * data -- so this is the one place a "when to compact" signal can live.
21
+ *
22
+ * Invariant: **no subprocess, no network.** The script runs on every new
23
+ * assistant message (debounced 300ms; a new trigger cancels an in-flight
24
+ * run), so a spawn here would be the most frequently paid cost in the whole
25
+ * harness. Git state is read straight from `.git/HEAD` via `node:fs` -- a
26
+ * local synchronous file read, not a `git` shell-out -- and memory from
27
+ * `process.availableMemory()`/`os.totalmem()`, local syscalls (see
28
+ * {@link resolveMemory} for why not `os.freemem()`). Every other field
29
+ * (`context_window.*`, `workspace.*`, `model`, `effort`, `cost`,
30
+ * `rate_limits`, `prompt_cache`, `agent`, ...) already arrives on stdin.
31
+ *
32
+ * Threshold values (70 / 90) match Anthropic's own documented multi-line
33
+ * status-line example (green under 70, yellow 70-89, red 90+) rather than
34
+ * inventing project-specific numbers.
35
+ *
36
+ * Advisory-only: any parse or read failure falls back to a minimal
37
+ * `ctx --%` segment rather than an empty or broken status line.
38
+ */
39
+ import { readFileSync, realpathSync } from "node:fs";
40
+ import os from "node:os";
41
+ import { dirname, isAbsolute, join } from "node:path";
42
+ import process from "node:process";
43
+ import { fileURLToPath } from "node:url";
44
+ import {
45
+ BLUE,
46
+ CYAN,
47
+ DIM,
48
+ GREEN,
49
+ GUTTER_WIDTH,
50
+ MAGENTA,
51
+ PLACEHOLDER,
52
+ RED,
53
+ RESET,
54
+ SEGMENT_SEPARATOR,
55
+ YELLOW,
56
+ displayWidth,
57
+ fitRow,
58
+ formatDuration,
59
+ formatTokenCount,
60
+ terminalColumns,
61
+ } from "./statusline-layout.mjs";
62
+
63
+ export const WARN_THRESHOLD_PERCENT = 70;
64
+ export const HIGH_THRESHOLD_PERCENT = 90;
65
+ export const CONTEXT_BAR_WIDTH = 20;
66
+ export const QUOTA_BAR_WIDTH = 10;
67
+
68
+ /**
69
+ * @typedef {{ id: string, priority: number, text: string, minWidth: number }} RowSegment
70
+ */
71
+
72
+ /**
73
+ * Safe nested read: returns `undefined` as soon as any step along `path` is
74
+ * not an object, so every segment below can probe a payload field without a
75
+ * wall of `typeof` checks.
76
+ *
77
+ * @param {unknown} value
78
+ * @param {...string} path
79
+ * @returns {unknown}
80
+ */
81
+ function pick(value, ...path) {
82
+ let current = value;
83
+ for (const key of path) {
84
+ if (typeof current !== "object" || current === null) return undefined;
85
+ current = /** @type {Record<string, unknown>} */ (current)[key];
86
+ }
87
+ return current;
88
+ }
89
+
90
+ /**
91
+ * @param {unknown} value
92
+ * @returns {value is number}
93
+ */
94
+ function isFiniteNumber(value) {
95
+ return typeof value === "number" && Number.isFinite(value);
96
+ }
97
+
98
+ /**
99
+ * @param {unknown} value
100
+ * @returns {value is string}
101
+ */
102
+ function isNonEmptyString(value) {
103
+ return typeof value === "string" && value.length > 0;
104
+ }
105
+
106
+ /**
107
+ * Strips C0/C1 control characters and DEL from a payload string before it is
108
+ * rendered. Branch names, session names, and repo names are user- or
109
+ * remote-controlled text; an embedded escape sequence must not be able to
110
+ * repaint the terminal or forge a segment of its own.
111
+ *
112
+ * @param {string} text
113
+ * @returns {string}
114
+ */
115
+ export function sanitizeDisplayText(text) {
116
+ const controlChars = new RegExp(
117
+ "[" +
118
+ String.fromCharCode(0) +
119
+ "-" +
120
+ String.fromCharCode(31) +
121
+ String.fromCharCode(127) +
122
+ "-" +
123
+ String.fromCharCode(159) +
124
+ "]",
125
+ "g",
126
+ );
127
+ return text.replace(controlChars, "");
128
+ }
129
+
130
+ /**
131
+ * @param {unknown} payload the parsed statusLine stdin JSON
132
+ * @returns {number | null} `context_window.used_percentage`, rounded and
133
+ * clamped to `[0, 100]`, or null when the session has no context-window
134
+ * data yet (before the first API response, or immediately after
135
+ * `/compact`).
136
+ */
137
+ export function resolveUsedPercentage(payload) {
138
+ const pct = pick(payload, "context_window", "used_percentage");
139
+ return isFiniteNumber(pct)
140
+ ? Math.min(100, Math.max(0, Math.round(pct)))
141
+ : null;
142
+ }
143
+
144
+ /**
145
+ * @param {number | null} pct
146
+ * @returns {"unknown" | "ok" | "warn" | "high"}
147
+ */
148
+ export function zoneForPercentage(pct) {
149
+ if (pct === null) return "unknown";
150
+ if (pct >= HIGH_THRESHOLD_PERCENT) return "high";
151
+ if (pct >= WARN_THRESHOLD_PERCENT) return "warn";
152
+ return "ok";
153
+ }
154
+
155
+ /**
156
+ * @param {"unknown" | "ok" | "warn" | "high"} zone
157
+ * @returns {string} the ANSI color for a usage zone.
158
+ */
159
+ function zoneColor(zone) {
160
+ if (zone === "high") return RED;
161
+ if (zone === "warn") return YELLOW;
162
+ return GREEN;
163
+ }
164
+
165
+ /**
166
+ * @param {number} pct clamped to `[0, 100]`.
167
+ * @param {number} width
168
+ * @returns {string} a `█`/`░` bar `width` cells wide.
169
+ */
170
+ function renderBar(pct, width) {
171
+ const filled = Math.min(width, Math.max(0, Math.round((pct / 100) * width)));
172
+ return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
173
+ }
174
+
175
+ /**
176
+ * @param {unknown} headContent raw `.git/HEAD` file content.
177
+ * @returns {string | null} the branch name, or null for detached HEAD / a
178
+ * raw SHA / garbage.
179
+ */
180
+ export function parseHeadRef(headContent) {
181
+ if (typeof headContent !== "string") return null;
182
+ const match = /^ref:\s*refs\/heads\/(.+)$/.exec(headContent.trim());
183
+ return match ? match[1].trim() : null;
184
+ }
185
+
186
+ /**
187
+ * @param {unknown} headContent raw `.git/HEAD` file content.
188
+ * @returns {string | null} the abbreviated commit id when HEAD is detached (a
189
+ * rebase, a bisect, `git checkout <sha>`) -- a bare SHA-1 or SHA-256 hex id
190
+ * where a branch would be a `ref:` line -- or null. Kept apart from
191
+ * {@link parseHeadRef} so a commit id can never be mistaken for a branch name.
192
+ */
193
+ export function parseDetachedHead(headContent) {
194
+ if (typeof headContent !== "string") return null;
195
+ const sha = headContent.trim();
196
+ return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha) ? sha.slice(0, 7) : null;
197
+ }
198
+
199
+ /**
200
+ * @param {unknown} content raw `.git` file content (linked worktree /
201
+ * submodule case).
202
+ * @returns {string | null} the pointed-to gitdir path, or null.
203
+ */
204
+ export function parseGitdirPointer(content) {
205
+ if (typeof content !== "string") return null;
206
+ const match = /^gitdir:\s*(.+)$/m.exec(content);
207
+ return match ? match[1].trim() : null;
208
+ }
209
+
210
+ /**
211
+ * Walks upward from `startDir` looking for `.git` (a directory, the normal
212
+ * case, or a file pointing at the real gitdir, the linked-worktree /
213
+ * submodule case) and returns the directory that holds it -- the resolved
214
+ * workspace root. Pure with respect to actual disk I/O -- all reads go
215
+ * through the injected `readFile` -- so this is directly unit-testable
216
+ * without touching a real filesystem.
217
+ *
218
+ * `payload.workspace.current_dir` is not always the repo root: it diverges the
219
+ * moment a session `cd`s into a subdirectory or enters a worktree
220
+ * in-session, so the branch is resolved by walking up rather than trusting
221
+ * `startDir` verbatim.
222
+ *
223
+ * @param {(path: string) => string | null} readFile injected file reader;
224
+ * returns the file content or null when unreadable/absent.
225
+ * @param {unknown} startDir directory to start the upward walk from; a
226
+ * non-string or empty value returns null rather than throwing.
227
+ * @returns {string | null} the resolved workspace root, or null when no
228
+ * `.git` is found within the walk bound.
229
+ */
230
+ export function resolveWorkspaceRoot(readFile, startDir) {
231
+ if (typeof startDir !== "string" || startDir.length === 0) return null;
232
+
233
+ let dir = startDir;
234
+ for (let i = 0; i < 40; i++) {
235
+ if (typeof readFile(join(dir, ".git", "HEAD")) === "string") return dir;
236
+ if (typeof readFile(join(dir, ".git")) === "string") return dir;
237
+
238
+ const parent = dirname(dir);
239
+ if (parent === dir) return null;
240
+ dir = parent;
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Reads the raw `HEAD` file from the workspace root {@link resolveWorkspaceRoot}
247
+ * finds by walking upward from `startDir` -- handling both a `.git` directory
248
+ * (the normal case) and a `.git` file pointing at the real gitdir (the
249
+ * linked-worktree / submodule case).
250
+ *
251
+ * @param {(path: string) => string | null} readFile injected file reader;
252
+ * returns the file content or null when unreadable/absent.
253
+ * @param {unknown} startDir directory to start the upward walk from; a
254
+ * non-string or empty value returns null rather than throwing.
255
+ * @returns {string | null} the `HEAD` file's content, or null when it can't
256
+ * be resolved.
257
+ */
258
+ export function readHead(readFile, startDir) {
259
+ const dir = resolveWorkspaceRoot(readFile, startDir);
260
+ if (dir === null) return null;
261
+
262
+ const headContent = readFile(join(dir, ".git", "HEAD"));
263
+ if (typeof headContent === "string") return headContent;
264
+
265
+ const gitEntry = readFile(join(dir, ".git"));
266
+ if (typeof gitEntry === "string") {
267
+ const pointer = parseGitdirPointer(gitEntry);
268
+ if (pointer === null || pointer.length === 0) return null;
269
+ const resolvedGitDir = isAbsolute(pointer) ? pointer : join(dir, pointer);
270
+ const linkedHead = readFile(join(resolvedGitDir, "HEAD"));
271
+ return typeof linkedHead === "string" ? linkedHead : null;
272
+ }
273
+
274
+ return null;
275
+ }
276
+
277
+ /**
278
+ * Builds a `{ id, priority, text, minWidth }` row segment, or `null` when
279
+ * `text` is absent -- the shared shape every `format*Segment` function below
280
+ * returns so `fitRow` can budget/drop them uniformly. Higher `priority`
281
+ * survives longer as a row narrows.
282
+ *
283
+ * @param {string} id
284
+ * @param {number} priority
285
+ * @param {string | null | undefined} text
286
+ * @param {number} minWidth
287
+ * @returns {RowSegment | null}
288
+ */
289
+ function seg(id, priority, text, minWidth) {
290
+ return text === null || text === undefined
291
+ ? null
292
+ : { id, priority, text, minWidth };
293
+ }
294
+
295
+ /**
296
+ * @param {string} label
297
+ * @returns {string} the dim, fixed-width row label.
298
+ */
299
+ function gutter(label) {
300
+ return `${DIM}${label.padEnd(GUTTER_WIDTH)}${RESET}`;
301
+ }
302
+
303
+ /**
304
+ * @param {string} label
305
+ * @param {ReadonlyArray<RowSegment | null>} segments
306
+ * @param {number} columns
307
+ * @returns {string} one rendered row: a fixed gutter label plus the
308
+ * width-fit, separator-joined segments -- `PLACEHOLDER` when every segment
309
+ * is absent, so every row always renders exactly one non-empty line.
310
+ */
311
+ function buildRow(label, segments, columns) {
312
+ const g = gutter(label);
313
+ const nonEmpty = /** @type {RowSegment[]} */ (
314
+ segments.filter((s) => s !== null)
315
+ );
316
+ if (nonEmpty.length === 0) return `${g}${PLACEHOLDER}`;
317
+ const budget = columns - displayWidth(g);
318
+ return `${g}${fitRow(nonEmpty, budget, SEGMENT_SEPARATOR)}`;
319
+ }
320
+
321
+ /**
322
+ * @param {unknown} payload
323
+ * @returns {RowSegment | null} dim `↳ agent-name` segment, or null when absent.
324
+ */
325
+ export function formatAgentSegment(payload) {
326
+ const name = pick(payload, "agent", "name");
327
+ if (!isNonEmptyString(name)) return null;
328
+ return seg("agent", 55, `${DIM}↳ ${sanitizeDisplayText(name)}${RESET}`, 6);
329
+ }
330
+
331
+ /**
332
+ * Always renders, unlike most segments here: a session with no name is itself
333
+ * worth seeing, and `payload.session_name` is absent until a `--name`,
334
+ * `/rename`, or an AI-generated title exists.
335
+ *
336
+ * @param {unknown} payload
337
+ * @returns {RowSegment} the session name, or a dim `unnamed` marker -- never
338
+ * null.
339
+ */
340
+ export function formatSessionNameSegment(payload) {
341
+ const name = pick(payload, "session_name");
342
+ const text = isNonEmptyString(name)
343
+ ? sanitizeDisplayText(name)
344
+ : `${DIM}unnamed${RESET}`;
345
+ return /** @type {RowSegment} */ (seg("session_name", 100, text, 12));
346
+ }
347
+
348
+ /**
349
+ * @param {string | null} branchName the resolved branch, or null.
350
+ * @param {string | null} [detachedSha] the abbreviated commit id when HEAD is
351
+ * detached; only shown when there is no branch, so a rebase or bisect keeps
352
+ * its git segment instead of silently losing it.
353
+ * @returns {RowSegment | null} the branch segment. `main` is flagged as a
354
+ * warning: the baseline's workflow is feature branches and PRs, never work
355
+ * directly on `main`.
356
+ */
357
+ export function formatBranchSegment(branchName, detachedSha = null) {
358
+ if (!isNonEmptyString(branchName)) {
359
+ if (!isNonEmptyString(detachedSha)) return null;
360
+ return seg(
361
+ "branch",
362
+ 95,
363
+ `${YELLOW}detached @ ${sanitizeDisplayText(detachedSha)}${RESET}`,
364
+ 8,
365
+ );
366
+ }
367
+ const text =
368
+ branchName === "main"
369
+ ? `${RED}⚠ main${RESET}`
370
+ : `${BLUE}🌿 ${sanitizeDisplayText(branchName)}${RESET}`;
371
+ return seg("branch", 95, text, 6);
372
+ }
373
+
374
+ /**
375
+ * @param {unknown} payload
376
+ * @returns {RowSegment | null} the `🌳 name` segment, or null when
377
+ * `workspace.git_worktree` is absent/empty.
378
+ */
379
+ export function formatWorktreeSegment(payload) {
380
+ const name = pick(payload, "workspace", "git_worktree");
381
+ if (!isNonEmptyString(name)) return null;
382
+ return seg(
383
+ "worktree",
384
+ 85,
385
+ `${BLUE}🌳 ${sanitizeDisplayText(name)}${RESET}`,
386
+ 6,
387
+ );
388
+ }
389
+
390
+ /**
391
+ * @param {unknown} payload
392
+ * @returns {RowSegment | null} dim `owner/name` for the `origin` remote, with
393
+ * the host prefixed unless it is github.com; null outside a git repository
394
+ * or when no `origin` remote is configured.
395
+ */
396
+ export function formatOriginRepoSegment(payload) {
397
+ const host = pick(payload, "workspace", "repo", "host");
398
+ const owner = pick(payload, "workspace", "repo", "owner");
399
+ const name = pick(payload, "workspace", "repo", "name");
400
+ if (!isNonEmptyString(owner) || !isNonEmptyString(name)) return null;
401
+ const prefix =
402
+ isNonEmptyString(host) && host !== "github.com" ? `${host}/` : "";
403
+ return seg(
404
+ "origin_repo",
405
+ 40,
406
+ `${DIM}${sanitizeDisplayText(`${prefix}${owner}/${name}`)}${RESET}`,
407
+ 10,
408
+ );
409
+ }
410
+
411
+ /**
412
+ * @param {unknown} payload
413
+ * @returns {RowSegment | null} the model's display name (falling back to its
414
+ * id), or null when neither is present.
415
+ */
416
+ export function formatModelSegment(payload) {
417
+ const display = pick(payload, "model", "display_name");
418
+ const id = pick(payload, "model", "id");
419
+ const name = isNonEmptyString(display) ? display : id;
420
+ if (!isNonEmptyString(name)) return null;
421
+ return seg("model", 100, `${CYAN}${sanitizeDisplayText(name)}${RESET}`, 6);
422
+ }
423
+
424
+ /**
425
+ * @param {unknown} payload
426
+ * @returns {RowSegment | null} `effort.level`, or null when the current model
427
+ * doesn't support the effort parameter.
428
+ */
429
+ export function formatEffortSegment(payload) {
430
+ const level = pick(payload, "effort", "level");
431
+ if (!isNonEmptyString(level)) return null;
432
+ return seg(
433
+ "effort",
434
+ 80,
435
+ `${DIM}effort${RESET} ${MAGENTA}${sanitizeDisplayText(level)}${RESET}`,
436
+ 8,
437
+ );
438
+ }
439
+
440
+ /**
441
+ * @param {unknown} payload
442
+ * @returns {RowSegment | null} a `thinking` marker when extended thinking is
443
+ * enabled; hidden when it is off, since off is the uninteresting case.
444
+ */
445
+ export function formatThinkingSegment(payload) {
446
+ return pick(payload, "thinking", "enabled") === true
447
+ ? seg("thinking", 60, `${MAGENTA}thinking${RESET}`, 8)
448
+ : null;
449
+ }
450
+
451
+ /**
452
+ * @param {unknown} payload
453
+ * @returns {RowSegment | null} a `⚡ fast` marker when fast mode is on.
454
+ */
455
+ export function formatFastModeSegment(payload) {
456
+ return pick(payload, "fast_mode") === true
457
+ ? seg("fast_mode", 70, `${YELLOW}⚡ fast${RESET}`, 6)
458
+ : null;
459
+ }
460
+
461
+ /**
462
+ * @param {unknown} payload
463
+ * @returns {RowSegment | null} the output style's name, hidden for `default`.
464
+ */
465
+ export function formatOutputStyleSegment(payload) {
466
+ const name = pick(payload, "output_style", "name");
467
+ if (!isNonEmptyString(name) || name === "default") return null;
468
+ return seg(
469
+ "output_style",
470
+ 40,
471
+ `${DIM}style ${sanitizeDisplayText(name)}${RESET}`,
472
+ 8,
473
+ );
474
+ }
475
+
476
+ /**
477
+ * @param {unknown} payload
478
+ * @returns {RowSegment | null} the vim mode, or null when vim mode is off.
479
+ */
480
+ export function formatVimModeSegment(payload) {
481
+ const mode = pick(payload, "vim", "mode");
482
+ if (!isNonEmptyString(mode)) return null;
483
+ return seg("vim_mode", 50, `${CYAN}${sanitizeDisplayText(mode)}${RESET}`, 6);
484
+ }
485
+
486
+ /**
487
+ * @param {unknown} payload
488
+ * @returns {RowSegment | null} the context-window usage bar, colored by
489
+ * zone; null when the session has no context data yet.
490
+ */
491
+ export function formatContextBarSegment(payload) {
492
+ const pct = resolveUsedPercentage(payload);
493
+ if (pct === null) return null;
494
+ const color = zoneColor(zoneForPercentage(pct));
495
+ return seg(
496
+ "context_bar",
497
+ 50,
498
+ `${color}${renderBar(pct, CONTEXT_BAR_WIDTH)}${RESET}`,
499
+ 10,
500
+ );
501
+ }
502
+
503
+ /**
504
+ * Always renders: a dim `--%` stands in before the first API response and
505
+ * right after `/compact`, when `used_percentage` is `null`.
506
+ *
507
+ * @param {unknown} payload
508
+ * @returns {RowSegment}
509
+ */
510
+ export function formatContextPercentSegment(payload) {
511
+ const pct = resolveUsedPercentage(payload);
512
+ const text =
513
+ pct === null
514
+ ? `${DIM}--%${RESET}`
515
+ : `${zoneColor(zoneForPercentage(pct))}${pct}%${RESET}`;
516
+ return /** @type {RowSegment} */ (seg("context_pct", 100, text, 3));
517
+ }
518
+
519
+ /**
520
+ * @param {unknown} payload
521
+ * @returns {RowSegment | null} `45k/200k`: tokens in the window over the
522
+ * window's size; null when either is absent or non-positive.
523
+ */
524
+ export function formatContextDenominatorSegment(payload) {
525
+ const numerator = pick(payload, "context_window", "total_input_tokens");
526
+ const denominator = pick(payload, "context_window", "context_window_size");
527
+ if (
528
+ !isFiniteNumber(numerator) ||
529
+ numerator <= 0 ||
530
+ !isFiniteNumber(denominator) ||
531
+ denominator <= 0
532
+ ) {
533
+ return null;
534
+ }
535
+ const text = `${formatTokenCount(numerator)}/${formatTokenCount(denominator)}`;
536
+ return seg("context_denom", 80, text, 10);
537
+ }
538
+
539
+ /**
540
+ * @param {"unknown" | "ok" | "warn" | "high"} zone
541
+ * @returns {string | null} a `/compact` nudge once the window is filling up,
542
+ * null while there is plenty of room. This is the "when to compact" signal;
543
+ * what survives a compaction is a separate concern.
544
+ */
545
+ export function buildCompactSuggestion(zone) {
546
+ if (zone === "high") return "/compact now";
547
+ if (zone === "warn") return "/compact soon";
548
+ return null;
549
+ }
550
+
551
+ /**
552
+ * @param {unknown} payload
553
+ * @returns {RowSegment | null} `~155k left`, plus a compact suggestion once
554
+ * the window passes the warn threshold; null when the remaining share or
555
+ * window size is absent.
556
+ */
557
+ export function formatContextHeadroomSegment(payload) {
558
+ const remainingPct = pick(payload, "context_window", "remaining_percentage");
559
+ const windowSize = pick(payload, "context_window", "context_window_size");
560
+ if (
561
+ !isFiniteNumber(remainingPct) ||
562
+ !isFiniteNumber(windowSize) ||
563
+ windowSize <= 0
564
+ ) {
565
+ return null;
566
+ }
567
+ const clamped = Math.min(100, Math.max(0, remainingPct));
568
+ const color = zoneColor(zoneForPercentage(Math.round(100 - clamped)));
569
+ const left = `~${formatTokenCount((clamped / 100) * windowSize)} left`;
570
+ const suggestion = buildCompactSuggestion(
571
+ zoneForPercentage(Math.round(100 - clamped)),
572
+ );
573
+ const text =
574
+ suggestion === null
575
+ ? `${color}${left}${RESET}`
576
+ : `${color}${left}${RESET}${SEGMENT_SEPARATOR}${color}${suggestion}${RESET}`;
577
+ return seg("context_headroom", 60, text, 10);
578
+ }
579
+
580
+ /**
581
+ * @param {string} id
582
+ * @param {string} label
583
+ * @param {number} priority
584
+ * @param {unknown} window one of `rate_limits.five_hour` / `seven_day` /
585
+ * `spend_limit`.
586
+ * @param {number} nowMs
587
+ * @returns {RowSegment | null} `5h ███░░░░░░░ 23% ↻2h10m`; null when the
588
+ * window is absent (each window is independently absent, and Claude Code
589
+ * drops one once its `resets_at` passes).
590
+ */
591
+ function formatQuotaWindow(id, label, priority, window, nowMs) {
592
+ const used = pick(window, "used_percentage");
593
+ if (!isFiniteNumber(used)) return null;
594
+
595
+ const barPct = Math.min(100, Math.max(0, used));
596
+ const color = zoneColor(zoneForPercentage(Math.round(barPct)));
597
+ const resetsAt = pick(window, "resets_at");
598
+ const resetText = isFiniteNumber(resetsAt)
599
+ ? ` ${DIM}↻${formatDuration(Math.floor(resetsAt - nowMs / 1000))}${RESET}`
600
+ : "";
601
+ const text = `${DIM}${label}${RESET} ${color}${renderBar(barPct, QUOTA_BAR_WIDTH)}${RESET} ${Math.round(used)}%${resetText}`;
602
+ return seg(id, priority, text, 8);
603
+ }
604
+
605
+ /**
606
+ * @param {unknown} payload
607
+ * @param {number} nowMs
608
+ * @returns {RowSegment | null}
609
+ */
610
+ export function formatFiveHourSegment(payload, nowMs) {
611
+ return formatQuotaWindow(
612
+ "five_hour",
613
+ "5h",
614
+ 100,
615
+ pick(payload, "rate_limits", "five_hour"),
616
+ nowMs,
617
+ );
618
+ }
619
+
620
+ /**
621
+ * @param {unknown} payload
622
+ * @param {number} nowMs
623
+ * @returns {RowSegment | null}
624
+ */
625
+ export function formatSevenDaySegment(payload, nowMs) {
626
+ return formatQuotaWindow(
627
+ "seven_day",
628
+ "7d",
629
+ 90,
630
+ pick(payload, "rate_limits", "seven_day"),
631
+ nowMs,
632
+ );
633
+ }
634
+
635
+ /**
636
+ * @param {unknown} payload
637
+ * @param {number} nowMs
638
+ * @returns {RowSegment | null} present only behind a Claude apps gateway that
639
+ * sets a spend limit for the user.
640
+ */
641
+ export function formatSpendLimitSegment(payload, nowMs) {
642
+ return formatQuotaWindow(
643
+ "spend_limit",
644
+ "spend",
645
+ 70,
646
+ pick(payload, "rate_limits", "spend_limit"),
647
+ nowMs,
648
+ );
649
+ }
650
+
651
+ /**
652
+ * @param {unknown} payload
653
+ * @returns {RowSegment | null} the estimated session cost in USD.
654
+ */
655
+ export function formatCostSegment(payload) {
656
+ const cost = pick(payload, "cost", "total_cost_usd");
657
+ if (!isFiniteNumber(cost) || cost < 0) return null;
658
+ return seg("cost", 100, `$${cost.toFixed(2)}`, 5);
659
+ }
660
+
661
+ /**
662
+ * @param {unknown} payload
663
+ * @returns {RowSegment | null} elapsed wall-clock time since the session
664
+ * started; hidden at zero, where `formatDuration` would read `now`.
665
+ */
666
+ export function formatDurationSegment(payload) {
667
+ const ms = pick(payload, "cost", "total_duration_ms");
668
+ if (!isFiniteNumber(ms) || ms <= 0) return null;
669
+ return seg(
670
+ "duration",
671
+ 90,
672
+ `${DIM}${formatDuration(Math.floor(ms / 1000))}${RESET}`,
673
+ 3,
674
+ );
675
+ }
676
+
677
+ /**
678
+ * @param {unknown} payload
679
+ * @returns {RowSegment | null} `+156/-23`; null when nothing has changed.
680
+ */
681
+ export function formatLinesChangedSegment(payload) {
682
+ const added = pick(payload, "cost", "total_lines_added");
683
+ const removed = pick(payload, "cost", "total_lines_removed");
684
+ const a = isFiniteNumber(added) ? added : 0;
685
+ const r = isFiniteNumber(removed) ? removed : 0;
686
+ if (a <= 0 && r <= 0) return null;
687
+ return seg(
688
+ "lines",
689
+ 70,
690
+ `${GREEN}+${a}${RESET}${DIM}/${RESET}${RED}-${r}${RESET}`,
691
+ 6,
692
+ );
693
+ }
694
+
695
+ /**
696
+ * @param {unknown} payload
697
+ * @returns {RowSegment | null} `cache 91%`: the prompt-cache hit ratio, green
698
+ * while the cache is warm and yellow once it has gone cold (the next request
699
+ * then pays to rebuild it); null before the first API response.
700
+ */
701
+ export function formatCacheSegment(payload) {
702
+ const ratio = pick(payload, "prompt_cache", "hit_ratio");
703
+ if (!isFiniteNumber(ratio)) return null;
704
+ const pct = Math.round(Math.min(1, Math.max(0, ratio)) * 100);
705
+ const color = pick(payload, "prompt_cache", "warm") === true ? GREEN : YELLOW;
706
+ return seg("cache", 60, `${DIM}cache${RESET} ${color}${pct}%${RESET}`, 8);
707
+ }
708
+
709
+ /**
710
+ * @param {number} bytes
711
+ * @returns {string} gibibytes to one decimal, trailing `.0` dropped.
712
+ */
713
+ function gigabytes(bytes) {
714
+ return String(Math.round((bytes / 2 ** 30) * 10) / 10);
715
+ }
716
+
717
+ /**
718
+ * Picks the free/total memory figures to display, identically on macOS and
719
+ * Linux, with no subprocess.
720
+ *
721
+ * `os.freemem()` is not portable in meaning: on Linux it is `MemAvailable`
722
+ * (free plus reclaimable cache), but on macOS it is only the free and
723
+ * speculative pages, leaving out the large inactive/purgeable share the
724
+ * kernel will happily reclaim -- so it understates what is available and can
725
+ * leave the segment needlessly red on a healthy machine. `process.availableMemory()`
726
+ * (Node 22+) counts that reclaimable share on both platforms, and on Linux also
727
+ * respects a cgroup limit, so it is preferred. When it is missing (an older
728
+ * Node in an adopted project) macOS shows no memory segment at all rather
729
+ * than a number that means something else, while Linux falls back to
730
+ * `os.freemem()`, whose meaning is right there.
731
+ *
732
+ * `total` is likewise capped at the cgroup limit when one applies, so a
733
+ * container with a small limit on a large host reads as a fraction of the
734
+ * container, not of the host.
735
+ *
736
+ * Pure and dependency-injected so both platforms' branches are testable from
737
+ * either one.
738
+ *
739
+ * @param {{
740
+ * platform: string;
741
+ * totalmem: number;
742
+ * freemem: number;
743
+ * availableMemory?: unknown;
744
+ * constrainedMemory?: unknown;
745
+ * }} sources
746
+ * @returns {{ freemem: number, totalmem: number } | Record<string, never>}
747
+ * the figures in bytes, or `{}` when no trustworthy free figure exists.
748
+ */
749
+ export function resolveMemory(sources) {
750
+ const { platform, availableMemory, constrainedMemory } = sources;
751
+ let total = sources.totalmem;
752
+ if (
753
+ isFiniteNumber(constrainedMemory) &&
754
+ constrainedMemory > 0 &&
755
+ constrainedMemory < total
756
+ ) {
757
+ total = constrainedMemory;
758
+ }
759
+ if (!isFiniteNumber(total) || total <= 0) return {};
760
+
761
+ let free;
762
+ if (isFiniteNumber(availableMemory) && availableMemory >= 0) {
763
+ free = availableMemory;
764
+ } else if (platform === "darwin") {
765
+ return {};
766
+ } else {
767
+ free = sources.freemem;
768
+ }
769
+ if (!isFiniteNumber(free)) return {};
770
+
771
+ return { freemem: Math.min(free, total), totalmem: total };
772
+ }
773
+
774
+ /**
775
+ * @param {{ freemem?: unknown, totalmem?: unknown } | undefined} env
776
+ * @returns {RowSegment | null} `9.2/32G free`, colored by how much is in use;
777
+ * null when either figure is absent.
778
+ */
779
+ export function formatMemorySegment(env) {
780
+ const freemem = env?.freemem;
781
+ const totalmem = env?.totalmem;
782
+ if (
783
+ typeof freemem !== "number" ||
784
+ !Number.isFinite(freemem) ||
785
+ typeof totalmem !== "number" ||
786
+ !Number.isFinite(totalmem) ||
787
+ totalmem <= 0
788
+ ) {
789
+ return null;
790
+ }
791
+ const freePct = (freemem / totalmem) * 100;
792
+ const zone = zoneForPercentage(100 - freePct);
793
+ const text = `${zoneColor(zone)}${gigabytes(freemem)}/${gigabytes(totalmem)}G free${RESET}`;
794
+ return seg("memory", 50, text, 10);
795
+ }
796
+
797
+ /**
798
+ * @param {unknown} payload
799
+ * @param {{ branch?: unknown, detachedSha?: unknown } | undefined} env
800
+ * @param {number} columns
801
+ * @returns {string} the session row: session name, branch, worktree, agent,
802
+ * origin repo.
803
+ */
804
+ export function buildSessionRow(payload, env, columns) {
805
+ return buildRow(
806
+ "session",
807
+ [
808
+ formatSessionNameSegment(payload),
809
+ formatBranchSegment(
810
+ typeof env?.branch === "string" ? env.branch : null,
811
+ typeof env?.detachedSha === "string" ? env.detachedSha : null,
812
+ ),
813
+ formatWorktreeSegment(payload),
814
+ formatAgentSegment(payload),
815
+ formatOriginRepoSegment(payload),
816
+ ],
817
+ columns,
818
+ );
819
+ }
820
+
821
+ /**
822
+ * @param {unknown} payload
823
+ * @param {number} columns
824
+ * @returns {string} the model row: model, effort, thinking, fast mode, output
825
+ * style, vim mode.
826
+ */
827
+ export function buildModelRow(payload, columns) {
828
+ return buildRow(
829
+ "model",
830
+ [
831
+ formatModelSegment(payload),
832
+ formatEffortSegment(payload),
833
+ formatThinkingSegment(payload),
834
+ formatFastModeSegment(payload),
835
+ formatOutputStyleSegment(payload),
836
+ formatVimModeSegment(payload),
837
+ ],
838
+ columns,
839
+ );
840
+ }
841
+
842
+ /**
843
+ * @param {unknown} payload
844
+ * @param {number} columns
845
+ * @returns {string} the context row: usage bar, percent, denominator,
846
+ * headroom (with a compact suggestion once the window fills).
847
+ */
848
+ export function buildContextRow(payload, columns) {
849
+ return buildRow(
850
+ "context",
851
+ [
852
+ formatContextBarSegment(payload),
853
+ formatContextPercentSegment(payload),
854
+ formatContextDenominatorSegment(payload),
855
+ formatContextHeadroomSegment(payload),
856
+ ],
857
+ columns,
858
+ );
859
+ }
860
+
861
+ /**
862
+ * @param {unknown} payload
863
+ * @param {{ now?: unknown } | undefined} env
864
+ * @param {number} columns
865
+ * @returns {string} the quota row: 5-hour, 7-day, and spend-limit windows.
866
+ * Renders a placeholder for anyone without a subscription rate limit.
867
+ */
868
+ export function buildQuotaRow(payload, env, columns) {
869
+ const nowMs = isFiniteNumber(env?.now) ? env.now : Date.now();
870
+ return buildRow(
871
+ "quota",
872
+ [
873
+ formatFiveHourSegment(payload, nowMs),
874
+ formatSevenDaySegment(payload, nowMs),
875
+ formatSpendLimitSegment(payload, nowMs),
876
+ ],
877
+ columns,
878
+ );
879
+ }
880
+
881
+ /**
882
+ * @param {unknown} payload
883
+ * @param {{ freemem?: unknown, totalmem?: unknown } | undefined} env
884
+ * @param {number} columns
885
+ * @returns {string} the work row: cost, duration, lines changed, cache, memory.
886
+ */
887
+ export function buildWorkRow(payload, env, columns) {
888
+ return buildRow(
889
+ "work",
890
+ [
891
+ formatCostSegment(payload),
892
+ formatDurationSegment(payload),
893
+ formatLinesChangedSegment(payload),
894
+ formatCacheSegment(payload),
895
+ formatMemorySegment(env),
896
+ ],
897
+ columns,
898
+ );
899
+ }
900
+
901
+ /**
902
+ * @param {unknown} payload
903
+ * @param {{
904
+ * now?: unknown;
905
+ * freemem?: unknown;
906
+ * totalmem?: unknown;
907
+ * branch?: unknown;
908
+ * detachedSha?: unknown;
909
+ * COLUMNS?: unknown;
910
+ * }} [env] local-only, non-payload context: current time (ms), free/total
911
+ * memory (bytes), the resolved git branch name (or, on a detached HEAD, its short commit id), and the terminal `COLUMNS`
912
+ * width. Defaults to `{}` so a bare `renderStatusLine(payload)` call works.
913
+ * @returns {string} the full, always-five-line status-line output.
914
+ */
915
+ export function renderStatusLine(payload, env = {}) {
916
+ const columns = terminalColumns(env);
917
+ return [
918
+ buildSessionRow(payload, env, columns),
919
+ buildModelRow(payload, columns),
920
+ buildContextRow(payload, columns),
921
+ buildQuotaRow(payload, env, columns),
922
+ buildWorkRow(payload, env, columns),
923
+ ].join("\n");
924
+ }
925
+
926
+ /**
927
+ * @param {string} path
928
+ * @returns {string | null}
929
+ */
930
+ function safeReadFile(path) {
931
+ try {
932
+ return readFileSync(path, "utf8");
933
+ } catch {
934
+ return null;
935
+ }
936
+ }
937
+
938
+ /**
939
+ * @param {string | null} headContent raw `HEAD` file content.
940
+ * @returns {{ branch: string | null, detachedSha: string | null }}
941
+ */
942
+ function resolveHeadState(headContent) {
943
+ return {
944
+ branch: parseHeadRef(headContent),
945
+ detachedSha: parseDetachedHead(headContent),
946
+ };
947
+ }
948
+
949
+ /**
950
+ * Whether this module is the process's entry point (as opposed to being
951
+ * imported by a test). `import.meta.url` is always the symlink-resolved path,
952
+ * but `process.argv[1]` is kept exactly as typed, so comparing them directly
953
+ * is false for any project under a symlinked directory -- macOS's `/tmp` and
954
+ * `/var`, a symlinked home or volume, a symlinked workspace on Linux -- and the
955
+ * script would then exit 0 having printed nothing at all.
956
+ *
957
+ * @returns {boolean}
958
+ */
959
+ function isEntryPoint() {
960
+ try {
961
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
962
+ } catch {
963
+ return false;
964
+ }
965
+ }
966
+
967
+ async function readStdin() {
968
+ const chunks = [];
969
+ for await (const chunk of process.stdin) chunks.push(chunk);
970
+ return Buffer.concat(chunks).toString("utf8");
971
+ }
972
+
973
+ // Only run when invoked directly, not when imported for testing.
974
+ if (isEntryPoint()) {
975
+ let output;
976
+ try {
977
+ const payload = JSON.parse(await readStdin());
978
+ const startDir =
979
+ pick(payload, "workspace", "current_dir") ?? pick(payload, "cwd");
980
+ output = renderStatusLine(payload, {
981
+ now: Date.now(),
982
+ ...resolveHeadState(readHead(safeReadFile, startDir)),
983
+ COLUMNS: process.env["COLUMNS"],
984
+ ...resolveMemory({
985
+ platform: process.platform,
986
+ totalmem: os.totalmem(),
987
+ freemem: os.freemem(),
988
+ availableMemory: process.availableMemory?.(),
989
+ constrainedMemory: process.constrainedMemory?.(),
990
+ }),
991
+ });
992
+ } catch {
993
+ output = "ctx --%";
994
+ }
995
+ process.stdout.write(`${output}\n`);
996
+ }