heyspex 0.2.2 → 0.2.4

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 (79) hide show
  1. package/.claude-plugin/marketplace.json +2 -1
  2. package/README.md +11 -0
  3. package/bin/hs-vault-binding.mjs +157 -0
  4. package/bin/hs.mjs +83 -79
  5. package/bin/spexcode.mjs +91 -0
  6. package/dev-factory/engine/worktree.mjs +36 -7
  7. package/dev-factory/lib/epic-detect.mjs +2 -2
  8. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  9. package/dev-factory/lib/fleet-state.mjs +2 -2
  10. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  11. package/dist/cli/hs-check.mjs +76 -46
  12. package/dist/cli/hs-claim.mjs +63 -33
  13. package/dist/cli/hs-connect.mjs +520 -214
  14. package/dist/cli/hs-defer.mjs +161 -19
  15. package/dist/cli/hs-dev.mjs +11 -5
  16. package/dist/cli/hs-env.mjs +187 -161
  17. package/dist/cli/hs-index-rebuild.mjs +123 -78
  18. package/dist/cli/hs-index-reconcile.mjs +203 -107
  19. package/dist/cli/hs-init.mjs +426 -179
  20. package/dist/cli/hs-install.mjs +759 -437
  21. package/dist/cli/hs-join.mjs +590 -383
  22. package/dist/cli/hs-vault-pull.mjs +359 -254
  23. package/dist/cli/hs-vault-push.mjs +151 -113
  24. package/dist/cli/hs-vault.mjs +271 -104
  25. package/dist/cli/hs-workspace.mjs +90 -57
  26. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  27. package/docs/copilot-plugin-install.md +200 -177
  28. package/docs/driving-heyspex-from-copilot.md +174 -165
  29. package/docs/mcp-client-config.md +93 -25
  30. package/package.json +257 -250
  31. package/plugins/heyspex/.claude-plugin/plugin.json +13 -12
  32. package/plugins/heyspex/.cursor-plugin/plugin.json +16 -15
  33. package/plugins/heyspex/.mcp.json +3 -4
  34. package/plugins/heyspex/README.md +323 -312
  35. package/plugins/heyspex/assets/heyspex-glyph-128.png +0 -0
  36. package/plugins/heyspex/assets/heyspex-glyph-256.png +0 -0
  37. package/plugins/heyspex/assets/heyspex-glyph.svg +12 -0
  38. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  39. package/plugins/heyspex/mcp.json +12 -13
  40. package/scripts/check-mcp-freshness.mjs +6 -4
  41. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  42. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  43. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  44. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  45. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  46. package/scripts/claude-statusline/statusline-command.mjs +1073 -0
  47. package/scripts/copilot-statusline.mjs +130 -0
  48. package/scripts/dev-server-status.mjs +3 -2
  49. package/scripts/dev-server-stop.mjs +3 -2
  50. package/scripts/hs-capture-cli.mjs +2 -2
  51. package/scripts/hs-settings.mjs +2 -2
  52. package/scripts/hs-view.mjs +3 -2
  53. package/scripts/install-copilot-hooks.mjs +152 -36
  54. package/scripts/lib/copilot-cli.mjs +108 -0
  55. package/scripts/lib/customer-kit.mjs +223 -30
  56. package/scripts/lib/dev-mode.mjs +28 -2
  57. package/scripts/lib/shared-path-helpers.mjs +32 -0
  58. package/scripts/lib/standalone-doctor.mjs +131 -32
  59. package/scripts/wip-ledger-health.mjs +3 -2
  60. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  61. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  62. package/spexharness/src/intent-directive.mjs +839 -626
  63. package/spexharness/src/rule-table.mjs +1 -1
  64. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  65. package/vendor/heyspex-mcp/cli.mjs +20 -4
  66. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  67. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  68. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  69. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  70. package/vendor/heyspex-mcp/http-transport.mjs +6 -1
  71. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  72. package/vendor/heyspex-mcp/server-identity.mjs +13 -0
  73. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  74. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  75. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  76. package/vendor/heyspex-mcp/tool-dispatch.mjs +63 -23
  77. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  78. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  79. package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
