agent-harness-kit 0.10.2 → 0.11.1

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 (28) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +13 -5
  4. package/package.json +3 -2
  5. package/src/core/render-templates.mjs +31 -3
  6. package/src/templates/.claude/keybindings.json.example +20 -0
  7. package/src/templates/.claude/skills/deliver-html/SKILL.md.hbs +5 -1
  8. package/src/templates/.claude/skills/deliver-html/SKILL.md.vi.hbs +5 -1
  9. package/src/templates/.claude/skills/deliver-html/scripts/wrap-html.mjs +0 -0
  10. package/src/templates/.claude/skills/setup-nightly-eval/SKILL.md +118 -0
  11. package/src/templates/docs/env-vars.md +54 -0
  12. package/src/templates/docs/memory-cheatsheet.md +82 -0
  13. package/src/templates/scripts/_lib/jp.sh +53 -0
  14. package/src/templates/scripts/_lib/statusline-cache.mjs +57 -0
  15. package/src/templates/scripts/_lib/telemetry.sh +45 -0
  16. package/src/templates/scripts/notify-on-block.sh.hbs +6 -23
  17. package/src/templates/scripts/pre-compact.sh.hbs +2 -20
  18. package/src/templates/scripts/pre-push.sh +2 -20
  19. package/src/templates/scripts/precompletion-checklist.sh.hbs +5 -31
  20. package/src/templates/scripts/pretooluse-bash-guard.sh.hbs +2 -20
  21. package/src/templates/scripts/pretooluse-edit-guard.sh.hbs +2 -14
  22. package/src/templates/scripts/session-end.sh.hbs +2 -14
  23. package/src/templates/scripts/session-start.sh.hbs +2 -20
  24. package/src/templates/scripts/statusline.mjs +327 -36
  25. package/src/templates/scripts/structural-test-on-edit.sh.hbs +2 -14
  26. package/src/templates/scripts/subagent-stop.sh.hbs +7 -18
  27. package/src/templates/scripts/telemetry-on-skill.sh +14 -20
  28. package/src/templates/scripts/userprompt-guard.sh.hbs +2 -20
@@ -6,26 +6,9 @@ set -eo pipefail
6
6
 
7
7
  INPUT=$(cat)
8
8
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
- have_jq() {
10
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
11
- command -v jq >/dev/null 2>&1
12
- }
13
- have_jp() {
14
- have_jq && return 0
15
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
16
- return 1
17
- }
18
- jp() {
19
- if have_jq; then
20
- if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
21
- else
22
- if [ -n "$2" ]; then
23
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
24
- else
25
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
26
- fi
27
- fi
28
- }
9
+ _LIB_DIR="$SCRIPT_DIR/_lib"
10
+ . "$_LIB_DIR/jp.sh"
11
+ . "$_LIB_DIR/telemetry.sh"
29
12
 
30
13
  if [ "${AHK_DISABLE_NOTIFY:-}" = "1" ]; then
31
14
  exit 0
@@ -46,12 +29,12 @@ if [ -n "$TYPE" ]; then
46
29
  fi
47
30
  [ -z "$BODY" ] && BODY="Claude Code wants your attention."
48
31
 
49
- mkdir -p .harness
50
32
  TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
