heyspex 0.2.1 → 0.2.3
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.
- package/README.md +10 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +99 -81
- package/bin/spexcode.mjs +91 -0
- package/dev-factory/engine/worktree.mjs +36 -7
- package/dev-factory/lib/epic-detect.mjs +2 -2
- package/dev-factory/lib/fleet-readiness.mjs +3 -5
- package/dev-factory/lib/fleet-state.mjs +2 -2
- package/dev-factory/lib/sufficiency-score.mjs +3 -5
- package/dist/cli/hs-check.mjs +76 -46
- package/dist/cli/hs-claim.mjs +63 -33
- package/dist/cli/hs-connect.mjs +520 -214
- package/dist/cli/hs-defer.mjs +161 -19
- package/dist/cli/hs-dev.mjs +11 -5
- package/dist/cli/hs-env.mjs +187 -161
- package/dist/cli/hs-index-rebuild.mjs +123 -78
- package/dist/cli/hs-index-reconcile.mjs +203 -107
- package/dist/cli/hs-init.mjs +426 -179
- package/dist/cli/hs-install.mjs +759 -437
- package/dist/cli/hs-join.mjs +590 -383
- package/dist/cli/hs-vault-pull.mjs +359 -254
- package/dist/cli/hs-vault-push.mjs +151 -113
- package/dist/cli/hs-vault.mjs +271 -104
- package/dist/cli/hs-workspace.mjs +90 -57
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
- package/docs/copilot-plugin-install.md +189 -162
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +255 -249
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -309
- package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
- package/plugins/heyspex/mcp.json +12 -13
- package/scripts/check-mcp-freshness.mjs +6 -4
- package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
- package/scripts/claude-hooks/research-method-guard.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
- package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
- package/scripts/claude-statusline/statusline-command.mjs +1058 -0
- package/scripts/dev-server-status.mjs +3 -2
- package/scripts/dev-server-stop.mjs +3 -2
- package/scripts/hs-capture-cli.mjs +2 -2
- package/scripts/hs-settings.mjs +2 -2
- package/scripts/hs-view.mjs +3 -2
- package/scripts/install-copilot-hooks.mjs +318 -0
- package/scripts/lib/copilot-cli.mjs +108 -0
- package/scripts/lib/customer-kit.mjs +223 -30
- package/scripts/lib/dev-mode.mjs +28 -2
- package/scripts/lib/shared-path-helpers.mjs +32 -0
- package/scripts/lib/standalone-doctor.mjs +131 -32
- package/scripts/wip-ledger-health.mjs +3 -2
- package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
- package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
- package/spexharness/src/intent-directive.mjs +839 -626
- package/spexharness/src/rule-table.mjs +1 -1
- package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
- package/vendor/heyspex-mcp/cli.mjs +20 -4
- package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
- package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
- package/vendor/heyspex-mcp/events-tools.mjs +1 -1
- package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
- package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
- package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
- package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
|
@@ -0,0 +1,1058 @@
|
|
|
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) {
|
|
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
|
+
|
|
364
|
+
const specId = await fetchPinnedSpecId(base);
|
|
365
|
+
writeFocusCache(cacheFile, specId);
|
|
366
|
+
return specId;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Session-matched only — deliberately no "most-recently-active claim"
|
|
370
|
+
// cross-session fallback. That fallback was the original bug: every window
|
|
371
|
+
// with no claim of its own showed whatever ANY agent/session last claimed,
|
|
372
|
+
// so the segment never changed window to window. The live-relevance signal
|
|
373
|
+
// (mostRecentTranscriptSpecRef, below) replaces it for the "nothing claimed
|
|
374
|
+
// yet" case.
|
|
375
|
+
function readActiveClaim(root, sessionId) {
|
|
376
|
+
if (!sessionId) return null;
|
|
377
|
+
try {
|
|
378
|
+
const raw = fs.readFileSync(path.join(root, '.heyspex', 'active-claims.json'), 'utf8');
|
|
379
|
+
const parsed = JSON.parse(raw);
|
|
380
|
+
const claims = parsed && typeof parsed === 'object' && parsed.claims ? parsed.claims : {};
|
|
381
|
+
for (const claim of Object.values(claims)) {
|
|
382
|
+
if (claim && claim.sessionId === sessionId) return claim;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Most recent spec/task reference in the LIVE conversation — a HeySpex tool
|
|
391
|
+
// call's spec_id argument, a Read/Edit/Write on a specs/ file, or a plain
|
|
392
|
+
// HS-XXXX mention in real user text. Bounded tail read keeps cost flat
|
|
393
|
+
// regardless of session length — "most relevant right now" only needs recent
|
|
394
|
+
// activity, not full history. Scans backward and returns on the first hit,
|
|
395
|
+
// so it's always the CHRONOLOGICALLY LAST reference in the window.
|
|
396
|
+
//
|
|
397
|
+
// Deliberately narrow about what counts as a "real" reference: only genuine
|
|
398
|
+
// user text and specific tool-call arguments are scanned — never tool_result
|
|
399
|
+
// content, system-reminder blocks, or CLAUDE.md dumps that get re-shown every
|
|
400
|
+
// turn. Those are full of unrelated HS- ids (examples, cross-links, other
|
|
401
|
+
// people's specs) that would otherwise hijack the segment.
|
|
402
|
+
function mostRecentTranscriptSpecRef(transcriptPath) {
|
|
403
|
+
let raw;
|
|
404
|
+
try {
|
|
405
|
+
const size = fs.statSync(transcriptPath).size;
|
|
406
|
+
const len = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
407
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
408
|
+
const buf = Buffer.alloc(len);
|
|
409
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
410
|
+
fs.closeSync(fd);
|
|
411
|
+
raw = buf.toString('utf8');
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
const lines = raw.split('\n').filter((l) => l.trim());
|
|
416
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
417
|
+
let entry;
|
|
418
|
+
try {
|
|
419
|
+
entry = JSON.parse(lines[i]);
|
|
420
|
+
} catch {
|
|
421
|
+
continue; // torn line at the tail boundary — keep walking back
|
|
422
|
+
}
|
|
423
|
+
const isRealUser = entry?.type === 'user' && !entry.isMeta;
|
|
424
|
+
const content = entry?.message?.content;
|
|
425
|
+
if (isRealUser && typeof content === 'string') {
|
|
426
|
+
const id = specIdFromUserText(content);
|
|
427
|
+
if (id) return { specId: id, at: entry.timestamp };
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!Array.isArray(content)) continue;
|
|
431
|
+
for (const block of content) {
|
|
432
|
+
if (!block) continue;
|
|
433
|
+
if (block.type === 'tool_use' && SPEC_TARGETING_TOOLS.has(block.name)) {
|
|
434
|
+
const id =
|
|
435
|
+
extractSpecIdToken(block.input?.spec_id) ??
|
|
436
|
+
extractSpecIdToken(block.input?.specId) ??
|
|
437
|
+
extractSpecIdToken(block.input?.id);
|
|
438
|
+
if (id) return { specId: id, at: entry.timestamp };
|
|
439
|
+
}
|
|
440
|
+
if (block.type === 'tool_use' && SPEC_FILE_TOOLS.has(block.name)) {
|
|
441
|
+
const fp = block.input?.file_path;
|
|
442
|
+
if (typeof fp === 'string' && /[\\/]specs[\\/].*\.md$/i.test(fp)) {
|
|
443
|
+
const id = extractSpecIdToken(basename(fp));
|
|
444
|
+
if (id) return { specId: id, at: entry.timestamp };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (isRealUser && block.type === 'text') {
|
|
448
|
+
const id = specIdFromUserText(String(block.text || ''));
|
|
449
|
+
if (id) return { specId: id, at: entry.timestamp };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function specIdFromUserText(text) {
|
|
457
|
+
const clean = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
458
|
+
if (!clean || /^<(command-|local-command|task-notification|persisted-output)/.test(clean)) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
return extractSpecIdToken(clean);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Cached wrapper: reuses the per-session label-cache file (one JSON file per
|
|
465
|
+
// session already exists for the sticky thread label) so this doesn't add a
|
|
466
|
+
// new file, and re-scans only when the transcript has actually grown since
|
|
467
|
+
// last checked — the common case (idle between messages, a refreshInterval
|
|
468
|
+
// tick with nothing new) costs a single fs.statSync, not a tail read.
|
|
469
|
+
function relevantSpecFromTranscript(session) {
|
|
470
|
+
const sessionId = session?.session_id;
|
|
471
|
+
const transcript = session?.transcript_path;
|
|
472
|
+
if (!sessionId || !transcript) return null;
|
|
473
|
+
const file = labelCacheFile(sessionId);
|
|
474
|
+
const cache = readLabelCache(file);
|
|
475
|
+
let size;
|
|
476
|
+
try {
|
|
477
|
+
size = fs.statSync(transcript).size;
|
|
478
|
+
} catch {
|
|
479
|
+
return cache.relevantDoc ?? null;
|
|
480
|
+
}
|
|
481
|
+
if (cache.relevantDocScannedSize === size) return cache.relevantDoc ?? null;
|
|
482
|
+
const ref = mostRecentTranscriptSpecRef(transcript);
|
|
483
|
+
writeLabelCache(file, { ...cache, relevantDoc: ref ?? null, relevantDocScannedSize: size });
|
|
484
|
+
return ref;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Picks the single most relevant spec for this thread right now: this
|
|
488
|
+
// session's own claim vs. the live transcript reference, whichever is more
|
|
489
|
+
// recent. A claim wins ties/indeterminate timestamps — it's the steadier,
|
|
490
|
+
// cheaper signal (owner: "if a single file has been claimed, that will most
|
|
491
|
+
// likely be the most relevant filename"). HEYSPEX_STATUSLINE_NO_CLAIM=1
|
|
492
|
+
// disables the transcript scan, restricting the segment to explicit claims
|
|
493
|
+
// only (name kept for backward compat with the original "suppress fallback"
|
|
494
|
+
// meaning — the fallback it originally guarded no longer exists).
|
|
495
|
+
function pickRelevantSpec(root, session) {
|
|
496
|
+
const claim = readActiveClaim(root, session?.session_id);
|
|
497
|
+
const claimAt = claim ? Date.parse(claim.lastActivityAt ?? claim.claimedAt ?? '') : NaN;
|
|
498
|
+
const ref = process.env.HEYSPEX_STATUSLINE_NO_CLAIM === '1'
|
|
499
|
+
? null
|
|
500
|
+
: relevantSpecFromTranscript(session);
|
|
501
|
+
const refAt = ref?.at ? Date.parse(ref.at) : NaN;
|
|
502
|
+
if (claim && ref) {
|
|
503
|
+
return Number.isFinite(refAt) && Number.isFinite(claimAt) && refAt > claimAt
|
|
504
|
+
? { specId: ref.specId }
|
|
505
|
+
: { specId: claim.specId };
|
|
506
|
+
}
|
|
507
|
+
if (claim) return { specId: claim.specId };
|
|
508
|
+
if (ref) return { specId: ref.specId };
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function readGitBranch(root) {
|
|
513
|
+
try {
|
|
514
|
+
const res = spawnSync('git', ['-C', root, 'branch', '--show-current'], {
|
|
515
|
+
encoding: 'utf8',
|
|
516
|
+
timeout: GIT_TIMEOUT_MS,
|
|
517
|
+
windowsHide: true,
|
|
518
|
+
});
|
|
519
|
+
if (res.status !== 0) return null;
|
|
520
|
+
const out = (res.stdout || '').trim();
|
|
521
|
+
return out.length > 0 ? out : null;
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Where the OS keeps the per-user copy of the font, mirroring install-statusline.mjs. */
|
|
528
|
+
function osFontPath() {
|
|
529
|
+
if (process.platform === 'win32') {
|
|
530
|
+
return process.env.LOCALAPPDATA
|
|
531
|
+
? path.join(process.env.LOCALAPPDATA, 'Microsoft', 'Windows', 'Fonts', 'HeySpexIcons.ttf')
|
|
532
|
+
: null;
|
|
533
|
+
}
|
|
534
|
+
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Fonts', 'HeySpexIcons.ttf');
|
|
535
|
+
return path.join(os.homedir(), '.fonts', 'HeySpexIcons.ttf');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function marker(root, env = process.env) {
|
|
539
|
+
if (env.HEYSPEX_STATUSLINE_GLYPH !== '1') return PLAIN_MARKER;
|
|
540
|
+
// The gate is the OS-INSTALLED font, not the repo copy — only the installed
|
|
541
|
+
// one decides what the terminal can actually draw. Byte size stands in for
|
|
542
|
+
// identity here (a full hash on every 5s repaint is not worth it); it is
|
|
543
|
+
// enough to catch the failure this guards against: a machine still carrying
|
|
544
|
+
// an OLD single-glyph build renders the U+E001/U+E002 marks as boxes while
|
|
545
|
+
// the repo copy sits happily on disk. Mismatch → degrade to text, never tofu.
|
|
546
|
+
// Checked relative to THIS SCRIPT first — the TTF ships next to the renderer,
|
|
547
|
+
// and in a customer vault the renderer runs from the platform checkout while
|
|
548
|
+
// `root` is the customer repo (which has no scripts/ tree). Root-relative
|
|
549
|
+
// kept as the legacy fallback. See [[statusline-install-protocol]].
|
|
550
|
+
try {
|
|
551
|
+
const installed = osFontPath();
|
|
552
|
+
if (!installed || !fs.existsSync(installed)) return PLAIN_MARKER;
|
|
553
|
+
const installedSize = fs.statSync(installed).size;
|
|
554
|
+
const here = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fonts', 'HeySpexIcons.ttf');
|
|
555
|
+
const ttf = fs.existsSync(here)
|
|
556
|
+
? here
|
|
557
|
+
: path.join(root, 'scripts', 'claude-statusline', 'fonts', 'HeySpexIcons.ttf');
|
|
558
|
+
if (!fs.existsSync(ttf)) return PLAIN_MARKER;
|
|
559
|
+
if (fs.statSync(ttf).size === installedSize) return GLYPH;
|
|
560
|
+
} catch {
|
|
561
|
+
// fall through
|
|
562
|
+
}
|
|
563
|
+
return PLAIN_MARKER;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function basename(p) {
|
|
567
|
+
if (!p) return null;
|
|
568
|
+
return path.basename(p);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// Worked-doc segment — "HS-<fileKey>-<familyKey>-<TYPE>" for the spec most
|
|
573
|
+
// RELEVANT to this thread right now (owner ask 2026-08-13, correcting an
|
|
574
|
+
// earlier claim-only design: [[HS-O6QA-RES-branded-coding-agent-statusline-customer]]).
|
|
575
|
+
// "Relevant" is not "claimed" — it's whichever of these is more recent:
|
|
576
|
+
// 1) this session's own active claim (session-matched only — a claim made
|
|
577
|
+
// by a DIFFERENT session/agent is never shown; that was the original bug
|
|
578
|
+
// report, every window showing the same stale cross-session claim).
|
|
579
|
+
// 2) the most recent spec/task reference found by scanning the live
|
|
580
|
+
// transcript — a HeySpex tool call's spec_id argument, a Read/Edit/Write
|
|
581
|
+
// on a specs/ file, or a plain HS-XXXX mention in real user text. This
|
|
582
|
+
// is what lets the segment track a document being discussed/edited
|
|
583
|
+
// before (or instead of) being formally claimed, and update live as the
|
|
584
|
+
// conversation's focus moves — see mostRecentTranscriptSpecRef() below.
|
|
585
|
+
// A single active claim is still the strongest, cheapest signal (owner:
|
|
586
|
+
// "if a single file has been claimed, that will most likely be the most
|
|
587
|
+
// relevant filename"), so it wins ties / indeterminate timestamps; the
|
|
588
|
+
// transcript reference only overrides it when strictly more recent.
|
|
589
|
+
// Claims/mentions carry only the short file key (e.g. HS-XKCD), so the id is
|
|
590
|
+
// resolved against specs/ filenames to recover the doc type and family key;
|
|
591
|
+
// resolution is cached because it scans the vault. Naming eras differ
|
|
592
|
+
// ([[how-spec-ids-and-naming-work]]): the TYPE token's position tells the
|
|
593
|
+
// era, so both eras are parsed positionally.
|
|
594
|
+
|
|
595
|
+
const DOC_TYPE_LABELS = {
|
|
596
|
+
MIL: 'Milestone',
|
|
597
|
+
EPIC: 'Epic',
|
|
598
|
+
TASK: 'Task',
|
|
599
|
+
RES: 'Research',
|
|
600
|
+
CAP: 'Capture',
|
|
601
|
+
REV: 'Review',
|
|
602
|
+
DIAG: 'Diagnose',
|
|
603
|
+
DOC: 'Doc',
|
|
604
|
+
ADR: 'ADR',
|
|
605
|
+
PROTO: 'Protocol',
|
|
606
|
+
};
|
|
607
|
+
const KEY_RE = /^[A-Z0-9]{3,}$/;
|
|
608
|
+
const DOC_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
609
|
+
const DOC_CACHE_NEG_TTL_MS = 60 * 1000; // a just-filed spec should appear within a minute
|
|
610
|
+
|
|
611
|
+
// Matches a short spec/task key immediately after "HS-" — e.g. "HS-XKCD" out
|
|
612
|
+
// of "HS-XKCD-3PSH-TASK-foo.md" or a plain-text "please look at HS-89JG".
|
|
613
|
+
// \b naturally stops at the next hyphen, so this only ever captures the one
|
|
614
|
+
// token right after "HS-", never the whole slug.
|
|
615
|
+
const SPEC_ID_TOKEN_RE = /\bHS-([A-Z0-9]{3,})\b/i;
|
|
616
|
+
// Tail-read bound for transcript scanning (see mostRecentTranscriptSpecRef) —
|
|
617
|
+
// large enough to cover several recent turns even with big tool outputs
|
|
618
|
+
// interspersed, small enough to keep every statusline tick fast.
|
|
619
|
+
const TRANSCRIPT_TAIL_BYTES = 500_000;
|
|
620
|
+
// HeySpex tool calls that name a spec directly — scanned for their spec_id
|
|
621
|
+
// argument specifically (not free-text) so an unrelated id elsewhere in the
|
|
622
|
+
// same tool-call JSON can't be picked up by accident.
|
|
623
|
+
const SPEC_TARGETING_TOOLS = new Set([
|
|
624
|
+
'mcp__heyspex__heyspex_claim_spec',
|
|
625
|
+
'mcp__heyspex__heyspex_report_progress',
|
|
626
|
+
'mcp__heyspex__heyspex_submit_for_review',
|
|
627
|
+
'mcp__heyspex__heyspex_release_spec',
|
|
628
|
+
'mcp__heyspex__heyspex_defer_spec',
|
|
629
|
+
'mcp__heyspex__heyspex_record_wip',
|
|
630
|
+
'mcp__heyspex__heyspex_file_spec',
|
|
631
|
+
]);
|
|
632
|
+
const SPEC_FILE_TOOLS = new Set(['Read', 'Edit', 'Write']);
|
|
633
|
+
|
|
634
|
+
function extractSpecIdToken(value) {
|
|
635
|
+
if (typeof value !== 'string') return null;
|
|
636
|
+
const m = SPEC_ID_TOKEN_RE.exec(value);
|
|
637
|
+
return m ? `HS-${m[1].toUpperCase()}` : null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function docCacheFile(root) {
|
|
641
|
+
return path.join(root, '.heyspex', 'statusline-doc-cache.json');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function parseSpecBasename(base) {
|
|
645
|
+
const tokens = base.replace(/\.md$/i, '').split('-');
|
|
646
|
+
if ((tokens[0] || '').toUpperCase() === 'HS') tokens.shift();
|
|
647
|
+
const typeIdx = tokens.findIndex((t) => DOC_TYPE_LABELS[t.toUpperCase()]);
|
|
648
|
+
if (typeIdx === -1) return null;
|
|
649
|
+
const type = tokens[typeIdx].toUpperCase();
|
|
650
|
+
// Era 3 (file-key-first): <fileKey>-<familyKey>-<TYPE>-<slug>
|
|
651
|
+
if (typeIdx === 2 && KEY_RE.test(tokens[0]) && KEY_RE.test(tokens[1])) {
|
|
652
|
+
return { type, fileId: tokens[0], family: tokens[1] };
|
|
653
|
+
}
|
|
654
|
+
// Era 2 (family-first): <familyKey>-<TYPE>-<fileKey>-<slug>
|
|
655
|
+
if (typeIdx === 1 && KEY_RE.test(tokens[0]) && KEY_RE.test(tokens[2] ?? '')) {
|
|
656
|
+
return { type, fileId: tokens[2], family: tokens[0] };
|
|
657
|
+
}
|
|
658
|
+
// Legacy (e.g. 4-digit numeric epics): <id>-<TYPE>-<slug>
|
|
659
|
+
if (typeIdx === 1 && KEY_RE.test(tokens[0])) {
|
|
660
|
+
return { type, fileId: tokens[0], family: null };
|
|
661
|
+
}
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Head of a spec file — enough to cover frontmatter without paying to read a
|
|
667
|
+
* whole doc on a 5s repaint. Frontmatter is read by regex on purpose: pulling a
|
|
668
|
+
* YAML parser into the renderer would cost more than the three fields we want.
|
|
669
|
+
*/
|
|
670
|
+
function readSpecHead(file) {
|
|
671
|
+
try {
|
|
672
|
+
const fd = fs.openSync(file, 'r');
|
|
673
|
+
try {
|
|
674
|
+
const buf = Buffer.alloc(4096);
|
|
675
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
676
|
+
return buf.toString('utf8', 0, n);
|
|
677
|
+
} finally {
|
|
678
|
+
fs.closeSync(fd);
|
|
679
|
+
}
|
|
680
|
+
} catch {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function frontmatterField(head, field) {
|
|
686
|
+
if (!head) return null;
|
|
687
|
+
const m = new RegExp(`^${field}:[ \\t]*(.+)$`, 'm').exec(head);
|
|
688
|
+
if (!m) return null;
|
|
689
|
+
const value = m[1].trim().replace(/^['"]|['"]$/g, '').trim();
|
|
690
|
+
return value || null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** `[[HS-PILR-console-platform]]` → "Console platform" (the pillar's `name:`). */
|
|
694
|
+
function pillarName(root, raw) {
|
|
695
|
+
const base = String(raw).replace(/^\[\[/, '').replace(/\]\]$/, '').trim();
|
|
696
|
+
if (!base) return null;
|
|
697
|
+
const name = frontmatterField(
|
|
698
|
+
readSpecHead(path.join(specsRootFor(root), 'specs', 'pillars', `${base}.md`)),
|
|
699
|
+
'name',
|
|
700
|
+
);
|
|
701
|
+
if (name) return name;
|
|
702
|
+
// Pillar file absent (customer vault, or renamed) — derive from the slug.
|
|
703
|
+
const slug = base.replace(/^HS-PILR-/i, '').replace(/-/g, ' ').trim();
|
|
704
|
+
return slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Pillar of the worked doc. Only epics carry `pillar:` (entity-model), so a
|
|
709
|
+
* task inherits it by walking `parent:` upward. Two hops covers task → epic and
|
|
710
|
+
* the occasional task → task → epic without risking a cycle.
|
|
711
|
+
*/
|
|
712
|
+
function resolvePillar(root, specFile, byBasename) {
|
|
713
|
+
let file = specFile;
|
|
714
|
+
for (let hop = 0; hop < 3 && file; hop++) {
|
|
715
|
+
const head = readSpecHead(file);
|
|
716
|
+
const pillar = frontmatterField(head, 'pillar');
|
|
717
|
+
if (pillar) return pillarName(root, pillar);
|
|
718
|
+
const parent = frontmatterField(head, 'parent');
|
|
719
|
+
if (!parent) return null;
|
|
720
|
+
const base = parent.replace(/^\[\[/, '').replace(/\]\]$/, '').trim();
|
|
721
|
+
file = byBasename.get(base.toUpperCase()) ?? null;
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Resolve a short claim id (HS-XKCD) to its display form by scanning specs/
|
|
727
|
+
// filenames for the key. Returns e.g. { display: "HS-XKCD-3PSH-TASK", pillar:
|
|
728
|
+
// "Console platform" }; display falls back to the bare id when no file matches
|
|
729
|
+
// (spec not on disk yet, or an unparseable name) and pillar to null.
|
|
730
|
+
//
|
|
731
|
+
// Display mirrors the FILENAME order — HS-<fileKey>-<familyKey>-<TYPE> (owner
|
|
732
|
+
// ask 2026-08-14, replacing the earlier "Task: HS-XKCD-3PSH" prose form). That
|
|
733
|
+
// is the type-suffixed variant sanctioned by the display contract in CLAUDE.md
|
|
734
|
+
// ("with the type visible — suffixed, or as adjacent prose, never both"), and
|
|
735
|
+
// it makes the segment copy-pasteable against `specs/` on disk.
|
|
736
|
+
function resolveWorkedDoc(root, specId) {
|
|
737
|
+
const key = String(specId).replace(/^HS-/i, '').toUpperCase();
|
|
738
|
+
if (!KEY_RE.test(key)) return { display: specId, pillar: null };
|
|
739
|
+
const specsDir = path.join(specsRootFor(root), 'specs');
|
|
740
|
+
let entries;
|
|
741
|
+
try {
|
|
742
|
+
entries = fs.readdirSync(specsDir, { recursive: true });
|
|
743
|
+
} catch {
|
|
744
|
+
return { display: specId, pillar: null };
|
|
745
|
+
}
|
|
746
|
+
// One pass builds both the basename index (for the parent hop) and the match.
|
|
747
|
+
const byBasename = new Map();
|
|
748
|
+
let hit = null;
|
|
749
|
+
let familyHit = null;
|
|
750
|
+
for (const entry of entries) {
|
|
751
|
+
const rel = String(entry);
|
|
752
|
+
const base = path.basename(rel);
|
|
753
|
+
if (!/\.md$/i.test(base)) continue;
|
|
754
|
+
byBasename.set(base.replace(/\.md$/i, '').toUpperCase(), path.join(specsDir, rel));
|
|
755
|
+
if (!base.toUpperCase().includes(key)) continue;
|
|
756
|
+
const parsed = parseSpecBasename(base);
|
|
757
|
+
if (!parsed) continue;
|
|
758
|
+
const display = `HS-${parsed.fileId}${parsed.family ? `-${parsed.family}` : ''}-${parsed.type}`;
|
|
759
|
+
if (parsed.fileId.toUpperCase() === key) {
|
|
760
|
+
if (!hit) hit = { display, file: path.join(specsDir, rel) };
|
|
761
|
+
} else if (!familyHit && parsed.family && parsed.family.toUpperCase() === key) {
|
|
762
|
+
familyHit = { display, file: path.join(specsDir, rel) };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const match = hit ?? familyHit;
|
|
766
|
+
if (!match) return { display: specId, pillar: null };
|
|
767
|
+
return { display: match.display, pillar: resolvePillar(root, match.file, byBasename) };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** { display, pillar } for the worked doc, cached — the resolve walks specs/. */
|
|
771
|
+
function workedDoc(root, claim) {
|
|
772
|
+
const specId = claim?.specId;
|
|
773
|
+
if (!specId) return null;
|
|
774
|
+
const file = docCacheFile(root);
|
|
775
|
+
try {
|
|
776
|
+
const cached = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
777
|
+
if (cached?.specId === specId && typeof cached.display === 'string') {
|
|
778
|
+
const ttl = cached.resolved ? DOC_CACHE_TTL_MS : DOC_CACHE_NEG_TTL_MS;
|
|
779
|
+
if (Date.now() - (cached.at ?? 0) < ttl) {
|
|
780
|
+
return { display: cached.display, pillar: cached.pillar ?? null };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
} catch {
|
|
784
|
+
// miss — resolve below
|
|
785
|
+
}
|
|
786
|
+
const resolved = resolveWorkedDoc(root, specId);
|
|
787
|
+
try {
|
|
788
|
+
fs.writeFileSync(
|
|
789
|
+
file,
|
|
790
|
+
JSON.stringify({
|
|
791
|
+
specId,
|
|
792
|
+
display: resolved.display,
|
|
793
|
+
pillar: resolved.pillar,
|
|
794
|
+
resolved: resolved.display !== specId,
|
|
795
|
+
at: Date.now(),
|
|
796
|
+
}),
|
|
797
|
+
);
|
|
798
|
+
} catch {
|
|
799
|
+
// cache write is best-effort
|
|
800
|
+
}
|
|
801
|
+
return resolved;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// Sticky thread label — a 2-6 word summary of what this session is about
|
|
806
|
+
// (owner ask 2026-08-12, capture [[HS-GWZ8]]). Deliberately sticky: a detached
|
|
807
|
+
// background copy of this script (--refresh-label) asks Haiku to re-judge the
|
|
808
|
+
// label only when new user messages exist, and the prompt rewrites it ONLY on
|
|
809
|
+
// a genuine topic pivot — natural progress within one effort keeps the label.
|
|
810
|
+
// The fast path reads a small per-session cache and never waits on the LLM.
|
|
811
|
+
// Cache lives under ~/.claude/statusline-cache/ (machine state, not vault state).
|
|
812
|
+
|
|
813
|
+
const LABEL_CHECK_INTERVAL_MS = 10_000;
|
|
814
|
+
const LABEL_REFRESH_STALE_MS = 120_000; // consider a background refresh dead after this
|
|
815
|
+
const LABEL_FAIL_BACKOFF_MS = 60_000;
|
|
816
|
+
const LABEL_MAX_MSGS = 8;
|
|
817
|
+
const LABEL_MAX_MSG_CHARS = 400;
|
|
818
|
+
|
|
819
|
+
function labelCacheFile(sessionId) {
|
|
820
|
+
return path.join(os.homedir(), '.claude', 'statusline-cache', `${sessionId}.json`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function readLabelCache(file) {
|
|
824
|
+
try {
|
|
825
|
+
return JSON.parse(fs.readFileSync(file, 'utf8')) ?? {};
|
|
826
|
+
} catch {
|
|
827
|
+
return {};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function writeLabelCache(file, obj) {
|
|
832
|
+
try {
|
|
833
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
834
|
+
fs.writeFileSync(file, JSON.stringify(obj));
|
|
835
|
+
} catch {
|
|
836
|
+
// best-effort — the statusline must never throw
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Real human turns only: skip meta entries, tool results, slash-command
|
|
841
|
+
// wrappers, and system-reminder noise in the session transcript JSONL.
|
|
842
|
+
function transcriptUserMessages(transcriptPath) {
|
|
843
|
+
let raw;
|
|
844
|
+
try {
|
|
845
|
+
raw = fs.readFileSync(transcriptPath, 'utf8');
|
|
846
|
+
} catch {
|
|
847
|
+
return [];
|
|
848
|
+
}
|
|
849
|
+
const out = [];
|
|
850
|
+
for (const line of raw.split('\n')) {
|
|
851
|
+
if (!line.includes('"type":"user"')) continue;
|
|
852
|
+
let entry;
|
|
853
|
+
try {
|
|
854
|
+
entry = JSON.parse(line);
|
|
855
|
+
} catch {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (entry.type !== 'user' || entry.isMeta) continue;
|
|
859
|
+
const content = entry.message?.content;
|
|
860
|
+
let text = '';
|
|
861
|
+
if (typeof content === 'string') text = content;
|
|
862
|
+
else if (Array.isArray(content))
|
|
863
|
+
text = content
|
|
864
|
+
.filter((b) => b.type === 'text')
|
|
865
|
+
.map((b) => b.text)
|
|
866
|
+
.join('\n');
|
|
867
|
+
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
868
|
+
if (!text) continue;
|
|
869
|
+
if (/^<(command-|local-command|task-notification|persisted-output)/.test(text)) continue;
|
|
870
|
+
if (text.startsWith('[Request interrupted')) continue;
|
|
871
|
+
out.push(text);
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function sanitizeLabel(rawOutput) {
|
|
877
|
+
if (!rawOutput) return '';
|
|
878
|
+
let label = rawOutput.split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
|
|
879
|
+
label = label.replace(/^["'`]+|["'`.]+$/g, '').replace(/\s+/g, ' ').trim();
|
|
880
|
+
// Defense-in-depth for the "never a raw id" rule in labelPrompt() below —
|
|
881
|
+
// the id already has its own segment (see pickRelevantSpec); repeating it
|
|
882
|
+
// here defeats the label's whole purpose, a plain-English recall aid.
|
|
883
|
+
// Reject rather than strip: a title built around an id (e.g. "Promote
|
|
884
|
+
// HS-89JG") reads as broken with the id removed, so this forces a retry.
|
|
885
|
+
if (SPEC_ID_TOKEN_RE.test(label)) return '';
|
|
886
|
+
const words = label.split(' ');
|
|
887
|
+
if (words.length > 6) label = words.slice(0, 6).join(' ');
|
|
888
|
+
if (!label || label.length > 60) return '';
|
|
889
|
+
return label;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function labelPrompt(current, recent) {
|
|
893
|
+
const msgs = recent.map((m, i) => `${i + 1}. ${m}`).join('\n');
|
|
894
|
+
return [
|
|
895
|
+
'You maintain a short, sticky title for an ongoing coding-assistant session.',
|
|
896
|
+
'',
|
|
897
|
+
current ? `Current title: "${current}"` : 'Current title: none yet.',
|
|
898
|
+
'',
|
|
899
|
+
'Recent user messages, oldest to newest:',
|
|
900
|
+
msgs,
|
|
901
|
+
'',
|
|
902
|
+
'Rules:',
|
|
903
|
+
"- If there is no current title, write one: 2-6 plain words naming the session's main focus.",
|
|
904
|
+
'- If the current title still fits what the user is working on now, reply with the current title EXACTLY as given, unchanged.',
|
|
905
|
+
'- Only if the conversation has clearly pivoted to a different focus, write a new 2-6 word title for the new focus.',
|
|
906
|
+
'- Lean strongly toward keeping the current title. Natural progress, follow-ups, sub-steps, or debugging within the same effort are NOT a pivot.',
|
|
907
|
+
'- 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.',
|
|
908
|
+
'',
|
|
909
|
+
'Reply with ONLY the title text - no quotes, no trailing punctuation, no explanation.',
|
|
910
|
+
].join('\n');
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Background child (--refresh-label): one Haiku call, then update the cache.
|
|
914
|
+
function refreshLabel(cacheFile, transcriptPath, countStr) {
|
|
915
|
+
const count = Number(countStr) || 0;
|
|
916
|
+
const cache = readLabelCache(cacheFile);
|
|
917
|
+
const recent = transcriptUserMessages(transcriptPath)
|
|
918
|
+
.slice(-LABEL_MAX_MSGS)
|
|
919
|
+
.map((m) => (m.length > LABEL_MAX_MSG_CHARS ? `${m.slice(0, LABEL_MAX_MSG_CHARS)} …` : m));
|
|
920
|
+
if (recent.length === 0) {
|
|
921
|
+
writeLabelCache(cacheFile, { ...cache, refreshStartedAt: null });
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const res = spawnSync('claude -p --model haiku --strict-mcp-config', {
|
|
925
|
+
shell: true,
|
|
926
|
+
input: labelPrompt(cache.label || '', recent),
|
|
927
|
+
encoding: 'utf8',
|
|
928
|
+
timeout: 90_000,
|
|
929
|
+
windowsHide: true,
|
|
930
|
+
cwd: os.tmpdir(),
|
|
931
|
+
});
|
|
932
|
+
const label = res.status === 0 ? sanitizeLabel(res.stdout) : '';
|
|
933
|
+
const fresh = readLabelCache(cacheFile); // fast path may have bumped lastCheckAt meanwhile
|
|
934
|
+
if (label) {
|
|
935
|
+
writeLabelCache(cacheFile, {
|
|
936
|
+
...fresh,
|
|
937
|
+
label,
|
|
938
|
+
msgCountAtLabel: count,
|
|
939
|
+
refreshStartedAt: null,
|
|
940
|
+
lastFailAt: null,
|
|
941
|
+
updatedAt: Date.now(),
|
|
942
|
+
});
|
|
943
|
+
} else {
|
|
944
|
+
writeLabelCache(cacheFile, { ...fresh, refreshStartedAt: null, lastFailAt: Date.now() });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Fast path: return the cached label instantly; kick off a detached refresh
|
|
949
|
+
// when new user messages exist and no refresh is already running.
|
|
950
|
+
function threadLabel(session) {
|
|
951
|
+
const sessionId = session?.session_id;
|
|
952
|
+
const transcript = session?.transcript_path;
|
|
953
|
+
if (!sessionId) return null;
|
|
954
|
+
const file = labelCacheFile(sessionId);
|
|
955
|
+
const cache = readLabelCache(file);
|
|
956
|
+
const now = Date.now();
|
|
957
|
+
if (transcript && now - (cache.lastCheckAt ?? 0) > LABEL_CHECK_INTERVAL_MS) {
|
|
958
|
+
const count = transcriptUserMessages(transcript).length;
|
|
959
|
+
const refreshing =
|
|
960
|
+
cache.refreshStartedAt && now - cache.refreshStartedAt < LABEL_REFRESH_STALE_MS;
|
|
961
|
+
const backingOff = cache.lastFailAt && now - cache.lastFailAt < LABEL_FAIL_BACKOFF_MS;
|
|
962
|
+
if (
|
|
963
|
+
!refreshing &&
|
|
964
|
+
!backingOff &&
|
|
965
|
+
count > 0 &&
|
|
966
|
+
(!cache.label || count !== cache.msgCountAtLabel)
|
|
967
|
+
) {
|
|
968
|
+
writeLabelCache(file, { ...cache, lastCheckAt: now, refreshStartedAt: now });
|
|
969
|
+
try {
|
|
970
|
+
const child = spawn(
|
|
971
|
+
process.execPath,
|
|
972
|
+
[fileURLToPath(import.meta.url), '--refresh-label', file, transcript, String(count)],
|
|
973
|
+
{ detached: true, stdio: 'ignore', windowsHide: true },
|
|
974
|
+
);
|
|
975
|
+
child.unref();
|
|
976
|
+
} catch {
|
|
977
|
+
// label refresh is best-effort
|
|
978
|
+
}
|
|
979
|
+
} else {
|
|
980
|
+
writeLabelCache(file, { ...cache, lastCheckAt: now });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return typeof cache.label === 'string' && cache.label ? cache.label : null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function build(root, session) {
|
|
987
|
+
const segments = [];
|
|
988
|
+
|
|
989
|
+
const mark = marker(root);
|
|
990
|
+
|
|
991
|
+
// Segment order (owner ask 2026-08-14): Claude first, then GitHub, then the
|
|
992
|
+
// HeySpex items — left to right, harness → repo → vault.
|
|
993
|
+
const model = session?.model?.display_name;
|
|
994
|
+
|
|
995
|
+
const usedPct =
|
|
996
|
+
typeof session?.context_window?.remaining_percentage === 'number'
|
|
997
|
+
? Math.round(100 - session.context_window.remaining_percentage)
|
|
998
|
+
: null;
|
|
999
|
+
// Model + tokens + context share one dot-joined segment (owner ask
|
|
1000
|
+
// 2026-08-12): "Fable 5 · 162.0k tok · 38% used" — no pipe between model
|
|
1001
|
+
// and token usage, and dots (not asterisks) inside the token/context pair.
|
|
1002
|
+
const telemetrySegment = formatTelemetry(
|
|
1003
|
+
sessionTelemetry(session) ?? readFocusCache(focusCacheFile(root))?.telemetry,
|
|
1004
|
+
usedPct,
|
|
1005
|
+
);
|
|
1006
|
+
const modelSegment = [model, telemetrySegment].filter(Boolean).join(' · ');
|
|
1007
|
+
if (modelSegment)
|
|
1008
|
+
segments.push(mark === GLYPH ? `${CLAUDE_GLYPH} ${modelSegment}` : modelSegment);
|
|
1009
|
+
|
|
1010
|
+
// GitHub cluster: repo name + branch under one GitHub-mark prefix.
|
|
1011
|
+
// Plain mode keeps the generic ⎇ ([HS] here would read as a claim segment).
|
|
1012
|
+
const dir = basename(session?.workspace?.current_dir ?? session?.cwd) ?? basename(root);
|
|
1013
|
+
const branch = readGitBranch(root);
|
|
1014
|
+
const githubCluster = [dir, branch].filter(Boolean).join(' > ');
|
|
1015
|
+
if (githubCluster) segments.push(`${mark === GLYPH ? GITHUB_GLYPH : '⎇'} ${githubCluster}`);
|
|
1016
|
+
|
|
1017
|
+
const pinned = await readPinnedSpecId(root);
|
|
1018
|
+
if (pinned) segments.push(`${mark} ${pinned}`);
|
|
1019
|
+
|
|
1020
|
+
// HeySpex cluster, far right: "<glyph> <pillar> > <fileID> > <summary>"
|
|
1021
|
+
// (owner ask 2026-08-14 added the pillar). Pillar: which perpetual area of
|
|
1022
|
+
// the product this work belongs to, inherited from the worked doc's epic.
|
|
1023
|
+
// Worked-doc: the spec most RELEVANT to this thread right now — this
|
|
1024
|
+
// session's own claim vs. the live transcript reference, whichever is more
|
|
1025
|
+
// recent (pickRelevantSpec, above) — so it updates as the conversation's
|
|
1026
|
+
// focus moves, and never shows another session's claim.
|
|
1027
|
+
// Summary: what this session is about, in 2-6 words (sticky, pivot-only).
|
|
1028
|
+
const worked = workedDoc(root, pickRelevantSpec(root, session));
|
|
1029
|
+
const label = threadLabel(session);
|
|
1030
|
+
const heyspexCluster = [worked?.pillar, worked?.display, label].filter(Boolean).join(' > ');
|
|
1031
|
+
if (heyspexCluster) segments.push(`${mark} ${heyspexCluster}`);
|
|
1032
|
+
|
|
1033
|
+
return segments.join(' | ');
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function main() {
|
|
1037
|
+
try {
|
|
1038
|
+
const root = repoRoot();
|
|
1039
|
+
// stdin is read with a tiny timeout — if Claude Code didn't pipe
|
|
1040
|
+
// session JSON (e.g. smoke-test from a shell), we still render the
|
|
1041
|
+
// segments that don't depend on it.
|
|
1042
|
+
const raw = readStdinSync();
|
|
1043
|
+
const session = parseSession(raw);
|
|
1044
|
+
if (process.env.HS_SL_DEBUG === '1') console.error('DEBUG raw:', JSON.stringify(raw));
|
|
1045
|
+
const line = await build(root, session);
|
|
1046
|
+
if (line) process.stdout.write(`${line}\n`);
|
|
1047
|
+
} catch {
|
|
1048
|
+
// Never break Claude Code — silent degrade.
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (isDirectRun) {
|
|
1053
|
+
if (process.argv[2] === '--refresh-label') {
|
|
1054
|
+
refreshLabel(process.argv[3], process.argv[4], process.argv[5]);
|
|
1055
|
+
} else {
|
|
1056
|
+
main();
|
|
1057
|
+
}
|
|
1058
|
+
}
|