@@ -0,0 +1,1073 @@
1
+ #!/usr/bin/env node
2
+ // HeySpex Claude Code statusline — renders a one-line footer with the
3
+ // current model + context %, a sticky 2-6 word thread label, the pinned
4
+ // focus epic, the git branch, and the most recent worked-on doc with its
5
+ // pillar ("HS-<fileKey>-<familyKey>-<TYPE>"). Wired via `.claude/settings.json`
6
+ // `statusLine.command` by `scripts/install-statusline.mjs`.
7
+ //
8
+ // Both the pinned-epic segment and the active-claim segment use the same
9
+ // HeySpex marker (glyph at U+E000 when HeySpexIcons.ttf is installed and
10
+ // HEYSPEX_STATUSLINE_GLYPH=1; plain `[HS]` otherwise). The earlier `▶`
11
+ // claim prefix was retired so the two segments brand consistently.
12
+ //
13
+ // Contract:
14
+ // stdin ← Claude Code session JSON (see code.claude.com/docs/en/statusline)
15
+ // stdout → one line of text (may contain ANSI / U+E000 glyph)
16
+ // exit 0 always — a failing statusline silently degrades, never breaks
17
+ // Claude Code rendering.
18
+ //
19
+ // Data sources (all under $CLAUDE_PROJECT_DIR):
20
+ // GET <console>/api/control/pin — pin.specId (the pinned epic/milestone),
21
+ // read from Neon. Console URL comes from
22
+ // .heyspex/console-url (or HEYSPEX_CONSOLE_URL).
23
+ // Focus has no focus.yaml fallback
24
+ // ([[no-fallbacks-by-default]]); when the
25
+ // console is not running this segment is dropped.
26
+ //
27
+ // Focus-pin caching (hybrid push + pull, [[HS-RES-YCPL]]): Claude Code re-invokes
28
+ // this script after every assistant message AND on the `refreshInterval` timer in
29
+ // settings.json, so an uncached GET would hit the console dozens of times in a
30
+ // burst during active sessions.
31
+ // - PUSH: the console's control-plane pin writers (setFocusPin/clearFocusPin)
32
+ // write the current pin to `.heyspex/statusline-focus-cache.json` on every
33
+ // change, so the file reflects the live pin within ms of a change.
34
+ // - PULL: within FOCUS_CACHE_TTL_MS we read that file with no HTTP at all
35
+ // (including a negative-cached null when the console is down); only past the
36
+ // TTL do we fall back to one GET to reconcile (console restarted, cache
37
+ // deleted, first run before any pin change), then rewrite the file.
38
+ // Because push keeps the value fresh, the TTL is just a reconciliation interval —
39
+ // default 60s, overridable via HEYSPEX_STATUSLINE_FOCUS_TTL_MS (0 = always fetch).
40
+ // .heyspex/active-claims.json — claims[*].sessionId / specId / status (still
41
+ // file-backed pending its own Neon migration, [[HS-TASK-ACLM]]).
42
+ // .git (via `git branch --show-current`) — current branch
43
+ //
44
+ // Each segment degrades independently: an unreachable console / unreadable file
45
+ // drops just that segment. The script never throws.
46
+
47
+ import fs from 'node:fs';
48
+ import os from 'node:os';
49
+ import path from 'node:path';
50
+ import crypto from 'node:crypto';
51
+ import { spawn, spawnSync } from 'node:child_process';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { isMainModule } from '../lib/shared-path-helpers.mjs';
54
+
55
+ import { resolveActiveVault } from '../../vendor/heyspex-mcp/vault-resolve.mjs';
56
+
57
+ // Entry-point guard (HS-IVJE): true only when this file is run directly as a script
58
+ // (`node scripts/claude-statusline/statusline-command.mjs`, which is how Claude Code's
59
+ // statusLine.command and the CLI invoke it) — false when another module `import`s it for
60
+ // its exports (welcome.mjs imports `marker`/GLYPH/PLAIN_MARKER, HS-IVJE). Both the
61
+ // off-switch below and the CLI dispatch at the bottom of the file are gated on it so an
62
+ // import never triggers the off-switch's process.exit, stdin reads, git spawns, or stdout
63
+ // writes that main() performs — side effects that belong to running the CLI, not to
64
+ // reading its exports.
65
+ const isDirectRun = isMainModule(import.meta.url);
66
+
67
+ // HeySpex master off-switch (`hs mode off`). Every segment this script renders
68
+ // — pinned epic, active claim, the [HS] marker — is HeySpex state, so when the
69
+ // layer is off there is nothing honest left to draw: print nothing and let
70
+ // Claude Code fall back to its own footer. Kept inline and first so the check
71
+ // costs one small read, and fail-open so a corrupt state file can never blank
72
+ // a statusline that should be live. Contract: scripts/heyspex-mode.mjs.
73
+ if (isDirectRun) {
74
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
75
+ let off = false;
76
+ try {
77
+ const raw = String(process.env.HEYSPEX_MODE ?? '').trim().toLowerCase();
78
+ off = raw
79
+ ? ['off', '0', 'false', 'disabled', 'disable', 'no'].includes(raw)
80
+ : JSON.parse(fs.readFileSync(path.join(repoRoot, '.heyspex', 'mode.json'), 'utf8'))?.mode === 'off';
81
+ } catch {
82
+ off = false;
83
+ }
84
+ if (off) process.exit(0);
85
+ }
86
+
87
+ export const GLYPH = ''; // Private-Use codepoint used by HeySpexIcons.ttf
88
+ export const PLAIN_MARKER = '[HS]';
89
+ // GitHub mark (MIT-licensed octicon) at U+E001 in HeySpexIcons.ttf — the
90
+ // branch-segment prefix in glyph mode (owner ask 2026-08-12). Ships in the
91
+ // same font as the brand glyph, so the U+E000 gate covers it too.
92
+ const GITHUB_GLYPH = '';
93
+ // Claude spark (12-ray starburst) at U+E002 - the model-segment prefix in
94
+ // glyph mode (owner ask 2026-08-12). Same font, same gate.
95
+ const CLAUDE_GLYPH = "";
96
+ const GIT_TIMEOUT_MS = 300;
97
+ const STDIN_TIMEOUT_MS = 200;
98
+ const CONSOLE_TIMEOUT_MS = 400;
99
+
100
+ // Reconciliation interval for the focus-pin cache (see header). The console pushes
101
+ // every pin change to the cache file, so this is just how long we trust the file
102
+ // before doing one reconciliation GET. Default 60s; HEYSPEX_STATUSLINE_FOCUS_TTL_MS
103
+ // overrides (0 disables caching — every invocation fetches).
104
+ const FOCUS_CACHE_TTL_MS = (() => {
105
+ const raw = Number(process.env.HEYSPEX_STATUSLINE_FOCUS_TTL_MS);
106
+ return Number.isFinite(raw) && raw >= 0 ? raw : 60000;
107
+ })();
108
+
109
+ // A null result from a *pull* may be a transient miss (console cold-starting or
110
+ // briefly down), so we trust it only briefly before retrying — unlike a real spec
111
+ // id or a control-plane-pushed clear (source: "control-plane"), which are trusted
112
+ // for the full window. Without this, one cold-route timeout would hide the pin for
113
+ // a whole TTL.
114
+ const FOCUS_CACHE_NEG_TTL_MS = Math.min(5000, FOCUS_CACHE_TTL_MS);
115
+
116
+ function repoRoot() {
117
+ // $CLAUDE_PROJECT_DIR is set by Claude Code when this script runs as
118
+ // a statusLine command. Falling back to walking up from this file so
119
+ // the script also works when invoked directly for smoke-testing.
120
+ if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
121
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
122
+ }
123
+
124
+ // Repo split (2026-08-31): specs/ lives in the vault repo, so the worked-doc
125
+ // and pillar segments read it from the registry-resolved active vault when the
126
+ // project root has none. Memoized — this script runs after every assistant
127
+ // message. `.heyspex/` state files keep using repoRoot() (code-repo state).
128
+ let _specsRootCache;
129
+ function specsRootFor(root) {
130
+ if (_specsRootCache !== undefined) return _specsRootCache;
131
+ if (fs.existsSync(path.join(root, 'specs'))) {
132
+ _specsRootCache = root;
133
+ return root;
134
+ }
135
+ try {
136
+ const resolved = resolveActiveVault();
137
+ if (resolved?.specsDirAbs && fs.existsSync(resolved.specsDirAbs)) {
138
+ _specsRootCache = resolved.repoRootAbs;
139
+ return _specsRootCache;
140
+ }
141
+ } catch {
142
+ // resolver unavailable — degrade to the project root as before
143
+ }
144
+ _specsRootCache = root;
145
+ return root;
146
+ }
147
+
148
+ function readStdinSync() {
149
+ if (process.stdin.isTTY) return null;
150
+ try {
151
+ // Use a one-shot blocking read of fd 0; Claude Code closes stdin
152
+ // after piping the session JSON, so this returns immediately.
153
+ const chunks = [];
154
+ const buf = Buffer.alloc(65536);
155
+ while (true) {
156
+ let bytes;
157
+ try {
158
+ bytes = fs.readSync(0, buf, 0, buf.length, null);
159
+ } catch (err) {
160
+ if (err && err.code === 'EAGAIN') break;
161
+ break;
162
+ }
163
+ if (!bytes) break;
164
+ chunks.push(Buffer.from(buf.subarray(0, bytes)));
165
+ if (bytes < buf.length) break;
166
+ }
167
+ if (chunks.length === 0) return null;
168
+ return Buffer.concat(chunks).toString('utf8');
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function parseSession(raw) {
175
+ if (!raw) return {};
176
+ try {
177
+ return JSON.parse(raw) ?? {};
178
+ } catch {
179
+ return {};
180
+ }
181
+ }
182
+
183
+ // Console base URL — the dev server publishes its bound URL to
184
+ // `.heyspex/console-url` (ensure-next-dev.mjs); HEYSPEX_CONSOLE_URL overrides.
185
+ // The file is removed when the dev server stops, so an absent pointer means
186
+ // "console not running" → we skip the HTTP call entirely (no wait, no throw).
187
+ function consoleUrl(root) {
188
+ const env = process.env.HEYSPEX_CONSOLE_URL;
189
+ if (env && env.trim()) return env.trim().replace(/\/+$/, '');
190
+ try {
191
+ const u = fs.readFileSync(path.join(root, '.heyspex', 'console-url'), 'utf8').trim();
192
+ if (u) return u.replace(/\/+$/, '');
193
+ } catch {
194
+ // Missing or unreadable — console not running.
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // Cache file for the focus-pin result. Lives in the vault's `.heyspex/` (gitignored)
200
+ // so the console's control-plane pin writers and this reader agree on one path
201
+ // (statusline-focus-cache.ts → STATUSLINE_FOCUS_CACHE_BASENAME). Co-located with
202
+ // the vault, so the temp+rename below stays on one filesystem (atomic rename).
203
+ function focusCacheFile(root) {
204
+ return path.join(root, '.heyspex', 'statusline-focus-cache.json');
205
+ }
206
+
207
+ // Fresh-cache read. Returns `{ specId, telemetry }` on a hit within the TTL
208
+ // (specId may be a string OR a negative-cached null; telemetry is the optional
209
+ // block pushed by the console precompute), or `undefined` on miss/stale/
210
+ // unreadable so the caller falls through to a live fetch.
211
+ function readFocusCache(file) {
212
+ if (FOCUS_CACHE_TTL_MS === 0) return undefined;
213
+ try {
214
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
215
+ if (!parsed || typeof parsed.at !== 'number') return undefined;
216
+ const specId = typeof parsed.specId === 'string' ? parsed.specId : null;
217
+ const ttl =
218
+ specId === null && parsed.source !== 'control-plane'
219
+ ? FOCUS_CACHE_NEG_TTL_MS
220
+ : FOCUS_CACHE_TTL_MS;
221
+ if (Date.now() - parsed.at >= ttl) return undefined;
222
+ return { specId, telemetry: parsed.telemetry };
223
+ } catch {
224
+ return undefined;
225
+ }
226
+ }
227
+
228
+ // Budget-warning threshold as a percentage of a plan rate-limit window. The
229
+ // warning must trip BEFORE the cap, so the default is 80%.
230
+ // HEYSPEX_STATUSLINE_BUDGET_WARN_PCT overrides.
231
+ const BUDGET_WARN_PCT = (() => {
232
+ const raw = Number(process.env.HEYSPEX_STATUSLINE_BUDGET_WARN_PCT);
233
+ return Number.isFinite(raw) && raw > 0 && raw <= 100 ? raw : 80;
234
+ })();
235
+
236
+ // Live telemetry from the stdin session payload — the primary source, because it
237
+ // is about THIS session, is always current, and costs nothing (Claude Code has
238
+ // already computed it; see code.claude.com/docs/en/statusline). The console's
239
+ // DB-backed feed remains the fallback for the cross-session aggregate.
240
+ //
241
+ // `context_window.total_input_tokens` already includes cache reads and writes;
242
+ // adding output tokens gives total burn in the window. Both are 0 before the
243
+ // first API response, and `rate_limits` is absent for non-subscription auth —
244
+ // each is treated as "no signal", never as a zero worth rendering.
245
+ //
246
+ // Note on `burnSoFar`: this is "tokens currently loaded in the context window
247
+ // as of the most recent API response," not a running total of everything ever
248
+ // spent this session — it can go DOWN after a `/compact`. It is still always
249
+ // scoped to this session/thread alone, never a weekly or cross-session figure
250
+ // (that's `rate_limits.*.used_percentage`, which this script does not render).
251
+ function sessionTelemetry(session) {
252
+ const cw = session?.context_window;
253
+ if (typeof cw?.total_input_tokens !== 'number') return null;
254
+ const out = typeof cw.total_output_tokens === 'number' ? cw.total_output_tokens : 0;
255
+ const cost = session?.cost?.total_cost_usd;
256
+ const pcts = [
257
+ session?.rate_limits?.five_hour?.used_percentage,
258
+ session?.rate_limits?.seven_day?.used_percentage,
259
+ ].filter((n) => typeof n === 'number');
260
+ return {
261
+ burnSoFar: cw.total_input_tokens + out,
262
+ ...(typeof cost === 'number' ? { costUSD: cost } : {}),
263
+ budgetWarning: pcts.some((p) => p >= BUDGET_WARN_PCT) || session?.exceeds_200k_tokens === true,
264
+ };
265
+ }
266
+
267
+ // Tokens + context segment ([[HS-4TLP]] + owner ask 2026-08-12): rendered as
268
+ // "xxx tok * xx% used", unbranded (no glyph), dollar cost hidden. Token burn
269
+ // comes from the live session payload when Claude Code piped one, else from
270
+ // the optional `telemetry` block the console precompute pushes into the focus
271
+ // cache; the context % is live-payload only. Untrusted input — each half is
272
+ // rendered only when its shape is sane, and the segment is omitted entirely
273
+ // when neither is available (never stale or invented numbers).
274
+ //
275
+ // `usedPct` is explicitly labeled "used" (owner report 2026-08-12: a bare
276
+ // number here read as "% used" when it was actually `remaining_percentage`,
277
+ // i.e. backwards from the natural reading). Computed as 100 - remaining so
278
+ // the label always matches what's shown.
279
+ function formatTelemetry(telemetry, usedPct) {
280
+ const burn = typeof telemetry?.burnSoFar === 'number' ? telemetry.burnSoFar : null;
281
+ const burnStr =
282
+ burn === null ? null
283
+ : burn >= 1_000_000 ? `${(burn / 1_000_000).toFixed(1)}M tok`
284
+ : burn >= 1_000 ? `${(burn / 1_000).toFixed(1)}k tok`
285
+ : `${burn} tok`;
286
+ const parts = [burnStr, typeof usedPct === 'number' ? `${usedPct}% used` : null].filter(
287
+ Boolean,
288
+ );
289
+ if (parts.length === 0) return null;
290
+ // Budget-warning ⚠ removed from this segment by owner ask 2026-08-12.
291
+ return parts.join(' · ');
292
+ }
293
+
294
+ // Best-effort atomic cache write: temp file in the same dir (so rename is atomic)
295
+ // with a per-process-unique name, then rename into place. On Windows, concurrent
296
+ // cross-process renames of a shared cache file can throw EPERM (write-file-atomic
297
+ // #28); we already hold the value, so a failed write is harmless — clean up and
298
+ // move on rather than retry or throw.
299
+ function writeFocusCache(file, specId) {
300
+ if (FOCUS_CACHE_TTL_MS === 0) return;
301
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
302
+ // Carry the console-pushed `telemetry` block forward. This writer owns only the
303
+ // pin; without the read-back a pull-path refresh (every repaint on a cache miss)
304
+ // would drop telemetry the console precompute had pushed, and the segment could
305
+ // never survive to render.
306
+ let telemetry;
307
+ try {
308
+ telemetry = JSON.parse(fs.readFileSync(file, 'utf8'))?.telemetry;
309
+ } catch {
310
+ telemetry = undefined;
311
+ }
312
+ try {
313
+ fs.writeFileSync(
314
+ tmp,
315
+ JSON.stringify({
316
+ specId: specId ?? null,
317
+ at: Date.now(),
318
+ source: 'pull',
319
+ ...(telemetry ? { telemetry } : {}),
320
+ }),
321
+ );
322
+ fs.renameSync(tmp, file);
323
+ } catch {
324
+ try {
325
+ fs.unlinkSync(tmp);
326
+ } catch {
327
+ /* ignore */
328
+ }
329
+ }
330
+ }
331
+
332
+ // Live focus-pin fetch from the console. Short timeout so a slow/absent console
333
+ // never makes the statusline sluggish. Returns the spec id, or null when the
334
+ // console is unreachable / has no pin.
335
+ async function fetchPinnedSpecId(base) {
336
+ const ctrl = new AbortController();
337
+ const timer = setTimeout(() => ctrl.abort(), CONSOLE_TIMEOUT_MS);
338
+ try {
339
+ const res = await fetch(`${base}/api/control/pin`, { signal: ctrl.signal });
340
+ if (!res.ok) return null;
341
+ const body = await res.json();
342
+ const id = body?.pin?.specId;
343
+ return typeof id === 'string' && id ? id : null;
344
+ } catch {
345
+ return null;
346
+ } finally {
347
+ clearTimeout(timer);
348
+ }
349
+ }
350
+
351
+ // The pinned focus spec id, read from Neon via the console. Focus has no
352
+ // focus.yaml fallback ([[no-fallbacks-by-default]]) — when the console is not
353
+ // running this returns null and the segment is simply dropped. A short-TTL file
354
+ // cache (see header) collapses the per-message / per-refresh burst of statusline
355
+ // invocations into at most one backend call per TTL window.
356
+ async function readPinnedSpecId(root, opts = {}) {
357
+ const base = consoleUrl(root);
358
+ if (!base) return null;
359
+
360
+ const cacheFile = focusCacheFile(root);
361
+ const cached = readFocusCache(cacheFile);
362
+ if (cached) return cached.specId;
363
+ // Non-Claude hosts (Copilot) never block on the network: serve the last pushed
364
+ // pin even when past its TTL, or drop the segment.
365
+ if (opts.cacheOnly) {
366
+ try {
367
+ const parsed = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
368
+ return typeof parsed?.specId === 'string' && parsed.specId ? parsed.specId : null;
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
374
+ const specId = await fetchPinnedSpecId(base);
375
+ writeFocusCache(cacheFile, specId);
376
+ return specId;
377
+ }
378
+
379
+ // Session-matched only — deliberately no "most-recently-active claim"
380
+ // cross-session fallback. That fallback was the original bug: every window
381
+ // with no claim of its own showed whatever ANY agent/session last claimed,
382
+ // so the segment never changed window to window. The live-relevance signal
383
+ // (mostRecentTranscriptSpecRef, below) replaces it for the "nothing claimed
384
+ // yet" case.
385
+ function readActiveClaim(root, sessionId) {
386
+ if (!sessionId) return null;
387
+ try {
388
+ const raw = fs.readFileSync(path.join(root, '.heyspex', 'active-claims.json'), 'utf8');
389
+ const parsed = JSON.parse(raw);
390
+ const claims = parsed && typeof parsed === 'object' && parsed.claims ? parsed.claims : {};
391
+ for (const claim of Object.values(claims)) {
392
+ if (claim && claim.sessionId === sessionId) return claim;
393
+ }
394
+ return null;
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ // Most recent spec/task reference in the LIVE conversation — a HeySpex tool
401
+ // call's spec_id argument, a Read/Edit/Write on a specs/ file, or a plain
402
+ // HS-XXXX mention in real user text. Bounded tail read keeps cost flat
403
+ // regardless of session length — "most relevant right now" only needs recent
404
+ // activity, not full history. Scans backward and returns on the first hit,
405
+ // so it's always the CHRONOLOGICALLY LAST reference in the window.
406
+ //
407
+ // Deliberately narrow about what counts as a "real" reference: only genuine
408
+ // user text and specific tool-call arguments are scanned — never tool_result
409
+ // content, system-reminder blocks, or CLAUDE.md dumps that get re-shown every
410
+ // turn. Those are full of unrelated HS- ids (examples, cross-links, other
411
+ // people's specs) that would otherwise hijack the segment.
412
+ function mostRecentTranscriptSpecRef(transcriptPath) {
413
+ let raw;
414
+ try {
415
+ const size = fs.statSync(transcriptPath).size;
416
+ const len = Math.min(size, TRANSCRIPT_TAIL_BYTES);
417
+ const fd = fs.openSync(transcriptPath, 'r');
418
+ const buf = Buffer.alloc(len);
419
+ fs.readSync(fd, buf, 0, len, size - len);
420
+ fs.closeSync(fd);
421
+ raw = buf.toString('utf8');
422
+ } catch {
423
+ return null;
424
+ }
425
+ const lines = raw.split('\n').filter((l) => l.trim());
426
+ for (let i = lines.length - 1; i >= 0; i--) {
427
+ let entry;
428
+ try {
429
+ entry = JSON.parse(lines[i]);
430
+ } catch {
431
+ continue; // torn line at the tail boundary — keep walking back
432
+ }
433
+ const isRealUser = entry?.type === 'user' && !entry.isMeta;
434
+ const content = entry?.message?.content;
435
+ if (isRealUser && typeof content === 'string') {
436
+ const id = specIdFromUserText(content);
437
+ if (id) return { specId: id, at: entry.timestamp };
438
+ continue;
439
+ }
440
+ if (!Array.isArray(content)) continue;
441
+ for (const block of content) {
442
+ if (!block) continue;
443
+ if (block.type === 'tool_use' && SPEC_TARGETING_TOOLS.has(block.name)) {
444
+ const id =
445
+ extractSpecIdToken(block.input?.spec_id) ??
446
+ extractSpecIdToken(block.input?.specId) ??
447
+ extractSpecIdToken(block.input?.id);
448
+ if (id) return { specId: id, at: entry.timestamp };
449
+ }
450
+ if (block.type === 'tool_use' && SPEC_FILE_TOOLS.has(block.name)) {
451
+ const fp = block.input?.file_path;
452
+ if (typeof fp === 'string' && /[\\/]specs[\\/].*\.md$/i.test(fp)) {
453
+ const id = extractSpecIdToken(basename(fp));
454
+ if (id) return { specId: id, at: entry.timestamp };
455
+ }
456
+ }
457
+ if (isRealUser && block.type === 'text') {
458
+ const id = specIdFromUserText(String(block.text || ''));
459
+ if (id) return { specId: id, at: entry.timestamp };
460
+ }
461
+ }
462
+ }
463
+ return null;
464
+ }
465
+
466
+ function specIdFromUserText(text) {
467
+ const clean = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
468
+ if (!clean || /^<(command-|local-command|task-notification|persisted-output)/.test(clean)) {
469
+ return null;
470
+ }
471
+ return extractSpecIdToken(clean);
472
+ }
473
+
474
+ // Cached wrapper: reuses the per-session label-cache file (one JSON file per
475
+ // session already exists for the sticky thread label) so this doesn't add a
476
+ // new file, and re-scans only when the transcript has actually grown since
477
+ // last checked — the common case (idle between messages, a refreshInterval
478
+ // tick with nothing new) costs a single fs.statSync, not a tail read.
479
+ function relevantSpecFromTranscript(session) {
480
+ const sessionId = session?.session_id;
481
+ const transcript = session?.transcript_path;
482
+ if (!sessionId || !transcript) return null;
483
+ const file = labelCacheFile(sessionId);
484
+ const cache = readLabelCache(file);
485
+ let size;
486
+ try {
487
+ size = fs.statSync(transcript).size;
488
+ } catch {
489
+ return cache.relevantDoc ?? null;
490
+ }
491
+ if (cache.relevantDocScannedSize === size) return cache.relevantDoc ?? null;
492
+ const ref = mostRecentTranscriptSpecRef(transcript);
493
+ writeLabelCache(file, { ...cache, relevantDoc: ref ?? null, relevantDocScannedSize: size });
494
+ return ref;
495
+ }
496
+
497
+ // Picks the single most relevant spec for this thread right now: this
498
+ // session's own claim vs. the live transcript reference, whichever is more
499
+ // recent. A claim wins ties/indeterminate timestamps — it's the steadier,
500
+ // cheaper signal (owner: "if a single file has been claimed, that will most
501
+ // likely be the most relevant filename"). HEYSPEX_STATUSLINE_NO_CLAIM=1
502
+ // disables the transcript scan, restricting the segment to explicit claims
503
+ // only (name kept for backward compat with the original "suppress fallback"
504
+ // meaning — the fallback it originally guarded no longer exists).
505
+ function pickRelevantSpec(root, session, opts = {}) {
506
+ const claim = readActiveClaim(root, session?.session_id);
507
+ const claimAt = claim ? Date.parse(claim.lastActivityAt ?? claim.claimedAt ?? '') : NaN;
508
+ const ref = process.env.HEYSPEX_STATUSLINE_NO_CLAIM === '1' || opts.noTranscript
509
+ ? null
510
+ : relevantSpecFromTranscript(session);
511
+ const refAt = ref?.at ? Date.parse(ref.at) : NaN;
512
+ if (claim && ref) {
513
+ return Number.isFinite(refAt) && Number.isFinite(claimAt) && refAt > claimAt
514
+ ? { specId: ref.specId }
515
+ : { specId: claim.specId };
516
+ }
517
+ if (claim) return { specId: claim.specId };
518
+ if (ref) return { specId: ref.specId };
519
+ return null;
520
+ }
521
+
522
+ function readGitBranch(root) {
523
+ try {
524
+ const res = spawnSync('git', ['-C', root, 'branch', '--show-current'], {
525
+ encoding: 'utf8',
526
+ timeout: GIT_TIMEOUT_MS,
527
+ windowsHide: true,
528
+ });
529
+ if (res.status !== 0) return null;
530
+ const out = (res.stdout || '').trim();
531
+ return out.length > 0 ? out : null;
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+
537
+ /** Where the OS keeps the per-user copy of the font, mirroring install-statusline.mjs. */
538
+ function osFontPath() {
539
+ if (process.platform === 'win32') {
540
+ return process.env.LOCALAPPDATA
541
+ ? path.join(process.env.LOCALAPPDATA, 'Microsoft', 'Windows', 'Fonts', 'HeySpexIcons.ttf')
542
+ : null;
543
+ }
544
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Fonts', 'HeySpexIcons.ttf');
545
+ return path.join(os.homedir(), '.fonts', 'HeySpexIcons.ttf');
546
+ }
547
+
548
+ export function marker(root, env = process.env) {
549
+ if (env.HEYSPEX_STATUSLINE_GLYPH !== '1') return PLAIN_MARKER;
550
+ // The gate is the OS-INSTALLED font, not the repo copy — only the installed
551
+ // one decides what the terminal can actually draw. Byte size stands in for
552
+ // identity here (a full hash on every 5s repaint is not worth it); it is
553
+ // enough to catch the failure this guards against: a machine still carrying
554
+ // an OLD single-glyph build renders the U+E001/U+E002 marks as boxes while
555
+ // the repo copy sits happily on disk. Mismatch → degrade to text, never tofu.
556
+ // Checked relative to THIS SCRIPT first — the TTF ships next to the renderer,
557
+ // and in a customer vault the renderer runs from the platform checkout while
558
+ // `root` is the customer repo (which has no scripts/ tree). Root-relative
559
+ // kept as the legacy fallback. See [[statusline-install-protocol]].
560
+ try {
561
+ const installed = osFontPath();
562
+ if (!installed || !fs.existsSync(installed)) return PLAIN_MARKER;
563
+ const installedSize = fs.statSync(installed).size;
564
+ const here = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fonts', 'HeySpexIcons.ttf');
565
+ const ttf = fs.existsSync(here)
566
+ ? here
567
+ : path.join(root, 'scripts', 'claude-statusline', 'fonts', 'HeySpexIcons.ttf');
568
+ if (!fs.existsSync(ttf)) return PLAIN_MARKER;
569
+ if (fs.statSync(ttf).size === installedSize) return GLYPH;
570
+ } catch {
571
+ // fall through
572
+ }
573
+ return PLAIN_MARKER;
574
+ }
575
+
576
+ function basename(p) {
577
+ if (!p) return null;
578
+ return path.basename(p);
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // Worked-doc segment — "HS-<fileKey>-<familyKey>-<TYPE>" for the spec most
583
+ // RELEVANT to this thread right now (owner ask 2026-08-13, correcting an
584
+ // earlier claim-only design: [[HS-O6QA-RES-branded-coding-agent-statusline-customer]]).
585
+ // "Relevant" is not "claimed" — it's whichever of these is more recent:
586
+ // 1) this session's own active claim (session-matched only — a claim made
587
+ // by a DIFFERENT session/agent is never shown; that was the original bug
588
+ // report, every window showing the same stale cross-session claim).
589
+ // 2) the most recent spec/task reference found by scanning the live
590
+ // transcript — a HeySpex tool call's spec_id argument, a Read/Edit/Write
591
+ // on a specs/ file, or a plain HS-XXXX mention in real user text. This
592
+ // is what lets the segment track a document being discussed/edited
593
+ // before (or instead of) being formally claimed, and update live as the
594
+ // conversation's focus moves — see mostRecentTranscriptSpecRef() below.
595
+ // A single active claim is still the strongest, cheapest signal (owner:
596
+ // "if a single file has been claimed, that will most likely be the most
597
+ // relevant filename"), so it wins ties / indeterminate timestamps; the
598
+ // transcript reference only overrides it when strictly more recent.
599
+ // Claims/mentions carry only the short file key (e.g. HS-XKCD), so the id is
600
+ // resolved against specs/ filenames to recover the doc type and family key;
601
+ // resolution is cached because it scans the vault. Naming eras differ
602
+ // ([[how-spec-ids-and-naming-work]]): the TYPE token's position tells the
603
+ // era, so both eras are parsed positionally.
604
+
605
+ const DOC_TYPE_LABELS = {
606
+ MIL: 'Milestone',
607
+ EPIC: 'Epic',
608
+ TASK: 'Task',
609
+ RES: 'Research',
610
+ CAP: 'Capture',
611
+ REV: 'Review',
612
+ DIAG: 'Diagnose',
613
+ DOC: 'Doc',
614
+ ADR: 'ADR',
615
+ PROTO: 'Protocol',
616
+ };
617
+ const KEY_RE = /^[A-Z0-9]{3,}$/;
618
+ const DOC_CACHE_TTL_MS = 10 * 60 * 1000;
619
+ const DOC_CACHE_NEG_TTL_MS = 60 * 1000; // a just-filed spec should appear within a minute
620
+
621
+ // Matches a short spec/task key immediately after "HS-" — e.g. "HS-XKCD" out
622
+ // of "HS-XKCD-3PSH-TASK-foo.md" or a plain-text "please look at HS-89JG".
623
+ // \b naturally stops at the next hyphen, so this only ever captures the one
624
+ // token right after "HS-", never the whole slug.
625
+ const SPEC_ID_TOKEN_RE = /\bHS-([A-Z0-9]{3,})\b/i;
626
+ // Tail-read bound for transcript scanning (see mostRecentTranscriptSpecRef) —
627
+ // large enough to cover several recent turns even with big tool outputs
628
+ // interspersed, small enough to keep every statusline tick fast.
629
+ const TRANSCRIPT_TAIL_BYTES = 500_000;
630
+ // HeySpex tool calls that name a spec directly — scanned for their spec_id
631
+ // argument specifically (not free-text) so an unrelated id elsewhere in the
632
+ // same tool-call JSON can't be picked up by accident.
633
+ const SPEC_TARGETING_TOOLS = new Set([
634
+ 'mcp__heyspex__heyspex_claim_spec',
635
+ 'mcp__heyspex__heyspex_report_progress',
636
+ 'mcp__heyspex__heyspex_submit_for_review',
637
+ 'mcp__heyspex__heyspex_release_spec',
638
+ 'mcp__heyspex__heyspex_defer_spec',
639
+ 'mcp__heyspex__heyspex_record_wip',
640
+ 'mcp__heyspex__heyspex_file_spec',
641
+ ]);
642
+ const SPEC_FILE_TOOLS = new Set(['Read', 'Edit', 'Write']);
643
+
644
+ function extractSpecIdToken(value) {
645
+ if (typeof value !== 'string') return null;
646
+ const m = SPEC_ID_TOKEN_RE.exec(value);
647
+ return m ? `HS-${m[1].toUpperCase()}` : null;
648
+ }
649
+
650
+ function docCacheFile(root) {
651
+ return path.join(root, '.heyspex', 'statusline-doc-cache.json');
652
+ }
653
+
654
+ function parseSpecBasename(base) {
655
+ const tokens = base.replace(/\.md$/i, '').split('-');
656
+ if ((tokens[0] || '').toUpperCase() === 'HS') tokens.shift();
657
+ const typeIdx = tokens.findIndex((t) => DOC_TYPE_LABELS[t.toUpperCase()]);
658
+ if (typeIdx === -1) return null;
659
+ const type = tokens[typeIdx].toUpperCase();
660
+ // Era 3 (file-key-first): <fileKey>-<familyKey>-<TYPE>-<slug>
661
+ if (typeIdx === 2 && KEY_RE.test(tokens[0]) && KEY_RE.test(tokens[1])) {
662
+ return { type, fileId: tokens[0], family: tokens[1] };
663
+ }
664
+ // Era 2 (family-first): <familyKey>-<TYPE>-<fileKey>-<slug>
665
+ if (typeIdx === 1 && KEY_RE.test(tokens[0]) && KEY_RE.test(tokens[2] ?? '')) {
666
+ return { type, fileId: tokens[2], family: tokens[0] };
667
+ }
668
+ // Legacy (e.g. 4-digit numeric epics): <id>-<TYPE>-<slug>
669
+ if (typeIdx === 1 && KEY_RE.test(tokens[0])) {
670
+ return { type, fileId: tokens[0], family: null };
671
+ }
672
+ return null;
673
+ }
674
+
675
+ /**
676
+ * Head of a spec file — enough to cover frontmatter without paying to read a
677
+ * whole doc on a 5s repaint. Frontmatter is read by regex on purpose: pulling a
678
+ * YAML parser into the renderer would cost more than the three fields we want.
679
+ */
680
+ function readSpecHead(file) {
681
+ try {
682
+ const fd = fs.openSync(file, 'r');
683
+ try {
684
+ const buf = Buffer.alloc(4096);
685
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
686
+ return buf.toString('utf8', 0, n);
687
+ } finally {
688
+ fs.closeSync(fd);
689
+ }
690
+ } catch {
691
+ return null;
692
+ }
693
+ }
694
+
695
+ function frontmatterField(head, field) {
696
+ if (!head) return null;
697
+ const m = new RegExp(`^${field}:[ \\t]*(.+)$`, 'm').exec(head);
698
+ if (!m) return null;
699
+ const value = m[1].trim().replace(/^['"]|['"]$/g, '').trim();
700
+ return value || null;
701
+ }
702
+
703
+ /** `[[HS-PILR-console-platform]]` → "Console platform" (the pillar's `name:`). */
704
+ function pillarName(root, raw) {
705
+ const base = String(raw).replace(/^\[\[/, '').replace(/\]\]$/, '').trim();
706
+ if (!base) return null;
707
+ const name = frontmatterField(
708
+ readSpecHead(path.join(specsRootFor(root), 'specs', 'pillars', `${base}.md`)),
709
+ 'name',
710
+ );
711
+ if (name) return name;
712
+ // Pillar file absent (customer vault, or renamed) — derive from the slug.
713
+ const slug = base.replace(/^HS-PILR-/i, '').replace(/-/g, ' ').trim();
714
+ return slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : null;
715
+ }
716
+
717
+ /**
718
+ * Pillar of the worked doc. Only epics carry `pillar:` (entity-model), so a
719
+ * task inherits it by walking `parent:` upward. Two hops covers task → epic and
720
+ * the occasional task → task → epic without risking a cycle.
721
+ */
722
+ function resolvePillar(root, specFile, byBasename) {
723
+ let file = specFile;
724
+ for (let hop = 0; hop < 3 && file; hop++) {
725
+ const head = readSpecHead(file);
726
+ const pillar = frontmatterField(head, 'pillar');
727
+ if (pillar) return pillarName(root, pillar);
728
+ const parent = frontmatterField(head, 'parent');
729
+ if (!parent) return null;
730
+ const base = parent.replace(/^\[\[/, '').replace(/\]\]$/, '').trim();
731
+ file = byBasename.get(base.toUpperCase()) ?? null;
732
+ }
733
+ return null;
734
+ }
735
+
736
+ // Resolve a short claim id (HS-XKCD) to its display form by scanning specs/
737
+ // filenames for the key. Returns e.g. { display: "HS-XKCD-3PSH-TASK", pillar:
738
+ // "Console platform" }; display falls back to the bare id when no file matches
739
+ // (spec not on disk yet, or an unparseable name) and pillar to null.
740
+ //
741
+ // Display mirrors the FILENAME order — HS-<fileKey>-<familyKey>-<TYPE> (owner
742
+ // ask 2026-08-14, replacing the earlier "Task: HS-XKCD-3PSH" prose form). That
743
+ // is the type-suffixed variant sanctioned by the display contract in CLAUDE.md
744
+ // ("with the type visible — suffixed, or as adjacent prose, never both"), and
745
+ // it makes the segment copy-pasteable against `specs/` on disk.
746
+ function resolveWorkedDoc(root, specId) {
747
+ const key = String(specId).replace(/^HS-/i, '').toUpperCase();
748
+ if (!KEY_RE.test(key)) return { display: specId, pillar: null };
749
+ const specsDir = path.join(specsRootFor(root), 'specs');
750
+ let entries;
751
+ try {
752
+ entries = fs.readdirSync(specsDir, { recursive: true });
753
+ } catch {
754
+ return { display: specId, pillar: null };
755
+ }
756
+ // One pass builds both the basename index (for the parent hop) and the match.
757
+ const byBasename = new Map();
758
+ let hit = null;
759
+ let familyHit = null;
760
+ for (const entry of entries) {
761
+ const rel = String(entry);
762
+ const base = path.basename(rel);
763
+ if (!/\.md$/i.test(base)) continue;
764
+ byBasename.set(base.replace(/\.md$/i, '').toUpperCase(), path.join(specsDir, rel));
765
+ if (!base.toUpperCase().includes(key)) continue;
766
+ const parsed = parseSpecBasename(base);
767
+ if (!parsed) continue;
768
+ const display = `HS-${parsed.fileId}${parsed.family ? `-${parsed.family}` : ''}-${parsed.type}`;
769
+ if (parsed.fileId.toUpperCase() === key) {
770
+ if (!hit) hit = { display, file: path.join(specsDir, rel) };
771
+ } else if (!familyHit && parsed.family && parsed.family.toUpperCase() === key) {
772
+ familyHit = { display, file: path.join(specsDir, rel) };
773
+ }
774
+ }
775
+ const match = hit ?? familyHit;
776
+ if (!match) return { display: specId, pillar: null };
777
+ return { display: match.display, pillar: resolvePillar(root, match.file, byBasename) };
778
+ }
779
+
780
+ /** { display, pillar } for the worked doc, cached — the resolve walks specs/. */
781
+ function workedDoc(root, claim) {
782
+ const specId = claim?.specId;
783
+ if (!specId) return null;
784
+ const file = docCacheFile(root);
785
+ try {
786
+ const cached = JSON.parse(fs.readFileSync(file, 'utf8'));
787
+ if (cached?.specId === specId && typeof cached.display === 'string') {
788
+ const ttl = cached.resolved ? DOC_CACHE_TTL_MS : DOC_CACHE_NEG_TTL_MS;
789
+ if (Date.now() - (cached.at ?? 0) < ttl) {
790
+ return { display: cached.display, pillar: cached.pillar ?? null };
791
+ }
792
+ }
793
+ } catch {
794
+ // miss — resolve below
795
+ }
796
+ const resolved = resolveWorkedDoc(root, specId);
797
+ try {
798
+ fs.writeFileSync(
799
+ file,
800
+ JSON.stringify({
801
+ specId,
802
+ display: resolved.display,
803
+ pillar: resolved.pillar,
804
+ resolved: resolved.display !== specId,
805
+ at: Date.now(),
806
+ }),
807
+ );
808
+ } catch {
809
+ // cache write is best-effort
810
+ }
811
+ return resolved;
812
+ }
813
+
814
+ // ---------------------------------------------------------------------------
815
+ // Sticky thread label — a 2-6 word summary of what this session is about
816
+ // (owner ask 2026-08-12, capture [[HS-GWZ8]]). Deliberately sticky: a detached
817
+ // background copy of this script (--refresh-label) asks Haiku to re-judge the
818
+ // label only when new user messages exist, and the prompt rewrites it ONLY on
819
+ // a genuine topic pivot — natural progress within one effort keeps the label.
820
+ // The fast path reads a small per-session cache and never waits on the LLM.
821
+ // Cache lives under ~/.claude/statusline-cache/ (machine state, not vault state).
822
+
823
+ const LABEL_CHECK_INTERVAL_MS = 10_000;
824
+ const LABEL_REFRESH_STALE_MS = 120_000; // consider a background refresh dead after this
825
+ const LABEL_FAIL_BACKOFF_MS = 60_000;
826
+ const LABEL_MAX_MSGS = 8;
827
+ const LABEL_MAX_MSG_CHARS = 400;
828
+
829
+ function labelCacheFile(sessionId) {
830
+ return path.join(os.homedir(), '.claude', 'statusline-cache', `${sessionId}.json`);
831
+ }
832
+
833
+ function readLabelCache(file) {
834
+ try {
835
+ return JSON.parse(fs.readFileSync(file, 'utf8')) ?? {};
836
+ } catch {
837
+ return {};
838
+ }
839
+ }
840
+
841
+ function writeLabelCache(file, obj) {
842
+ try {
843
+ fs.mkdirSync(path.dirname(file), { recursive: true });
844
+ fs.writeFileSync(file, JSON.stringify(obj));
845
+ } catch {
846
+ // best-effort — the statusline must never throw
847
+ }
848
+ }
849
+
850
+ // Real human turns only: skip meta entries, tool results, slash-command
851
+ // wrappers, and system-reminder noise in the session transcript JSONL.
852
+ function transcriptUserMessages(transcriptPath) {
853
+ let raw;
854
+ try {
855
+ raw = fs.readFileSync(transcriptPath, 'utf8');
856
+ } catch {
857
+ return [];
858
+ }
859
+ const out = [];
860
+ for (const line of raw.split('\n')) {
861
+ if (!line.includes('"type":"user"')) continue;
862
+ let entry;
863
+ try {
864
+ entry = JSON.parse(line);
865
+ } catch {
866
+ continue;
867
+ }
868
+ if (entry.type !== 'user' || entry.isMeta) continue;
869
+ const content = entry.message?.content;
870
+ let text = '';
871
+ if (typeof content === 'string') text = content;
872
+ else if (Array.isArray(content))
873
+ text = content
874
+ .filter((b) => b.type === 'text')
875
+ .map((b) => b.text)
876
+ .join('\n');
877
+ text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
878
+ if (!text) continue;
879
+ if (/^<(command-|local-command|task-notification|persisted-output)/.test(text)) continue;
880
+ if (text.startsWith('[Request interrupted')) continue;
881
+ out.push(text);
882
+ }
883
+ return out;
884
+ }
885
+
886
+ function sanitizeLabel(rawOutput) {
887
+ if (!rawOutput) return '';
888
+ let label = rawOutput.split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
889
+ label = label.replace(/^["'`]+|["'`.]+$/g, '').replace(/\s+/g, ' ').trim();
890
+ // Defense-in-depth for the "never a raw id" rule in labelPrompt() below —
891
+ // the id already has its own segment (see pickRelevantSpec); repeating it
892
+ // here defeats the label's whole purpose, a plain-English recall aid.
893
+ // Reject rather than strip: a title built around an id (e.g. "Promote
894
+ // HS-89JG") reads as broken with the id removed, so this forces a retry.
895
+ if (SPEC_ID_TOKEN_RE.test(label)) return '';
896
+ const words = label.split(' ');
897
+ if (words.length > 6) label = words.slice(0, 6).join(' ');
898
+ if (!label || label.length > 60) return '';
899
+ return label;
900
+ }
901
+
902
+ function labelPrompt(current, recent) {
903
+ const msgs = recent.map((m, i) => `${i + 1}. ${m}`).join('\n');
904
+ return [
905
+ 'You maintain a short, sticky title for an ongoing coding-assistant session.',
906
+ '',
907
+ current ? `Current title: "${current}"` : 'Current title: none yet.',
908
+ '',
909
+ 'Recent user messages, oldest to newest:',
910
+ msgs,
911
+ '',
912
+ 'Rules:',
913
+ "- If there is no current title, write one: 2-6 plain words naming the session's main focus.",
914
+ '- If the current title still fits what the user is working on now, reply with the current title EXACTLY as given, unchanged.',
915
+ '- Only if the conversation has clearly pivoted to a different focus, write a new 2-6 word title for the new focus.',
916
+ '- Lean strongly toward keeping the current title. Natural progress, follow-ups, sub-steps, or debugging within the same effort are NOT a pivot.',
917
+ '- NEVER include a raw spec/task id (e.g. "HS-XKCD", "HS-89JG") in the title. Describe the actual work in plain words instead — the id already appears elsewhere in the footer, so repeating it here wastes the one thing this title is for: letting the user recall what the thread is about without decoding an id.',
918
+ '',
919
+ 'Reply with ONLY the title text - no quotes, no trailing punctuation, no explanation.',
920
+ ].join('\n');
921
+ }
922
+
923
+ // Background child (--refresh-label): one Haiku call, then update the cache.
924
+ function refreshLabel(cacheFile, transcriptPath, countStr) {
925
+ const count = Number(countStr) || 0;
926
+ const cache = readLabelCache(cacheFile);
927
+ const recent = transcriptUserMessages(transcriptPath)
928
+ .slice(-LABEL_MAX_MSGS)
929
+ .map((m) => (m.length > LABEL_MAX_MSG_CHARS ? `${m.slice(0, LABEL_MAX_MSG_CHARS)} …` : m));
930
+ if (recent.length === 0) {
931
+ writeLabelCache(cacheFile, { ...cache, refreshStartedAt: null });
932
+ return;
933
+ }
934
+ const res = spawnSync('claude -p --model haiku --strict-mcp-config', {
935
+ shell: true,
936
+ input: labelPrompt(cache.label || '', recent),
937
+ encoding: 'utf8',
938
+ timeout: 90_000,
939
+ windowsHide: true,
940
+ cwd: os.tmpdir(),
941
+ });
942
+ const label = res.status === 0 ? sanitizeLabel(res.stdout) : '';
943
+ const fresh = readLabelCache(cacheFile); // fast path may have bumped lastCheckAt meanwhile
944
+ if (label) {
945
+ writeLabelCache(cacheFile, {
946
+ ...fresh,
947
+ label,
948
+ msgCountAtLabel: count,
949
+ refreshStartedAt: null,
950
+ lastFailAt: null,
951
+ updatedAt: Date.now(),
952
+ });
953
+ } else {
954
+ writeLabelCache(cacheFile, { ...fresh, refreshStartedAt: null, lastFailAt: Date.now() });
955
+ }
956
+ }
957
+
958
+ // Fast path: return the cached label instantly; kick off a detached refresh
959
+ // when new user messages exist and no refresh is already running.
960
+ function threadLabel(session) {
961
+ const sessionId = session?.session_id;
962
+ const transcript = session?.transcript_path;
963
+ if (!sessionId) return null;
964
+ const file = labelCacheFile(sessionId);
965
+ const cache = readLabelCache(file);
966
+ const now = Date.now();
967
+ if (transcript && now - (cache.lastCheckAt ?? 0) > LABEL_CHECK_INTERVAL_MS) {
968
+ const count = transcriptUserMessages(transcript).length;
969
+ const refreshing =
970
+ cache.refreshStartedAt && now - cache.refreshStartedAt < LABEL_REFRESH_STALE_MS;
971
+ const backingOff = cache.lastFailAt && now - cache.lastFailAt < LABEL_FAIL_BACKOFF_MS;
972
+ if (
973
+ !refreshing &&
974
+ !backingOff &&
975
+ count > 0 &&
976
+ (!cache.label || count !== cache.msgCountAtLabel)
977
+ ) {
978
+ writeLabelCache(file, { ...cache, lastCheckAt: now, refreshStartedAt: now });
979
+ try {
980
+ const child = spawn(
981
+ process.execPath,
982
+ [fileURLToPath(import.meta.url), '--refresh-label', file, transcript, String(count)],
983
+ { detached: true, stdio: 'ignore', windowsHide: true },
984
+ );
985
+ child.unref();
986
+ } catch {
987
+ // label refresh is best-effort
988
+ }
989
+ } else {
990
+ writeLabelCache(file, { ...cache, lastCheckAt: now });
991
+ }
992
+ }
993
+ return typeof cache.label === 'string' && cache.label ? cache.label : null;
994
+ }
995
+
996
+ // `opts` (all optional; the Claude Code path passes none and is unchanged):
997
+ // host: 'copilot' - no Claude glyph, no Haiku thread label, no transcript scan
998
+ // (Copilot's transcript_path is a directory, and there is no `claude` CLI), and
999
+ // a cache-only focus-pin read so the line never waits on the console.
1000
+ export async function build(root, session, opts = {}) {
1001
+ const foreign = opts.host === 'copilot';
1002
+ const segments = [];
1003
+
1004
+ const mark = marker(root);
1005
+
1006
+ // Segment order (owner ask 2026-08-14): Claude first, then GitHub, then the
1007
+ // HeySpex items — left to right, harness → repo → vault.
1008
+ const model = session?.model?.display_name;
1009
+
1010
+ const usedPct =
1011
+ typeof session?.context_window?.remaining_percentage === 'number'
1012
+ ? Math.round(100 - session.context_window.remaining_percentage)
1013
+ : null;
1014
+ // Model + tokens + context share one dot-joined segment (owner ask
1015
+ // 2026-08-12): "Fable 5 · 162.0k tok · 38% used" — no pipe between model
1016
+ // and token usage, and dots (not asterisks) inside the token/context pair.
1017
+ const telemetrySegment = formatTelemetry(
1018
+ sessionTelemetry(session) ?? readFocusCache(focusCacheFile(root))?.telemetry,
1019
+ usedPct,
1020
+ );
1021
+ const modelSegment = [model, telemetrySegment].filter(Boolean).join(' · ');
1022
+ if (modelSegment)
1023
+ segments.push(mark === GLYPH && !foreign ? `${CLAUDE_GLYPH} ${modelSegment}` : modelSegment);
1024
+
1025
+ // GitHub cluster: repo name + branch under one GitHub-mark prefix.
1026
+ // Plain mode keeps the generic ⎇ ([HS] here would read as a claim segment).
1027
+ const dir = basename(session?.workspace?.current_dir ?? session?.cwd) ?? basename(root);
1028
+ const branch = readGitBranch(root);
1029
+ const githubCluster = [dir, branch].filter(Boolean).join(' > ');
1030
+ if (githubCluster) segments.push(`${mark === GLYPH ? GITHUB_GLYPH : foreign ? '' : '⎇'} ${githubCluster}`.trim());
1031
+
1032
+ const pinned = await readPinnedSpecId(root, { cacheOnly: foreign });
1033
+ if (pinned) segments.push(`${mark} ${pinned}`);
1034
+
1035
+ // HeySpex cluster, far right: "<glyph> <pillar> > <fileID> > <summary>"
1036
+ // (owner ask 2026-08-14 added the pillar). Pillar: which perpetual area of
1037
+ // the product this work belongs to, inherited from the worked doc's epic.
1038
+ // Worked-doc: the spec most RELEVANT to this thread right now — this
1039
+ // session's own claim vs. the live transcript reference, whichever is more
1040
+ // recent (pickRelevantSpec, above) — so it updates as the conversation's
1041
+ // focus moves, and never shows another session's claim.
1042
+ // Summary: what this session is about, in 2-6 words (sticky, pivot-only).
1043
+ const worked = workedDoc(root, pickRelevantSpec(root, session, { noTranscript: foreign }));
1044
+ const label = foreign ? null : threadLabel(session);
1045
+ const heyspexCluster = [worked?.pillar, worked?.display, label].filter(Boolean).join(' > ');
1046
+ if (heyspexCluster) segments.push(`${mark} ${heyspexCluster}`);
1047
+
1048
+ return segments.join(' | ');
1049
+ }
1050
+
1051
+ async function main() {
1052
+ try {
1053
+ const root = repoRoot();
1054
+ // stdin is read with a tiny timeout — if Claude Code didn't pipe
1055
+ // session JSON (e.g. smoke-test from a shell), we still render the
1056
+ // segments that don't depend on it.
1057
+ const raw = readStdinSync();
1058
+ const session = parseSession(raw);
1059
+ if (process.env.HS_SL_DEBUG === '1') console.error('DEBUG raw:', JSON.stringify(raw));
1060
+ const line = await build(root, session);
1061
+ if (line) process.stdout.write(`${line}\n`);
1062
+ } catch {
1063
+ // Never break Claude Code — silent degrade.
1064
+ }
1065
+ }
1066
+
1067
+ if (isDirectRun) {
1068
+ if (process.argv[2] === '--refresh-label') {
1069
+ refreshLabel(process.argv[3], process.argv[4], process.argv[5]);
1070
+ } else {
1071
+ main();
1072
+ }
1073
+ }