51
33
  ESCAPED_TITLE=${TITLE//\"/\\\"}
52
34
  ESCAPED_BODY=${BODY//\"/\\\"}
53
- printf '{"ts":"%s","hook":"Notification","type":"%s","title":"%s","body":"%s"}\n' \
54
- "$TS" "$TYPE" "$ESCAPED_TITLE" "$ESCAPED_BODY" >> .harness/telemetry.jsonl
35
+ LINE=$(printf '{"ts":"%s","hook":"Notification","type":"%s","title":"%s","body":"%s"}' \
36
+ "$TS" "$TYPE" "$ESCAPED_TITLE" "$ESCAPED_BODY")
37
+ telemetry_append "$LINE"
55
38
 
56
39
  OS_KIND=$(uname -s 2>/dev/null || echo "Unknown")
57
40
  case "$OS_KIND" in
@@ -28,26 +28,8 @@ set -eo pipefail
28
28
 
29
29
  INPUT=$(cat)
30
30
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
31
- have_jq() {
32
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
33
- command -v jq >/dev/null 2>&1
34
- }
35
- have_jp() {
36
- have_jq && return 0
37
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
38
- return 1
39
- }
40
- jp() {
41
- if have_jq; then
42
- if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
43
- else
44
- if [ -n "$2" ]; then
45
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
46
- else
47
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
48
- fi
49
- fi
50
- }
31
+ _LIB_DIR="$SCRIPT_DIR/_lib"
32
+ . "$_LIB_DIR/jp.sh"
51
33
 
52
34
  TRIGGER=""
53
35
  TOKENS=""
@@ -8,26 +8,8 @@ set -eo pipefail
8
8
  # Without this fallback, `jq` missing on a fresh CI image silently disabled
9
9
  # the baseline-monotonic guard — a known audit hole.
10
10
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11
- have_jq() {
12
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
13
- command -v jq >/dev/null 2>&1
14
- }
15
- have_jp() {
16
- have_jq && return 0
17
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
18
- return 1
19
- }
20
- jp() {
21
- if have_jq; then
22
- if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
23
- else
24
- if [ -n "$2" ]; then
25
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
26
- else
27
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
28
- fi
29
- fi
30
- }
11
+ _LIB_DIR="$SCRIPT_DIR/_lib"
12
+ . "$_LIB_DIR/jp.sh"
31
13
 
32
14
  # Baseline monotonic guard. .harness/structural-baseline.json is decreasing-
33
15
  # only — fixes REMOVE entries; no path should ADD them. Catches the "mask
@@ -14,37 +14,11 @@ INPUT=$(cat)
14
14
 
15
15
  # Resolve the directory this hook lives in (used to find _lib/json-pick.mjs).
16
16
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
-
18
- # have_jq env-overridable probe. AHK_DISABLE_JQ=1 forces the Node fallback,
19
- # used by tests to exercise the jq-less code path on machines that have jq
20
- # installed locally.
21
- have_jq() {
22
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
23
- command -v jq >/dev/null 2>&1
24
- }
25
- # jp — JSON picker. Uses `jq` when available, else falls back to a bundled
26
- # Node script with a jq-subset implementation. Keeps hooks portable on
27
- # minimal CI / Windows where jq is not installed by default. Without this
28
- # fallback, the entire pre-completion check used to be a silent no-op.
29
- jp() {
30
- if have_jq; then
31
- if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
32
- else
33
- if [ -n "$2" ]; then
34
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
35
- else
36
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
37
- fi
38
- fi
39
- }
40
- # Probe: do we have either jq or the Node fallback? Node is always
41
- # present (kit's `engines` field requires >=20), so this is just an explicit
42
- # probe and a fail-loud branch if even node is missing.
43
- have_jp() {
44
- have_jq && return 0
45
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
46
- return 1
47
- }
17
+ _LIB_DIR="$SCRIPT_DIR/_lib"
18
+ # have_jq / have_jp / jp shared across all hook scripts. AHK_DISABLE_JQ=1
19
+ # forces the Node fallback, used by tests to exercise the jq-less code path
20
+ # on machines that have jq installed locally.
21
+ . "$_LIB_DIR/jp.sh"
48
22
 
49
23
  # CRITICAL: avoid infinite loops. If the hook already ran, do not block again.
50
24
  if have_jp; then
@@ -32,26 +32,8 @@ set -eo pipefail
32
32
 
33
33
  INPUT=$(cat)
34
34
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
35
- have_jq() {
36
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
37
- command -v jq >/dev/null 2>&1
38
- }
39
- have_jp() {
40
- have_jq && return 0
41
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
42
- return 1
43
- }
44
- jp() {
45
- if have_jq; then
46
- if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
47
- else
48
- if [ -n "$2" ]; then
49
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
50
- else
51
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
52
- fi
53
- fi
54
- }
35
+ _LIB_DIR="$SCRIPT_DIR/_lib"
36
+ . "$_LIB_DIR/jp.sh"
55
37
 
56
38
  if ! have_jp; then
57
39
  # Without a JSON parser we can't read the command. Skip rather than
@@ -23,20 +23,8 @@ set -eo pipefail
23
23
 
24
24
  INPUT=$(cat)
25
25
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
26
- have_jq() {
27
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
28
- command -v jq >/dev/null 2>&1
29
- }
30
- have_jp() {
31
- have_jq && return 0
32
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
33
- return 1
34
- }
35
- jp() {
36
- if have_jq; then jq -r "$1"
37
- else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
38
- fi
39
- }
26
+ _LIB_DIR="$SCRIPT_DIR/_lib"
27
+ . "$_LIB_DIR/jp.sh"
40
28
  if ! have_jp; then exit 0; fi
41
29
 
42
30
  # Resolve target file. Write/Edit ship .tool_input.file_path; MultiEdit ships
@@ -19,20 +19,8 @@ set -eo pipefail
19
19
 
20
20
  INPUT=$(cat)
21
21
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
- have_jq() {
23
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
24
- command -v jq >/dev/null 2>&1
25
- }
26
- have_jp() {
27
- have_jq && return 0
28
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
29
- return 1
30
- }
31
- jp() {
32
- if have_jq; then jq -r "$1"
33
- else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
34
- fi
35
- }
22
+ _LIB_DIR="$SCRIPT_DIR/_lib"
23
+ . "$_LIB_DIR/jp.sh"
36
24
 
37
25
  REASON=""
38
26
  SESSION_ID=""
@@ -22,26 +22,8 @@ set -eo pipefail
22
22
 
23
23
  INPUT=$(cat)
24
24
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
- have_jq() {
26
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
27
- command -v jq >/dev/null 2>&1
28
- }
29
- have_jp() {
30
- have_jq && return 0
31
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
32
- return 1
33
- }
34
- jp() {
35
- if have_jq; then
36
- if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
37
- else
38
- if [ -n "$2" ]; then
39
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
40
- else
41
- node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
42
- fi
43
- fi
44
- }
25
+ _LIB_DIR="$SCRIPT_DIR/_lib"
26
+ . "$_LIB_DIR/jp.sh"
45
27
 
46
28
  SOURCE=""
47
29
  if have_jp; then
@@ -1,14 +1,47 @@
1
1
  #!/usr/bin/env node
2
- // statusLine — single compact line into Claude Code's TUI status bar.
3
- // Reads stdin (Claude Code payload), augments with kit state, emits to
4
- // stdout. Failure mode: print nothing rather than crash.
2
+ // statusLine — "Two-line Dashboard" (V4).
3
+ //
4
+ // LINE 1 vitals (always emitted when any segment resolves):
5
+ // ▶ Opus│terse│⏱1h12m main(±3) feat:health-endpoint ▓▓▓▓░ 42% $0.83 +156/-23
6
+ //
7
+ // LINE 2 — alerts (only when ≥1 trigger fires; otherwise omitted):
8
+ // ⚠ >200K — auto-compact next msg ⚠ ctx 84% ⏳ 5h limit 78%, resets in 1h12m 🚫 last-block: <title>
9
+ //
10
+ // Payload (Claude Code v2.1.132+ schema):
11
+ // model.display_name, output_style.name, session_id, version,
12
+ // cost.{total_cost_usd, total_duration_ms, total_lines_added, total_lines_removed},
13
+ // context_window.{used_percentage, context_window_size, total_input_tokens},
14
+ // exceeds_200k_tokens, rate_limits.five_hour.{used_percentage, resets_at}
15
+ //
16
+ // Behaviour gates:
17
+ // - NO_COLOR env or non-TTY → ANSI escapes stripped, plain text only.
18
+ // - harness.config.json#statusline.compact = true → line 2 dropped.
19
+ // - harness.config.json#statusline.{lang,showLines,showRateLimit,showLastBlock}
20
+ // toggle individual segments. Defaults: full features, lang from
21
+ // claudeMd.humanLanguage.
22
+ //
23
+ // Caching:
24
+ // - Git branch / dirty count cached 5s per session_id.
25
+ // - feature_list.json cached 30s.
26
+ // - telemetry tail cached 10s.
27
+ // - harness.config.json cached 60s.
28
+ //
29
+ // Failure mode: print nothing rather than crash. The TUI never breaks
30
+ // because of a statusline bug.
5
31
 
6
32
  import { readFileSync, existsSync } from "node:fs";
7
33
  import { resolve } from "node:path";
8
34
  import { spawnSync } from "node:child_process";
35
+ import { cached } from "./_lib/statusline-cache.mjs";
9
36
 
10
37
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
38
+ const NO_COLOR =
39
+ process.env.NO_COLOR != null && process.env.NO_COLOR !== "" ||
40
+ process.env.AHK_STATUSLINE_NO_COLOR === "1";
11
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // Tiny helpers.
44
+ // ---------------------------------------------------------------------------
12
45
  function safeRead(rel) {
13
46
  try { return readFileSync(resolve(CWD, rel), "utf8"); }
14
47
  catch { return null; }
@@ -21,43 +54,301 @@ function safeJSON(rel) {
21
54
  function readStdinSync() {
22
55
  try { return readFileSync(0, "utf8"); } catch { return ""; }
23
56
  }
57
+ function num(x, def = 0) {
58
+ return typeof x === "number" && Number.isFinite(x) ? x : def;
59
+ }
24
60
 
25
- function pieces() {
26
- const out = [];
27
- const lock = safeJSON(".harness/installed.json");
28
- if (lock?.version) out.push(`{kit-v${lock.version}}`);
29
-
30
- const features = safeJSON("feature_list.json");
31
- if (features?.features && Array.isArray(features.features)) {
32
- const open = features.features.find((f) => f.passes === false);
33
- out.push(open ? `feat:${open.id}` : "feat:clean");
34
- }
35
-
36
- try {
37
- const br = spawnSync("git", ["branch", "--show-current"], {
38
- cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
39
- });
40
- const status = spawnSync("git", ["status", "--short"], {
41
- cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
42
- });
43
- if (br.status === 0 && br.stdout.trim()) {
44
- const dirty = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
45
- out.push(dirty > 0 ? `${br.stdout.trim()}(±${dirty})` : br.stdout.trim());
61
+ // ANSI wrappers. When NO_COLOR is set, return the bare string — every caller
62
+ // produces uncoloured output without branching.
63
+ const RESET = NO_COLOR ? "" : "\x1b[0m";
64
+ function c(code, s) {
65
+ if (NO_COLOR || !s) return s ?? "";
66
+ return `\x1b[${code}m${s}${RESET}`;
67
+ }
68
+ const cyan = (s) => c("36", s);
69
+ const green = (s) => c("32", s);
70
+ const yellow = (s) => c("33", s);
71
+ const red = (s) => c("31", s);
72
+ const magenta = (s) => c("35", s);
73
+ const dim = (s) => c("2", s);
74
+ const dimGreen = (s) => c("2;32", s);
75
+ const dimRed = (s) => c("2;31", s);
76
+
77
+ // Color the context bar gradient by percentage band.
78
+ function ctxColor(pct) {
79
+ if (pct >= 80) return red;
80
+ if (pct >= 50) return yellow;
81
+ return cyan;
82
+ }
83
+
84
+ // Color the cost by tier — under $1 dim, $1–5 default, $5+ yellow.
85
+ function costStr(usd) {
86
+ const v = num(usd, 0);
87
+ const str = "$" + (v < 1 ? v.toFixed(2) : v < 10 ? v.toFixed(2) : v.toFixed(1));
88
+ if (v < 1) return dim(str);
89
+ if (v < 5) return str;
90
+ return yellow(str);
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Config & locale.
95
+ // ---------------------------------------------------------------------------
96
+ const DEFAULT_CONFIG = {
97
+ compact: false,
98
+ lang: null,
99
+ showLines: true,
100
+ showRateLimit: true,
101
+ showLastBlock: true,
102
+ };
103
+
104
+ function readConfig(sessionId) {
105
+ const raw = cached(
106
+ { sessionId, key: "config", ttlMs: 60_000 },
107
+ () => safeRead("harness.config.json") ?? "",
108
+ );
109
+ if (!raw) return { config: DEFAULT_CONFIG, humanLanguage: "en" };
110
+ let parsed;
111
+ try { parsed = JSON.parse(raw); } catch { return { config: DEFAULT_CONFIG, humanLanguage: "en" }; }
112
+ const config = { ...DEFAULT_CONFIG, ...(parsed?.statusline ?? {}) };
113
+ const humanLanguage = parsed?.claudeMd?.humanLanguage || "en";
114
+ return { config, humanLanguage };
115
+ }
116
+
117
+ const STRINGS = {
118
+ en: {
119
+ compact_soon: " — /compact soon",
120
+ over_200k: ">200K — auto-compact next msg",
121
+ rate_resets: ", resets in ",
122
+ last_block: "last-block: ",
123
+ },
124
+ vi: {
125
+ compact_soon: " — /compact sắp tới",
126
+ over_200k: ">200K — sắp auto-compact",
127
+ rate_resets: ", reset trong ",
128
+ last_block: "vừa block: ",
129
+ },
130
+ };
131
+
132
+ function pickLang(config, humanLanguage) {
133
+ const lang = config.lang || humanLanguage;
134
+ return STRINGS[lang] ? lang : "en";
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Segment data fetchers (cached).
139
+ // ---------------------------------------------------------------------------
140
+ function fetchGit(sessionId) {
141
+ const raw = cached(
142
+ { sessionId, key: "git", ttlMs: 5_000 },
143
+ () => {
144
+ const br = spawnSync("git", ["branch", "--show-current"], {
145
+ cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 500,
146
+ });
147
+ if (br.status !== 0 || !br.stdout) return "";
148
+ const branch = br.stdout.trim();
149
+ if (!branch) return "";
150
+ const st = spawnSync("git", ["status", "--short"], {
151
+ cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 500,
152
+ });
153
+ const dirty = st.stdout ? st.stdout.split("\n").filter(Boolean).length : 0;
154
+ // Conflict marker check — short and cheap on already-fetched output.
155
+ const conflict = /^(UU|AA|DD)/m.test(st.stdout || "");
156
+ return JSON.stringify({ branch, dirty, conflict });
157
+ },
158
+ );
159
+ if (!raw) return null;
160
+ try { return JSON.parse(raw); } catch { return null; }
161
+ }
162
+
163
+ function fetchFeature(sessionId) {
164
+ const raw = cached(
165
+ { sessionId, key: "feat", ttlMs: 30_000 },
166
+ () => safeRead("feature_list.json") ?? "",
167
+ );
168
+ if (!raw) return null;
169
+ let features;
170
+ try { features = JSON.parse(raw); } catch { return null; }
171
+ if (!features?.features || !Array.isArray(features.features)) return null;
172
+ const open = features.features.find((f) => f.passes === false);
173
+ return { open: open?.id ?? null, clean: !open };
174
+ }
175
+
176
+ // Returns the most recent Notification record from .harness/telemetry.jsonl
177
+ // (the file notify-on-block.sh writes a record to on every Notification
178
+ // hook firing). Returns {ts, title} if found within the last 5 min, else
179
+ // null. Caching avoids re-reading the JSONL on every refresh.
180
+ function fetchLastBlock(sessionId) {
181
+ const raw = cached(
182
+ { sessionId, key: "tele", ttlMs: 10_000 },
183
+ () => {
184
+ const body = safeRead(".harness/telemetry.jsonl");
185
+ if (!body) return "";
186
+ const lines = body.split("\n").filter(Boolean);
187
+ for (let i = lines.length - 1; i >= 0 && i >= lines.length - 50; i--) {
188
+ try {
189
+ const rec = JSON.parse(lines[i]);
190
+ if (rec.hook === "Notification") {
191
+ return JSON.stringify({ ts: rec.ts, title: rec.title || rec.body || "" });
192
+ }
193
+ } catch { /* skip malformed */ }
194
+ }
195
+ return "";
196
+ },
197
+ );
198
+ if (!raw) return null;
199
+ let rec;
200
+ try { rec = JSON.parse(raw); } catch { return null; }
201
+ if (!rec?.ts) return null;
202
+ const ageMs = Date.now() - new Date(rec.ts).getTime();
203
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 5 * 60_000) return null;
204
+ return rec;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Format helpers.
209
+ // ---------------------------------------------------------------------------
210
+ function fmtDuration(ms) {
211
+ const s = Math.floor(num(ms, 0) / 1000);
212
+ if (s < 60) return `${s}s`;
213
+ const m = Math.floor(s / 60);
214
+ if (m < 60) return `${m}m`;
215
+ const h = Math.floor(m / 60);
216
+ const rm = m % 60;
217
+ return `${h}h${String(rm).padStart(2, "0")}m`;
218
+ }
219
+
220
+ function fmtCountdown(epochSeconds) {
221
+ const target = Number(epochSeconds) * 1000;
222
+ if (!Number.isFinite(target)) return "?";
223
+ const ms = target - Date.now();
224
+ if (ms <= 0) return "0m";
225
+ const m = Math.floor(ms / 60_000);
226
+ if (m < 60) return `${m}m`;
227
+ const h = Math.floor(m / 60);
228
+ const rm = m % 60;
229
+ return `${h}h${String(rm).padStart(2, "0")}m`;
230
+ }
231
+
232
+ function bar(pct, width = 10) {
233
+ const p = Math.max(0, Math.min(100, num(pct, 0)));
234
+ const filled = Math.round((p / 100) * width);
235
+ return "▓".repeat(filled) + "░".repeat(width - filled);
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Line 1 — vitals.
240
+ // ---------------------------------------------------------------------------
241
+ function renderLine1(payload, git, feat, config) {
242
+ const left = []; // identity group: model, style, duration
243
+ const mid = []; // workspace group: branch, feat
244
+ const right = []; // burn group: ctx, cost, lines
245
+
246
+ const modelName = payload?.model?.display_name;
247
+ if (modelName) left.push(cyan(`▶ ${modelName}`));
248
+
249
+ const styleName = payload?.output_style?.name;
250
+ if (styleName && styleName !== "default") left.push(dim(styleName));
251
+
252
+ const durMs = payload?.cost?.total_duration_ms;
253
+ if (durMs && durMs >= 1000) left.push(dim(`⏱${fmtDuration(durMs)}`));
254
+
255
+ if (git?.branch) {
256
+ const tag = git.conflict ? red(`${git.branch}!CONFLICT`)
257
+ : git.dirty > 0 ? yellow(`${git.branch}(±${git.dirty})`)
258
+ : green(git.branch);
259
+ mid.push(tag);
260
+ }
261
+
262
+ if (feat) {
263
+ mid.push(feat.open ? magenta(`feat:${feat.open}`) : dimGreen("feat:clean"));
264
+ }
265
+
266
+ const pct = payload?.context_window?.used_percentage;
267
+ if (typeof pct === "number") {
268
+ const col = ctxColor(pct);
269
+ right.push(`${col(bar(pct))} ${col(`${Math.round(pct)}%`)}`);
270
+ }
271
+
272
+ const cost = payload?.cost?.total_cost_usd;
273
+ if (typeof cost === "number" && cost > 0) {
274
+ right.push(costStr(cost));
275
+ }
276
+
277
+ if (config.showLines) {
278
+ const add = num(payload?.cost?.total_lines_added, 0);
279
+ const rem = num(payload?.cost?.total_lines_removed, 0);
280
+ if (add > 0 || rem > 0) {
281
+ right.push(`${green("+" + add)}/${dimRed("-" + rem)}`);
46
282
  }
47
- } catch { /* git not on PATH — skip */ }
283
+ }
48
284
 
49
- const raw = readStdinSync();
50
- let payload = null;
51
- if (raw) { try { payload = JSON.parse(raw); } catch { /* ignore */ } }
52
- if (payload?.context && typeof payload.context.percentage === "number") {
53
- out.push(`ctx:${Math.round(payload.context.percentage)}%`);
285
+ const parts = [];
286
+ if (left.length) parts.push(left.join(dim("│")));
287
+ if (mid.length) parts.push(mid.join(" "));
288
+ if (right.length) parts.push(right.join(" "));
289
+ return parts.join(" ");
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Line 2 — alerts.
294
+ // ---------------------------------------------------------------------------
295
+ function renderLine2(payload, sessionId, config, lang) {
296
+ if (config.compact) return "";
297
+ const t = STRINGS[lang];
298
+ const alerts = [];
299
+
300
+ // Order by severity: hardest stop first.
301
+ if (payload?.exceeds_200k_tokens === true) {
302
+ alerts.push(red(`⚠ ${t.over_200k}`));
54
303
  }
55
- if (payload?.cost && typeof payload.cost.total === "number") {
56
- const v = payload.cost.total;
57
- out.push(`$${v < 1 ? v.toFixed(2) : v.toFixed(1)}`);
304
+
305
+ const pct = payload?.context_window?.used_percentage;
306
+ if (typeof pct === "number" && pct >= 80 && payload?.exceeds_200k_tokens !== true) {
307
+ alerts.push(red(`⚠ ctx ${Math.round(pct)}%${t.compact_soon}`));
58
308
  }
59
- return out;
309
+
310
+ if (config.showRateLimit) {
311
+ const five = payload?.rate_limits?.five_hour;
312
+ if (five && typeof five.used_percentage === "number" && five.used_percentage >= 75) {
313
+ const resetTxt = five.resets_at ? `${t.rate_resets}${fmtCountdown(five.resets_at)}` : "";
314
+ alerts.push(yellow(`⏳ 5h limit ${Math.round(five.used_percentage)}%${resetTxt}`));
315
+ }
316
+ }
317
+
318
+ if (config.showLastBlock) {
319
+ const lb = fetchLastBlock(sessionId);
320
+ if (lb) {
321
+ const title = String(lb.title || "").slice(0, 40);
322
+ alerts.push(red(`🚫 ${t.last_block}${title}`));
323
+ }
324
+ }
325
+
326
+ return alerts.join(" ");
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Main.
331
+ // ---------------------------------------------------------------------------
332
+ function main() {
333
+ const raw = readStdinSync();
334
+ let payload = {};
335
+ if (raw) {
336
+ try { payload = JSON.parse(raw) || {}; } catch { payload = {}; }
337
+ }
338
+ const sessionId = payload?.session_id || "no-session";
339
+ const { config, humanLanguage } = readConfig(sessionId);
340
+ const lang = pickLang(config, humanLanguage);
341
+
342
+ const git = fetchGit(sessionId);
343
+ const feat = fetchFeature(sessionId);
344
+
345
+ const line1 = renderLine1(payload, git, feat, config);
346
+ const line2 = renderLine2(payload, sessionId, config, lang);
347
+
348
+ const out = [];
349
+ if (line1) out.push(line1);
350
+ if (line2) out.push(line2);
351
+ if (out.length) process.stdout.write(out.join("\n"));
60
352
  }
61
353
 
62
- const line = pieces().join(" ");
63
- if (line) process.stdout.write(line);
354
+ try { main(); } catch { /* swallow — never crash the TUI */ }
@@ -13,20 +13,8 @@ INPUT=$(cat)
13
13
  # when jq is missing — silently skipping the structural check on jq-less
14
14
  # environments (minimal CI, Windows without WSL+brew) was a known audit hole.
15
15
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16
- have_jq() {
17
- [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
18
- command -v jq >/dev/null 2>&1
19
- }
20
- have_jp() {
21
- have_jq && return 0
22
- command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
23
- return 1
24
- }
25
- jp() {
26
- if have_jq; then jq -r "$1"
27
- else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
28
- fi
29
- }
16
+ _LIB_DIR="$SCRIPT_DIR/_lib"
17
+ . "$_LIB_DIR/jp.sh"
30
18
  if ! have_jp; then
31
19
  echo "[ahk] structural-test-on-edit: no JSON parser available (need jq OR node + scripts/_lib/json-pick.mjs)." >&2
32
20
  exit 0